From c5d482ee7db5bb1bca90084a58d637f0696ab883 Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 9 Oct 2026 23:54:06 +0300 Subject: [PATCH] feat(mcp): add exposeSharedState to the mcp route options The dev server and the hub always passed exposeSharedState: true to the MCP route, so devframe_state_read and the devframe://state resources listed every shared state. The mcp route options now take exposeSharedState (boolean or key filter, default true) and both mounts forward it. --- docs/content/1.guide/15.agent-native.md | 2 +- docs/content/1.guide/18.hub-initiate.md | 2 +- docs/content/2.adapters/7.mcp.md | 10 ++ .../src/adapters/__tests__/initiate.test.ts | 41 ++++++++ packages/devframe/src/adapters/_shared.ts | 3 + packages/devframe/src/adapters/initiate.ts | 2 +- packages/devframe/src/types/devframe.ts | 10 ++ .../hub/src/node/__tests__/initiate.test.ts | 99 +++++++++++++++++++ packages/hub/src/node/initiate.ts | 2 +- .../tsnapi/devframe/index.snapshot.d.ts | 1 + 10 files changed, 168 insertions(+), 4 deletions(-) diff --git a/docs/content/1.guide/15.agent-native.md b/docs/content/1.guide/15.agent-native.md index 4ed4357d..6953bfd1 100644 --- a/docs/content/1.guide/15.agent-native.md +++ b/docs/content/1.guide/15.agent-native.md @@ -96,7 +96,7 @@ ctx.agent.registerResource({ }) ``` -Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out. +Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. `exposeSharedState: false` (or a filter) on `createMcpServer`, or inside the `mcp` route options of the dev server and the hub, opts out. ## Starting the MCP server diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index e84001d0..3394526f 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -89,7 +89,7 @@ Registrations are validated fail-fast: one module per type (`DF8108`), an existi The hub's **single Auth** is one gate at the shared transport for every mounted devframe, built-ins, and the MCP route; one handshake (OTP, magic link, or pre-shared token) unlocks the namespace; `auth: false` disables it for localhost. -The aggregate MCP route mounts through the `'auto'` default once any mounted devframe (or an agent-flagged hub command) exposes agent tools; `mcp: true` forces it on, `mcp: false` off. It has its own origin gate, independent of this RPC Auth: the mounted route trusts same-machine callers, and `mcp: { authorization }` adds an identity check when the hub is reachable beyond loopback. The hub exposes one aggregate route over every mounted devframe's tools. +The aggregate MCP route mounts through the `'auto'` default once any mounted devframe (or an agent-flagged hub command) exposes agent tools; `mcp: true` forces it on, `mcp: false` off. It has its own origin gate, independent of this RPC Auth: the mounted route trusts same-machine callers, and `mcp: { authorization }` adds an identity check when the hub is reachable beyond loopback. `mcp: { exposeSharedState }` takes `false` or a `(key) => boolean` filter to choose which shared-state keys agents can read through `devframe_state_read` and `devframe://state/`. The hub exposes one aggregate route over every mounted devframe's tools. ## Singular vs hub mounting diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index 8a51d898..269a647c 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -59,6 +59,16 @@ A request presents the bearer as `Authorization: Bearer `, matched in con Never place the token in a URL, in `__connection.json`, in the instance registry, in logs, or on the command line; it belongs only in configuration and the `Authorization` header. +### Choosing which shared state agents see + +Every shared-state key is exposed to agents by default, through the `devframe_state_read` tool and the `devframe://state/` resources. `exposeSharedState` on the route options narrows that to the keys a filter accepts, or turns it off with `false`. The UI keeps using the same states over RPC. + +```ts +devframeViteBridge(myDevframe, { + mcp: { exposeSharedState: key => !key.startsWith('my-tool:private:') }, +}) +``` + ### Hosted bridges Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`: diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 28723151..8133c92e 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -319,6 +319,47 @@ describe('adapters/handler', () => { } }) + it('mcp: exposeSharedState filters the shared states the route serves', async () => { + const wsPort = await getPort({ port: 18143, host: '127.0.0.1' }) + const def = defineDevframe({ + id: 'handler-mcp-state', + name: 'State Handler Test', + version: '0.0.0', + packageName: 'devframe-handler-test', + homepage: 'https://example.test', + description: 'Test devframe with shared state.', + setup: async (ctx: DevframeNodeContext) => { + await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } }) + await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } }) + }, + }) + const devtools = initDevframe(def, { + base: '/__handler-mcp-state/', + auth: false, + mcp: { exposeSharedState: key => key.startsWith('visible:') }, + ws: { port: wsPort }, + }) + + try { + await devtools.ready + const res = await devtools.handler(new Request('http://localhost:3000/__handler-mcp-state/__mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + 'origin': 'http://localhost:3000', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'devframe_state_read', arguments: {} } }), + })) + const raw = await res.text() + expect(raw).toContain('visible:key') + expect(raw).not.toContain('hidden:key') + } + finally { + await devtools.close() + } + }) + // The `'auto'` default: an omitted `mcp` mounts the route exactly when // `setup()` left a non-empty agent surface. function defineAgentTestDef(id: string) { diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index 5a4337f2..a06f01cd 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -68,6 +68,8 @@ export interface ResolvedMcpConfig { path?: string /** Origin allow-list, or `false` to disable the origin gate. */ allowedOrigins?: readonly string[] | false + /** Shared-state exposure from the route options; omitted means every key. */ + exposeSharedState?: boolean | ((key: string) => boolean) /** The resolved identity policy: a bearer token, callback, or `false`. */ authorization: McpAuthorization } @@ -96,6 +98,7 @@ export function resolveMcpConfig(mcp: McpSetting | undefined): ResolvedMcpConfig return { ...(mcp.path !== undefined ? { path: mcp.path } : {}), ...(mcp.allowedOrigins !== undefined ? { allowedOrigins: mcp.allowedOrigins } : {}), + ...(mcp.exposeSharedState !== undefined ? { exposeSharedState: mcp.exposeSharedState } : {}), authorization, } } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 1a4dd516..2b000d88 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -379,7 +379,7 @@ async function mountMcpRoute( const mounted = module.mountMcpHttp(app, context, joinURL(base, route), { serverName: `${def.id} (devframe)`, serverVersion: def.version ?? '0.0.0', - exposeSharedState: true, + exposeSharedState: config.exposeSharedState ?? true, authorization: config.authorization, allowedOrigins: config.allowedOrigins, }) diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index f1a17371..719c125d 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -164,6 +164,16 @@ export interface McpRouteOptions { * prove identity too. */ allowedOrigins?: readonly string[] | false + /** + * Which shared-state keys the route exposes to agents, through the built-in + * `devframe_state_read` tool and the `devframe://state/` resources. + * - `true` (default): every key the host publishes + * - `false`: none + * - `(key) => boolean`: only the keys the filter accepts + * + * The states themselves stay available to the UI over RPC. + */ + exposeSharedState?: boolean | ((key: string) => boolean) } export interface DevframeCliOptions { diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 5ef4d164..00c5d9c1 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -324,6 +324,105 @@ describe('initHub', () => { } }) + describe('aggregate MCP shared-state exposure', () => { + const origin = 'http://localhost:3000' + + function makeStateFrame(): DevframeDefinition { + const frame = makeFrame('state') + return { + ...frame, + async setup(ctx: DevframeNodeContext) { + await frame.setup(ctx) + await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } }) + await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } }) + }, + } + } + + async function callMcp(hub: ReturnType, method: string, params?: Record): Promise { + const res = await hub.handler(new Request(`${origin}/__devframes/__mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + origin, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + })) + expect(res.status).toBe(200) + return await res.text() + } + + it('exposes every shared state by default', async () => { + const wsPort = await getPort({ port: 18235, host: '127.0.0.1' }) + const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, mcp: true, devframes: [makeStateFrame()] }) + + try { + await hub.ready + const keys = await callMcp(hub, 'tools/call', { name: 'devframe_state_read', arguments: {} }) + expect(keys).toContain('visible:key') + expect(keys).toContain('hidden:key') + const resources = await callMcp(hub, 'resources/list') + expect(resources).toContain('devframe://state/visible%3Akey') + expect(resources).toContain('devframe://state/hidden%3Akey') + } + finally { + await hub.close() + } + }) + + it('hides the shared states the mcp.exposeSharedState filter rejects', async () => { + const wsPort = await getPort({ port: 18236, host: '127.0.0.1' }) + const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + auth: false, + host: '127.0.0.1', + ws: { port: wsPort }, + mcp: { exposeSharedState: key => key.startsWith('visible:') }, + devframes: [makeStateFrame()], + }) + + try { + await hub.ready + const keys = await callMcp(hub, 'tools/call', { name: 'devframe_state_read', arguments: {} }) + expect(keys).toContain('visible:key') + expect(keys).not.toContain('hidden:key') + const hidden = await callMcp(hub, 'tools/call', { name: 'devframe_state_read', arguments: { key: 'hidden:key' } }) + expect(hidden).toContain('Unknown shared-state key') + const resources = await callMcp(hub, 'resources/list') + expect(resources).toContain('devframe://state/visible%3Akey') + expect(resources).not.toContain('hidden%3Akey') + } + finally { + await hub.close() + } + }) + + it('hides devframe_state_read when mcp.exposeSharedState is false', async () => { + const wsPort = await getPort({ port: 18237, host: '127.0.0.1' }) + const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + auth: false, + host: '127.0.0.1', + ws: { port: wsPort }, + mcp: { exposeSharedState: false }, + devframes: [makeStateFrame()], + }) + + try { + await hub.ready + const tools = await callMcp(hub, 'tools/list') + expect(tools).toContain('state-tool') + expect(tools).not.toContain('devframe_state_read') + const resources = await callMcp(hub, 'resources/list') + expect(resources).not.toContain('devframe://state/') + } + finally { + await hub.close() + } + }) + }) + it('aggregate MCP omitted: mounts once a mounted frame exposes agent tools', async () => { const wsPort = await getPort({ port: 18233, host: '127.0.0.1' }) const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, devframes: [makeFrame('alpha')] }) diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 3edb91ed..a3285c24 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -522,7 +522,7 @@ export function initHub(options: InitHubOptions): HubInstance { const mounted = mcpModule.mountMcpHttp(app, ctx, joinURL(base, mcpRoute), { serverName: options.name ?? 'devframes-hub', serverVersion: options.version ?? '0.0.0', - exposeSharedState: true, + exposeSharedState: mcpConfig.exposeSharedState ?? true, authorization: mcpConfig.authorization, allowedOrigins: mcpConfig.allowedOrigins, }) diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 9d0ebbbf..84973181 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -444,6 +444,7 @@ export interface McpRouteOptions { path?: string; authorization?: McpAuthorization; allowedOrigins?: readonly string[] | false; + exposeSharedState?: boolean | ((_: string) => boolean); } export interface McpServerHandle { stop: () => Promise;