Skip to content

Commit 56baf78

Browse files
feat(mcp): per-request server instructions on McpAuthoringInjection, answered only where the authoring family registers (#31141)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiUfC8G7ZWNaCdELoQcXkaCC)_ 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`. <details><summary>Package tests, typecheck, lint (@objectstack/mcp)</summary> ``` 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`. </details> <details><summary>Derived gate families (dispatch-gates --commands): 60 derived, 60 run, 0 NOT-MEASURED</summary> 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)`. </details> --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ffe37e commit 56baf78

4 files changed

Lines changed: 152 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
feat(mcp): a host passes per-request server instructions on `McpAuthoringInjection`, answered at `initialize` only where the authoring family registers
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
`McpAuthoringInjection` gains an optional `instructions` string. `MCPServerRuntime.handleHttpRequest` answers `initialize` with it when the request's grant registers the injected authoring family (a scope list naming `metadata:write`, the same predicate that admits the registry). Every other request — a `data:read`, `data:write` or `actions:execute` token, an API key or a session — is answered with the runtime's configured `instructions`, or the default sentence, and never sees the injected text; an authoring request whose injection carries no `instructions` gets the configured or default text too. A host that serves a build door can now tell the MCP client where its authoring rules live without widening what a read-only connection is told. Nothing existing changes: a host that passes no `instructions` answers exactly as before.

‎packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
* injected registry when the scope is absent, and the long-lived stdio
2323
* bridge (`bridgeTools`) still calls its registry with ONE argument — the
2424
* shared registration body gained a seam, not a behaviour change.
25+
* 4. [#31022] The INSTRUCTIONS. A host's per-request instructions on the
26+
* injection answer `initialize` only under the grant that registers the
27+
* family; a `data:read` token, a key or a session gets the configured or
28+
* default text, and so does an authoring request that carries none.
2529
*/
2630

2731
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -141,6 +145,8 @@ interface CallOptions {
141145
headers?: Record<string, string>;
142146
/** Omit the data bridge entirely (authoring-only host). */
143147
noBridge?: boolean;
148+
/** [#31022] Per-request server instructions carried on the authoring injection. */
149+
instructions?: string;
144150
}
145151

146152
async function call(runtime: MCPServerRuntime, bridge: any, body: unknown, o: CallOptions = {}) {
@@ -158,7 +164,14 @@ async function call(runtime: MCPServerRuntime, bridge: any, body: unknown, o: Ca
158164
parsedBody: body,
159165
...(o.grantedScopes ? { toolOptions: { grantedScopes: o.grantedScopes } } : {}),
160166
...(o.registry
161-
? { authoring: { registry: o.registry, principal: PRINCIPAL, environmentId: ENVIRONMENT_ID } }
167+
? {
168+
authoring: {
169+
registry: o.registry,
170+
principal: PRINCIPAL,
171+
environmentId: ENVIRONMENT_ID,
172+
...(o.instructions !== undefined ? { instructions: o.instructions } : {}),
173+
},
174+
}
162175
: {}),
163176
});
164177
// The buffered JSON-RPC answer, read untyped on purpose: these pins assert
@@ -603,3 +616,92 @@ describe('[#26464] stdio parity — bridgeTools still calls its ToolRegistry wit
603616
expect(arities).toEqual([1]);
604617
});
605618
});
619+
620+
describe('[#31022] per-request instructions on the authoring injection — answered at initialize', () => {
621+
let runtime: MCPServerRuntime;
622+
let bridge: ReturnType<typeof makeBridge>;
623+
let registry: RecordingAuthoringRegistry;
624+
const HOST_INSTRUCTIONS = 'Authoring rules: call get_authoring_rules with no arguments for the index.';
625+
626+
beforeEach(() => {
627+
runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' });
628+
bridge = makeBridge();
629+
registry = makeAuthoringRegistry();
630+
});
631+
632+
async function instructionsAtInitialize(rt: MCPServerRuntime, o: CallOptions = {}): Promise<unknown> {
633+
const { status, json } = await call(rt, bridge, {
634+
jsonrpc: '2.0',
635+
id: 1,
636+
method: 'initialize',
637+
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'pin', version: '0' } },
638+
}, o);
639+
expect(status).toBe(200);
640+
expect(json.error).toBeUndefined();
641+
return json.result.instructions;
642+
}
643+
644+
it('a metadata:write request whose injection carries instructions is answered with them', async () => {
645+
const control = await instructionsAtInitialize(runtime, { grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], registry });
646+
const subject = await instructionsAtInitialize(runtime, {
647+
grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE],
648+
registry,
649+
instructions: HOST_INSTRUCTIONS,
650+
});
651+
expect(subject).toBe(HOST_INSTRUCTIONS);
652+
// Control leg: the same door without the field answers something else, so
653+
// the equality above is the injection's doing and not a coincidence.
654+
expect(control).not.toBe(HOST_INSTRUCTIONS);
655+
});
656+
657+
it('a data:read request never sees the injected text — it gets the default', async () => {
658+
const defaultText = await instructionsAtInitialize(runtime, { grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ] });
659+
const subject = await instructionsAtInitialize(runtime, {
660+
grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ],
661+
registry,
662+
instructions: HOST_INSTRUCTIONS,
663+
});
664+
expect(typeof defaultText).toBe('string');
665+
expect(subject).toBe(defaultText);
666+
expect(subject).not.toBe(HOST_INSTRUCTIONS);
667+
});
668+
669+
it('the undefined grant (API key / session) and a data:write token never see it either', async () => {
670+
const defaultText = await instructionsAtInitialize(runtime, {});
671+
expect(await instructionsAtInitialize(runtime, { registry, instructions: HOST_INSTRUCTIONS })).toBe(defaultText);
672+
expect(
673+
await instructionsAtInitialize(runtime, {
674+
grantedScopes: [MCP_OAUTH_SCOPE_DATA_WRITE],
675+
registry,
676+
instructions: HOST_INSTRUCTIONS,
677+
}),
678+
).toBe(defaultText);
679+
});
680+
681+
it('a metadata:write request whose injection carries no instructions gets the default', async () => {
682+
const defaultText = await instructionsAtInitialize(runtime, { grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ] });
683+
const subject = await instructionsAtInitialize(runtime, { grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], registry });
684+
expect(subject).toBe(defaultText);
685+
});
686+
687+
it('a runtime configured with instructions keeps them for every request the injection does not reach', async () => {
688+
const CONFIGURED = 'Configured server-wide instructions.';
689+
const configured = new MCPServerRuntime({ name: 't', version: '1.0.0', instructions: CONFIGURED });
690+
expect(
691+
await instructionsAtInitialize(configured, {
692+
grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ],
693+
registry,
694+
instructions: HOST_INSTRUCTIONS,
695+
}),
696+
).toBe(CONFIGURED);
697+
expect(await instructionsAtInitialize(configured, { grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], registry })).toBe(CONFIGURED);
698+
// Under the admitting grant the per-request text wins over the server-wide one.
699+
expect(
700+
await instructionsAtInitialize(configured, {
701+
grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE],
702+
registry,
703+
instructions: HOST_INSTRUCTIONS,
704+
}),
705+
).toBe(HOST_INSTRUCTIONS);
706+
});
707+
});

‎packages/mcp/src/mcp-server-runtime.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,27 @@ export function registerAuthoringTools(
12851285
return registered;
12861286
}
12871287

1288+
/**
1289+
* [#31022] The per-request server instructions a host injected beside its
1290+
* authoring registry, or `undefined` when this request must not see them.
1291+
*
1292+
* The text rides {@link McpAuthoringInjection} rather than
1293+
* `handleHttpRequest`'s own options so that the authoring family stays its
1294+
* only reader: it is read under {@link grantsAuthoringTools}, the one
1295+
* predicate that admits the registry in {@link registerAuthoringTools}. A
1296+
* grant that registers the family is answered with the host's text; a
1297+
* `data:read` / `data:write` / `actions:execute` token, an empty grant, and
1298+
* the undefined grant of an API key or a session are not, whatever the host
1299+
* injected — they fall through to the configured or default instructions.
1300+
*/
1301+
function authoringInstructions(
1302+
authoring: McpAuthoringInjection | undefined,
1303+
grantedScopes: readonly string[] | undefined,
1304+
): string | undefined {
1305+
if (!authoring || !grantsAuthoringTools(grantedScopes)) return undefined;
1306+
return authoring.instructions;
1307+
}
1308+
12881309
/**
12891310
* MCPServerRuntime — Bridges ObjectStack kernel services to the Model Context Protocol.
12901311
*
@@ -1914,6 +1935,7 @@ export class MCPServerRuntime {
19141935
* @param opts.toolOptions Tool exposure options (system objects, query limits, the token's `grantedScopes`).
19151936
* @param opts.authoring [#26464] The host's authoring registry plus the token principal and environment
19161937
* that become each call's provenance; registered only for a `metadata:write` grant.
1938+
* [#31022] Its optional `instructions` answer `initialize` under that same grant.
19171939
*/
19181940
async handleHttpRequest(
19191941
request: Request,
@@ -1972,7 +1994,13 @@ export class MCPServerRuntime {
19721994
// declaration is what makes the handlers installable, and it is gated
19731995
// on the seam actually being there — declared IS served.
19741996
capabilities: { ...(skillBridge ? { prompts: {} } : {}) },
1997+
// [#31022] A host's per-request instructions ride the authoring
1998+
// injection and are read under the grant that admits the family
1999+
// (`authoringInstructions`), so a request the family does not
2000+
// register for — a `data:read` token, a key, a session — is answered
2001+
// with the configured or default text and never sees them.
19752002
instructions:
2003+
authoringInstructions(opts.authoring, opts.toolOptions?.grantedScopes) ??
19762004
this.config.instructions ??
19772005
'ObjectStack MCP Server — query and modify your app\'s data objects as tools.',
19782006
},

‎packages/mcp/src/types.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,15 @@ export interface McpAuthoringInjection {
112112
principal: McpTokenPrincipal;
113113
/** The environment the host dispatched this request for. */
114114
environmentId?: string;
115+
/**
116+
* The server instructions this request's `initialize` answers with — the
117+
* text an MCP client puts in its model's context (where the authoring
118+
* rules live, which ones exist). Read under the SAME grant that admits
119+
* {@link registry}: a request whose grant registers the authoring family
120+
* is answered with this text; any other request — a `data:read` token, an
121+
* API key, a session — gets the runtime's configured or default
122+
* instructions and never sees it. Omitted, the authoring request gets the
123+
* configured or default instructions too.
124+
*/
125+
instructions?: string;
115126
}

0 commit comments

Comments
 (0)