From 62c24900b8f89a5367fc0b0126678006f2d75c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:28:34 +0000 Subject: [PATCH 1/2] feat(mcp): per-request server instructions on the authoring injection 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 Claude-Session: https://claude.ai/code/session_017ihL2dLQEXWGsE2SZ3Qn5F --- ...mcp-server-runtime.authoring-scope.test.ts | 104 +++++++++++++++++- packages/mcp/src/mcp-server-runtime.ts | 28 +++++ packages/mcp/src/types.ts | 11 ++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts index 20b41b70031..5f2c3f41e86 100644 --- a/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts +++ b/packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts @@ -22,6 +22,10 @@ * injected registry when the scope is absent, and the long-lived stdio * bridge (`bridgeTools`) still calls its registry with ONE argument — the * shared registration body gained a seam, not a behaviour change. + * 4. [#31022] The INSTRUCTIONS. A host's per-request instructions on the + * injection answer `initialize` only under the grant that registers the + * family; a `data:read` token, a key or a session gets the configured or + * default text, and so does an authoring request that carries none. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -141,6 +145,8 @@ interface CallOptions { headers?: Record; /** Omit the data bridge entirely (authoring-only host). */ noBridge?: boolean; + /** [#31022] Per-request server instructions carried on the authoring injection. */ + instructions?: string; } 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 parsedBody: body, ...(o.grantedScopes ? { toolOptions: { grantedScopes: o.grantedScopes } } : {}), ...(o.registry - ? { authoring: { registry: o.registry, principal: PRINCIPAL, environmentId: ENVIRONMENT_ID } } + ? { + authoring: { + registry: o.registry, + principal: PRINCIPAL, + environmentId: ENVIRONMENT_ID, + ...(o.instructions !== undefined ? { instructions: o.instructions } : {}), + }, + } : {}), }); // 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 expect(arities).toEqual([1]); }); }); + +describe('[#31022] per-request instructions on the authoring injection — answered at initialize', () => { + let runtime: MCPServerRuntime; + let bridge: ReturnType; + let registry: RecordingAuthoringRegistry; + const HOST_INSTRUCTIONS = 'Authoring rules: call get_authoring_rules with no arguments for the index.'; + + beforeEach(() => { + runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' }); + bridge = makeBridge(); + registry = makeAuthoringRegistry(); + }); + + async function instructionsAtInitialize(rt: MCPServerRuntime, o: CallOptions = {}): Promise { + const { status, json } = await call(rt, bridge, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'pin', version: '0' } }, + }, o); + expect(status).toBe(200); + expect(json.error).toBeUndefined(); + return json.result.instructions; + } + + it('a metadata:write request whose injection carries instructions is answered with them', async () => { + const control = await instructionsAtInitialize(runtime, { grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], registry }); + const subject = await instructionsAtInitialize(runtime, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE], + registry, + instructions: HOST_INSTRUCTIONS, + }); + expect(subject).toBe(HOST_INSTRUCTIONS); + // Control leg: the same door without the field answers something else, so + // the equality above is the injection's doing and not a coincidence. + expect(control).not.toBe(HOST_INSTRUCTIONS); + }); + + it('a data:read request never sees the injected text — it gets the default', async () => { + const defaultText = await instructionsAtInitialize(runtime, { grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ] }); + const subject = await instructionsAtInitialize(runtime, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ], + registry, + instructions: HOST_INSTRUCTIONS, + }); + expect(typeof defaultText).toBe('string'); + expect(subject).toBe(defaultText); + expect(subject).not.toBe(HOST_INSTRUCTIONS); + }); + + it('the undefined grant (API key / session) and a data:write token never see it either', async () => { + const defaultText = await instructionsAtInitialize(runtime, {}); + expect(await instructionsAtInitialize(runtime, { registry, instructions: HOST_INSTRUCTIONS })).toBe(defaultText); + expect( + await instructionsAtInitialize(runtime, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_WRITE], + registry, + instructions: HOST_INSTRUCTIONS, + }), + ).toBe(defaultText); + }); + + it('a metadata:write request whose injection carries no instructions gets the default', async () => { + const defaultText = await instructionsAtInitialize(runtime, { grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ] }); + const subject = await instructionsAtInitialize(runtime, { grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], registry }); + expect(subject).toBe(defaultText); + }); + + it('a runtime configured with instructions keeps them for every request the injection does not reach', async () => { + const CONFIGURED = 'Configured server-wide instructions.'; + const configured = new MCPServerRuntime({ name: 't', version: '1.0.0', instructions: CONFIGURED }); + expect( + await instructionsAtInitialize(configured, { + grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ], + registry, + instructions: HOST_INSTRUCTIONS, + }), + ).toBe(CONFIGURED); + expect(await instructionsAtInitialize(configured, { grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], registry })).toBe(CONFIGURED); + // Under the admitting grant the per-request text wins over the server-wide one. + expect( + await instructionsAtInitialize(configured, { + grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], + registry, + instructions: HOST_INSTRUCTIONS, + }), + ).toBe(HOST_INSTRUCTIONS); + }); +}); diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index a2b4b1a4e12..f68539a8ec2 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -1285,6 +1285,27 @@ export function registerAuthoringTools( return registered; } +/** + * [#31022] The per-request server instructions a host injected beside its + * authoring registry, or `undefined` when this request must not see them. + * + * The text rides {@link McpAuthoringInjection} rather than + * `handleHttpRequest`'s own options so that the authoring family stays its + * only reader: it is read under {@link grantsAuthoringTools}, the one + * predicate that admits the registry in {@link registerAuthoringTools}. A + * grant that registers the family is answered with the host's text; a + * `data:read` / `data:write` / `actions:execute` token, an empty grant, and + * the undefined grant of an API key or a session are not, whatever the host + * injected — they fall through to the configured or default instructions. + */ +function authoringInstructions( + authoring: McpAuthoringInjection | undefined, + grantedScopes: readonly string[] | undefined, +): string | undefined { + if (!authoring || !grantsAuthoringTools(grantedScopes)) return undefined; + return authoring.instructions; +} + /** * MCPServerRuntime — Bridges ObjectStack kernel services to the Model Context Protocol. * @@ -1914,6 +1935,7 @@ export class MCPServerRuntime { * @param opts.toolOptions Tool exposure options (system objects, query limits, the token's `grantedScopes`). * @param opts.authoring [#26464] The host's authoring registry plus the token principal and environment * that become each call's provenance; registered only for a `metadata:write` grant. + * [#31022] Its optional `instructions` answer `initialize` under that same grant. */ async handleHttpRequest( request: Request, @@ -1972,7 +1994,13 @@ export class MCPServerRuntime { // declaration is what makes the handlers installable, and it is gated // on the seam actually being there — declared IS served. capabilities: { ...(skillBridge ? { prompts: {} } : {}) }, + // [#31022] A host's per-request instructions ride the authoring + // injection and are read under the grant that admits the family + // (`authoringInstructions`), so a request the family does not + // register for — a `data:read` token, a key, a session — is answered + // with the configured or default text and never sees them. instructions: + authoringInstructions(opts.authoring, opts.toolOptions?.grantedScopes) ?? this.config.instructions ?? 'ObjectStack MCP Server — query and modify your app\'s data objects as tools.', }, diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 4d5b9b83c9d..59f689a2951 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -112,4 +112,15 @@ export interface McpAuthoringInjection { principal: McpTokenPrincipal; /** The environment the host dispatched this request for. */ environmentId?: string; + /** + * The server instructions this request's `initialize` answers with — the + * text an MCP client puts in its model's context (where the authoring + * rules live, which ones exist). Read under the SAME grant that admits + * {@link registry}: a request whose grant registers the authoring family + * is answered with this text; any other request — a `data:read` token, an + * API key, a session — gets the runtime's configured or default + * instructions and never sees it. Omitted, the authoring request gets the + * configured or default instructions too. + */ + instructions?: string; } From e5540c8fdccbf43495ef331b840ee8b8d8adc027 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:30:31 +0000 Subject: [PATCH 2/2] chore(changeset): mcp per-request authoring instructions Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dLQEXWGsE2SZ3Qn5F --- .changeset/mcp-per-request-authoring-instructions.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/mcp-per-request-authoring-instructions.md diff --git a/.changeset/mcp-per-request-authoring-instructions.md b/.changeset/mcp-per-request-authoring-instructions.md new file mode 100644 index 00000000000..1103cd42450 --- /dev/null +++ b/.changeset/mcp-per-request-authoring-instructions.md @@ -0,0 +1,10 @@ +--- +'@objectstack/mcp': patch +--- + +feat(mcp): a host passes per-request server instructions on `McpAuthoringInjection`, answered at `initialize` only where the authoring family registers + +Clause-②: yes +Metadata-protocol: none + +`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.