Skip to content

Commit 958ec79

Browse files
committed
feat(devframe): add MCP resource subscriptions
1 parent d25c4b5 commit 958ec79

22 files changed

Lines changed: 607 additions & 77 deletions

File tree

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,25 @@ 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+
Devframe assigns `devframe://resource/<encoded-id>` by default. Set `uri` to expose another URI. `read` runs for every MCP read.
100+
101+
## Publishing resource updates
102+
103+
Resource handles publish invalidations after their underlying value changes:
104+
105+
```ts
106+
const buildResource = ctx.agent.registerResource({
107+
id: 'live-build',
108+
name: 'Live build',
109+
read: () => ({ json: currentBuild() }),
110+
})
111+
112+
buildEvents.on('changed', () => buildResource.notifyUpdated())
113+
```
114+
115+
`notifyUpdated()` sends the resource URI as an invalidation. MCP 2026 callers receive it through `subscriptions/listen` when their `resourceSubscriptions` filter contains that URI, then call `resources/read` for the current value. MCP 2025 callers pull current values through `resources/list` and `resources/read`.
116+
117+
Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/<encoded-key>` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. MCP 2026 subscriptions receive shared-state invalidations under the same encoded URI. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out.
100118

101119
## Starting the MCP server
102120

@@ -135,7 +153,7 @@ In `claude_desktop_config.json`:
135153
}
136154
```
137155

138-
Restart; tools appear in the drawer, resources as `devframe://resource/<id>` / `devframe://state/<key>` URIs.
156+
Restart; tools appear in the drawer. Resources use their declared URI, the generated `devframe://resource/<id>` URI, or `devframe://state/<key>` for implicit shared state.
139157

140158
## Writing descriptions agents act on
141159

docs/content/8.references/3.events.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Emitted on `ctx.agent.events`; adapters (e.g. the MCP server) re-publish their m
6969
| `agent:manifest:changed` | any tool/resource/provider change ||
7070
| `agent:tool:registered` / `agent:tool:unregistered` | `registerTool` / `unregisterTool` | `AgentTool` / id |
7171
| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` / id |
72+
| `agent:resource:updated` | resource handle `notifyUpdated` | resource URI |
7273

7374
### RPC client connection events
7475

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
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { DevframeDefinition } from '../../../../types/devframe'
2+
import { createMcpServer } from '../../build-server'
3+
4+
const definition: DevframeDefinition = {
5+
id: 'resource-stdio-test',
6+
name: 'Resource stdio test',
7+
version: '1.0.0',
8+
packageName: '@devframe/resource-stdio-test',
9+
homepage: 'https://example.com',
10+
description: 'Stdio resource test fixture.',
11+
async setup(ctx) {
12+
const state = await ctx.rpc.sharedState.get('stdio:counter', {
13+
initialValue: { count: 0 },
14+
})
15+
const fixed = ctx.agent.registerResource({
16+
id: 'status',
17+
uri: 'https://example.com/status',
18+
name: 'Status',
19+
read: () => ({ json: { status: 'ok' } }),
20+
})
21+
const ignored = ctx.agent.registerResource({
22+
id: 'ignored',
23+
name: 'Ignored',
24+
read: () => ({ json: { ignored: true } }),
25+
})
26+
ctx.agent.registerTool({
27+
id: 'increment-state',
28+
description: 'Increment the fixture state.',
29+
handler: () => {
30+
state.mutate(value => void (value.count += 1))
31+
fixed.notifyUpdated()
32+
ignored.notifyUpdated()
33+
},
34+
})
35+
},
36+
}
37+
38+
await createMcpServer(definition, { transport: 'stdio' })

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

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { StartedServer } from '../../../node/instance-shell'
22
import type { DevframeDefinition } from '../../../types/devframe'
33
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
4-
import { afterEach, describe, expect, it } from 'vitest'
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
55
import { createDevServer } from '../../dev'
66

77
function defineTestDef(overrides?: Partial<DevframeDefinition>): DevframeDefinition {
@@ -62,26 +62,40 @@ describe('mcp adapter (streamable http route)', () => {
6262

6363
// A native MCP client must send a (loopback) Origin so the route's gate —
6464
// which rejects Origin-less requests — accepts it.
65-
function originTransport(started: StartedServer): StreamableHTTPClientTransport {
65+
function originTransport(
66+
started: StartedServer,
67+
onRequest?: (request: Request) => void | Promise<void>,
68+
): StreamableHTTPClientTransport {
6669
return new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`), {
6770
requestInit: { headers: { origin: started.origin } },
71+
...(onRequest
72+
? {
73+
fetch: async (input, init) => {
74+
const request = new Request(input, init)
75+
await onRequest(request.clone())
76+
return fetch(request)
77+
},
78+
}
79+
: {}),
6880
})
6981
}
7082

