diff --git a/package-lock.json b/package-lock.json index 9509f24..5bf539f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instanode-mcp", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instanode-mcp", - "version": "0.10.0", + "version": "0.10.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2" diff --git a/package.json b/package.json index d3f2ed2..755121e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "instanode-mcp", - "version": "0.10.0", + "version": "0.10.1", "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.", "keywords": [ "mcp", diff --git a/server.json b/server.json index 8c1deaf..7d2ad99 100644 --- a/server.json +++ b/server.json @@ -6,27 +6,27 @@ "url": "https://github.com/InstaNode-dev/mcp", "source": "github" }, - "version": "0.9.0", + "version": "0.10.1", "websiteUrl": "https://instanode.dev", "packages": [ { "registryType": "npm", "identifier": "instanode-mcp", - "version": "0.9.0", + "version": "0.10.1", "transport": { "type": "stdio" }, "environmentVariables": [ { "name": "INSTANODE_TOKEN", - "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.", + "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.", "isRequired": false, "isSecret": true, "format": "string" }, { - "name": "INSTANODE_API_BASE", - "description": "Override the API base URL. Defaults to https://api.instanode.dev. Useful for self-hosted deployments.", + "name": "INSTANODE_API_URL", + "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.)", "isRequired": false, "isSecret": false, "format": "string", diff --git a/smithery.yaml b/smithery.yaml index 9ec6979..ee0e044 100644 --- a/smithery.yaml +++ b/smithery.yaml @@ -13,7 +13,7 @@ startCommand: Lifts the free-tier 5-per-subnet-per-day provisioning cap and auto- links new resources to your account. Leave empty to use the free tier anonymously (10 MB / 2 connections / 24h TTL Postgres). - instanodeApiBase: + instanodeApiUrl: type: string title: API Base URL description: | @@ -26,6 +26,6 @@ startCommand: args: ['-y', 'instanode-mcp@latest'], env: { ...(config.instanodeToken ? { INSTANODE_TOKEN: config.instanodeToken } : {}), - ...(config.instanodeApiBase ? { INSTANODE_API_BASE: config.instanodeApiBase } : {}) + ...(config.instanodeApiUrl ? { INSTANODE_API_URL: config.instanodeApiUrl } : {}) } }) diff --git a/src/client.ts b/src/client.ts index 5103ac6..7182381 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,8 +19,16 @@ * POST /deploy/new — Container deployment (multipart/form-data) * GET /api/v1/deployments, GET /api/v1/deployments/:id * POST /deploy/:id/redeploy, DELETE /deploy/:id - * GET /api/me/resources, POST /api/me/claim, DELETE /api/me/resources/{token} - * GET /api/me/token + * GET /api/v1/resources — list resources for the authenticated team + * DELETE /api/v1/resources/{token} — soft-delete (Pro+; free-tier rows auto-expire) + * POST /claim — convert anonymous JWT → authenticated team + * POST /api/v1/auth/api-keys — mint a fresh bearer JWT + * + * Historical note: an earlier MCP build called /api/me/resources, /api/me/claim, + * /api/me/token — these were never live (a typo'd /api/me prefix that the + * router never registered). Every such call returned 404 and the agent saw + * "instanode.dev error (404)" with no path forward. FIX-E #C5 rewired the + * client to the canonical routes above. */ const DEFAULT_BASE_URL = "https://api.instanode.dev"; @@ -272,13 +280,40 @@ export class ApiError extends Error { readonly status: number; readonly code?: string; readonly upgradeURL?: string; - - constructor(status: number, message: string, code?: string, upgradeURL?: string) { + /** + * The `agent_action` field from the API's error envelope, when present. + * + * The API copies a verbatim sentence the agent should surface to the human + * user (e.g. "Tell the user they've hit the hobby tier storage limit — have + * them upgrade at https://instanode.dev/pricing"). FIX-E #C7 plumbs this + * through to the formatError handler so the MCP user actually sees it. + * Previously the MCP discarded `agent_action` entirely and the LLM had to + * guess at the action from a generic "API error 402" string. + */ + readonly agentAction?: string; + /** + * The `claim_url` field from the API's error envelope on + * `free_tier_recycle_requires_claim` (the anonymous-fingerprint recycle gate). + * Distinct from `upgradeURL` — `claimURL` is the identity step (anon → claimed), + * `upgradeURL` is the tier step (claimed → paid). + */ + readonly claimURL?: string; + + constructor( + status: number, + message: string, + code?: string, + upgradeURL?: string, + agentAction?: string, + claimURL?: string + ) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.upgradeURL = upgradeURL; + this.agentAction = agentAction; + this.claimURL = claimURL; } } @@ -305,6 +340,17 @@ export class InstantClient { ); } + /** + * API base URL (where /db/new, /claim, /start etc. live). Distinct from + * `dashboardURL` — the dashboard host is for the human signin flow, the API + * host is what /start redirects FROM. The `claim_resource` MCP tool builds + * `{apiBaseURL}/start?t=` because /start is a route on the API, not the + * dashboard. + */ + apiBaseURL(): string { + return this.baseURL; + } + /** Read the bearer token fresh from the environment on every call. */ private bearerToken(): string | undefined { const tok = process.env["INSTANODE_TOKEN"]; @@ -314,7 +360,7 @@ export class InstantClient { private headers(): Record { const h: Record = { "Content-Type": "application/json", - "User-Agent": "instanode-mcp/0.10.0", + "User-Agent": "instanode-mcp/0.10.1", }; const tok = this.bearerToken(); if (tok) { @@ -329,7 +375,7 @@ export class InstantClient { */ private authHeaders(): Record { const h: Record = { - "User-Agent": "instanode-mcp/0.10.0", + "User-Agent": "instanode-mcp/0.10.1", }; const tok = this.bearerToken(); if (tok) { @@ -384,9 +430,18 @@ export class InstantClient { error?: string; message?: string; upgrade_url?: string; + agent_action?: string; + claim_url?: string; }; const message = err.message ?? "upstream error"; - throw new ApiError(resp.status, message, err.error, err.upgrade_url); + throw new ApiError( + resp.status, + message, + err.error, + err.upgrade_url, + err.agent_action, + err.claim_url + ); } return data as T; @@ -441,9 +496,18 @@ export class InstantClient { error?: string; message?: string; upgrade_url?: string; + agent_action?: string; + claim_url?: string; }; const message = err.message ?? "upstream error"; - throw new ApiError(resp.status, message, err.error, err.upgrade_url); + throw new ApiError( + resp.status, + message, + err.error, + err.upgrade_url, + err.agent_action, + err.claim_url + ); } return data as T; @@ -479,38 +543,87 @@ export class InstantClient { return this.request("POST", "/webhook/new", { name }); } - /** GET /api/me/resources — list resources claimed by the authenticated caller. Requires bearer. */ + /** + * GET /api/v1/resources — list resources for the authenticated team. + * + * The canonical route is `/api/v1/resources` (the previous `/api/me/resources` + * path was never registered — every call 404'd). The live API returns + * `{ ok, items, total }`; this helper unwraps to the raw items array so the + * tool can iterate naturally. + */ async listResources(): Promise { - return this.request("GET", "/api/me/resources", undefined, { - requireAuth: true, - }); + const wrapped = await this.request<{ ok: boolean; items: Resource[]; total: number }>( + "GET", + "/api/v1/resources", + undefined, + { requireAuth: true } + ); + return wrapped.items ?? []; } - /** POST /api/me/claim — attach an anonymous token to the authenticated account. */ - async claimToken(token: string): Promise { + /** + * 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. + */ + async claimToken(jwt: string, email: string): Promise { return this.request( "POST", - "/api/me/claim", - { token }, - { requireAuth: true } + "/claim", + { jwt, email }, + { requireAuth: false } ); } - /** DELETE /api/me/resources/{token} — paid-only hard-delete. */ + /** + * DELETE /api/v1/resources/{token} — soft-delete a resource (paid tier only). + * + * The path parameter is the resource's UUID token (the same value emitted + * as `token` by every create_* response). Free-tier and anonymous resources + * auto-expire and cannot be deleted manually — the API surfaces the upgrade + * URL in the 403 envelope. + */ async deleteResource(token: string): Promise { return this.request( "DELETE", - `/api/me/resources/${encodeURIComponent(token)}`, + `/api/v1/resources/${encodeURIComponent(token)}`, undefined, { requireAuth: true } ); } - /** GET /api/me/token — mint a fresh bearer JWT. Requires an existing bearer or session cookie. */ - async getApiToken(): Promise { - return this.request("GET", "/api/me/token", undefined, { - requireAuth: true, - }); + /** + * POST /api/v1/auth/api-keys — mint a fresh bearer key for the authenticated team. + * + * Requires an existing bearer (you have to be signed in to mint another key). + * The API returns the plaintext key exactly once in the `key` field — it is + * never recoverable after this response. Default name "instanode-mcp" so the + * dashboard's key list shows where the key came from. + */ + async getApiToken(name?: string): Promise { + const raw = await this.request<{ + ok: boolean; + id: string; + name: string; + key: string; + note?: string; + created_at: string; + }>( + "POST", + "/api/v1/auth/api-keys", + { name: name && name.length > 0 ? name : "instanode-mcp", scopes: ["read", "write"] }, + { requireAuth: true } + ); + return { + ok: raw.ok, + token: raw.key, + // Mint returns no explicit expiry — keys are revocation-based, not + // time-bound. Surface a sentinel (0) so the tool description can adapt. + expires_in: 0, + }; } /** diff --git a/src/index.ts b/src/index.ts index f1f02e5..6f1b834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,32 +65,60 @@ const server = new McpServer({ version: pkgVersion, }); -/** Format an error thrown by the client into a short text block for the agent. */ +/** + * Format an error thrown by the client into a short text block for the agent. + * + * FIX-E #C7: when the API returns an error envelope carrying `agent_action`, + * surface it verbatim — it's a sentence the platform copy-edited specifically + * for the LLM to read aloud to the user. Previously the MCP discarded + * `agent_action` entirely and the LLM had to guess at the right action from a + * generic "API error 402" string, which often produced wrong-by-default + * suggestions (e.g. "try again" on a tier-gate that won't resolve without an + * upgrade). Format: + * + * {short status summary} + * + * Action: {agent_action verbatim} + * + * Upgrade: {upgrade_url, when present} + * Claim: {claim_url, when present} + */ function formatError(err: unknown): string { if (err instanceof AuthRequiredError) { return err.message; } if (err instanceof ApiError) { + // Build the headline. We keep this short — `agent_action` does the real + // work below. + let headline: string; if (err.status === 401) { - return ( + headline = "Request rejected (401 unauthorized). " + - "Mint a token at https://instanode.dev/dashboard and set INSTANODE_TOKEN in your MCP server env." - ); + "Mint a token at https://instanode.dev/dashboard and set INSTANODE_TOKEN in your MCP server env."; + } else if (err.status === 403 && err.code === "paid_tier_only") { + headline = + "Free-tier resource cannot be deleted — it will auto-expire in 24h."; + } else if (err.status === 429) { + headline = + "Rate limited (5 anonymous provisions/day per /24 subnet). " + + "Set INSTANODE_TOKEN to a paid bearer to remove the cap."; + } else if (err.code) { + headline = `instanode.dev error (${err.status} ${err.code}): ${err.message}`; + } else { + headline = `instanode.dev error (${err.status}): ${err.message}`; } - if (err.status === 403 && err.code === "paid_tier_only") { - const upgrade = err.upgradeURL ?? "https://instanode.dev/pricing.html"; - return `Free-tier resource cannot be deleted — it will auto-expire in 24h.\nUpgrade for hard-delete: ${upgrade}`; + + const lines: string[] = [headline]; + if (err.agentAction && err.agentAction.length > 0) { + lines.push("", `Action: ${err.agentAction}`); } - if (err.status === 429) { - return ( - "Rate limited (5 anonymous provisions/day per /24 subnet). " + - "Set INSTANODE_TOKEN to a paid bearer to remove the cap." - ); + if (err.upgradeURL && err.upgradeURL.length > 0) { + lines.push(`Upgrade: ${err.upgradeURL}`); } - if (err.code) { - return `instanode.dev error (${err.status} ${err.code}): ${err.message}`; + if (err.claimURL && err.claimURL.length > 0) { + lines.push(`Claim: ${err.claimURL}`); } - return `instanode.dev error (${err.status}): ${err.message}`; + return lines.join("\n"); } const msg = err instanceof Error ? err.message : String(err); return `instanode.dev error: ${msg}`; @@ -400,10 +428,11 @@ With INSTANODE_TOKEN (paid): 1000+ stored per tier, permanent.`, server.tool( "claim_resource", - `Turn an anonymous resource's upgrade JWT into the dashboard claim URL the -agent should direct the user to. NO API call — pure helper: builds -https://instanode.dev/start?t= from the JWT the create_* tools return in -the 'upgrade_jwt' field. + `Turn an anonymous resource's upgrade JWT into the API claim URL the agent +should direct the user to. NO API call — pure helper: builds +https://api.instanode.dev/start?t= from the JWT the create_* tools return +in the 'upgrade_jwt' field. /start issues a 302 redirect to the dashboard's +/claim page, which drives the email login. Use this when: 1. You just provisioned an anonymous resource via create_postgres / @@ -415,8 +444,8 @@ Use this when: The MCP server cannot complete the claim for the user — it requires a browser session for OAuth login. Show the URL and tell the user to click it. -If you (the agent) already have INSTANODE_TOKEN set, use 'claim_token' instead -to claim programmatically.`, +If you (the agent) already have the user's email and the upgrade JWT, use +'claim_token' instead to claim programmatically (POST /claim).`, { upgrade_jwt: z .string() @@ -436,20 +465,24 @@ to claim programmatically.`, } catch { // Not a URL — assume it's a raw JWT, which is the common case. } - const dashboard = client.dashboardURL(); - const claimURL = `${dashboard}/start?t=${encodeURIComponent(jwt)}`; + // /start lives on the API host (api.instanode.dev), NOT the dashboard + // host (instanode.dev). The API issues a 302 to the dashboard's /claim + // page — that's the indirection so the dashboard host can move without + // breaking JWTs already-issued and pasted into chats. FIX-E #C6. + const apiBase = client.apiBaseURL(); + const claimURL = `${apiBase}/start?t=${encodeURIComponent(jwt)}`; const lines = [ `Claim URL ready. Direct the user here to keep the resource past 24h:`, ``, ` ${claimURL}`, ``, `What happens when they click it:`, - ` 1. GET /start?t= on the API redirects them to the dashboard /claim page.`, + ` 1. GET /start?t= on the API issues a 302 to the dashboard's /claim page.`, ` 2. They sign in with GitHub or Google (or magic link).`, ` 3. The resource is attached to their account. Free tier keeps it visible;`, ` paid tier (hobby/pro/team) makes it permanent and lifts anonymous limits.`, ``, - `If you have INSTANODE_TOKEN set, call 'claim_token' instead to attach`, + `If you have the user's email handy, call 'claim_token' instead to attach`, `the resource programmatically without a browser round-trip.`, ]; return textResult(lines.join("\n")); @@ -460,36 +493,56 @@ to claim programmatically.`, server.tool( "claim_token", - `Attach an anonymous resource (returned by any create_* tool) to the -authenticated caller's account programmatically (POST /api/me/claim). -Idempotent — re-claiming a token you already own returns the same payload. + `Convert an anonymous upgrade JWT into a claimed team programmatically +(POST /claim). Same flow the dashboard's /claim page drives — but lets an +agent that already knows the user's email skip the browser round-trip. -For paid callers, the resource's tier is upgraded to the team's plan tier -('hobby'/'pro'/'team') and its expiry is cleared. For free authenticated -callers, the resource stays anonymous-tier but is now visible on the -dashboard. +The 'upgrade_jwt' is the JWT returned in the upgrade_jwt field of any +create_* tool response (NOT the per-resource UUID token). It can also be +extracted from the 't=' query param of the upgrade URL. -Requires INSTANODE_TOKEN. If you don't have one (typical agent flow), use -'claim_resource' instead to get a URL the user can click in their browser. +On success the API creates (or attaches to) the user's team, links every +anonymous resource issued under that JWT, and returns a 24h session token. +The session token isn't returned to the agent here — use 'get_api_token' +or have the user mint one in the dashboard to authenticate future MCP calls. -Pass the resource's 'token' field (UUID), not the upgrade JWT.`, +If you don't have the user's email yet, use 'claim_resource' instead to get +a URL the user can click in their browser.`, { - token: z + upgrade_jwt: z .string() .min(1) .describe( - "Resource token (UUID) returned in the 'token' field by any create_* tool." + "The 'upgrade_jwt' field returned by any create_* tool (or the raw JWT from the 'upgrade' URL). Required." + ), + email: z + .string() + .email() + .describe( + "User's email address. The team will be created or matched against this email." ), }, - async ({ token }) => { + async ({ upgrade_jwt, email }) => { try { - const result = await client.claimToken(token); + // Accept either a raw JWT or a full https://...start?t= URL. + let jwt = upgrade_jwt.trim(); + try { + const u = new URL(jwt); + const t = u.searchParams.get("t"); + if (t) jwt = t; + } catch { + // Not a URL — common case, leave jwt as-is. + } + const result = await client.claimToken(jwt, email); const lines = [ - `Token claimed.`, - `Resource type: ${result.resource_type}`, - `Token: ${result.token}`, - `Tier: ${result.tier}`, - `Status: ${result.status}`, + `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}`); return textResult(lines.join("\n")); @@ -504,11 +557,11 @@ Pass the resource's 'token' field (UUID), not the upgrade JWT.`, server.tool( "list_resources", `List resources on the caller's instanode.dev account, newest first -(GET /api/me/resources). +(GET /api/v1/resources). Requires INSTANODE_TOKEN to be set. Mint one at https://instanode.dev/dashboard. -Returns each resource's type (postgres/cache/nosql/queue/storage/webhook), +Returns each resource's type (postgres/cache/nosql/queue/storage/webhook/vector), token, tier, status, name, and expiry.`, {}, async () => { @@ -544,7 +597,7 @@ token, tier, status, name, and expiry.`, server.tool( "delete_resource", `Permanently delete one of the caller's resources -(DELETE /api/me/resources/{token}). Drops the underlying Postgres/Mongo +(DELETE /api/v1/resources/{token}). Drops the underlying Postgres/Mongo database, Redis ACL user, NATS user, storage prefix, or clears the webhook's request log, then marks the row status='deleted'. @@ -578,30 +631,43 @@ Requires INSTANODE_TOKEN.`, server.tool( "get_api_token", - `Mint a fresh 30-day bearer JWT for the authenticated caller and return it -as plain text (GET /api/me/token). The user should paste the returned token + `Mint a fresh API key for the authenticated caller and return it as plain +text (POST /api/v1/auth/api-keys). The user should paste the returned key into their MCP server config as INSTANODE_TOKEN (or export it as an env var for CLI use). -Requires an existing INSTANODE_TOKEN (or a session cookie, though session -cookies aren't available in this transport). This is primarily useful for -rotating an expiring token.`, - {}, - async () => { +API keys are revocation-based (not time-bound) — they live until revoked +in the dashboard. Requires an existing INSTANODE_TOKEN to bootstrap a new +one; the typical flow is: claim once via the dashboard (browser), mint a +key, paste it into the MCP env, then use this tool to rotate as needed.`, + { + name: z + .string() + .min(1) + .max(120) + .optional() + .describe( + "Optional human-readable label so you can identify this key in the dashboard. Defaults to 'instanode-mcp'." + ), + }, + async ({ name }) => { try { - const result = await client.getApiToken(); + const result = await client.getApiToken(name); const lines = [ - `New bearer token minted.`, - `Expires in: ${result.expires_in} seconds (~${Math.round(result.expires_in / 86400)} days)`, + `New API key minted.`, + `(Keys are revocation-based — they live until you revoke them in the dashboard.)`, ``, - `Token:`, + `Key:`, result.token, ``, `Set it in your MCP server config:`, - ` "env": { "INSTANODE_TOKEN": "" }`, + ` "env": { "INSTANODE_TOKEN": "" }`, ``, `Or export it in your shell:`, - ` export INSTANODE_TOKEN=`, + ` export INSTANODE_TOKEN=`, + ``, + `The plaintext key is shown ONCE — save it now. To rotate later, mint a new`, + `key here and revoke the old one at https://instanode.dev/dashboard/settings.`, ]; return textResult(lines.join("\n")); } catch (err) { diff --git a/test.sh b/test.sh index ed3fa22..c706f3a 100755 --- a/test.sh +++ b/test.sh @@ -166,5 +166,35 @@ assert '/start?t=ey.url.jwt' in text, f'expected JWT extracted from URL, got: {t " || fail "claim_resource did not extract JWT from a full URL" pass "claim_resource extracts JWT from a full /start?t= URL" +# Test 7: FIX-E #C6 — claim_resource MUST point at the API host (api.instanode.dev), +# NOT the dashboard host. /start is a route on the API. Earlier versions built +# https://instanode.dev/start?t=..., which 404'd (dashboard has no /start route; +# the dashboard's path is /claim). +CLAIM_API_HOST='{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"claim_resource","arguments":{"upgrade_jwt":"ey.host.jwt"}}}' +RESP=$(printf "%s\n%s\n" "$INIT" "$CLAIM_API_HOST" | INSTANODE_API_URL="$BASE_URL" $MCP 2>/dev/null | tail -1) +echo "$RESP" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +text = d['result']['content'][0]['text'] +# When BASE_URL is the default api.instanode.dev or a local override, the +# claim URL must originate on the API host, never the dashboard host. +assert 'api.instanode.dev/start' in text or 'localhost' in text or '127.0.0.1' in text or '://api.' in text, f'claim_resource should use API host, got: {text}' +" || fail "claim_resource still points at the wrong host (#C6)" +pass "claim_resource uses API host (api.instanode.dev) for /start" + +# Test 8: FIX-E #C5 — claim_token now takes both upgrade_jwt AND email. +# Schema regression check: calling with the OLD shape (just token) should fail. +OLD_CLAIM='{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"claim_token","arguments":{"token":"00000000-0000-0000-0000-000000000000"}}}' +RESP=$(printf "%s\n%s\n" "$INIT" "$OLD_CLAIM" | INSTANODE_API_URL="$BASE_URL" $MCP 2>/dev/null | tail -1) +echo "$RESP" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +# The old shape should be rejected (zod) or surface a missing-field error. +err = d.get('error') or (d.get('result') or {}).get('isError') +text = (d.get('result') or {}).get('content', [{}])[0].get('text', '') if d.get('result') else '' +assert err or 'email' in text.lower() or 'upgrade_jwt' in text.lower(), f'old claim_token shape should be rejected, got: {d}' +" || fail "claim_token still accepts the old single-token shape (#C5)" +pass "claim_token enforces new (upgrade_jwt, email) shape" + echo "" echo "All tests passed."