Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/content/1.guide/15.agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ ctx.agent.registerResource({
})
```

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

## Starting the MCP server

Expand Down
2 changes: 1 addition & 1 deletion docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key>`. The hub exposes one aggregate route over every mounted devframe's tools.

## Singular vs hub mounting

Expand Down
10 changes: 10 additions & 0 deletions docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ A request presents the bearer as `Authorization: Bearer <token>`, 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/<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.

```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`:
Expand Down
41 changes: 41 additions & 0 deletions packages/devframe/src/adapters/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions packages/devframe/src/adapters/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
10 changes: 10 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key>` 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 {
Expand Down
99 changes: 99 additions & 0 deletions packages/hub/src/node/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof initHub>, method: string, params?: Record<string, unknown>): Promise<string> {
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')] })
Expand Down
2 changes: 1 addition & 1 deletion packages/hub/src/node/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
1 change: 1 addition & 0 deletions tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export interface McpRouteOptions {
path?: string;
authorization?: McpAuthorization;
allowedOrigins?: readonly string[] | false;
exposeSharedState?: boolean | ((_: string) => boolean);
}
export interface McpServerHandle {
stop: () => Promise<void>;
Expand Down
Loading