diff --git a/mcp/CLAUDE.md b/mcp/CLAUDE.md index 88bd31cbb6..726da38752 100644 --- a/mcp/CLAUDE.md +++ b/mcp/CLAUDE.md @@ -187,7 +187,7 @@ type Principal = { An Artcraft session cannot be refreshed — there is no endpoint — only held or deleted. So: access tokens 1 h; refresh tokens rotate, 30 days idle, **90 days absolute**, then re-consent. -Personal tokens (M2) inherit the same 90-day ceiling. On expiry or revoke, always call the +Personal tokens inherit the same 90-day ceiling. On expiry or revoke, always call the credential's `revoke()`. The 90-day window is also the runway for swapping `UpstreamCredential` to a stronger backend token system if one appears: grants naturally roll over. @@ -280,6 +280,31 @@ Cite these; re-verify if the file references stop matching. (the repo's Unknown(String) pattern) — normalisers must accept the object form. - `/v1/jobs/batch` takes repeated `tokens=` params (openapi-fetch's default serialisation). +## Personal tokens (built 2026-08-25) + +For the two integrations that cannot run OAuth and take a fixed token instead: the OpenAI +Responses API `mcp` tool (`authorization`) and the Anthropic Messages API connector +(`authorization_token`, beta `mcp-client-2025-11-20`). Gemini CLI does its own OAuth +discovery, so it does not need one. Both APIs say "obtain and refresh the access token +yourself", which our 1 h access / rotating refresh makes hostile — hence a long-lived secret. + +- Format `artcraft_pat_` + 43 base64url chars (32 random bytes). The provider routes any + bearer that is not `a:b:c`-shaped straight to `resolveExternalToken` (verified in 0.10.3's + `dist`), so the hook's first move is the prefix check and every foreign bearer is a cheap + `null` → standard `401 invalid_token` challenge. +- At rest: `mcppat:token:` holds the record AES-GCM-sealed with an HKDF key + derived from the secret; `mcppat:user:` is the index (labels, hints, dates, + record keys). KV alone yields no session. Record TTL = token lifetime. +- The hook returns the same `GrantProps` shape as consent (`grantIssuedAt` = createdAt) with + `audience` = this origin + `/mcp`, so handler, `Principal`, and tools are unchanged. +- Created on `/connections` only, after a fresh password/Google confirmation that must be the + page's account; that sign-in becomes the token's own upstream session (never the browser's, + never the page's). Secret rendered once in the POST response — never in a redirect URL. + Fixed read-only scopes, lifetime 30 or 90 days, at most 10 live tokens per account. +- When upstream stops accepting the token's session the handler revokes the token, as it + does a grant. Revoke from the page cannot log the session out upstream (same limitation as + grants, `docs/backend-handoff.md`). + ## Client requirements that bite - Unauthenticated `/mcp` → **401** with @@ -362,6 +387,8 @@ Manual: MCP Inspector against `wrangler dev`. Dependency-free Node script that does exactly what a client does: PRM → AS metadata (CIMD flag) → DCR → consent page + CSRF → password sign-in → code → PKCE exchange → initialize → tools/list (must equal the 7 tool names) → one call per read-only tool → revoke → revoked token is 401. +Then the header-only leg: sign in on `/connections`, create a personal token, initialize and +call a tool with it, revoke it from the page, confirm the 401, sign out of the page. Prints step outcomes only, never tokens/bodies/credentials; exit 1 on the first failure. Runs after every production deploy (`mcp.yml`) and weekly (`mcp-smoke.yml`, Mondays 09:00 UTC, also `workflow_dispatch` with a `base_url`), both skipped with a notice until `SMOKE_USERNAME` / @@ -389,9 +416,6 @@ tool or flow changes, and keep the fake honest so the local run stays a real reh Keep these possible; do not build them in M1. -- Personal tokens for header-based clients (Gemini API/Vertex, OpenAI Responses API, - Anthropic Messages API connector): `resolveExternalToken` + `/connections` UI. Read-only - scopes only, 90-day max, shown once, hashed at rest. - `WebappRedirectAuthenticator` (`app.getartcraft.com/connect`) replacing the password form. Investigated 2026-08-25: without a backend hand-off code endpoint the only credential a webapp page can pass is the user's own browser session, which breaks per-grant disconnect — @@ -421,23 +445,27 @@ mcp/ │ ├── index.ts # Worker entry: environment invariant first, then the OAuthProvider │ ├── runtime.ts # memoized Config + Hono app per isolate (failure memoized too) │ ├── oauth-env.d.ts # adds env.OAUTH_PROVIDER (OAuthHelpers) to the generated Env +│ ├── encoding.ts # base64url helpers shared by ids, secrets, and sealed records │ ├── app.ts # unprotected Hono routes: /healthz, / (landing), /authorize, /connections, /sse (405 pointer) │ ├── auth/ -│ │ ├── oauth.ts # provider options: endpoints, scopes, TTLs, CIMD+DCR, error logging +│ │ ├── resource.ts # MCP_ROUTES, SCOPES, RESOURCE_NAME — importable without the provider wiring +│ │ ├── oauth.ts # provider options: endpoints, TTLs, CIMD+DCR, resolveExternalToken, error logging │ │ ├── authorize.ts # /authorize GET (render) + POST (CSRF → re-validate → deny | sign in → finish) │ │ ├── authenticator.ts # Authenticator seam (swap point 1); Artcraft impl proxies /v1/login, google_sso │ │ ├── consent-page.tsx # pure render of the sign-in + consent page (Hono JSX; everything escaped) │ │ ├── csrf.ts # double-submit token: cookie + hidden field, constant-time compare │ │ ├── finish-authorization.ts # requested ∩ supported scopes, completeAuthorization, grant props │ │ └── grant-age.ts # 90-day absolute grant lifetime, enforced in tokenExchangeCallback -│ ├── tokens/ # TokenResolver, Principal, GrantStore adapter +│ ├── tokens/ +│ │ ├── principal.ts # Principal: the one shape every token resolves to (from grant props) +│ │ ├── personal-token-store.ts # artcraft_pat_ secrets: hash-keyed, secret-sealed records, per-user index +│ │ └── resolve-personal-token.ts # the provider's resolveExternalToken hook → GrantProps + audience │ ├── upstream/ │ │ ├── allowlist.json # the ONLY upstream routes, with `use: auth | read`; also drives the generator │ │ ├── allowlist.ts # validation + lookups by template path and by concrete pathname │ │ ├── credential.ts # UpstreamCredential interface; session implementation (swap point 2) │ │ ├── client.ts # openapi-fetch client; middleware enforces allowlist, use, origin, credential │ │ └── schema.d.ts # GENERATED from the spec snapshot by `pnpm gen:api`; do not edit -│ ├── tokens/principal.ts # Principal: the one shape every token resolves to (from grant props) │ ├── mcp/ │ │ ├── handler.ts # protected route: props → Principal → per-request McpServer + Streamable HTTP │ │ ├── server.ts # registers tools the principal's scopes allow; error → isError results diff --git a/mcp/docs/backend-handoff.md b/mcp/docs/backend-handoff.md index d25e4a6c63..1917e8a1a1 100644 --- a/mcp/docs/backend-handoff.md +++ b/mcp/docs/backend-handoff.md @@ -41,7 +41,9 @@ but we cannot call `POST /v1/logout` for it — the props can no longer be decry `user_sessions` row therefore lives until its (unchecked) one-year expiry. **Our side.** Documented on the page; sessions that fail upstream are logged out from the -handler path whenever a token is still live. +handler path whenever a token is still live. Personal tokens (`/connections` → Personal +tokens) have the same shape: revoking one deletes its sealed record, and the session inside +is unreachable but not logged out. **Backend fix.** A session-scoped endpoint to end *other* sessions of the same user (or a "revoke all sessions created via API key/MCP" action) would let the connections page finish diff --git a/mcp/scripts/smoke.mjs b/mcp/scripts/smoke.mjs index 06351fadf3..55c3d08463 100644 --- a/mcp/scripts/smoke.mjs +++ b/mcp/scripts/smoke.mjs @@ -1,6 +1,7 @@ // Read-only smoke test against a deployed MCP server — the one place this project touches a // real Artcraft backend. Drives exactly what a client does: register, consent (password), -// exchange the code, initialize, list tools, call every read-only tool, then revoke the grant. +// exchange the code, initialize, list tools, call every read-only tool; then the header-only +// path — a personal token created on /connections, used, and revoked; then revoke the grant. // // SMOKE_BASE_URL=https://mcp.getartcraft.com SMOKE_USERNAME=… SMOKE_PASSWORD=… node scripts/smoke.mjs // @@ -137,6 +138,8 @@ async function main() { Array.isArray(s.jobs), ); + await smokePersonalToken(); + const revoke = await fetchWithTimeout(new URL(as.revocation_endpoint, BASE_URL).href, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, @@ -152,6 +155,98 @@ async function main() { console.log("smoke: OK"); } +/** + * The header-only path (Responses API, Messages API connector): sign in on /connections, + * create a personal token, use it at /mcp, revoke it from the page, sign out of the page. + */ +async function smokePersonalToken() { + const page = await fetchWithTimeout(`${BASE_URL}/connections`); + check(page.status === 200, "connections page renders"); + let csrf = consentCsrf(page); + check(csrf.length === 43, "connections CSRF issued"); + + const signIn = await fetchWithTimeout(`${BASE_URL}/connections/sign-in`, { + method: "POST", + redirect: "manual", + headers: { + "content-type": "application/x-www-form-urlencoded", + cookie: `artcraft_consent=${csrf}`, + }, + body: new URLSearchParams({ + csrf, + method: "password", + username_or_email: USERNAME, + password: PASSWORD, + }).toString(), + }); + check(signIn.status === 303, `connections sign-in redirects (got ${signIn.status})`); + const uiSession = + /artcraft_connections=([^;]+)/.exec(signIn.headers.get("set-cookie") ?? "")?.[1] ?? ""; + check(uiSession.length === 43, "connections page session issued"); + const cookies = () => `artcraft_consent=${csrf}; artcraft_connections=${uiSession}`; + + const created = await fetchWithTimeout(`${BASE_URL}/connections/tokens/create`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookies() }, + body: new URLSearchParams({ + csrf, + method: "password", + password: PASSWORD, + token_label: "smoke", + token_lifetime: "30", + }).toString(), + }); + check(created.status === 200, `personal token created (got ${created.status})`); + const pat = /artcraft_pat_[A-Za-z0-9_-]{43}/.exec(await created.text())?.[0] ?? ""; + check(pat.length > 0, "personal token issued"); + csrf = consentCsrf(created) || csrf; + + const init = await rpc(pat, 20, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "artcraft-mcp-smoke", version: "0" }, + }); + check(init.result?.serverInfo?.name === "artcraft", "personal token initializes"); + await tool(pat, 21, "get_credit_balance", {}, (s) => Number.isInteger(s.total_credits)); + + const listed = await fetchWithTimeout(`${BASE_URL}/connections`, { + headers: { cookie: cookies() }, + }); + csrf = consentCsrf(listed) || csrf; + const item = + (await listed.text()) + .split("
  • ") + .find((chunk) => chunk.includes(`artcraft_pat_\u2026${pat.slice(-4)}`)) ?? ""; + const tokenId = /name="token_id" value="([^"]+)"/.exec(item)?.[1] ?? ""; + check(tokenId.length > 0, "personal token listed by its hint"); + + const revoked = await fetchWithTimeout(`${BASE_URL}/connections/tokens/revoke`, { + method: "POST", + redirect: "manual", + headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookies() }, + body: new URLSearchParams({ csrf, token_id: tokenId }).toString(), + }); + check(revoked.status === 303, `personal token revoked (got ${revoked.status})`); + const after = await fetchWithTimeout(`${BASE_URL}/mcp`, { + method: "POST", + headers: { authorization: `Bearer ${pat}` }, + }); + check(after.status === 401, "revoked personal token is refused"); + + const signOut = await fetchWithTimeout(`${BASE_URL}/connections/sign-out`, { + method: "POST", + redirect: "manual", + headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookies() }, + body: new URLSearchParams({ csrf }).toString(), + }); + check(signOut.status === 303, "signed out of the connections page"); +} + +/** The CSRF cookie a page response (re)issues; empty when it did not set one. */ +function consentCsrf(response) { + return /artcraft_consent=([^;]+)/.exec(response.headers.get("set-cookie") ?? "")?.[1] ?? ""; +} + async function tool(bearer, id, name, args, predicate) { const response = await rpc(bearer, id, "tools/call", { name, arguments: args }); const result = response.result; diff --git a/mcp/src/pages/landing.tsx b/mcp/src/pages/landing.tsx index 145ec7d6e6..50145b3ab1 100644 --- a/mcp/src/pages/landing.tsx +++ b/mcp/src/pages/landing.tsx @@ -126,6 +126,21 @@ function LandingPage({ view }: { view: LandingView }) { +
    +

    Integrations that take a token

    +

    + The OpenAI Responses API and the Anthropic Messages API connector cannot sign you in; + they take a fixed token. Create a personal token at{" "} + + {view.origin} + {CONNECTIONS_PATHS.page} + {" "} + and pass it where the API expects an OAuth access token — Responses API{" "} + authorization, Messages API authorization_token. Personal + tokens are read-only and expire after at most 90 days. +

    +
    +

    Manage connections