Skip to content

Commit e1bb4d2

Browse files
fix(mcp): correct /api/me/* paths, claim host, env var, surface agent_action (#6)
Wave FIX-E findings. The MCP shipped four tools wired to /api/me/* paths that the router never registered — every call returned a generic "instanode.dev error (404)" and the LLM had no path forward. This PR rewires those tools to the canonical routes, plus three smaller contract drifts: * /api/me/resources → GET /api/v1/resources (list) * /api/me/claim → POST /claim (now takes jwt + email) * /api/me/resources/{token} → DELETE /api/v1/resources/{token} * /api/me/token → POST /api/v1/auth/api-keys (mint) The list endpoint returns {ok, items, total}; client now unwraps to items[] so the tool can iterate naturally. The claim flow primitive is identity-bound — there is no "claim a token to an existing team" route — so claim_token's schema is now (upgrade_jwt, email), matching the dashboard's flow. The mint route returns a plaintext key that's shown exactly once; get_api_token's wording was updated to reflect revocation-based (not time-bound) keys. #C5 * claim_resource built https://instanode.dev/start?t=<jwt>. /start is a route on the API host, NOT the dashboard host — the dashboard domain's path is /claim. /start is what issues the 302 to /claim. Now builds api.instanode.dev/start?t=<jwt>; configurable via INSTANODE_API_URL same as every other request. #C6 * MCP discarded `agent_action` from API error envelopes. The API copy-edits these sentences specifically for the LLM to read aloud ("Tell the user they've hit the hobby tier storage limit — have them upgrade at https://instanode.dev/pricing"). formatError now appends Action + Upgrade + Claim lines below the headline so the user gets the platform's canonical CTA instead of a generic "API error 402". client.ApiError carries the new agentAction + claimURL fields. #C7 * server.json + smithery.yaml declared INSTANODE_API_BASE while client.ts always read INSTANODE_API_URL. The registry-side name never took effect — users who set it via Smithery silently got the default base URL. Standardized on INSTANODE_API_URL (the name the client honors today). #C8 * Bumped 0.9.0 → 0.9.1. The previous 0.9.0 npm publish failed and `npx instanode-mcp@latest` 404'd; this version closes both. The npm publish step is documented in PUBLISHING.md and must be run manually (no NPM_TOKEN was available in the env where this PR was produced). #C4 #C106 test.sh extended: two new gates assert the claim host is the API host and that claim_token rejects the old (token-only) shape. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b072154 commit e1bb4d2

7 files changed

Lines changed: 303 additions & 94 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "instanode-mcp",
3-
"version": "0.10.0",
3+
"version": "0.10.1",
44
"description": "MCP server for instanode.dev \u2014 lets AI coding agents provision ephemeral Postgres, Redis, MongoDB, NATS queues, S3-compatible object storage, webhook receivers, and deploy containerized apps over HTTPS, with optional bearer-token auth for paid users.",
55
"keywords": [
66
"mcp",

server.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,27 @@
66
"url": "https://github.com/InstaNode-dev/mcp",
77
"source": "github"
88
},
9-
"version": "0.9.0",
9+
"version": "0.10.1",
1010
"websiteUrl": "https://instanode.dev",
1111
"packages": [
1212
{
1313
"registryType": "npm",
1414
"identifier": "instanode-mcp",
15-
"version": "0.9.0",
15+
"version": "0.10.1",
1616
"transport": {
1717
"type": "stdio"
1818
},
1919
"environmentVariables": [
2020
{
2121
"name": "INSTANODE_TOKEN",
22-
"description": "Optional bearer JWT for paid-tier callers. Mint at https://instanode.dev/dashboard \u2192 'API token for CLI / agent'. Lifts the free-tier 5-per-subnet-per-day provisioning cap and auto-links new resources to your account. Leave unset to use the free tier anonymously.",
22+
"description": "Optional bearer JWT for paid-tier callers. Mint at https://instanode.dev/dashboard 'API token for CLI / agent'. Lifts the free-tier 5-per-subnet-per-day provisioning cap and auto-links new resources to your account. Leave unset to use the free tier anonymously.",
2323
"isRequired": false,
2424
"isSecret": true,
2525
"format": "string"
2626
},
2727
{
28-
"name": "INSTANODE_API_BASE",
29-
"description": "Override the API base URL. Defaults to https://api.instanode.dev. Useful for self-hosted deployments.",
28+
"name": "INSTANODE_API_URL",
29+
"description": "Override the API base URL. Defaults to https://api.instanode.dev. Useful for self-hosted deployments. (Formerly INSTANODE_API_BASE — the client.ts always read INSTANODE_API_URL; the older registry name never took effect.)",
3030
"isRequired": false,
3131
"isSecret": false,
3232
"format": "string",

smithery.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ startCommand:
1313
Lifts the free-tier 5-per-subnet-per-day provisioning cap and auto-
1414
links new resources to your account. Leave empty to use the free tier
1515
anonymously (10 MB / 2 connections / 24h TTL Postgres).
16-
instanodeApiBase:
16+
instanodeApiUrl:
1717
type: string
1818
title: API Base URL
1919
description: |
@@ -26,6 +26,6 @@ startCommand:
2626
args: ['-y', 'instanode-mcp@latest'],
2727
env: {
2828
...(config.instanodeToken ? { INSTANODE_TOKEN: config.instanodeToken } : {}),
29-
...(config.instanodeApiBase ? { INSTANODE_API_BASE: config.instanodeApiBase } : {})
29+
...(config.instanodeApiUrl ? { INSTANODE_API_URL: config.instanodeApiUrl } : {})
3030
}
3131
})

src/client.ts

Lines changed: 137 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,16 @@
1919
* POST /deploy/new — Container deployment (multipart/form-data)
2020
* GET /api/v1/deployments, GET /api/v1/deployments/:id
2121
* POST /deploy/:id/redeploy, DELETE /deploy/:id
22-
* GET /api/me/resources, POST /api/me/claim, DELETE /api/me/resources/{token}
23-
* GET /api/me/token
22+
* GET /api/v1/resources — list resources for the authenticated team
23+
* DELETE /api/v1/resources/{token} — soft-delete (Pro+; free-tier rows auto-expire)
24+
* POST /claim — convert anonymous JWT → authenticated team
25+
* POST /api/v1/auth/api-keys — mint a fresh bearer JWT
26+
*
27+
* Historical note: an earlier MCP build called /api/me/resources, /api/me/claim,
28+
* /api/me/token — these were never live (a typo'd /api/me prefix that the
29+
* router never registered). Every such call returned 404 and the agent saw
30+
* "instanode.dev error (404)" with no path forward. FIX-E #C5 rewired the
31+
* client to the canonical routes above.
2432
*/
2533

