Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@
* Response shape from POST /deploy/:id/redeploy.
*
* The live API documents this as a bare 202 with NO body (see openapi.json),
* not a deployment record. The previous client mis-typed it as DeployGetResult

Check warning on line 263 in src/client.ts

View workflow job for this annotation

GitHub Actions / typos

"mis" should be "miss" or "mist".
* and the index.ts handler dereferenced `result.item.app_id`, blowing up
* with "Cannot read properties of undefined (reading 'app_id')" on every
* real call. BugBash B16 F1 (regression of task #170): use a body-less type
Expand Down Expand Up @@ -402,14 +402,28 @@
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 {
Expand Down Expand Up @@ -836,16 +850,22 @@
/**
* 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<ClaimResult> {
return this.request<ClaimResult>(
"POST",
"/claim",
{ jwt, email },
{ token: jwt, email },
{ requireAuth: false }
);
}
Expand Down
43 changes: 32 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
22 changes: 14 additions & 8 deletions test/client-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,13 @@
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) => {
Expand All @@ -613,11 +619,10 @@
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" } }
);
Expand All @@ -626,8 +631,9 @@
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 () => {
Expand Down Expand Up @@ -685,7 +691,7 @@
assert.match(url, /\/api\/v1\/deployments\/app%2Fwith%20slash$/);
});

it("deleteDeployment → DELETEs /deploy/:id and bubbles the body shape", async () => {

Check warning on line 694 in test/client-unit.test.ts

View workflow job for this annotation

GitHub Actions / typos

"DELET" should be "DELETE".
let method = "";
let url = "";
stubFetch((input: any, init?: any) => {
Expand Down
17 changes: 16 additions & 1 deletion test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
17 changes: 13 additions & 4 deletions test/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 49 additions & 17 deletions test/tools-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,13 @@
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 {
Expand All @@ -337,19 +343,28 @@
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 () => {
Expand All @@ -358,7 +373,7 @@
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 () => {
Expand Down Expand Up @@ -1092,7 +1107,11 @@
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,
Expand All @@ -1103,20 +1122,28 @@
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;
Expand All @@ -1125,7 +1152,12 @@
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 () => {
Expand Down Expand Up @@ -1274,7 +1306,7 @@
// (Already covered in index-unit, but exercising via the toolHandler
// path here keeps the branch lit on dist-test/src/index.js too.)
process.env["INSTANODE_TOKEN"] = VALID_TOKEN;
// create_deploy with an unparseable base64 throws (Buffer.from with

Check warning on line 1309 in test/tools-unit.test.ts

View workflow job for this annotation

GitHub Actions / typos

"unparseable" should be "unparsable".
// valid base64 doesn't throw, but tarball_base64 being a tiny string
// is fine — instead, stub fetch to throw a non-network error in a way
// that bubbles out as Error not ApiError. That's hard since the client
Expand Down
Loading