Skip to content

Commit ff4c1c7

Browse files
authored
feat(mcp): add exposeSharedState to the mcp route options (#444)
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.
1 parent 3ca4abb commit ff4c1c7

10 files changed

Lines changed: 168 additions & 4 deletions

File tree

‎docs/content/1.guide/15.agent-native.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ ctx.agent.registerResource({
9696
})
9797
```
9898

99-
Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/<key>` 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.
99+
Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/<key>` 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.
100100

101101
## Starting the MCP server
102102

‎docs/content/1.guide/18.hub-initiate.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Registrations are validated fail-fast: one module per type (`DF8108`), an existi
8989

9090
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.
9191

92-
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.
92+
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/<key>`. The hub exposes one aggregate route over every mounted devframe's tools.
9393

9494
## Singular vs hub mounting
9595

‎docs/content/2.adapters/7.mcp.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ A request presents the bearer as `Authorization: Bearer <token>`, matched in con
5959

6060
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.
6161

62+
### Choosing which shared state agents see
63+
64+
Every shared-state key is exposed to agents by default, through the `devframe_state_read` tool and the `devframe://state/<key>` 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.
65+
66+
```ts
67+
devframeViteBridge(myDevframe, {
68+
mcp: { exposeSharedState: key => !key.startsWith('my-tool:private:') },
69+
})
70+
```
71+
6272
### Hosted bridges
6373

6474
Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`:

‎packages/devframe/src/adapters/__tests__/initiate.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,47 @@ describe('adapters/handler', () => {
319319
}
320320
})
321321

322+
it('mcp: exposeSharedState filters the shared states the route serves', async () => {
323+
const wsPort = await getPort({ port: 18143, host: '127.0.0.1' })
324+
const def = defineDevframe({
325+
id: 'handler-mcp-state',
326+
name: 'State Handler Test',
327+
version: '0.0.0',
328+
packageName: 'devframe-handler-test',
329+
homepage: 'https://example.test',
330+
description: 'Test devframe with shared state.',
331+
setup: async (ctx: DevframeNodeContext) => {
332+
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
333+
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
334+
},
335+
})
336+
const devtools = initDevframe(def, {
337+
base: '/__handler-mcp-state/',
338+
auth: false,
339+
mcp: { exposeSharedState: key => key.startsWith('visible:') },
340+
ws: { port: wsPort },
341+
})
342+
343+
try {
344+
await devtools.ready
345+
const res = await devtools.handler(new Request('http://localhost:3000/__handler-mcp-state/__mcp', {
346+
method: 'POST',
347+
headers: {
348+
'content-type': 'application/json',
349+
'accept': 'application/json, text/event-stream',
350+
'origin': 'http://localhost:3000',
351+
},
352+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'devframe_state_read', arguments: {} } }),
353+
}))
354+
const raw = await res.text()
355+
expect(raw).toContain('visible:key')
356+
expect(raw).not.toContain('hidden:key')
357+
}
358+
finally {
359+
await devtools.close()
360+
}
361+
})
362+
322363
// The `'auto'` default: an omitted `mcp` mounts the route exactly when
323364
// `setup()` left a non-empty agent surface.
324365
function defineAgentTestDef(id: string) {

‎packages/devframe/src/adapters/_shared.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export interface ResolvedMcpConfig {
6868
path?: string
6969
/** Origin allow-list, or `false` to disable the origin gate. */
7070
allowedOrigins?: readonly string[] | false
71+
/** Shared-state exposure from the route options; omitted means every key. */
72+
exposeSharedState?: boolean | ((key: string) => boolean)
7173
/** The resolved identity policy: a bearer token, callback, or `false`. */
7274
authorization: McpAuthorization
7375
}
@@ -96,6 +98,7 @@ export function resolveMcpConfig(mcp: McpSetting | undefined): ResolvedMcpConfig
9698
return {
9799
...(mcp.path !== undefined ? { path: mcp.path } : {}),
98100
...(mcp.allowedOrigins !== undefined ? { allowedOrigins: mcp.allowedOrigins } : {}),
101+
...(mcp.exposeSharedState !== undefined ? { exposeSharedState: mcp.exposeSharedState } : {}),
99102
authorization,
100103
}
101104
}

‎packages/devframe/src/adapters/initiate.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ async function mountMcpRoute(
379379
const mounted = module.mountMcpHttp(app, context, joinURL(base, route), {
380380
serverName: `${def.id} (devframe)`,
381381
serverVersion: def.version ?? '0.0.0',
382-
exposeSharedState: true,
382+
exposeSharedState: config.exposeSharedState ?? true,
383383
authorization: config.authorization,
384384
allowedOrigins: config.allowedOrigins,
385385
})

‎packages/devframe/src/types/devframe.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,16 @@ export interface McpRouteOptions {
164164
* prove identity too.
165165
*/
166166
allowedOrigins?: readonly string[] | false
167+
/**
168+
* Which shared-state keys the route exposes to agents, through the built-in
169+
* `devframe_state_read` tool and the `devframe://state/<key>` resources.
170+
* - `true` (default): every key the host publishes
171+
* - `false`: none
172+
* - `(key) => boolean`: only the keys the filter accepts
173+
*
174+
* The states themselves stay available to the UI over RPC.
175+
*/
176+
exposeSharedState?: boolean | ((key: string) => boolean)
167177
}
168178

169179
export interface DevframeCliOptions {

‎packages/hub/src/node/__tests__/initiate.test.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,105 @@ describe('initHub', () => {
324324
}
325325
})
326326

327+
describe('aggregate MCP shared-state exposure', () => {
328+
const origin = 'http://localhost:3000'
329+
330+
function makeStateFrame(): DevframeDefinition {
331+
const frame = makeFrame('state')
332+
return {
333+
...frame,
334+
async setup(ctx: DevframeNodeContext) {
335+
await frame.setup(ctx)
336+
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
337+
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
338+
},
339+
}
340+
}
341+
342+
async function callMcp(hub: ReturnType<typeof initHub>, method: string, params?: Record<string, unknown>): Promise<string> {
343+
const res = await hub.handler(new Request(`${origin}/__devframes/__mcp`, {
344+
method: 'POST',
345+
headers: {
346+
'content-type': 'application/json',
347+
'accept': 'application/json, text/event-stream',
348+
origin,
349+
},
350+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
351+
}))
352+
expect(res.status).toBe(200)
353+
return await res.text()
354+
}
355+
356+
it('exposes every shared state by default', async () => {
357+
const wsPort = await getPort({ port: 18235, host: '127.0.0.1' })
358+
const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, mcp: true, devframes: [makeStateFrame()] })
359+
360+
try {
361+
await hub.ready
362+
const keys = await callMcp(hub, 'tools/call', { name: 'devframe_state_read', arguments: {} })
363+
expect(keys).toContain('visible:key')
364+
expect(keys).toContain('hidden:key')
365+
const resources = await callMcp(hub, 'resources/list')
366+
expect(resources).toContain('devframe://state/visible%3Akey')
367+
expect(resources).toContain('devframe://state/hidden%3Akey')
368+
}
369+
finally {
370+
await hub.close()
371+
}
372+
})
373+
374+
it('hides the shared states the mcp.exposeSharedState filter rejects', async () => {
375+
const wsPort = await getPort({ port: 18236, host: '127.0.0.1' })
376+
const hub = initHub({
377+
base: DEVFRAMES_HUB_BASE,
378+
auth: false,
379+
host: '127.0.0.1',
380+
ws: { port: wsPort },
381+
mcp: { exposeSharedState: key => key.startsWith('visible:') },
382+
devframes: [makeStateFrame()],
383+
})
384+
385+
try {
386+
await hub.ready
387+
const keys = await callMcp(hub, 'tools/call', { name: 'devframe_state_read', arguments: {} })
388+
expect(keys).toContain('visible:key')
389+
expect(keys).not.toContain('hidden:key')
390+
const hidden = await callMcp(hub, 'tools/call', { name: 'devframe_state_read', arguments: { key: 'hidden:key' } })
391+
expect(hidden).toContain('Unknown shared-state key')
392+
const resources = await callMcp(hub, 'resources/list')
393+
expect(resources).toContain('devframe://state/visible%3Akey')
394+
expect(resources).not.toContain('hidden%3Akey')
395+
}
396+
finally {
397+
await hub.close()
398+
}
399+
})
400+
401+
it('hides devframe_state_read when mcp.exposeSharedState is false', async () => {
402+
const wsPort = await getPort({ port: 18237, host: '127.0.0.1' })
403+
const hub = initHub({
404+
base: DEVFRAMES_HUB_BASE,
405+
auth: false,
406+
host: '127.0.0.1',
407+
ws: { port: wsPort },
408+
mcp: { exposeSharedState: false },
409+
devframes: [makeStateFrame()],
410+
})
411+
412+
try {
413+
await hub.ready
414+
const tools = await callMcp(hub, 'tools/list')
415+
expect(tools).toContain('state-tool')
416+
expect(tools).not.toContain('devframe_state_read')
417+
const resources = await callMcp(hub, 'resources/list')
418+
expect(resources).not.toContain('devframe://state/')
419+
}
420+
finally {
421+
await hub.close()
422+
}
423+
})
424+
})
425+
327426
it('aggregate MCP omitted: mounts once a mounted frame exposes agent tools', async () => {
328427
const wsPort = await getPort({ port: 18233, host: '127.0.0.1' })
329428
const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, devframes: [makeFrame('alpha')] })

‎packages/hub/src/node/initiate.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ export function initHub(options: InitHubOptions): HubInstance {
522522
const mounted = mcpModule.mountMcpHttp(app, ctx, joinURL(base, mcpRoute), {
523523
serverName: options.name ?? 'devframes-hub',
524524
serverVersion: options.version ?? '0.0.0',
525-
exposeSharedState: true,
525+
exposeSharedState: mcpConfig.exposeSharedState ?? true,
526526
authorization: mcpConfig.authorization,
527527
allowedOrigins: mcpConfig.allowedOrigins,
528528
})

‎tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@ export interface McpRouteOptions {
444444
path?: string;
445445
authorization?: McpAuthorization;
446446
allowedOrigins?: readonly string[] | false;
447+
exposeSharedState?: boolean | ((_: string) => boolean);
447448
}
448449
export interface McpServerHandle {
449450
stop: () => Promise<void>;

0 commit comments

Comments
 (0)