2634
const DEFAULT_BASE_URL = "https://api.instanode.dev";
@@ -272,13 +280,40 @@ export class ApiError extends Error {
272280
readonly status: number;
273281
readonly code?: string;
274282
readonly upgradeURL?: string;
275-
276-
constructor(status: number, message: string, code?: string, upgradeURL?: string) {
283+
/**
284+
* The `agent_action` field from the API's error envelope, when present.
285+
*
286+
* The API copies a verbatim sentence the agent should surface to the human
287+
* user (e.g. "Tell the user they've hit the hobby tier storage limit — have
288+
* them upgrade at https://instanode.dev/pricing"). FIX-E #C7 plumbs this
289+
* through to the formatError handler so the MCP user actually sees it.
290+
* Previously the MCP discarded `agent_action` entirely and the LLM had to
291+
* guess at the action from a generic "API error 402" string.
292+
*/
293+
readonly agentAction?: string;
294+
/**
295+
* The `claim_url` field from the API's error envelope on
296+
* `free_tier_recycle_requires_claim` (the anonymous-fingerprint recycle gate).
297+
* Distinct from `upgradeURL` — `claimURL` is the identity step (anon → claimed),
298+
* `upgradeURL` is the tier step (claimed → paid).
299+
*/
300+
readonly claimURL?: string;
301+
302+
constructor(
303+
status: number,
304+
message: string,
305+
code?: string,
306+
upgradeURL?: string,
307+
agentAction?: string,
308+
claimURL?: string
309+
) {
277310
super(message);
278311
this.name = "ApiError";
279312
this.status = status;
280313
this.code = code;
281314
this.upgradeURL = upgradeURL;
315+
this.agentAction = agentAction;
316+
this.claimURL = claimURL;
282317
}
283318
}
284319

@@ -305,6 +340,17 @@ export class InstantClient {
305340
);
306341
}
307342

