Repository navigation
fix(auth): record an MCP access token as used only after the MCP door admits the call - #31247
Conversation
… call IAuthService gains an optional recordMcpAccessTokenUse(principal). The plugin-auth verifier no longer schedules the consent last-use stamp; it remembers the principal object it returned, and the new member stamps only that object, once. The runtime MCP door calls it after the ban read and after the request context resolves, so a refused call (banned subject, grant-read outage) is never recorded as a use. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pERdWox6UVMyKRjPq
…s it does not implement Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pERdWox6UVMyKRjPq
📓 Docs Drift CheckThis PR changes 3 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 178 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4663a193741f49b549ab4184841ece0b63bfe1f4 && git checkout 4663a193741f49b549ab4184841ece0b63bfe1f4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 455229e7aebae2fe91ada77736377fe145defba3 c67d88b7e97cc357cbdb39d15870cfdbccd59d90 && git checkout -B drift-repro 455229e7aebae2fe91ada77736377fe145defba3 && git merge --no-ff c67d88b7e97cc357cbdb39d15870cfdbccd59d90
node scripts/docs-audit/affected-docs.mjs --json 455229e7aebae2fe91ada77736377fe145defba3
|
|
domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK Contract-review-claim: session_01FEkQ5mw7HFYn9o4LEGMQdK · head e8c0e99 · 2026-11-07T19:47Z |
|
domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK Contract reviewServed-tier: Inputs read: cards #31211 (body; comments 6710960650, 6711352406, 6712169396) and #30949 (body; comments 6686445086, 6709716960, 6710770858, 6710893443, 6710928373); PR #31247 body, file list and the net diff Gate verdicts on the head at the time of this record: every completed check-run is ① Derived judgments
② Semver levelThe changeset ③ Boundary flags
④ Design judgment
Should-fix (not blocking): sentence 4 says the member is called "only after its last refusal has had its say"; "its" is the door's, and that is true. But the dispatcher still renders authorization verdicts on the admitted principal after the door has returned and recorded: the environment-membership gate does not skip
Producer and reader (cited, read, not reviewed): Implemented-by: VERDICT: PASS |
…ill recorded as used Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pERdWox6UVMyKRjPq
|
domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK Contract review (delta)Served-tier: Delta from the previous record's head Gate verdicts on this head at the time of this record: completed and The should-fix from the previous recordAsked for: one sentence bounding "only after its last refusal has had its say" so that an authorization verdict rendered on the admitted principal later in the request is named as a use that is recorded. Delivered, in the docblock: "Admitting is the decision about WHO calls, not every later authorization verdict: once the principal is admitted, a refusal of what it then asks for, rendered further down the request (the MCP domain's 403 Each clause tried against the live tree at this head:
The should-fix is closed. The sentence is true of the producer, the reader and every later refusal on the MCP path at this head, and the mdx says the same thing in the same terms. New findingsNone must-fix. None should-fix. The only inexactness found is in the dev's report, not in the tree: the narrowing lives at Implemented-by: VERDICT: PASS |
|
domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK Contract review (delta)Served-tier: Second delta, after the records on The merge is pure
The net diff is the same eight files with the same content
Nothing on main since the base invalidates a judged sentenceOf the 21 main commits since
Not touched on main since the base: Gate verdicts on this headAt the time of this record: FindingsNone must-fix. None should-fix. The merge adds no reviewable content of its own; the previous two records' judgments carry to this head unchanged. Implemented-by: VERDICT: PASS |
|
domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK 维护者速读
席位核对Verdict: ACCEPT · head
Ruling-ref: the maintainer, 2026-11-07T16:00Z: 「你有 fable ,车道契约复审可以自己做」; 2026-11-07T16:59Z: 「你应该使用 pm-dispatch 技能审核和落地pr」; standing landing authorisation 2026-10-25T11:35Z: 「按照 pm-dispatch 规则复核过的都授权」. |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42414930416 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK Queue red triage (run 42414930416): the one failing test is |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42417438743 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK Second queue red (run 42417438743), same test, same cause: |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42425767147 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK The queue build 42425767147 failed on Test Core (6/8). The cause was The seat re-queued this PR once, with the head unchanged ( |
Requested by Leehom · project thread
Fixes #31211
Fixes #30949
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
IAuthService新增一个可选成员recordMcpAccessTokenUse(principal)。plugin-auth 的verifyMcpAccessToken不再顺手记「最近使用时间」,只记住自己返回的那个对象;MCP 门口在封禁检查和权限解析都通过之后,才把这个对象交回去记一次。sys_oauth_consent.last_used_at仍被写上,管理员在GET /admin/authorized-clients?userId=里会看到「这一小时用过」。修法是 6 号席在 MCP: a banned person's AI assistant is still recorded as used, because the last-use stamp (#30918) runs before the ban refusal #30949 上裁定的方案 A(门口唯一拥有「放行」)。packages/runtime/src/security/resolve-execution-context.ts(门口末尾那一处调用)、packages/plugins/plugin-auth/src/auth-manager.ts(verifyMcpAccessToken与新方法recordMcpAccessTokenUse,WeakMap 只认自己返回的对象)、packages/spec/src/contracts/auth-service.ts(新成员的契约文字)。pnpm --filter @objectstack/runtime exec vitest run src/security/resolve-execution-context.test.ts -t 31211,预期 6 条全过(放行记一次、封禁/503/未验证/转发凭据都不记、缺成员照样放行)。What changes
packages/spec/src/contracts/auth-service.ts):IAuthServicegains one optional member besideverifyMcpAccessToken,recordMcpAccessTokenUse(principal): void. Its docblock states the split: verifying a token is not admitting the call; the caller calls this once per admitted call, after its last refusal, with the very object the verifier resolved to; the provider records only an object it returned itself; it returns nothing, never throws and never makes the caller wait. The parameter type is derived from the verifier's own result type, so the two cannot drift.content/docs/kernel/contracts/auth-service.mdxlists the member and adds a third "read together" pair (verifyMcpAccessToken()/recordMcpAccessTokenUse()).auth-manager.ts,mcp-client-last-use.ts):verifyMcpAccessTokenno longer schedules the consent last-use stamp. It puts the principal it returns in aWeakMapwith the verified token'siat(issuedAtOf, moved tomcp-client-last-use.tswith thejosedecodeJwtread).recordMcpAccessTokenUsestamps only an object in that map, then deletes it, so a hand-built principal, a copy, or the same object handed back twice records nothing more. The stamp itself (McpClientLastUseStamper, at most one write per consent per hour per process, after the response) is unchanged. The whole member sits in atry, so it never throws.resolve-execution-context.ts): the door keeps the verifier's answer inverifiedTokenPrincipalonly when the ban read does not refuse it, awaitsresolveRequestExecutionContext, and only then callsauthService?.recordMcpAccessTokenUse?.(verifiedTokenPrincipal). So a banned subject, a grant-read outage (503, thrown before that line) and an unverified token all record nothing. A forward credential never sets it: it is the in-process continuation of a call already recorded. The member is reached through the declared, slot-typedIAuthService(no cast to an undeclared member)..changeset/31211-mcp-token-use-after-admission.md:patchon@objectstack/spec,@objectstack/plugin-auth,@objectstack/runtime(kindnonetakespatch; widening keepspatch, AGENTS.md Post-Task Checklist step 3), with both declaration lines and the host note.Which owner, and why: the door. Seat 6 ruled option A (record 6710928373 on #30949) of the four-axis analysis in report 6710893443: one owner of "admitted", so a refusal added at the door later needs no second edit, and no second ban read anywhere.
Tests
rig()ofmcp-client-last-use.real-engine.test.tswith a realAuthManagerover a real SQL engine, a bannedsys_userrow, a consent written by better-auth's adapter, a jwt-plugin token, resolved by runtimeresolveExecutionContext). Production sources restored to basefe97d85b:banned=true principalKind=guest ... stamp=HOUR_10 writes=1,AssertionError: a refused call stamps nothing, 1 failed | 1 passed. At head:banned=true principalKind=guest userId=undefined stamp=null writes=0,banned=false principalKind=agent ... writes=1, 2 passed.resolve-execution-context.test.ts, new describe[#31211], 6 cases): admitted records exactly once, with the verifier's own object (toBe); banned records nothing; a grant-read outage assertscodeSERVICE_UNAVAILABLE+status503 and records nothing; an unverified token records nothing; a forward credential records nothing; an auth service without the member still admits.mcp-client-last-use.real-engine.test.ts, new describe[#31211], 4 cases): verification alone reads and writes no stamp (control: the same token admitted stamps the hour); a hand-built principal and a spread copy record nothing (control: the verifier's object stamps); one object handed back again in a later hour writes nothing more; the member never throws on an unknown or null principal. The 23 existing calls that expected a stamp fromverifyMcpAccessTokennow go throughadmittedCall(verify, then record the returned object), as the door does.scripts/ablation-replace.mjs, each restored to the HEAD blob withgit diff HEADempty; both packages' tests import these sources directly, so no dist leg):admitted: recorded exactly oncered (expected [] to have a length of 1).verification alone reads no stampand the hand-built case red.?? {}): the hand-built case red.delete:the same object again is not a second admitted callred (2 vs 1).pnpm --filter @objectstack/plugin-auth test235 files, 4187 passed | 10 skipped (VERDICT command-exit 0); runtimesrc/security/+http-dispatcher.mcp-oauth.test.ts+mcp-federation-bridge.test.ts15 files, 228 passed, andresolve-execution-context.test.ts53 passed at the head; specsrc/contracts/52 files, 475 passed.pnpm typecheckfor@objectstack/spec,@objectstack/plugin-auth,@objectstack/runtime: exit 0 each (runtime re-run at the head).Acceptance notes
content/docs/kernel/contracts/auth-service.mdxis hand-written prose (not undercontent/docs/references/);check:docsand the other spec artifact gates are green with no regenerated file (pnpm --filter @objectstack/spec check:generated: all 14 artifacts up to date, against a spec dist built with this member).AuthManager.verifyMcpAccessTokenitself (outside the runtime door) no longer gets a stamp from verification; the changeset says to callrecordMcpAccessTokenUseonce it admits the call. No such caller exists in this repo (verifyMcpAccessTokenhas one non-test caller, the runtime door).Verification
Gate union derived by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(108 families for this diff), run on327f91d8, reconciled with--ran: "108 derived famil(ies) accounted for — 106 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)".check:where-matcherexit 1 flagged the new door test's table double (no combinator refusal), fixed ine8c0e991(it now refuses$operators and object values as the neighbouring double does), rerun exit 0.check:query-options-erasureexit 124 at a 300 s cap, rerun uncapped exit 0.check-plugin-teardown-shape.mjs --self-testexit 3 on the shallow clone, exit 0 aftergit fetch --depth=1 origin 621a4876.pnpm --filter @objectstack/spec run check:skill-examplesandpnpm check:dual-build-cjs-loads: exit 3 PREREQUISITE NOT MET (they read every package's built dist, e.g.packages/client-react/dist; only the runtime closure was built). CI runs both on a full build.e8c0e991(a test-only commit on top): re-rancheck:where-matcher,check:query-options-erasure,check:cross-package-test-inputs,check:test-source-alias,check:engine-double-contract,check:objectql-double-limit,check:nul-bytes,check:issue-citations,check:closed-ceremony-text, exit 0 each, plus the runtime typecheck.eslint --no-inline-configover the six changed source and test files: exit 0.origin/mainmoved 12 commits since the base; none touches a file of this diff.Generated by Claude Code