Skip to content

Commit 9138ff0

Browse files
fix(mcp): claim_token renders real ClaimResponse + sends canonical token (#35)
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) <noreply@anthropic.com>
1 parent 4423a61 commit 9138ff0

6 files changed

Lines changed: 155 additions & 52 deletions

File tree

src/client.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -402,14 +402,28 @@ export interface CreateStackParams {
402402
env?: string;
403403
}
404404

405+
/**
406+
* Response shape from POST /claim.
407+
*
408+
* Mirrors the live api's `ClaimResponse` schema (see
409+
* api/openapi.snapshot.json ClaimResponse): `{ok, team_id, user_id,
410+
* session_token?, message?}`. The legacy 201 direct-claim shape
411+
* (`{id, token, resource_type, tier, status}`) was retired 2026-05-20 — every
412+
* successful claim now goes through the magic-link flow and returns the
413+
* magic-link envelope (see mcp/test/mock-api.ts:427-429). The previous MCP
414+
* `ClaimResult` carried the retired fields verbatim, so `claim_token` rendered
415+
* `(see list_resources)` placeholders for every line instead of telling the
416+
* agent which team/user the claim landed against and (when present) handing
417+
* back the 24h `session_token` the agent can use to call other tools
418+
* immediately without a dashboard round-trip.
419+
*/
405420
export interface ClaimResult {
406421
ok: boolean;
407-
id: string;
408-
token: string;
409-
resource_type: string;
410-
name?: string;
411-
tier: string;
412-
status: string;
422+
team_id?: string;
423+
user_id?: string;
424+
/** 24h session JWT — returned by the legacy direct-claim path only. */
425+
session_token?: string;
426+
message?: string;
413427
}
414428

415429
export interface ApiTokenResult {
@@ -836,16 +850,22 @@ export class InstantClient {
836850
/**
837851
* POST /claim — convert an anonymous onboarding JWT into a claimed team.
838852
*
839-
* Note: `/claim` requires {jwt, email} — it's the same flow the dashboard
840-
* uses. There is no programmatic "claim a token to an existing team" route;
841-
* the canonical claim primitive is identity-bound. Pass the upgrade_jwt
842-
* returned by any anonymous provisioning response.
853+
* Wire field name (B5-P1, 2026-05-20): the canonical request field is
854+
* `token`. The api still accepts the legacy `jwt` alias for backward
855+
* compatibility (dashboard, sdk-go, curl recipes) and prefers `token` when
856+
* both are present — but the openapi ClaimRequest schema marks `jwt` as
857+
* `deprecated: true`. New callers send `token`. We were previously the only
858+
* surface still sending `jwt`-as-canonical, contributing to the three-name
859+
* drift (jwt / token / INSTANODE_TOKEN) the api ClaimRequest doc calls out.
860+
*
861+
* Response: `{ok, team_id, user_id, session_token?, message?}` —
862+
* NOT the retired 201 direct-claim shape. See the ClaimResult interface.
843863
*/
844864
async claimToken(jwt: string, email: string): Promise<ClaimResult> {
845865
return this.request<ClaimResult>(
846866
"POST",
847867
"/claim",
848-
{ jwt, email },
868+
{ token: jwt, email },
849869
{ requireAuth: false }
850870
);
851871
}

src/index.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -777,17 +777,38 @@ a URL the user can click in their browser.`,
777777
// Not a URL — common case, leave jwt as-is.
778778
}
779779
const result = await client.claimToken(jwt, email);
780-
const lines = [
781-
`JWT claimed.`,
782-
`Resource type: ${result.resource_type ?? "(see list_resources)"}`,
783-
`Token: ${result.token ?? "(see list_resources)"}`,
784-
`Tier: ${result.tier ?? "(see list_resources)"}`,
785-
`Status: ${result.status ?? "(see list_resources)"}`,
786-
``,
787-
`Mint a bearer token via 'get_api_token' (after signing in once at the dashboard)`,
788-
`to use the authenticated MCP tools (list_resources, delete_resource, etc.).`,
789-
];
790-
if (result.name) lines.push(`Name: ${result.name}`);
780+
// Live API contract (api/openapi.snapshot.json ClaimResponse, 2026-05-20):
781+
// a successful claim returns {ok, team_id, user_id, session_token?,
782+
// message?}. The previous renderer expected the retired direct-claim
783+
// shape ({resource_type, token, tier, status, name}) and so showed
784+
// "(see list_resources)" placeholders on every line of every successful
785+
// claim — the agent learned NOTHING about what just happened and never
786+
// surfaced the session_token the api hands back for immediate use.
787+
const lines = [`Claim accepted for ${email}.`];
788+
if (result.message) lines.push(`Message: ${result.message}`);
789+
if (result.team_id) lines.push(`Team ID: ${result.team_id}`);
790+
if (result.user_id) lines.push(`User ID: ${result.user_id}`);
791+
if (result.session_token) {
792+
lines.push(
793+
``,
794+
`Session token (24h, ready to use):`,
795+
` ${result.session_token}`,
796+
``,
797+
`Pass this as INSTANODE_TOKEN in your MCP env to call authenticated tools`,
798+
`(list_resources, delete_resource, get_api_token, etc.) immediately. To rotate`,
799+
`to a long-lived API key, sign in at https://instanode.dev/dashboard and call`,
800+
`get_api_token (PATs cannot mint other PATs — see get_api_token docs).`
801+
);
802+
} else {
803+
lines.push(
804+
``,
805+
`Magic link sent to ${email}. The user must click the link in their inbox to`,
806+
`finish signing in. After that, mint an API key in the dashboard (Settings → API`,
807+
`Keys) and set it as INSTANODE_TOKEN to use authenticated MCP tools.`,
808+
``,
809+
`Use list_resources (once authenticated) to confirm the resources transferred.`
810+
);
811+
}
791812
return textResult(lines.join("\n"));
792813
} catch (err) {
793814
return textResult(formatError(err));

test/client-unit.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,13 @@ describe("InstantClient — unit-level branch coverage", () => {
604604
assert.deepEqual(items, []);
605605
});
606606

607-
it("claimToken → POSTs /claim with {jwt, email} (no auth required)", async () => {
607+
it("claimToken → POSTs /claim with canonical {token, email} (no auth required)", async () => {
608+
// B5-P1 contract (api/openapi.snapshot.json ClaimRequest, 2026-05-20):
609+
// the canonical wire field is `token`; the legacy `jwt` alias is marked
610+
// deprecated in the openapi schema. The MCP previously sent `jwt` —
611+
// still accepted server-side, but it was the last named drift source
612+
// (dashboard + sdk-go migrated, MCP lagged). This test pins the wire
613+
// body to the new shape so any future revert is caught.
608614
let body: any = null;
609615
let url = "";
610616
stubFetch((input: any, init?: any) => {
@@ -613,11 +619,10 @@ describe("InstantClient — unit-level branch coverage", () => {
613619
return new Response(
614620
JSON.stringify({
615621
ok: true,
616-
id: "i",
617-
token: "t",
618-
resource_type: "postgres",
619-
tier: "free",
620-
status: "active",
622+
team_id: "1f2e3d4c-5b6a-7980-91a2-b3c4d5e6f708",
623+
user_id: "01020304-0506-0708-0900-010203040506",
624+
session_token: "session.jwt.value",
625+
message: "Magic link sent to email",
621626
}),
622627
{ status: 200, headers: { "content-type": "application/json" } }
623628
);
@@ -626,8 +631,9 @@ describe("InstantClient — unit-level branch coverage", () => {
626631
const c = new InstantClient({ baseURL: "https://example.test" });
627632
const r = await c.claimToken("the-jwt", "u@example.com");
628633
assert.match(url, /\/claim$/);
629-
assert.deepEqual(body, { jwt: "the-jwt", email: "u@example.com" });
630-
assert.equal(r.tier, "free");
634+
assert.deepEqual(body, { token: "the-jwt", email: "u@example.com" });
635+
assert.equal(r.session_token, "session.jwt.value");
636+
assert.equal(r.team_id, "1f2e3d4c-5b6a-7980-91a2-b3c4d5e6f708");
631637
});
632638

633639
it("listDeployments → returns the {ok,items,total} envelope verbatim", async () => {

test/integration.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,22 @@ describe("instanode-mcp integration suite", () => {
565565
name: "claim_token",
566566
arguments: { upgrade_jwt: "ey.valid.jwt", email: "dev@example.com" },
567567
});
568-
assert.ok(resultText(res).includes("JWT claimed."), `expected a successful claim:\n${resultText(res)}`);
568+
const text = resultText(res);
569+
assert.ok(
570+
text.includes("Claim accepted for dev@example.com."),
571+
`expected a successful claim:\n${text}`
572+
);
573+
// Live ClaimResponse shape (api/openapi.snapshot.json) carries
574+
// team_id + user_id; the mock returns a session_token too, which
575+
// the renderer must surface so the agent can use it immediately
576+
// as INSTANODE_TOKEN. Regression guard against the "(see
577+
// list_resources)" placeholder text the old renderer printed.
578+
assert.ok(text.includes("Team ID:"), `expected Team ID line:\n${text}`);
579+
assert.ok(text.includes("Session token"), `expected session token block:\n${text}`);
580+
assert.ok(
581+
!text.includes("(see list_resources)"),
582+
`must not regress to retired placeholder text:\n${text}`
583+
);
569584
} finally {
570585
await close();
571586
}

test/mock-api.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,24 +427,33 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P
427427
// Per openapi.json: returns 200 ClaimResponse {ok, team_id, user_id, session_token,
428428
// message} — the magic-link flow. The legacy 201 direct-claim shape (the old
429429
// {id, token, resource_type, tier, status} body) has been retired in the live API.
430+
// Canonical request field is `token` (B5-P1, 2026-05-20); the legacy `jwt` alias
431+
// is still accepted server-side for backward compatibility — `token` wins on
432+
// collision. Mirror that here so we can verify MCP sends the canonical name.
430433
if (method === "POST" && path === "/claim") {
431434
const raw = await readBody(req);
432-
let parsed: { jwt?: unknown; email?: unknown };
435+
let parsed: { token?: unknown; jwt?: unknown; email?: unknown };
433436
try {
434437
parsed = raw.length > 0 ? JSON.parse(raw.toString("utf8")) : {};
435438
} catch {
436439
sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "malformed JSON body" }));
437440
return;
438441
}
439-
if (typeof parsed.jwt !== "string" || parsed.jwt.length === 0) {
440-
sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "jwt is required" }));
442+
const tokField =
443+
typeof parsed.token === "string" && parsed.token.length > 0
444+
? parsed.token
445+
: typeof parsed.jwt === "string" && parsed.jwt.length > 0
446+
? parsed.jwt
447+
: "";
448+
if (tokField.length === 0) {
449+
sendJSON(res, 400, errorEnvelope({ error: "missing_token", message: "token is required" }));
441450
return;
442451
}
443452
if (typeof parsed.email !== "string" || parsed.email.length === 0) {
444453
sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "email is required" }));
445454
return;
446455
}
447-
if (parsed.jwt === "invalid.jwt") {
456+
if (tokField === "invalid.jwt") {
448457
sendJSON(
449458
res,
450459
409,

test/tools-unit.test.ts

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,13 @@ describe("tool handlers — claim helpers (pure, no network)", () => {
328328
const realFetch = globalThis.fetch;
329329
(globalThis as any).fetch = (async () =>
330330
new Response(
331-
JSON.stringify({ ok: true, resource_type: "x", token: "t", tier: "free", status: "active" }),
331+
JSON.stringify({
332+
ok: true,
333+
team_id: "t-1",
334+
user_id: "u-1",
335+
session_token: "sess.jwt",
336+
message: "Magic link sent",
337+
}),
332338
{ status: 200, headers: { "content-type": "application/json" } }
333339
)) as typeof globalThis.fetch;
334340
try {
@@ -337,19 +343,28 @@ describe("tool handlers — claim helpers (pure, no network)", () => {
337343
email: "u@example.com",
338344
});
339345
const text = flat(res);
340-
assert.match(text, /JWT claimed\./);
346+
assert.match(text, /Claim accepted for u@example\.com\./);
341347
} finally {
342348
(globalThis as any).fetch = realFetch;
343349
}
344350
});
345351

346-
it("claim_token → raw JWT + email → JWT claimed; mock returns magic-link shape", async () => {
352+
it("claim_token → raw JWT + email → renders team_id/user_id/session_token from live ClaimResponse shape", async () => {
347353
const res = await handlerFor("claim_token")({
348354
upgrade_jwt: "ey.valid.jwt",
349355
email: "u@example.com",
350356
});
351357
const text = flat(res);
352-
assert.match(text, /JWT claimed\./);
358+
assert.match(text, /Claim accepted for u@example\.com\./);
359+
assert.match(text, /Team ID:/);
360+
assert.match(text, /User ID:/);
361+
// Mock-api returns a session_token, so the session-token block must
362+
// render and the agent must be told how to use it as INSTANODE_TOKEN.
363+
assert.match(text, /Session token \(24h, ready to use\):/);
364+
assert.match(text, /INSTANODE_TOKEN/);
365+
// Guard against the placeholder regression from before this fix —
366+
// the previous renderer printed "(see list_resources)" on every line.
367+
assert.doesNotMatch(text, /\(see list_resources\)/);
353368
});
354369

355370
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)", () => {
358373
email: "u@example.com",
359374
});
360375
const text = flat(res);
361-
assert.match(text, /JWT claimed\./);
376+
assert.match(text, /Claim accepted for u@example\.com\./);
362377
});
363378

364379
it("claim_token → already-claimed conflict surfaces the formatError envelope", async () => {
@@ -1092,7 +1107,11 @@ describe("tool handlers — optional-field absent branches", () => {
10921107
assert.doesNotMatch(text, /Message:/);
10931108
});
10941109

1095-
it("claim_token → result missing optional fields: fallbacks to '(see list_resources)' chain", async () => {
1110+
it("claim_token → bare {ok:true} body: renders the magic-link branch (no session_token, no Team ID lines)", async () => {
1111+
// The api's ClaimResponse shape post-2026-05-20 always carries team_id +
1112+
// user_id + message — but a defensive minimal {ok:true} body still has to
1113+
// render without throwing. We must NOT regress to the old placeholder
1114+
// "(see list_resources)" lines this fix removed.
10961115
(globalThis as any).fetch = (async () =>
10971116
new Response(JSON.stringify({ ok: true }), {
10981117
status: 200,
@@ -1103,20 +1122,28 @@ describe("tool handlers — optional-field absent branches", () => {
11031122
email: "u@example.com",
11041123
});
11051124
const text = flat(res);
1106-
assert.match(text, /JWT claimed\./);
1107-
assert.match(text, /\(see list_resources\)/);
1108-
});
1109-
1110-
it("claim_token → result with `name` field renders 'Name: ...' line", async () => {
1125+
assert.match(text, /Claim accepted for u@example\.com\./);
1126+
// No team_id / user_id / session_token / message in this body → none rendered.
1127+
assert.doesNotMatch(text, /Team ID:/);
1128+
assert.doesNotMatch(text, /User ID:/);
1129+
assert.doesNotMatch(text, /Session token/);
1130+
// Falls into the magic-link branch (no session_token returned).
1131+
assert.match(text, /Magic link sent to u@example\.com/);
1132+
// Critical regression guard: never re-introduce the retired placeholder
1133+
// text the old renderer printed for every missing field.
1134+
assert.doesNotMatch(text, /\(see list_resources\)/);
1135+
});
1136+
1137+
it("claim_token → ClaimResponse with team_id + user_id + message but no session_token: renders all four lines + magic-link branch", async () => {
1138+
// Live magic-link envelope shape — what mock-api returns by default and
1139+
// what api/internal/handlers/onboarding.go currently emits.
11111140
(globalThis as any).fetch = (async () =>
11121141
new Response(
11131142
JSON.stringify({
11141143
ok: true,
1115-
resource_type: "postgres",
1116-
token: "t",
1117-
tier: "free",
1118-
status: "active",
1119-
name: "my-claimed-db",
1144+
team_id: "team-uuid",
1145+
user_id: "user-uuid",
1146+
message: "Magic link sent to email",
11201147
}),
11211148
{ status: 200, headers: { "content-type": "application/json" } }
11221149
)) as typeof globalThis.fetch;
@@ -1125,7 +1152,12 @@ describe("tool handlers — optional-field absent branches", () => {
11251152
email: "u@example.com",
11261153
});
11271154
const text = flat(res);
1128-
assert.match(text, /Name: my-claimed-db/);
1155+
assert.match(text, /Team ID: team-uuid/);
1156+
assert.match(text, /User ID: user-uuid/);
1157+
assert.match(text, /Message: Magic link sent to email/);
1158+
// No session_token → magic-link guidance, NOT the immediate-use block.
1159+
assert.doesNotMatch(text, /Session token/);
1160+
assert.match(text, /Magic link sent to u@example\.com/);
11291161
});
11301162

11311163
it("create_deploy → response url is empty string: shows 'URL: (pending)'", async () => {

0 commit comments

Comments
 (0)