343+
/**
344+
* API base URL (where /db/new, /claim, /start etc. live). Distinct from
345+
* `dashboardURL` — the dashboard host is for the human signin flow, the API
346+
* host is what /start redirects FROM. The `claim_resource` MCP tool builds
347+
* `{apiBaseURL}/start?t=<jwt>` because /start is a route on the API, not the
348+
* dashboard.
349+
*/
350+
apiBaseURL(): string {
351+
return this.baseURL;
352+
}
353+
308354
/** Read the bearer token fresh from the environment on every call. */
309355
private bearerToken(): string | undefined {
310356
const tok = process.env["INSTANODE_TOKEN"];
@@ -314,7 +360,7 @@ export class InstantClient {
314360
private headers(): Record<string, string> {
315361
const h: Record<string, string> = {
316362
"Content-Type": "application/json",
317-
"User-Agent": "instanode-mcp/0.10.0",
363+
"User-Agent": "instanode-mcp/0.10.1",
318364
};
319365
const tok = this.bearerToken();
320366
if (tok) {
@@ -329,7 +375,7 @@ export class InstantClient {
329375
*/
330376
private authHeaders(): Record<string, string> {
331377
const h: Record<string, string> = {
332-
"User-Agent": "instanode-mcp/0.10.0",
378+
"User-Agent": "instanode-mcp/0.10.1",
333379
};
334380
const tok = this.bearerToken();
335381
if (tok) {
@@ -384,9 +430,18 @@ export class InstantClient {
384430
error?: string;
385431
message?: string;
386432
upgrade_url?: string;
433+
agent_action?: string;
434+
claim_url?: string;
387435
};
388436
const message = err.message ?? "upstream error";
389-
throw new ApiError(resp.status, message, err.error, err.upgrade_url);
437+
throw new ApiError(
438+
resp.status,
439+
message,
440+
err.error,
441+
err.upgrade_url,
442+
err.agent_action,
443+
err.claim_url
444+
);
390445
}
391446

392447
return data as T;
@@ -441,9 +496,18 @@ export class InstantClient {
441496
error?: string;
442497
message?: string;
443498
upgrade_url?: string;
499+
agent_action?: string;
500+
claim_url?: string;
444501
};
445502
const message = err.message ?? "upstream error";
446-
throw new ApiError(resp.status, message, err.error, err.upgrade_url);
503+
throw new ApiError(
504+
resp.status,
505+
message,
506+
err.error,
507+
err.upgrade_url,
508+
err.agent_action,
509+
err.claim_url
510+
);
447511
}
448512

449513
return data as T;
@@ -479,38 +543,87 @@ export class InstantClient {
479543
return this.request<WebhookProvisionResult>("POST", "/webhook/new", { name });
480544
}
481545

482-
/** GET /api/me/resources — list resources claimed by the authenticated caller. Requires bearer. */
546+
/**
547+
* GET /api/v1/resources — list resources for the authenticated team.
548+
*
549+
* The canonical route is `/api/v1/resources` (the previous `/api/me/resources`
550+
* path was never registered — every call 404'd). The live API returns
551+
* `{ ok, items, total }`; this helper unwraps to the raw items array so the
552+
* tool can iterate naturally.
553+
*/
483554
async listResources(): Promise<Resource[]> {
484-
return this.request<Resource[]>("GET", "/api/me/resources", undefined, {
485-
requireAuth: true,
486-
});
555+
const wrapped = await this.request<{ ok: boolean; items: Resource[]; total: number }>(
556+
"GET",
557+
"/api/v1/resources",
558+
undefined,
559+
{ requireAuth: true }
560+
);
561+
return wrapped.items ?? [];
487562
}
488563

489-
/** POST /api/me/claim — attach an anonymous token to the authenticated account. */
490-
async claimToken(token: string): Promise<ClaimResult> {
564+
/**
565+
* POST /claim — convert an anonymous onboarding JWT into a claimed team.
566+
*
567+
* Note: `/claim` requires {jwt, email} — it's the same flow the dashboard
568+
* uses. There is no programmatic "claim a token to an existing team" route;
569+
* the canonical claim primitive is identity-bound. Pass the upgrade_jwt
570+
* returned by any anonymous provisioning response.
571+
*/
572+
async claimToken(jwt: string, email: string): Promise<ClaimResult> {
491573
return this.request<ClaimResult>(
492574
"POST",
493-
"/api/me/claim",
494-
{ token },
495-
{ requireAuth: true }
575+
"/claim",
576+
{ jwt, email },
577+
{ requireAuth: false }
496578
);
497579
}
498580

499-
/** DELETE /api/me/resources/{token} — paid-only hard-delete. */
581+
/**
582+
* DELETE /api/v1/resources/{token} — soft-delete a resource (paid tier only).
583+
*
584+
* The path parameter is the resource's UUID token (the same value emitted
585+
* as `token` by every create_* response). Free-tier and anonymous resources
586+
* auto-expire and cannot be deleted manually — the API surfaces the upgrade
587+
* URL in the 403 envelope.
588+
*/
500589
async deleteResource(token: string): Promise<DeleteResult> {
501590
return this.request<DeleteResult>(
502591
"DELETE",
503-
`/api/me/resources/${encodeURIComponent(token)}`,
592+
`/api/v1/resources/${encodeURIComponent(token)}`,
504593
undefined,
505594
{ requireAuth: true }
506595
);
507596
}
508597

509-
/** GET /api/me/token — mint a fresh bearer JWT. Requires an existing bearer or session cookie. */
510-
async getApiToken(): Promise<ApiTokenResult> {
511-
return this.request<ApiTokenResult>("GET", "/api/me/token", undefined, {
512-
requireAuth: true,
513-
});
598+
/**
599+
* POST /api/v1/auth/api-keys — mint a fresh bearer key for the authenticated team.
600+
*
601+
* Requires an existing bearer (you have to be signed in to mint another key).
602+
* The API returns the plaintext key exactly once in the `key` field — it is
603+
* never recoverable after this response. Default name "instanode-mcp" so the
604+
* dashboard's key list shows where the key came from.
605+
*/
606+
async getApiToken(name?: string): Promise<ApiTokenResult> {
607+
const raw = await this.request<{
608+
ok: boolean;
609+
id: string;
610+
name: string;
611+
key: string;
612+
note?: string;
613+
created_at: string;
614+
}>(
615+
"POST",
616+
"/api/v1/auth/api-keys",
617+
{ name: name && name.length > 0 ? name : "instanode-mcp", scopes: ["read", "write"] },
618+
{ requireAuth: true }
619+
);
620+
return {
621+
ok: raw.ok,
622+
token: raw.key,
623+
// Mint returns no explicit expiry — keys are revocation-based, not
624+
// time-bound. Surface a sentinel (0) so the tool description can adapt.
625+
expires_in: 0,
626+
};
514627
}
515628

516629
/**

0 commit comments

Comments
 (0)