Skip to content

Commit c7d8767

Browse files
committed
fix(devframe): subscribe implicit state resources
1 parent 4f3f436 commit c7d8767

8 files changed

Lines changed: 181 additions & 20 deletions

File tree

packages/devframe/src/adapters/initiate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ export function initDevframe(
321321
const mounted = mountMcpHttp(app, context, mcpPath, {
322322
serverName: `${def.id} (devframe)`,
323323
serverVersion: def.version ?? '0.0.0',
324-
exposeSharedState: true,
324+
exposeSharedState: mcpConfig.exposeSharedState ?? true,
325325
allowedOrigins: mcpConfig.allowedOrigins,
326326
})
327327
mcpDispose = mounted.dispose

packages/devframe/src/adapters/mcp/__tests__/fixtures/resource-stdio-server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ const definition: DevframeDefinition = {
88
packageName: '@devframe/resource-stdio-test',
99
homepage: 'https://example.com',
1010
description: 'Stdio resource test fixture.',
11-
setup(ctx) {
11+
async setup(ctx) {
12+
const state = await ctx.rpc.sharedState.get('stdio:counter', {
13+
initialValue: { count: 0 },
14+
})
15+
ctx.agent.registerTool({
16+
id: 'increment-state',
17+
description: 'Increment the fixture state.',
18+
handler: () => state.mutate(value => void (value.count += 1)),
19+
})
1220
const fixed = ctx.agent.registerResource({
1321
id: 'status',
1422
uri: 'https://example.com/status',

packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,58 @@ describe('mcp adapter (streamable http route)', () => {
126126
await client.close()
127127
})
128128

129+
it('pushes subscribed shared-state updates and cleans up on disconnect', async () => {
130+
let updateState: (() => void) | undefined
131+
const started = await boot(defineTestDef({
132+
async setup(ctx) {
133+
const state = await ctx.rpc.sharedState.get('build:status', {
134+
initialValue: { revision: 0 },
135+
})
136+
updateState = () => state.mutate(value => void (value.revision += 1))
137+
},
138+
}))
139+
const transport = originTransport(started)
140+
const client = new Client({ name: 'test-client', version: '0.0.0' })
141+
const notifications: string[] = []
142+
client.setNotificationHandler('notifications/resources/updated', (notification) => {
143+
notifications.push(notification.params.uri)
144+
})
145+
146+
await client.connect(transport)
147+
const uri = 'devframe://state/build%3Astatus'
148+
await client.subscribeResource({ uri })
149+
updateState!()
150+
await vi.waitFor(() => expect(notifications).toEqual([uri]))
151+
152+
await transport.terminateSession()
153+
updateState!()
154+
expect(notifications).toEqual([uri])
155+
await client.close()
156+
})
157+
158+
it('can disable implicit shared-state MCP exposure for the HTTP route', async () => {
159+
server = await createDevServer(defineTestDef({
160+
async setup(ctx) {
161+
await ctx.rpc.sharedState.get('hidden:state', { initialValue: { value: true } })
162+
},
163+
}), {
164+
host: '127.0.0.1',
165+
port: 0,
166+
mcp: { exposeSharedState: false },
167+
})
168+
const client = new Client({ name: 'test-client', version: '0.0.0' })
169+
try {
170+
await client.connect(originTransport(server))
171+
const resources = await client.listResources()
172+
const tools = await client.listTools()
173+
expect(resources.resources).toEqual([])
174+
expect(tools.tools.map(tool => tool.name)).not.toContain('devframe_state_read')
175+
}
176+
finally {
177+
await client.close()
178+
}
179+
})
180+
129181
it('tears the session down on DELETE and rejects reuse of the id', async () => {
130182
const started = await boot()
131183
const url = `${started.origin}/__mcp`

packages/devframe/src/adapters/mcp/__tests__/mcp-server.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,60 @@ describe('mcp adapter (in-memory)', () => {
471471
}
472472
})
473473

474+
it('subscribes to shared-state updates and removes the listener on unsubscribe', async () => {
475+
const { ctx, client, cleanup } = await bootPair()
476+
const notifications: string[] = []
477+
client.setNotificationHandler('notifications/resources/updated', (notification) => {
478+
notifications.push(notification.params.uri)
479+
})
480+
try {
481+
const state = await ctx.rpc.sharedState.get('my-plugin:counter', {
482+
initialValue: { count: 0 },
483+
})
484+
const uri = `devframe://state/${encodeURIComponent('my-plugin:counter')}`
485+
486+
await Promise.all([
487+
client.subscribeResource({ uri }),
488+
client.subscribeResource({ uri }),
489+
])
490+
state.mutate(value => void (value.count += 1))
491+
await vi.waitFor(() => expect(notifications).toEqual([uri]))
492+
493+
await client.unsubscribeResource({ uri })
494+
state.mutate(value => void (value.count += 1))
495+
await client.listResources()
496+
expect(notifications).toEqual([uri])
497+
}
498+
finally {
499+
await cleanup()
500+
}
501+
})
502+
503+
it('rejects hidden, missing, and malformed shared-state subscriptions', async () => {
504+
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
505+
await ctx.rpc.sharedState.get('visible:key', { initialValue: { value: true } })
506+
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { value: false } })
507+
const { server, dispose } = buildMcpServerFromContext(ctx, {
508+
serverName: 'test',
509+
serverVersion: '0.0.0-test',
510+
exposeSharedState: key => key.startsWith('visible:'),
511+
})
512+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
513+
await server.connect(serverTransport)
514+
const client = new Client({ name: 'test-client', version: '0.0.0' })
515+
await client.connect(clientTransport)
516+
try {
517+
await expect(client.subscribeResource({ uri: 'devframe://state/hidden%3Akey' })).rejects.toThrow('unknown resource URI')
518+
await expect(client.subscribeResource({ uri: 'devframe://state/missing' })).rejects.toThrow('unknown resource URI')
519+
await expect(client.subscribeResource({ uri: 'devframe://state/%E0%A4%A' })).rejects.toThrow('unknown resource URI')
520+
}
521+
finally {
522+
await client.close()
523+
await server.close()
524+
await dispose()
525+
}
526+
})
527+
474528
it('omits non-object output schemas (MCP requires type: "object")', async () => {
475529
const { ctx, client, cleanup } = await bootPair()
476530
try {
@@ -598,6 +652,7 @@ describe('mcp adapter (stdio)', () => {
598652
expect(resources.resources.map(resource => resource.uri)).toEqual(expect.arrayContaining([
599653
'https://example.com/status',
600654
'devframe://logs/app',
655+
'devframe://state/stdio%3Acounter',
601656
]))
602657
const templates = await client.listResourceTemplates()
603658
expect(templates.resourceTemplates.map(template => template.uriTemplate)).toEqual(['devframe://logs/{name}'])
@@ -611,7 +666,15 @@ describe('mcp adapter (stdio)', () => {
611666
expect(JSON.parse((template.contents[0] as { text: string }).text)).toEqual({ process: 'worker' })
612667

613668
await client.subscribeResource({ uri: 'https://example.com/status' })
614-
await vi.waitFor(() => expect(updates).toEqual(['https://example.com/status']))
669+
await client.subscribeResource({ uri: 'devframe://state/stdio%3Acounter' })
670+
const increment = await client.callTool({ name: 'increment-state', arguments: {} })
671+
expect(increment.isError).toBeFalsy()
672+
const updatedState = await client.readResource({ uri: 'devframe://state/stdio%3Acounter' })
673+
expect(JSON.parse((updatedState.contents[0] as { text: string }).text)).toEqual({ count: 1 })
674+
await vi.waitFor(() => expect(updates).toEqual(expect.arrayContaining([
675+
'https://example.com/status',
676+
'devframe://state/stdio%3Acounter',
677+
])))
615678
}
616679
finally {
617680
await client.close()

packages/devframe/src/adapters/mcp/build-server.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,11 @@ function registerResourceHandlers(
303303
ctx: DevframeNodeContext,
304304
exposeSharedState: boolean | ((key: string) => boolean),
305305
): () => Promise<void> {
306-
const subscriptions = new Map<string, () => void | Promise<void>>()
306+
const stateFilter = sharedStateFilter(exposeSharedState)
307+
const subscriptions = new Map<string, {
308+
kind: 'agent' | 'state'
309+
cleanup: () => void | Promise<void>
310+
}>()
307311
let subscriptionOperations = Promise.resolve()
308312
const runSubscriptionOperation = <Result>(operation: () => Promise<Result>): Promise<Result> => {
309313
const result = subscriptionOperations.then(operation)
@@ -325,10 +329,9 @@ function registerResourceHandlers(
325329
resources.push(...listed.resources)
326330
}
327331

328-
if (exposeSharedState !== false) {
329-
const filter = typeof exposeSharedState === 'function' ? exposeSharedState : () => true
332+
if (stateFilter) {
330333
for (const key of ctx.rpc.sharedState.keys()) {
331-
if (!filter(key))
334+
if (!stateFilter(key))
332335
continue
333336
resources.push({
334337
uri: `devframe://state/${encodeURIComponent(key)}`,
@@ -370,7 +373,7 @@ function registerResourceHandlers(
370373
}
371374

372375
const parsed = parseResourceUri(uri)
373-
if (parsed.kind === 'state') {
376+
if (parsed.kind === 'state' && stateFilter?.(parsed.key) && ctx.rpc.sharedState.keys().includes(parsed.key)) {
374377
const state = await ctx.rpc.sharedState.get(parsed.key)
375378
return {
376379
contents: [
@@ -393,24 +396,36 @@ function registerResourceHandlers(
393396
return {}
394397

395398
const resource = resolveAgentResource(ctx, uri)
396-
if (!resource)
399+
if (resource) {
400+
const cleanup = await ctx.agent.subscribeResource(resource.id, uri)
401+
subscriptions.set(uri, { kind: 'agent', cleanup })
402+
return {}
403+
}
404+
405+
const parsed = parseResourceUri(uri)
406+
if (parsed.kind !== 'state' || !stateFilter?.(parsed.key) || !ctx.rpc.sharedState.keys().includes(parsed.key))
397407
throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`)
398408

399-
const cleanup = await ctx.agent.subscribeResource(resource.id, uri)
400-
subscriptions.set(uri, cleanup)
409+
const state = await ctx.rpc.sharedState.get(parsed.key)
410+
const cleanup = state.on('updated', () => {
411+
if (!subscriptions.has(uri))
412+
return
413+
void server.sendResourceUpdated({ uri }).catch(() => { /* ignore transport errors */ })
414+
})
415+
subscriptions.set(uri, { kind: 'state', cleanup })
401416
return {}
402417
})
403418
})
404419

405420
server.setRequestHandler('resources/unsubscribe', async (request) => {
406421
const { uri } = request.params
407422
return await runSubscriptionOperation(async () => {
408-
const cleanup = subscriptions.get(uri)
409-
if (!cleanup)
423+
const subscription = subscriptions.get(uri)
424+
if (!subscription)
410425
return {}
411426

412427
subscriptions.delete(uri)
413-
await cleanup()
428+
await subscription.cleanup()
414429
return {}
415430
})
416431
})
@@ -423,13 +438,23 @@ function registerResourceHandlers(
423438

424439
const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => {
425440
void runSubscriptionOperation(async () => {
426-
for (const [uri, cleanup] of [...subscriptions]) {
441+
for (const [uri, subscription] of [...subscriptions]) {
442+
if (subscription.kind === 'state')
443+
continue
444+
try {
445+
await subscription.cleanup()
446+
}
447+
catch {
448+
continue
449+
}
427450
subscriptions.delete(uri)
428-
await cleanup()
429451
const resource = resolveAgentResource(ctx, uri)
430452
if (!resource)
431453
continue
432-
subscriptions.set(uri, await ctx.agent.subscribeResource(resource.id, uri))
454+
subscriptions.set(uri, {
455+
kind: 'agent',
456+
cleanup: await ctx.agent.subscribeResource(resource.id, uri),
457+
})
433458
}
434459
}).catch(() => { /* ignore subscription cleanup errors during reconciliation */ })
435460
})
@@ -440,7 +465,7 @@ function registerResourceHandlers(
440465
await runSubscriptionOperation(async () => {
441466
const active = [...subscriptions.values()]
442467
subscriptions.clear()
443-
await Promise.all(active.map(cleanup => cleanup()))
468+
await Promise.all(active.map(subscription => subscription.cleanup()))
444469
})
445470
}
446471
}
@@ -520,7 +545,13 @@ function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kin
520545
if (!match)
521546
return { kind: 'unknown' }
522547
const [, kind, rest] = match
523-
const decoded = decodeURIComponent(rest!)
548+
let decoded: string
549+
try {
550+
decoded = decodeURIComponent(rest!)
551+
}
552+
catch {
553+
return { kind: 'unknown' }
554+
}
524555
if (kind === 'resource')
525556
return { kind: 'resource', id: decoded }
526557
return { kind: 'state', key: decoded }

packages/devframe/src/types/devframe.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ export interface McpRouteOptions {
110110
* deprecated `allowedHosts`/`allowedOrigins` transport flags).
111111
*/
112112
allowedOrigins?: readonly string[] | false
113+
/**
114+
* Expose shared-state keys as MCP resources and through the built-in
115+
* `devframe_state_read` tool. Defaults to `true`; pass a predicate to
116+
* expose selected keys.
117+
*/
118+
exposeSharedState?: boolean | ((key: string) => boolean)
113119
}
114120

115121
export interface DevframeCliOptions {

packages/hub/src/node/initiate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ export function initHub(options: InitHubOptions): HubInstance {
575575
const mounted = mountMcpHttp(app, ctx, joinURL(base, mcpRoute), {
576576
serverName: options.name ?? 'devframes-hub',
577577
serverVersion: options.version ?? '0.0.0',
578-
exposeSharedState: true,
578+
exposeSharedState: mcpConfig.exposeSharedState ?? true,
579579
allowedOrigins: mcpConfig.allowedOrigins,
580580
})
581581
return { context: ctx, mcp: { path: mcpRoute }, dispose: mounted.dispose }

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ export interface EventUnsubscribe {
428428
export interface McpRouteOptions {
429429
path?: string;
430430
allowedOrigins?: readonly string[] | false;
431+
exposeSharedState?: boolean | ((_: string) => boolean);
431432
}
432433
export interface RemoteAssets {
433434
package: string;

0 commit comments

Comments
 (0)