Skip to content

Keep hosted write previews redeemable across MCP sessions of one consent - #41

Closed
christfarah wants to merge 1 commit into
oliverames:mainfrom
christfarah:fix/consent-bound-write-previews
Closed

christfarah wants to merge 1 commit into
oliverames:mainfrom
christfarah:fix/consent-bound-write-previews

Conversation

@christfarah

@christfarah christfarah commented Oct 14, 2026 •

Copy link
Copy Markdown

Fixes #40

Problem

Hosted previews lived in the memory of the Durable Object for one MCP session. Clients that open a new MCP session per tool call (ChatGPT's connector gateway does) could issue a preview but never redeem it, so every approved write failed with "Preview token is missing, expired, already used, or belongs to another authenticated session". Details and evidence are in the linked issue.

Change

  • lib/write-previews.mjs: new createStoredWritePreviews({ tenantId, store, ttlMs, now }). It has the same issue/claim/consume contract and result shape, with an async consent-scoped store. The in-memory createWritePreviews is unchanged apart from sharing two private helpers with it.
  • index.js: createYnabServer accepts an optional previewStore. Without one (local stdio), previews stay in session memory exactly as before. claim and issue are awaited.
  • worker/src/credential-store-core.js: /preview/issue and /preview/claim on the existing per-user OAuthCredentials Durable Object, behind the same consent check as /token and the journal. It stores only sha256(token), the plan fingerprint and the expiry, rejects malformed or over-long records (15-minute ceiling), and keeps at most 100 live previews per consent. /remove deletes a consent's previews. /delete-all already wiped them.
  • worker/src/ynab-oauth.js / hosted-options.js: createCredentialPreviewStore(env, binding), supplied as previewStore for credential-bound hosted sessions only. Legacy grants get none.
  • Docs: README and hosted connector notes now say previews are bound to the session locally and to the consent when hosted.

What stays the same

Single use (claim deletes atomically), the five-minute expiry, the exact fresh-state fingerprint check, the separate confirmed: true, write enablement and read-only defaults. A preview cannot cross consents: each consent has its own key prefix behind the credential binding check, and the fingerprint also binds the consent identity.

Trade-off

A preview from one MCP session can now be redeemed once by another session of the same consent (same YNAB user, client and credential). Hosted clients need this, and it matches how the operation journal already handles session rebinding.

Tests

  • test/write-preview.test.mjs: the stored manager works across two instances, is single-use, expires, rejects stale plans and other tenants, and never hands the plan to the store.
  • test/integration-mcp-http.test.mjs: preview on one MCP session, approved write on another, with the same consent sharing a store. Exactly one HTTP write; reuse and other-tenant attempts are rejected. Before the fix this test failed with the production error.
  • worker/test/worker.test.mjs: through the real CredentialStoreCore and hostedServerOptions, previews cross sessions of one consent only. Storage holds no raw token or plan text, /remove clears previews, legacy grants get no store, and malformed, expired, over-long and unbounded records are rejected or evicted.
  • The existing test "preview tokens cannot cross authenticated sessions or budgets and are single use" still passes unchanged for the local server.

Local checks: test:unit (242 pass), test:safety, test:eval, release:check, smoke:list-tools, worker npm test (62 pass) and wrangler deploy --dry-run.

Hosted previews lived in the memory of the Durable Object for one MCP
session. Clients that open a new MCP session for each tool call could
issue a preview but never redeem it: every approved write failed with
"Preview token is missing, expired, already used, or belongs to another
authenticated session".

The hosted connector now stores previews in the consent's credential
Durable Object, as a token hash, plan fingerprint and expiry only. Any
session of the same consent can redeem a preview once; other consents
cannot see it, and removing a consent removes its previews. Expiry,
single use, the fresh-state fingerprint check and separate confirmed:true
are unchanged. Local stdio previews stay session-bound in memory.
@christfarah
christfarah force-pushed the fix/consent-bound-write-previews branch from 9a5af1c to 098aacf Compare October 14, 2026 02:49
@oliverames

Copy link
Copy Markdown
Owner

The consent-scoped durable-preview behavior landed on main in 2ddad4d on October 25, so this PR is superseded. The implementation stores only a token hash, plan fingerprint and expiry in the consent's credential Durable Object. It retains single use, five-minute expiry, exact fresh-state validation and separate confirmed: true, while local previews remain session-bound.

I rechecked current main today: all 25 preview/MCP integration tests and six selected Worker tests pass. They cover approval from a new session of the same consent, rejection across clients and users, changed plans, replay, expiry, consent removal and the 100-preview bound. Issue #40 records the October 25 deployment and remains open for the signed-in ChatGPT write acceptance check.

Thanks for the fix and the reproduction. Closing this duplicate implementation preserves the version already on main; the contributor's fork branch is unchanged.

@oliverames oliverames closed this Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hosted connector: approved writes always fail when the client opens a new MCP session per tool call

2 participants