Skip to content

fix(mcp): the stdio door answers get_record misses and describe_object readonly as the HTTP door does - #33175

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-3d24w1
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-3d24w1

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #33030
Fixes #33156

Clause-②: no
Metadata-protocol: none

File surface (claim, as amended at review): packages/mcp/src/stdio-data-bridge.ts, packages/mcp/src/mcp-server-runtime.ts, packages/mcp/src/mcp-http-tools.ts (the get_record branch only), packages/runtime/src/domains/mcp.ts (bridge member, and the writeNeverSetsField JSDoc's last sentence), their tests, root .changeset/ (including .changeset/32530-describe-object-readonly-computed.md, one sentence).

维护者速读

  • 改了什么:stdio 传输的 MCP 门现在和 HTTP /mcp 门回答一致。get_record 查不到记录(不存在或调用者无权看到)时回带编码的 RECORD_NOT_FOUND / 404,不再是一句无编码的文字;describe_object 和 objectstack://objects/{name} 资源对「表达式被字段遮蔽隐藏的存储计算字段」回 readonly: true。
  • 为什么改:stdio 客户端按错误码分支,拿不到码就只能解析文字;readonly: false 会让代理去写一个引擎随后覆盖的字段,写了等于白写。
  • 风险与回滚:只改 stdio 门的回答,使其等于 HTTP 门已有的回答;不放宽任何可读可写范围,隐藏的表达式和它引用的字段依然不下发。不存在与无权看到回同一个信封,不构成存在性探测。回滚 = revert 本 PR,不涉及数据。
  • 先看:packages/mcp/src/mcp-server-runtime.ts 的 readObjectWithDeclaration 与 describeServedObject(def, declared);packages/mcp/src/stdio-data-bridge.ts 的 callerObjectView(注册声明读取);packages/mcp/src/mcp-http-tools.ts 的 get_record 空结果分支。
  • 两分钟验证:pnpm --filter @objectstack/mcp exec vitest run --maxWorkers=2 src/stdio-data-bridge.not-found.test.ts src/stdio-data-bridge.object-schema-mask.test.ts,预期 42/42 通过。
  • 需要你做的:无,席位落地。

What changes

  • get_record (MCP stdio get_record answers a missing or hidden id with uncoded error text, while the HTTP door answers RECORD_NOT_FOUND 404 #33030). The stdio bridge's get answers a by-id miss with null by design, and the tool rendered that as Record "ID" not found in "OBJECT" with no code. The null branch now throws recordNotFoundError(objectName, recordId) (@objectstack/core, the repo's one not-found envelope) into the tool's own catch, which renders it through dataDoorErrorResult. That is the value the HTTP bridge's get throws (protocol.getData through callData) and the renderer its catch uses, so both doors render one throw through one renderer: { "error": { "code": "RECORD_NOT_FOUND", "message": "Record ID not found in OBJECT", "status": 404 } }. A missing id and an id the row filter hides reach the tool as the same empty read and answer the same bytes (no existence oracle). update_record and delete_record were already coded; pinned here, unchanged.
  • describe_object and the objects resource (mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156). The stdio caller view (callerObjectView) now registers, beside the view it returns, a read that answers the served object AND the declaration it was projected from, from ONE read of the definition (setDeclaredObjectRead, package-internal, keyed weakly by the view like setRuntimeObjectView). readObjectWithDeclaration(view, name) asks it; a view that registered none masks nothing, so its object is its declaration. describeServedObject(def, declared = def) judges readonly with writeNeverSetsField(declared.fields[key]), exactly as the HTTP twin does, and builds every other member from the served object. The stdio bridge's describeObject and buildObjectSchemaResource both read through it; plugin.ts already wires the resource to callerObjectView, so it needed no change.
  • Why this option of the card's two. A second objectView.getObject read beside the masked one would read the definition twice, and the two reads could disagree mid-write; the registered read answers both halves from one read and serves nothing extra. Only writeNeverSetsField's boolean is read off the declaration: the expression and the field it names stay out of the answer (pinned, and ablated).

Tests

  • packages/mcp/src/stdio-data-bridge.not-found.test.ts, new top-level describe on the file's existing engine double (its find made row-filtering, so no new double enters the check:engine-double-contract ledger): over the long-lived server start() attaches stdio to, the real createStdioDataBridge:
    • a missing id answers isError and the coded envelope;
    • a hidden id (a row only its owner reads) answers the same envelope, and the read ran as the caller;
    • CONTROL: the owner is served that row;
    • PARITY: for both ids, the stdio tools/call result equals, deep-equal, the HTTP /mcp result from handleHttpRequest (real Streamable HTTP transport) over a bridge whose get throws recordNotFoundError, the value mcp-coded-refusal.test.ts pins as the HTTP face's answer;
    • update_record and delete_record on both ids answer the same envelope.
  • packages/mcp/src/stdio-data-bridge.object-schema-mask.test.ts, new top-level describe with the HTTP twin's fixture (marked_up is a stored expression over hidden_cost, which the member may not read):
    • precondition: the member's served object carries marked_up without its expression and no hidden_cost;
    • stdio describe_object and the resource (wired as plugin.ts wires it: callerObjectView over stdioObjectView) answer the full readonly map, marked_up: true, with no hidden_cost token and no record. expression text anywhere, and no expression member on marked_up;
    • the tool and the resource answer the same fields.
  • packages/runtime/src/domains/mcp-object-schema-mask.test.ts: a PARITY case on the existing real-layer harness (real ObjectQL over SqlDriver, real SecurityPlugin, the real /mcp entry) holding the same readonly map for the same member over the authored fields. Neither package depends on the other (the reason the two describeServedObject twins exist), so the doors' parity is that one literal, pinned on each side.

Acceptance notes

  • Deviation from the order's wording, declared. The order names errorResultWithCode for the null branch. The PR throws into the catch, which renders through dataDoorErrorResult, the renderer the HTTP door's own throw takes. For RECORD_NOT_FOUND the two produce the same bytes today (mcp-coded-refusal.test.ts pins both shapes); the catch path keeps them equal if the /data door ever adds a member (declaredCode, userMessage) to this code.
  • DELIBERATE CORRECTION of a pending release note. .changeset/32530-describe-object-readonly-computed.md (pending, landed with PR fix(mcp,runtime): describe_object serves readonly: true where a write never sets the value #33150) described the stdio door and the objects resource as judging the served field, which this PR makes false. Its two sentences "On the HTTP transport, readonly is judged on the field as declared, … and the expression itself stays hidden. Over stdio and on the resource, which hold only the object as the caller is served it, the expression case answers true where the caller is shown the expression." now read: "On every transport, the resource included, readonly is judged on the field as declared, so a stored expression the caller is not shown (it names a field they cannot read) still answers true, and the expression itself stays hidden." check-empty-changeset.mjs --base origin/main is red on that file by design (DELIBERATE CORRECTION class); confirmation is the seat's record.
  • The HTTP twin's JSDoc. writeNeverSetsField's last sentence in packages/runtime/src/domains/mcp.ts said the stdio twin "has only the masked object to ask"; it now says the stdio bridge and the resource ask the declared field too (readObjectWithDeclaration). Prose only.
  • Same class, outside the cards: the record resource. objectstack://objects/{name}/records/{id} on a miss answers {"error":"Record \"ghost\" not found in \"task\""} with no code / status (measured over the long-lived server at this head), while its two sibling resources answer { error, code, status } and its own catch already adds them for a thrown coded refusal (mcp-server-runtime.ts, bridgeResources, record_by_id). Reported to the seat as a finding; not fixed here.
  • Skills-docs: not owed (Metadata-protocol: none, no narrowing arm); skill-md.ts restates neither the not-found wording nor readonly.
  • Serial. #33150 merged before the mcp: stdio describe_object and the objects resource serve readonly: false for a stored expression field whose expression the caller's field mask hides, while the HTTP door answers true #33156 half started (28a2f0f5, merged into this branch once); protocol-stdout.ts and cli/src/commands/serve.ts (fix(cli): with the stdio MCP transport on, a host plugin's console output while serving a call reaches stderr #33159) untouched.

Verification

All readings at head 59ccee50 (base 28a2f0f5) unless named. Head 532a02e5 adds only the two prose edits above; its re-run gates are in the last block.

Measured at origin/main first (both premises hold)
Package tests and typecheck
  • pnpm --filter @objectstack/mcp test (at c1b63360; 59ccee50 only re-shapes the not-found test's double) — Test Files 64 passed (64), Tests 1148 passed (1148), VERDICT command-exit 0
  • stdio-data-bridge.not-found.test.ts at 59ccee50 — Tests 8 passed (8)
  • mcp-object-schema-mask.test.ts (runtime) — Tests 73 passed (73)
  • pnpm --filter @objectstack/mcp typecheck and pnpm --filter @objectstack/runtime typecheck — exit 0, each including check:test-typecheck over tsconfig.test.json
  • The runtime package's only change is that one test file; its full suite is declared to CI.
Ablations (4, all red, all restored)

Every leg through scripts/ablation-replace.mjs (anchor x1 → x0, blob changed, restored blob == HEAD, git diff HEAD empty). The suites import the source, so no dist/ leg.

  • get_record null branch back to the bare errorResult sentence → 3 failed (missing, hidden, PARITY).
  • describeServedObject judging the served field (writeNeverSetsField(f)) → 2 failed (tool and resource).
  • setDeclaredObjectRead registration deleted → 2 failed (tool and resource).
  • stdio describeObject building the answer from the DECLARATION → 9 failed, the new "neither its expression nor hidden_cost is served" case and the case-table masking pins among them.
Derived gates and lint

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 66 families, each run with stdin closed, exit code recorded before any pipe. --ran at 59ccee50: 66 accounted, 65 run, 1 NOT-MEASURED.

  • pnpm check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET (needs dist/ of packages outside this diff's closure). NOT MEASURED; CI runs it.
  • node scripts/check-plugin-teardown-shape.mjs --self-test first exited 3 (pinned positive control absent from the shallow clone); after fetching 621a4876, exit 0, 48 cases.
  • pnpm check:engine-double-contract first exited 1 (a second double in the not-found file); the test now reuses the file's one double, exit 0.
  • The gates that read test files were re-run at 59ccee50, exit 0. All other families exit 0.
  • pnpm check:type-check-debt — exit 0. pnpm lint — exit 0. No export renamed or removed.
  • At 532a02e5 (the two prose edits), the families that read those files, re-run: check-adr-0087-registration (--base, --self-test), check-changeset-no-major, check:changeset-gate-self-tests, check-closing-keyword-parity, check:doc-authoring, check:nul-bytes, check:issue-citations and its script, check:adr-symbol-anchors, both comment-mask gates, both docs-audit scripts, check:closed-ceremony-text, check:keyed-text-bounds, check:dispatcher-error-vocabulary, check:route-envelope, and eslint packages/runtime/src/domains/mcp.ts — exit 0. check-empty-changeset.mjs --base origin/main — exit 1, the DELIBERATE CORRECTION refusal above, expected.

Generated by Claude Code

…ded RECORD_NOT_FOUND

The stdio bridge answers a by-id miss with null, and get_record rendered
that as a bare sentence, while the HTTP door's bridge throws the shared
recordNotFoundError and answers the coded 404 envelope. get_record now
throws that same value into its own catch, so both doors render one throw
through one renderer. A missing id and an id the caller may not read
reach the tool as the same empty read and answer alike.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvhQvy8SbR3j9f7kcb
…ly on the declared field

The stdio caller view served only the masked object, so a stored field
whose expression names a field the caller cannot read lost its
expression in the mask and was described readonly: false, while the
engine still computes it. The caller view now registers a read that
answers the served object beside the declaration it was projected from,
from one read of the definition; describe_object and the objects
resource judge readonly on the declared field, as the HTTP twin does,
and build the answer from the served object only.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvhQvy8SbR3j9f7kcb
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/mcp, @objectstack/runtime, touching 12 documentable anchor(s).

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 588596af4320139479c7086dc8f300026123af35.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 588596af4320139479c7086dc8f300026123af35 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e542148584a037f950c1ccfb185e106eb8ca623a — the merge of head 532a02e508b59b19c5954b46dd125c2c7630d538 into base 588596af4320139479c7086dc8f300026123af35, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e542148584a037f950c1ccfb185e106eb8ca623a && git checkout e542148584a037f950c1ccfb185e106eb8ca623a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 588596af4320139479c7086dc8f300026123af35 532a02e508b59b19c5954b46dd125c2c7630d538 && git checkout -B drift-repro 588596af4320139479c7086dc8f300026123af35 && git merge --no-ff 532a02e508b59b19c5954b46dd125c2c7630d538

node scripts/docs-audit/affected-docs.mjs --json 588596af4320139479c7086dc8f300026123af35

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 588596af4320139479c7086dc8f300026123af35 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ion too

The HTTP twin's writeNeverSetsField JSDoc and the unreleased describe_object
readonly changeset still said the stdio door and the objects resource judge
the masked field. They now say what holds: every transport judges readonly
on the declared field.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvhQvy8SbR3j9f7kcb
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#32897#1 · os-tesla · session_01L9qibzoApSyVPuia3NCrBY

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 532a02e508b59b19c5954b46dd125c2c7630d538
Local-runs: none
Scope: packages/mcp/src/mcp-http-tools.ts, packages/mcp/src/mcp-server-runtime.ts, packages/mcp/src/stdio-data-bridge.ts, packages/runtime/src/domains/mcp.ts, .changeset/32530-describe-object-readonly-computed.md + none

No contract face (no packages/spec/src/**, no governed path, Clause-②: no, no narrowing): this is the dispatching seat's own record at the seat's tier, the same-head record check-empty-changeset.mjs asks for to confirm a DELIBERATE CORRECTION in a diff that also changes code. Inputs: the two cards and their comments, the PR body and file list, the net diff, the check-runs on this head.

① Derived judgments

  • stdio get_record, missing or hidden id: bare sentence → the coded RECORD_NOT_FOUND / 404 envelope. The null branch throws recordNotFoundError(objectName, recordId) from @objectstack/core into the tool's own catch (dataDoorErrorResult), the same value and renderer the HTTP bridge's throwing get reaches. Missing and hidden reach the branch as the same empty read, so no existence oracle. Right. Any other bridge that answers a miss with null gains the same coded answer; none answers worse.
  • stdio describe_object and objectstack://objects/{name}: readonly is judged on the declared field (describeServedObject(def, declared), writeNeverSetsField(declared.fields?.[key])); every other member of the answer is still built from the served (masked) definition, and only fields the served definition lists are iterated. The declaration crosses only through package-internal setDeclaredObjectRead / readObjectWithDeclaration (not re-exported). Right: the HTTP twin's answer, nothing hidden served.

② Semver level

Clause-②: no. @objectstack/mcp patch in both new changesets; @objectstack/runtime gains a JSDoc sentence only. Right.

③ Boundary flags

DELIBERATE CORRECTION confirmed, .changeset/32530-describe-object-readonly-computed.md, sentence by sentence:

  • Before: "On the HTTP transport, readonly is judged on the field as declared, so a stored expression the caller is not shown (it names a field they cannot read) still answers true, and the expression itself stays hidden. Over stdio and on the resource, which hold only the object as the caller is served it, the expression case answers true where the caller is shown the expression."
  • After: "On every transport, the resource included, readonly is judged on the field as declared, so a stored expression the caller is not shown (it names a field they cannot read) still answers true, and the expression itself stays hidden."
  • Judgment: the second "before" sentence becomes false with this diff (stdio and the resource now ask the declaration); the "after" sentence is true of this head per ① and adds no claim. The rest of that note is unchanged. Check Changeset is red on exactly this file, the DELIBERATE CORRECTION class (job step "Reject an empty-frontmatter changeset added by this PR"); pr-automation.yml runs on pull_request only, so the merge group does not run it.
  • packages/runtime/src/domains/mcp.ts writeNeverSetsField JSDoc, last sentence: before "which has only the masked object to ask: there the expression arm holds where the caller is served the expression", after "which the stdio bridge and the objectstack://objects/{name} resource ask of the declared field too … so every transport answers this verdict alike". True of this head.

Check-runs on this head: 55 — 35 success, 18 skipped (path filter), 2 failure, both Check Changeset (the correction above), 0 other.

Implemented-by: claude/project-thread-3d24w1
Reviewed-by: session_01L9qibzoApSyVPuia3NCrBY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#32897#1 · os-tesla · session_01L9qibzoApSyVPuia3NCrBY

维护者速读 · PR #33175 @ 532a02e5 · 2026-11-14T00:48Z

席位核对


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants