Skip to content

feat(mcp): per-request server instructions on McpAuthoringInjection, answered only where the authoring family registers - #31141

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-31022-mcp-per-request-instructions
Nov 7, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-31022-mcp-per-request-instructions

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread

Fixes #31022

Clause-②: yes
Metadata-protocol: none

维护者速读

  • 改了什么:MCP 宿主现在可以在「搭建入口」的注入里按请求带一段说明(McpAuthoringInjection.instructions),客户端连上来握手时拿到的就是这段说明,而不是那句固定默认话。
  • 为什么改:cloud 的搭建入口要告诉 Claude 等客户端「规则在哪、有哪些」,原来平台只能给全服务器一段固定说明,没法按请求区分;cloud#3658 等这个口子接线。
  • 风险与回滚:只读权限(data:read)、API key、会话等拿不到搭建工具的请求,永远看不到这段说明,仍是原来的默认话;不传这个字段时行为与今天完全一样。回滚即 revert 本 PR。
  • 先看:packages/mcp/src/mcp-server-runtime.ts(authoringInstructions 与 handleHttpRequest 里 instructions: 那一行)、packages/mcp/src/types.ts(新字段的注释)、packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts(末尾新加的 describe)。
  • 两分钟验证:pnpm --filter @objectstack/mcp exec vitest run src/mcp-server-runtime.authoring-scope.test.ts,预期 24 个用例全绿,其中 5 个是本次新增的说明用例。
  • 需要你做的:无,席位落地。

What changes

  • McpAuthoringInjection (packages/mcp/src/types.ts) gains an optional instructions?: string.
  • MCPServerRuntime.handleHttpRequest builds the per-request server with authoringInstructions(opts.authoring, grantedScopes) ?? this.config.instructions ?? DEFAULT_SENTENCE (the hard-coded default). The new private helper returns the injected text only when grantsAuthoringTools(grantedScopes) admits the request, the same predicate registerAuthoringTools uses to register the family. Every other request (a data:read, data:write or actions:execute token, an empty grant, the undefined grant of an API key or a session) gets the configured or default instructions.
  • Why on the injection, not on handleHttpRequest's opts: an opts-level field would be a second, ungated input that any request could carry, so keeping the data:read guarantee would need a second gate beside the registry's. On the injection, the text sits inside the object only the authoring family reads, and the one predicate that admits the registry also admits the text. A host cannot hand it to a request the family does not serve.
  • Changeset: @objectstack/mcp patch (Metadata-protocol none).

Tests

A new top-level describe in the existing shared harness packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts. Each case reads result.instructions off a real initialize round-trip through handleHttpRequest:

  1. A metadata:write request whose injection carries instructions is answered with them. A control leg (same door, no field) answers something else.
  2. A data:read request with the same injection gets the default, byte-equal to a data:read request with no injection.
  3. The undefined grant (API key or session) and a data:write token get the default too.
  4. A metadata:write request whose injection carries no instructions gets the default.
  5. A runtime configured with server-wide instructions keeps them for every request the injection does not reach. Under the admitting grant, the per-request text wins.

The default sentence is not pinned verbatim. Each "gets the default" assertion compares against a control request on the same runtime.

Ablation (one behaviour, the grant gate): scripts/ablation-replace.mjs replaced if (!authoring || !grantsAuthoringTools(grantedScopes)) return undefined; with if (!authoring) return undefined;. Anchor 1→0, blob f68539a8ec22 → 83181ec01751. The file went 3 failed / 21 passed (24), failing cases 2, 3 and 5 above. The restore put the blob back to HEAD and git diff HEAD was empty. The test imports ./mcp-server-runtime.js from source, so no dist rebuild was involved.

Acceptance notes

  • When the grant admits the family, the injected text is served even if the host's registry holds no tools. The gate is the grant predicate, as for registration itself. What the registry holds and what the instructions say are the host's business.
  • An empty-string instructions is passed through as given, and the SDK then omits instructions from the answer. Only undefined falls back to the configured or default text.
  • The long-lived stdio server (MCPServerRuntime constructor) is untouched. It has no per-request authoring injection.

Verification

All readings were taken on head e5540c8f. git merge origin/main was a no-op: origin/main is still the branch point 5f003744, read via ls-remote.

Package tests, typecheck, lint (@objectstack/mcp)
pnpm --filter '@objectstack/mcp^...' build                    :: exit 0   (lock VERDICT command-exit 0)
vitest run src/mcp-server-runtime.authoring-scope.test.ts      :: exit 0   Tests 24 passed (24)
pnpm --filter @objectstack/mcp test                            :: exit 0   Test Files 60 passed (60) · Tests 767 passed (767)
pnpm --filter @objectstack/mcp typecheck                       :: exit 0   check:test-typecheck OK (debt ledger held)
eslint packages/mcp --no-inline-config  (repo `pnpm lint` scoped) :: exit 0

@objectstack/mcp has no lint script of its own, so the repo pnpm lint command (eslint . --no-inline-config) was run scoped to packages/mcp.

Derived gate families (dispatch-gates --commands): 60 derived, 60 run, 0 NOT-MEASURED

All 60 commands exited 0. Four exited 3 on the first pass and were re-run green:

  • check:dual-build-cjs-loads and check:lean-entry-closure had no dist/. After a full pnpm build --concurrency=2 (exit 0), both returned exit 0.
  • check:type-check-debt hit a lock queue-timeout. Re-run under the lock, exit 0.
  • check-plugin-teardown-shape --self-test could not reach its pinned fixture commit in the shallow clone. Once that object was present, exit 0.

dispatch-gates --ran with exit codes recorded: 60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED (a DERIVED zero).


🤖 Generated with Claude Code

McpAuthoringInjection gains an optional `instructions`; handleHttpRequest
answers `initialize` with it only under the grant that registers the
authoring family, and with the configured or default text otherwise.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dLQEXWGsE2SZ3Qn5F
@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 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 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; 104 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 — 12 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 af3451b08bc39e7b4c163f52397cffe728b3e99d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9084e583bb2afbf701517074a9f3641df978a6eb — the merge of head e5540c8fdccbf43495ef331b840ee8b8d8adc027 into base af3451b08bc39e7b4c163f52397cffe728b3e99d, 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 9084e583bb2afbf701517074a9f3641df978a6eb && git checkout 9084e583bb2afbf701517074a9f3641df978a6eb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin af3451b08bc39e7b4c163f52397cffe728b3e99d e5540c8fdccbf43495ef331b840ee8b8d8adc027 && git checkout -B drift-repro af3451b08bc39e7b4c163f52397cffe728b3e99d && git merge --no-ff e5540c8fdccbf43495ef331b840ee8b8d8adc027

node scripts/docs-audit/affected-docs.mjs --json af3451b08bc39e7b4c163f52397cffe728b3e99d

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F

Contract review

Served-tier: TIER_DEFAULT
Head-sha: e5540c8fdccbf43495ef331b840ee8b8d8adc027
Local-runs: none

① Derived judgments

  • Face: declaration only (Clause-②: yes on the claim 6706888456, PR body and changeset). No path under packages/spec/**, no governed path, no error code, no tool, no route. Diff read in full: 4 files, +152/−1.
  • The widening is one optional field, McpAuthoringInjection.instructions?: string (packages/mcp/src/types.ts), on a host-injection type already exported from @objectstack/mcp. A host that passes nothing answers exactly as before: instructions: authoringInstructions(...) ?? this.config.instructions ?? DEFAULT — the new term is undefined whenever the field is absent.
  • The field is read only under grantsAuthoringTools(grantedScopes), the same predicate registerAuthoringTools uses to admit the registry, so "declared is served" holds: a request that gets the text is a request that gets the authoring family. Pins: data:read, data:write, the undefined grant (API key/session) get the configured/default text; an authoring request without the field gets the default; a configured server-wide text survives for every other request. Dev ablation (gate removed → 3 of 24 fail) recorded in report 6708031096.
  • Placement on the injection rather than on handleHttpRequest opts is the narrower choice: an opts field would be an ungated second input any caller could set.

② Semver level

  • Additive optional field on an exported interface ⇒ minor-compatible; the changeset declares @objectstack/mcp patch, consistent with the repo's pre-1.x/fleet practice for additive host seams (no consumer breaks). No narrowing arm.

③ Boundary flags

  • Security/permission boundary: none moved. The text is host-authored, reaches only grants that already admit the authoring family, and carries no credential. Empty string passes through and the SDK omits it (noted in the PR's acceptance notes) — no fallback hides a host error.

Implemented-by: claude/issue-31022-mcp-per-request-instructions
Reviewed-by: session_017ihL2dLQEXWGsE2SZ3Qn5F

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F

席位核对 — ACCEPT · domain:server#4 · Head-sha: e5540c8fdccbf43495ef331b840ee8b8d8adc027

  • Shape: draft → main, attribution lines first, Fixes #31022 once, Clause-②: yes and Metadata-protocol: none line-initial in body and changeset (@objectstack/mcp patch). 4 files, +152/−1, packages/mcp/** + one .changeset; not governed, under 3000 lines (check-governed-merges --pr 31141).
  • Contract review (declaration face): PASS at TIER_DEFAULT, same head — the record directly above this comment.
  • Gates (check-runs on the head): 48 runs — 35 success, 13 skipped, 0 failed; Lint & Repo Gates and TypeScript Type Check success. Dev's local dispatch-gates 60/60 exit 0 (report 6708031096).
  • Acceptance: the card's three tests are pinned (authoring+instructions → injected text; data:read → default; authoring without the field → default), plus key/session/data:write and configured-text fallback.
  • Out of scope: none filed. Consumer wiring is cloud#3658's, after the pin moves.
  • Landing: Tier: none. Seat lands: pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 7, 2026 14:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 7, 2026 14:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42382546156 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (1/8) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL   unit  test/platform-page-i18n-parity.test.ts > shipped platform record pages -- i18n ownership (#14817) > holds every inline locale map on those pages complete in every 
      ↳ 失败原因: @objectstack/cli:test: AssertionError: expected [ { …(2) } ] to deeply equal []
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 16 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F

Queue ejection read · build 42382546156 · Test Core (1/8) · test/platform-page-i18n-parity.test.ts (AssertionError). Anchor #31153 lists this PR as an inherited row of stack S1 rooted at #31120 (Arabic platform-object translations); this diff is packages/mcp/src/** + one changeset and touches no platform page or translation. #31137, the other inherited row, re-queued without #31120 ahead of it and merged (d5fd948e). The seat re-arms auto-merge once on the same head e5540c8fdccbf43495ef331b840ee8b8d8adc027; a second ejection on this signature without #31120 ahead stops the re-queue and goes to diagnosis.

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