71-
it('serves the modern era statelessly and lists agent tools', async () => {
83+
it('serves MCP 2026 statelessly and lists agent tools', async () => {
84+
expect.assertions(5)
7285
const started = await boot()
7386
const transport = originTransport(started)
74-
// Negotiate the 2026-07-28 era via `server/discover`.
87+
// Negotiate MCP 2026-07-28 via `server/discover`.
7588
const client = new Client(
7689
{ name: 'test-client', version: '0.0.0' },
7790
{ versionNegotiation: { mode: 'auto' } },
7891
)
7992
try {
8093
await client.connect(transport)
81-
// Stateless per-request serving: the modern era negotiates no
94+
// Stateless per-request serving negotiates no
8295
// `Mcp-Session-Id` — there is no session to key state on.
8396
expect(client.getProtocolEra()).toBe('modern')
8497
expect(transport.sessionId).toBeUndefined()
98+
expect(client.getServerCapabilities()?.resources).toEqual({ listChanged: true, subscribe: true })
8599

86100
const tools = await client.listTools()
87101
expect(tools.tools.map(t => t.name)).toContain('greet')
@@ -95,6 +109,149 @@ describe('mcp adapter (streamable http route)', () => {
95109
}
96110
})
97111

112+
it('delivers filtered resource updates through an MCP 2026 streaming POST', async () => {
113+
expect.assertions(3)
114+
let notifyBuildUpdated!: () => void
115+
let notifyIgnoredUpdated!: () => void
116+
let updateExistingState!: () => void
117+
let createAndUpdateLateState!: () => Promise<void>
118+
let removeLateState!: () => void
119+
const started = await boot(defineTestDef({
120+
async setup(ctx) {
121+
const build = ctx.agent.registerResource({
122+
id: 'build',
123+
name: 'Build',
124+
read: () => ({ json: { status: 'ok' } }),
125+
})
126+
const ignored = ctx.agent.registerResource({
127+
id: 'ignored',
128+
name: 'Ignored',
129+
read: () => ({ json: { ignored: true } }),
130+
})
131+
const existingState = await ctx.rpc.sharedState.get('build:status', {
132+
initialValue: { revision: 0 },
133+
})
134+
notifyBuildUpdated = build.notifyUpdated
135+
notifyIgnoredUpdated = ignored.notifyUpdated
136+
updateExistingState = () => existingState.mutate(value => void (value.revision += 1))
137+
createAndUpdateLateState = async () => {
138+
const lateState = await ctx.rpc.sharedState.get('build:late', {
139+
initialValue: { revision: 0 },
140+
})
141+
lateState.mutate(value => void (value.revision += 1))
142+
}
143+
removeLateState = () => {
144+
ctx.rpc.sharedState.delete('build:late')
145+
}
146+
},
147+
}))
148+
const client = new Client(
149+
{ name: 'test-client', version: '0.0.0' },
150+
{ versionNegotiation: { mode: 'auto' } },
151+
)
152+
const updates: string[] = []
153+
let resourceListChanges = 0
154+
const listenRequestMethods: string[] = []
155+
client.setNotificationHandler('notifications/resources/updated', (notification) => {
156+
updates.push(notification.params.uri)
157+
})
158+
client.setNotificationHandler('notifications/resources/list_changed', () => {
159+
resourceListChanges += 1
160+
})
161+
162+
await client.connect(originTransport(started, async (request) => {
163+
const body = await request.json().catch(() => undefined) as { method?: string } | undefined
164+
if (body?.method === 'subscriptions/listen')
165+
listenRequestMethods.push(request.method)
166+
}))
167+
const subscription = await client.listen({
168+
resourcesListChanged: true,
169+
resourceSubscriptions: [
170+
'devframe://resource/build',
171+
'devframe://state/build%3Astatus',
172+
'devframe://state/build%3Alate',
173+
],
174+
})
175+
try {
176+
notifyIgnoredUpdated()
177+
notifyBuildUpdated()
178+
updateExistingState()
179+
await createAndUpdateLateState()
180+
removeLateState()
181+
182+
await vi.waitFor(() => {
183+
if (updates.length !== 3 || resourceListChanges !== 2)
184+
throw new Error('Waiting for resource update and list-change notifications')
185+
})
186+
expect(listenRequestMethods).toEqual(['POST'])
187+
expect(updates).toEqual([
188+
'devframe://resource/build',
189+
'devframe://state/build%3Astatus',
190+
'devframe://state/build%3Alate',
191+
])
192+
expect(resourceListChanges).toBe(2)
193+
}
194+
finally {
195+
await subscription.close()
196+
await client.close()
197+
}
198+
})
199+
200+
it('keeps MCP 2025 HTTP resource access pull-only', async () => {
201+
expect.assertions(5)
202+
const started = await boot(defineTestDef({
203+
setup(ctx) {
204+
ctx.agent.registerResource({
205+
id: 'build',
206+
name: 'Build',
207+
read: () => ({ json: { status: 'ok' } }),
208+
})
209+
},
210+
}))
211+
const client = new Client({ name: 'mcp-2025-test-client', version: '0.0.0' })
212+
try {
213+
await client.connect(originTransport(started))
214+
expect(client.getProtocolEra()).toBe('legacy')
215+
expect(client.getServerCapabilities()?.resources).toEqual({ listChanged: true })
216+
217+
const resources = await client.listResources()
218+
expect(resources.resources.map(resource => resource.uri)).toContain('devframe://resource/build')
219+
const result = await client.readResource({ uri: 'devframe://resource/build' })
220+
expect(JSON.parse((result.contents[0] as { text: string }).text)).toEqual({ status: 'ok' })
221+
await expect(client.subscribeResource({ uri: 'devframe://resource/build' })).rejects.toThrow()
222+
}
223+
finally {
224+
await client.close()
225+
}
226+
})
227+
228+
it('can disable implicit shared-state MCP exposure for the HTTP route', async () => {
229+
expect.assertions(2)
230+
server = await createDevServer(defineTestDef({
231+
async setup(ctx) {
232+
await ctx.rpc.sharedState.get('hidden:state', { initialValue: { value: true } })
233+
},
234+
}), {
235+
host: '127.0.0.1',
236+
port: 0,
237+
mcp: { exposeSharedState: false },
238+
})
239+
const client = new Client(
240+
{ name: 'test-client', version: '0.0.0' },
241+
{ versionNegotiation: { mode: 'auto' } },
242+
)
243+
try {
244+
await client.connect(originTransport(server))
245+
const resources = await client.listResources()
246+
const tools = await client.listTools()
247+
expect(resources.resources).toEqual([])
248+
expect(tools.tools.map(tool => tool.name)).not.toContain('devframe_state_read')
249+
}
250+
finally {
251+
await client.close()
252+
}
253+
})
254+
98255
it('answers a bare GET with 405 (no session lifecycle)', async () => {
99256
const started = await boot()
100257
// Stateless serving has no session stream to open — the SDK answers a

0 commit comments

Comments
 (0)