The landing page tells Responses API / Messages API users where to mint a token; the smoke script now also signs in on /connections, creates a token, uses it at /mcp, revokes it and confirms the 401 (verified against wrangler dev + the fake). CLAUDE.md records the verified provider dispatch rule, the storage design, and the creation rules; the handoff notes that revoking a token cannot log its session out upstream either. Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
6.1 KiB
Backend handoff notes
Findings about storyteller-web and its published OpenAPI document that the MCP project works
around but cannot fix (this project never changes Rust — mcp/CLAUDE.md). Each entry says what
we observed, what we did about it on our side, and what a backend change would unlock. Newest
first. Add an entry whenever a PR description surfaces a discrepancy.
A signed-in webapp user cannot be handed to the MCP without exporting their browser session
Observed (2026-08-25). The industry-standard consent shape is "redirect to the product's own
sign-in, come back with a credential" (app.getartcraft.com/connect). The webapp holds the
signed session JWT in localStorage["artcraft_signed_session"] (frontend/libs/api/src/lib/ApiManager.ts),
so such a page could post it to the MCP — but that is the user's long-lived browser session,
not a purpose-made one. The only other route to it is GET /v1/session_token, which the handler
itself marks as a security hole. There is no endpoint that lets a logged-in user mint a second,
independently revocable session, and /v1/api_keys/* keys are accepted only by the
omni_api/omni_gen generate, upload and job_status routes — none of the session-only reads
below.
Our side. Not built. The MCP keeps its own consent page, which signs the user in with
/v1/login or Google SSO and therefore holds a session created for that grant; /v1/logout
on disconnect ends exactly that session and nothing else. A grant that carried the browser
session would make "disconnect" unable to end upstream access without logging the user out of
the webapp, and would make the 90-day grant cap cosmetic upstream.
What a backend change would unlock. One endpoint pair, e.g.
POST /v1/sessions/handoff (session-authenticated; returns a single-use code with a ~60 s TTL,
bound to an audience string) and POST /v1/sessions/redeem (anonymous; returns a fresh
signed_session for that user, recorded with the audience so it can be listed/revoked
separately). With that, /connect becomes a ~80-line webapp page and the MCP's
WebappRedirectAuthenticator replaces the password form with no credential ever leaving the
user's own sign-in. Until then the password/Google consent page is the safer design, not a
compromise.
Disconnecting an MCP grant cannot end its upstream session
Observed. The MCP holds each grant's Artcraft session encrypted with key material wrapped
by that grant's tokens. When a user disconnects an app on mcp.getartcraft.com/connections
we revoke the grant (its tokens die and the session becomes cryptographically unreachable),
but we cannot call POST /v1/logout for it — the props can no longer be decrypted. The
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. 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
the job. Checking expires_at on read would bound the exposure regardless.
Path parameters are declared as one object-typed parameter
Observed (api.json, 2026-08-24). Every operation with path parameters declares a single
parameter named path whose schema is a $ref to a …PathInfo object, e.g.
GET /v1/jobs/job/{token} → { "name": "path", "in": "path", "schema": { "$ref": "…GetInferenceJobStatusPathInfo" } }. OpenAPI expects one parameter per template variable;
typed literally this yields params.path.path.token while the URL needs params.path.token.
Also observed. POST /v1/login and POST /v1/accounts/google_sso declare their JSON body
twice: once correctly as requestBody and once as a path parameter named request
({ "name": "request", "in": "path", "schema": { "$ref": "…LoginRequest" } }) on a path
that has no template variables at all. Generators that trust the document would require
username_or_email and password as path segments.
Our side. scripts/gen-api.mjs flattens template-matching object parameters into one per
property, and drops a path parameter whose schema duplicates the operation's requestBody
(flattenObjectParameters). It refuses to generate if it meets a shape it does not
recognise. The committed snapshot test/fixtures/api.json is therefore not byte-identical
to the published document for these operations.
Backend fix. In utoipa, annotate path/query structs with #[into_params(parameter_in = Path)] / Query so each field becomes its own parameter, and remove the params(...) entry
for Json<…> request bodies on the affected handlers (login_handler, google_sso_handler).
Cost estimates are anonymous
Observed. POST /v1/omni_gen/cost/* performs no session lookup, so is_free and
is_unlimited never reflect the caller's plan.
Our side. Tool output labels estimates as public pricing.
Backend fix. Look the caller up with the same session/API-key dual path the generate handlers use.
Session-only endpoints block API-key use
Observed. /v1/credits/namespace/{ns}, /v1/session, /v1/jobs/session, and
/v1/subscriptions/namespace/{ns} accept only the session cookie/header
(maybe_get_user_session_from_connection), not an Authorization API key.
Our side. The MCP forwards an Artcraft session rather than a per-connection API key.
Backend fix. Switch those handlers to require_api_or_web_session. That would let this
service move to least-privilege API keys and support header-based clients directly.
Security observations (not blocking us)
api_keys.api_keyis stored in plaintext and matched by equality.- No rate limiting on
/v1/login,/v1/create_account,/v1/api_keys/create, or any/v1/omni_api/*route; thelimitlessheader disables the limiter by presence alone. user_sessions.expires_atis never checked on read.