Skip to content

Commit 9789d03

Browse files
committed
feat(devframe): add MCP resource subscriptions and templates
1 parent d25c4b5 commit 9789d03

22 files changed

Lines changed: 989 additions & 101 deletions

File tree

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,47 @@ 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+
## Registering a resource template
102+
103+
RFC 6570 templates describe resources whose URI contains variables. Devframe passes the concrete URI and parsed variables to `read`.
104+
105+
```ts
106+
const processLogs = ctx.agent.registerResource({
107+
id: 'process-logs',
108+
uriTemplate: 'devframe://resource/processes/{processId}/logs/{path}',
109+
name: 'Process log',
110+
mimeType: 'text/plain',
111+
read: (_uri, variables) => ({
112+
text: readProcessLog(String(variables.processId), String(variables.path)),
113+
}),
114+
})
115+
116+
processEvents.on('log-changed', ({ processId, path }) => {
117+
processLogs.notifyUpdated(`devframe://resource/processes/${processId}/logs/${path}`)
118+
})
119+
```
120+
121+
MCP exposes dynamic templates through `resources/templates/list`. Callers read a concrete matching URI through `resources/read`; dynamic entries stay out of `resources/list`.
122+
123+
## Publishing resource updates
124+
125+
Resource handles publish invalidations after their underlying value changes:
126+
127+
```ts
128+
const buildResource = ctx.agent.registerResource({
129+
id: 'live-build',
130+
name: 'Live build',
131+
read: () => ({ json: currentBuild() }),
132+
})
133+
134+
buildEvents.on('changed', () => buildResource.notifyUpdated())
135+
```
136+
137+
`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`, `resources/templates/list`, and `resources/read`.
138+
139+
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. Configure the exposed keys with `createMcpServer`'s `exposeSharedState` option.
100140

101141
## Starting the MCP server
102142

@@ -135,7 +175,7 @@ In `claude_desktop_config.json`:
135175
}
136176
```
137177

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

140180
## Writing descriptions agents act on
141181

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ Emitted on `ctx.agent.events`; adapters (e.g. the MCP server) re-publish their m
6868
|---|---|---|
6969
| `agent:manifest:changed` | any tool/resource/provider change ||
7070
| `agent:tool:registered` / `agent:tool:unregistered` | `registerTool` / `unregisterTool` | `AgentTool` / id |
71-
| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` / id |
71+
| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` or `AgentResourceTemplate` / id |
72+
| `agent:resource:updated` | resource or template handle `notifyUpdated` | concrete 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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { AgentResourceVariables } from '../../../../types/agent'
2+
import type { DevframeDefinition } from '../../../../types/devframe'
3+
import { createMcpServer } from '../../build-server'
4+
5+
const definition: DevframeDefinition = {
6+
id: 'resource-stdio-test',
7+
name: 'Resource stdio test',
8+
version: '1.0.0',
9+
packageName: '@devframe/resource-stdio-test',
10+
homepage: 'https://example.com',
11+
description: 'Stdio resource test fixture.',
12+
async setup(ctx) {
13+
const state = await ctx.rpc.sharedState.get('stdio:counter', {
14+
initialValue: { count: 0 },
15+
})
16+
const fixed = ctx.agent.registerResource({
17+
id: 'status',
18+
uri: 'https://example.com/status',
19+
name: 'Status',
20+
read: () => ({ json: { status: 'ok' } }),
21+
})
22+
const ignored = ctx.agent.registerResource({
23+
id: 'ignored',
24+
name: 'Ignored',
25+
read: () => ({ json: { ignored: true } }),
26+
})
27+
const artifact = ctx.agent.registerResource({
28+
id: 'artifact',
29+
uriTemplate: 'devframe://resource/artifacts/{artifactId}',
30+
name: 'Artifact',
31+
read: (_uri: URL, variables: AgentResourceVariables) => ({ json: variables }),
32+
})
33+
ctx.agent.registerTool({
34+
id: 'increment-state',
35+
description: 'Increment the fixture state.',
36+
handler: () => {
37+
state.mutate(value => void (value.count += 1))
38+
fixed.notifyUpdated()
39+
artifact.notifyUpdated('devframe://resource/artifacts/42')
40+
ignored.notifyUpdated()
41+
},
42+
})
43+
},
44+
}
45+
46+
await createMcpServer(definition, { transport: 'stdio' })

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

Lines changed: 186 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { StartedServer } from '../../../node/instance-shell'
2+
import type { AgentResourceVariables } from '../../../types/agent'
23
import type { DevframeDefinition } from '../../../types/devframe'
34
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
4-
import { afterEach, describe, expect, it } from 'vitest'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
56
import { createDevServer } from '../../dev'
67

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

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

71-
it('serves the modern era statelessly and lists agent tools', async () => {
84+
it('serves MCP 2026 statelessly and lists agent tools', async () => {
85+
expect.assertions(5)
7286
const started = await boot()
7387
const transport = originTransport(started)
74-
// Negotiate the 2026-07-28 era via `server/discover`.
88+
// Negotiate MCP 2026-07-28 via `server/discover`.
7589
const client = new Client(
7690
{ name: 'test-client', version: '0.0.0' },
7791
{ versionNegotiation: { mode: 'auto' } },
7892
)
7993
try {
8094
await client.connect(transport)
81-
// Stateless per-request serving: the modern era negotiates no
95+
// Stateless per-request serving: MCP 2026 negotiates no
8296
// `Mcp-Session-Id` — there is no session to key state on.
8397
expect(client.getProtocolEra()).toBe('modern')
8498
expect(transport.sessionId).toBeUndefined()
99+
expect(client.getServerCapabilities()?.resources).toEqual({ listChanged: true, subscribe: true })
85100

86101
const tools = await client.listTools()
87102
expect(tools.tools.map(t => t.name)).toContain('greet')
@@ -95,6 +110,172 @@ describe('mcp adapter (streamable http route)', () => {
95110
}
96111
})
97112

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

0 commit comments

Comments
 (0)