From c0ebd97fb95da811842b9d42cdc44e98873a678f Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 30 May 2026 22:42:22 +0530 Subject: [PATCH] fix(mcp): claim_token renders real ClaimResponse + sends canonical `token` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim_token tool was rendering fields from the retired 201 direct-claim shape ({resource_type, token, tier, status, name}) that the api stopped returning on 2026-05-20. Every successful claim printed "(see list_resources)" four times — the agent learned NOTHING about what just happened and never surfaced the 24h session_token the api hands back for immediate use. Now mirrors the live ClaimResponse shape (api/openapi.snapshot.json): {ok, team_id, user_id, session_token?, message?}. Branches into a "session token ready to use" block (legacy direct-claim path) vs a "magic link sent, check inbox" block depending on whether session_token came back, so the agent has actionable next-step copy in both cases. Wire body also flips from {jwt, email} → {token, email}. `jwt` is marked deprecated in the openapi ClaimRequest schema; the api still accepts it (`token` wins on collision) but the MCP was the last drift source the ClaimRequest doc explicitly called out (dashboard + sdk-go already moved). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/client.ts | 42 ++++++++++++++++++------- src/index.ts | 43 +++++++++++++++++++------- test/client-unit.test.ts | 22 +++++++++----- test/integration.test.ts | 17 ++++++++++- test/mock-api.ts | 17 ++++++++--- test/tools-unit.test.ts | 66 +++++++++++++++++++++++++++++----------- 6 files changed, 155 insertions(+), 52 deletions(-) diff --git a/src/client.ts b/src/client.ts index 9b1f404..ee94744 100644 --- a/src/client.ts +++ b/src/client.ts @@ -402,14 +402,28 @@ export interface CreateStackParams { env?: string; } +/** + * Response shape from POST /claim. + * + * Mirrors the live api's `ClaimResponse` schema (see + * api/openapi.snapshot.json ClaimResponse): `{ok, team_id, user_id, + * session_token?, message?}`. The legacy 201 direct-claim shape + * (`{id, token, resource_type, tier, status}`) was retired 2026-05-20 — every + * successful claim now goes through the magic-link flow and returns the + * magic-link envelope (see mcp/test/mock-api.ts:427-429). The previous MCP + * `ClaimResult` carried the retired fields verbatim, so `claim_token` rendered + * `(see list_resources)` placeholders for every line instead of telling the + * agent which team/user the claim landed against and (when present) handing + * back the 24h `session_token` the agent can use to call other tools + * immediately without a dashboard round-trip. + */ export interface ClaimResult { ok: boolean; - id: string; - token: string; - resource_type: string; - name?: string; - tier: string; - status: string; + team_id?: string; + user_id?: string; + /** 24h session JWT — returned by the legacy direct-claim path only. */ + session_token?: string; + message?: string; } export interface ApiTokenResult { @@ -836,16 +850,22 @@ export class InstantClient { /** * POST /claim — convert an anonymous onboarding JWT into a claimed team. * - * Note: `/claim` requires {jwt, email} — it's the same flow the dashboard - * uses. There is no programmatic "claim a token to an existing team" route; - * the canonical claim primitive is identity-bound. Pass the upgrade_jwt - * returned by any anonymous provisioning response. + * Wire field name (B5-P1, 2026-05-20): the canonical request field is + * `token`. The api still accepts the legacy `jwt` alias for backward + * compatibility (dashboard, sdk-go, curl recipes) and prefers `token` when + * both are present — but the openapi ClaimRequest schema marks `jwt` as + * `deprecated: true`. New callers send `token`. We were previously the only + * surface still sending `jwt`-as-canonical, contributing to the three-name + * drift (jwt / token / INSTANODE_TOKEN) the api ClaimRequest doc calls out. + * + * Response: `{ok, team_id, user_id, session_token?, message?}` — + * NOT the retired 201 direct-claim shape. See the ClaimResult interface. */ async claimToken(jwt: string, email: string): Promise { return this.request( "POST", "/claim", - { jwt, email }, + { token: jwt, email }, { requireAuth: false } ); } diff --git a/src/index.ts b/src/index.ts index 1e6b9da..da65de2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -777,17 +777,38 @@ a URL the user can click in their browser.`, // Not a URL — common case, leave jwt as-is. } const result = await client.claimToken(jwt, email); - const lines = [ - `JWT claimed.`, - `Resource type: ${result.resource_type ?? "(see list_resources)"}`, - `Token: ${result.token ?? "(see list_resources)"}`, - `Tier: ${result.tier ?? "(see list_resources)"}`, - `Status: ${result.status ?? "(see list_resources)"}`, - ``, - `Mint a bearer token via 'get_api_token' (after signing in once at the dashboard)`, - `to use the authenticated MCP tools (list_resources, delete_resource, etc.).`, - ]; - if (result.name) lines.push(`Name: ${result.name}`); + // Live API contract (api/openapi.snapshot.json ClaimResponse, 2026-05-20): + // a successful claim returns {ok, team_id, user_id, session_token?, + // message?}. The previous renderer expected the retired direct-claim + // shape ({resource_type, token, tier, status, name}) and so showed + // "(see list_resources)" placeholders on every line of every successful + // claim — the agent learned NOTHING about what just happened and never + // surfaced the session_token the api hands back for immediate use. + const lines = [`Claim accepted for ${email}.`]; + if (result.message) lines.push(`Message: ${result.message}`); + if (result.team_id) lines.push(`Team ID: ${result.team_id}`); + if (result.user_id) lines.push(`User ID: ${result.user_id}`); + if (result.session_token) { + lines.push( + ``, + `Session token (24h, ready to use):`, + ` ${result.session_token}`, + ``, + `Pass this as INSTANODE_TOKEN in your MCP env to call authenticated tools`, + `(list_resources, delete_resource, get_api_token, etc.) immediately. To rotate`, + `to a long-lived API key, sign in at https://instanode.dev/dashboard and call`, + `get_api_token (PATs cannot mint other PATs — see get_api_token docs).` + ); + } else { + lines.push( + ``, + `Magic link sent to ${email}. The user must click the link in their inbox to`, + `finish signing in. After that, mint an API key in the dashboard (Settings → API`, + `Keys) and set it as INSTANODE_TOKEN to use authenticated MCP tools.`, + ``, + `Use list_resources (once authenticated) to confirm the resources transferred.` + ); + } return textResult(lines.join("\n")); } catch (err) { return textResult(formatError(err)); diff --git a/test/client-unit.test.ts b/test/client-unit.test.ts index 866058a..981e8ac 100644 --- a/test/client-unit.test.ts +++ b/test/client-unit.test.ts @@ -604,7 +604,13 @@ describe("InstantClient — unit-level branch coverage", () => { assert.deepEqual(items, []); }); - it("claimToken → POSTs /claim with {jwt, email} (no auth required)", async () => { + it("claimToken → POSTs /claim with canonical {token, email} (no auth required)", async () => { + // B5-P1 contract (api/openapi.snapshot.json ClaimRequest, 2026-05-20): + // the canonical wire field is `token`; the legacy `jwt` alias is marked + // deprecated in the openapi schema. The MCP previously sent `jwt` — + // still accepted server-side, but it was the last named drift source + // (dashboard + sdk-go migrated, MCP lagged). This test pins the wire + // body to the new shape so any future revert is caught. let body: any = null; let url = ""; stubFetch((input: any, init?: any) => { @@ -613,11 +619,10 @@ describe("InstantClient — unit-level branch coverage", () => { return new Response( JSON.stringify({ ok: true, - id: "i", - token: "t", - resource_type: "postgres", - tier: "free", - status: "active", + team_id: "1f2e3d4c-5b6a-7980-91a2-b3c4d5e6f708", + user_id: "01020304-0506-0708-0900-010203040506", + session_token: "session.jwt.value", + message: "Magic link sent to email", }), { status: 200, headers: { "content-type": "application/json" } } ); @@ -626,8 +631,9 @@ describe("InstantClient — unit-level branch coverage", () => { const c = new InstantClient({ baseURL: "https://example.test" }); const r = await c.claimToken("the-jwt", "u@example.com"); assert.match(url, /\/claim$/); - assert.deepEqual(body, { jwt: "the-jwt", email: "u@example.com" }); - assert.equal(r.tier, "free"); + assert.deepEqual(body, { token: "the-jwt", email: "u@example.com" }); + assert.equal(r.session_token, "session.jwt.value"); + assert.equal(r.team_id, "1f2e3d4c-5b6a-7980-91a2-b3c4d5e6f708"); }); it("listDeployments → returns the {ok,items,total} envelope verbatim", async () => { diff --git a/test/integration.test.ts b/test/integration.test.ts index f8f80a5..f0c3c35 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -565,7 +565,22 @@ describe("instanode-mcp integration suite", () => { name: "claim_token", arguments: { upgrade_jwt: "ey.valid.jwt", email: "dev@example.com" }, }); - assert.ok(resultText(res).includes("JWT claimed."), `expected a successful claim:\n${resultText(res)}`); + const text = resultText(res); + assert.ok( + text.includes("Claim accepted for dev@example.com."), + `expected a successful claim:\n${text}` + ); + // Live ClaimResponse shape (api/openapi.snapshot.json) carries + // team_id + user_id; the mock returns a session_token too, which + // the renderer must surface so the agent can use it immediately + // as INSTANODE_TOKEN. Regression guard against the "(see + // list_resources)" placeholder text the old renderer printed. + assert.ok(text.includes("Team ID:"), `expected Team ID line:\n${text}`); + assert.ok(text.includes("Session token"), `expected session token block:\n${text}`); + assert.ok( + !text.includes("(see list_resources)"), + `must not regress to retired placeholder text:\n${text}` + ); } finally { await close(); } diff --git a/test/mock-api.ts b/test/mock-api.ts index 041567d..18388a9 100644 --- a/test/mock-api.ts +++ b/test/mock-api.ts @@ -427,24 +427,33 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P // Per openapi.json: returns 200 ClaimResponse {ok, team_id, user_id, session_token, // message} — the magic-link flow. The legacy 201 direct-claim shape (the old // {id, token, resource_type, tier, status} body) has been retired in the live API. + // Canonical request field is `token` (B5-P1, 2026-05-20); the legacy `jwt` alias + // is still accepted server-side for backward compatibility — `token` wins on + // collision. Mirror that here so we can verify MCP sends the canonical name. if (method === "POST" && path === "/claim") { const raw = await readBody(req); - let parsed: { jwt?: unknown; email?: unknown }; + let parsed: { token?: unknown; jwt?: unknown; email?: unknown }; try { parsed = raw.length > 0 ? JSON.parse(raw.toString("utf8")) : {}; } catch { sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "malformed JSON body" })); return; } - if (typeof parsed.jwt !== "string" || parsed.jwt.length === 0) { - sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "jwt is required" })); + const tokField = + typeof parsed.token === "string" && parsed.token.length > 0 + ? parsed.token + : typeof parsed.jwt === "string" && parsed.jwt.length > 0 + ? parsed.jwt + : ""; + if (tokField.length === 0) { + sendJSON(res, 400, errorEnvelope({ error: "missing_token", message: "token is required" })); return; } if (typeof parsed.email !== "string" || parsed.email.length === 0) { sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "email is required" })); return; } - if (parsed.jwt === "invalid.jwt") { + if (tokField === "invalid.jwt") { sendJSON( res, 409, diff --git a/test/tools-unit.test.ts b/test/tools-unit.test.ts index fb4a102..0b5049f 100644 --- a/test/tools-unit.test.ts +++ b/test/tools-unit.test.ts @@ -328,7 +328,13 @@ describe("tool handlers — claim helpers (pure, no network)", () => { const realFetch = globalThis.fetch; (globalThis as any).fetch = (async () => new Response( - JSON.stringify({ ok: true, resource_type: "x", token: "t", tier: "free", status: "active" }), + JSON.stringify({ + ok: true, + team_id: "t-1", + user_id: "u-1", + session_token: "sess.jwt", + message: "Magic link sent", + }), { status: 200, headers: { "content-type": "application/json" } } )) as typeof globalThis.fetch; try { @@ -337,19 +343,28 @@ describe("tool handlers — claim helpers (pure, no network)", () => { email: "u@example.com", }); const text = flat(res); - assert.match(text, /JWT claimed\./); + assert.match(text, /Claim accepted for u@example\.com\./); } finally { (globalThis as any).fetch = realFetch; } }); - it("claim_token → raw JWT + email → JWT claimed; mock returns magic-link shape", async () => { + it("claim_token → raw JWT + email → renders team_id/user_id/session_token from live ClaimResponse shape", async () => { const res = await handlerFor("claim_token")({ upgrade_jwt: "ey.valid.jwt", email: "u@example.com", }); const text = flat(res); - assert.match(text, /JWT claimed\./); + assert.match(text, /Claim accepted for u@example\.com\./); + assert.match(text, /Team ID:/); + assert.match(text, /User ID:/); + // Mock-api returns a session_token, so the session-token block must + // render and the agent must be told how to use it as INSTANODE_TOKEN. + assert.match(text, /Session token \(24h, ready to use\):/); + assert.match(text, /INSTANODE_TOKEN/); + // Guard against the placeholder regression from before this fix — + // the previous renderer printed "(see list_resources)" on every line. + assert.doesNotMatch(text, /\(see list_resources\)/); }); it("claim_token → URL-form upgrade_jwt extracted via URL parse branch", async () => { @@ -358,7 +373,7 @@ describe("tool handlers — claim helpers (pure, no network)", () => { email: "u@example.com", }); const text = flat(res); - assert.match(text, /JWT claimed\./); + assert.match(text, /Claim accepted for u@example\.com\./); }); it("claim_token → already-claimed conflict surfaces the formatError envelope", async () => { @@ -1092,7 +1107,11 @@ describe("tool handlers — optional-field absent branches", () => { assert.doesNotMatch(text, /Message:/); }); - it("claim_token → result missing optional fields: fallbacks to '(see list_resources)' chain", async () => { + it("claim_token → bare {ok:true} body: renders the magic-link branch (no session_token, no Team ID lines)", async () => { + // The api's ClaimResponse shape post-2026-05-20 always carries team_id + + // user_id + message — but a defensive minimal {ok:true} body still has to + // render without throwing. We must NOT regress to the old placeholder + // "(see list_resources)" lines this fix removed. (globalThis as any).fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200, @@ -1103,20 +1122,28 @@ describe("tool handlers — optional-field absent branches", () => { email: "u@example.com", }); const text = flat(res); - assert.match(text, /JWT claimed\./); - assert.match(text, /\(see list_resources\)/); - }); - - it("claim_token → result with `name` field renders 'Name: ...' line", async () => { + assert.match(text, /Claim accepted for u@example\.com\./); + // No team_id / user_id / session_token / message in this body → none rendered. + assert.doesNotMatch(text, /Team ID:/); + assert.doesNotMatch(text, /User ID:/); + assert.doesNotMatch(text, /Session token/); + // Falls into the magic-link branch (no session_token returned). + assert.match(text, /Magic link sent to u@example\.com/); + // Critical regression guard: never re-introduce the retired placeholder + // text the old renderer printed for every missing field. + assert.doesNotMatch(text, /\(see list_resources\)/); + }); + + it("claim_token → ClaimResponse with team_id + user_id + message but no session_token: renders all four lines + magic-link branch", async () => { + // Live magic-link envelope shape — what mock-api returns by default and + // what api/internal/handlers/onboarding.go currently emits. (globalThis as any).fetch = (async () => new Response( JSON.stringify({ ok: true, - resource_type: "postgres", - token: "t", - tier: "free", - status: "active", - name: "my-claimed-db", + team_id: "team-uuid", + user_id: "user-uuid", + message: "Magic link sent to email", }), { status: 200, headers: { "content-type": "application/json" } } )) as typeof globalThis.fetch; @@ -1125,7 +1152,12 @@ describe("tool handlers — optional-field absent branches", () => { email: "u@example.com", }); const text = flat(res); - assert.match(text, /Name: my-claimed-db/); + assert.match(text, /Team ID: team-uuid/); + assert.match(text, /User ID: user-uuid/); + assert.match(text, /Message: Magic link sent to email/); + // No session_token → magic-link guidance, NOT the immediate-use block. + assert.doesNotMatch(text, /Session token/); + assert.match(text, /Magic link sent to u@example\.com/); }); it("create_deploy → response url is empty string: shows 'URL: (pending)'", async () => {