Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/mcp-per-request-authoring-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 103 additions & 1 deletion packages/mcp/src/mcp-server-runtime.authoring-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,6 +145,8 @@ interface CallOptions {
headers?: Record<string, string>;
/** 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 = {}) {
Expand All @@ -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
Expand Down Expand Up @@ -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<typeof makeBridge>;
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<unknown> {
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);
});
});
28 changes: 28 additions & 0 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.',
},
Expand Down
11 changes: 11 additions & 0 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading