Skip to content

Commit 703d0cf

Browse files
committed
feat(devframe): add MCP resource templates, providers, and updates
1 parent 5f38376 commit 703d0cf

22 files changed

Lines changed: 983 additions & 108 deletions

File tree

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

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,72 @@ 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 and may receive the requested `URL`.
100+
101+
## Registering resource templates
102+
103+
Templates describe resources whose URI contains variables. Devframe uses the MCP SDK's URI-template parser and passes the parsed variables to `read`.
104+
105+
```ts
106+
const logsResource = ctx.agent.registerResource({
107+
id: 'process-logs',
108+
uriTemplate: 'rolldown://logs/{process}',
109+
name: 'Process logs',
110+
mimeType: 'text/plain',
111+
list: () => ({
112+
resources: runningProcesses().map(process => ({
113+
uri: `rolldown://logs/${encodeURIComponent(process.name)}`,
114+
name: `${process.name} logs`,
115+
mimeType: 'text/plain',
116+
})),
117+
}),
118+
read: (_uri, variables) => ({
119+
text: readLogs(String(variables.process)),
120+
}),
121+
})
122+
123+
logsResource.notifyUpdated('rolldown://logs/worker')
124+
```
125+
126+
MCP exposes templates through `resources/templates/list`. When `list` is present, its concrete entries also appear in `resources/list`.
127+
128+
## Publishing resource updates
129+
130+
Resource handles publish invalidations after their underlying value changes:
131+
132+
```ts
133+
const buildResource = ctx.agent.registerResource({
134+
id: 'live-build',
135+
name: 'Live build',
136+
read: () => ({ json: currentBuild() }),
137+
})
138+
139+
buildEvents.on('changed', () => buildResource.notifyUpdated())
140+
```
141+
142+
`notifyUpdated()` sends no content. MCP 2026 callers receive the invalidation through `subscriptions/listen` when its `resourceSubscriptions` filter contains the URI, then call `resources/read` for the current value. Legacy MCP callers use `resources/list`, `resources/templates/list`, and `resources/read` to pull current values.
143+
144+
## Deriving resources from other state
145+
146+
Resource providers are queried when Devframe lists, resolves, or reads resources. Use them when another registry already owns the definitions.
147+
148+
```ts
149+
const resources = ctx.agent.registerResourceProvider(() =>
150+
currentDatasets().map(dataset => ({
151+
id: `dataset:${dataset.id}`,
152+
uri: `dataset://${dataset.id}`,
153+
name: dataset.name,
154+
read: () => ({ json: dataset.snapshot() }),
155+
})),
156+
)
157+
158+
resources.notifyChanged() // resources/list_changed
159+
resources.notifyUpdated('dataset://builds/active') // resources/updated through MCP 2026 subscriptions/listen
160+
```
161+
162+
Direct registrations win over providers. Earlier providers win over later providers, and exact resource URIs win over templates.
163+
164+
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.
100165

101166
## Starting the MCP server
102167

@@ -135,7 +200,7 @@ In `claude_desktop_config.json`:
135200
}
136201
```
137202

138-
Restart; tools appear in the drawer, resources as `devframe://resource/<id>` / `devframe://state/<key>` URIs.
203+
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.
139204

140205
## Writing descriptions agents act on
141206

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 provider handle `notifyUpdated` | concrete 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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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: uri => ({ json: { uri: uri.toString(), 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+
ctx.agent.registerResource({
36+
id: 'logs',
37+
uriTemplate: 'devframe://logs/{name}',
38+
name: 'Logs',
39+
list: () => ({ resources: [{ uri: 'devframe://logs/app', name: 'App logs' }] }),
40+
read: (_uri: URL, variables: Readonly<Record<string, string | string[]>>) => ({
41+
json: { process: variables.name },
42+
}),
43+
})
44+
},
45+
}
46+
47+
await createMcpServer(definition, { transport: 'stdio' })

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

Lines changed: 120 additions & 1 deletion
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 {
@@ -82,6 +82,7 @@ describe('mcp adapter (streamable http route)', () => {
8282
// `Mcp-Session-Id` — there is no session to key state on.
8383
expect(client.getProtocolEra()).toBe('modern')
8484
expect(transport.sessionId).toBeUndefined()
85+
expect(client.getServerCapabilities()?.resources).toEqual({ listChanged: true, subscribe: true })
8586

8687
const tools = await client.listTools()
8788
expect(tools.tools.map(t => t.name)).toContain('greet')
@@ -95,6 +96,124 @@ describe('mcp adapter (streamable http route)', () => {
9596
}
9697
})
9798

99+
it('delivers filtered resource updates through modern subscriptions/listen', async () => {
100+
let notifyBuildUpdated!: () => void
101+
let notifyIgnoredUpdated!: () => void
102+
let updateExistingState!: () => void
103+
let createAndUpdateLateState!: () => Promise<void>
104+
const started = await boot(defineTestDef({
105+
async setup(ctx) {
106+
const build = ctx.agent.registerResource({
107+
id: 'build',
108+
name: 'Build',
109+
read: () => ({ json: { status: 'ok' } }),
110+
})
111+
const ignored = ctx.agent.registerResource({
112+
id: 'ignored',
113+
name: 'Ignored',
114+
read: () => ({ json: { ignored: true } }),
115+
})
116+
const existingState = await ctx.rpc.sharedState.get('build:status', {
117+
initialValue: { revision: 0 },
118+
})
119+
notifyBuildUpdated = build.notifyUpdated
120+
notifyIgnoredUpdated = ignored.notifyUpdated
121+
updateExistingState = () => existingState.mutate(value => void (value.revision += 1))
122+
createAndUpdateLateState = async () => {
123+
const lateState = await ctx.rpc.sharedState.get('build:late', {
124+
initialValue: { revision: 0 },
125+
})
126+
lateState.mutate(value => void (value.revision += 1))
127+
}
128+
},
129+
}))
130+
const client = new Client(
131+
{ name: 'test-client', version: '0.0.0' },
132+
{ versionNegotiation: { mode: 'auto' } },
133+
)
134+
const updates: string[] = []
135+
client.setNotificationHandler('notifications/resources/updated', (notification) => {
136+
updates.push(notification.params.uri)
137+
})
138+
139+
await client.connect(originTransport(started))
140+
const subscription = await client.listen({
141+
resourceSubscriptions: [
142+
'devframe://resource/build',
143+
'devframe://state/build%3Astatus',
144+
'devframe://state/build%3Alate',
145+
],
146+
})
147+
try {
148+
notifyIgnoredUpdated()
149+
notifyBuildUpdated()
150+
updateExistingState()
151+
await createAndUpdateLateState()
152+
153+
await vi.waitFor(() => expect(updates).toEqual([
154+
'devframe://resource/build',
155+
'devframe://state/build%3Astatus',
156+
'devframe://state/build%3Alate',
157+
]))
158+
}
159+
finally {
160+
await subscription.close()
161+
await client.close()
162+
}
163+
})
164+
165+
it('keeps legacy resource access pull-only', async () => {
166+
const started = await boot(defineTestDef({
167+
setup(ctx) {
168+
ctx.agent.registerResource({
169+
id: 'build',
170+
name: 'Build',
171+
read: () => ({ json: { status: 'ok' } }),
172+
})
173+
},
174+
}))
175+
const client = new Client({ name: 'legacy-test-client', version: '0.0.0' })
176+
try {
177+
await client.connect(originTransport(started))
178+
expect(client.getProtocolEra()).toBe('legacy')
179+
expect(client.getServerCapabilities()?.resources).toEqual({})
180+
181+
const resources = await client.listResources()
182+
expect(resources.resources.map(resource => resource.uri)).toContain('devframe://resource/build')
183+
const result = await client.readResource({ uri: 'devframe://resource/build' })
184+
expect(JSON.parse((result.contents[0] as { text: string }).text)).toEqual({ status: 'ok' })
185+
}
186+
finally {
187+
await client.close()
188+
}
189+
})
190+
191+
it('can disable implicit shared-state MCP exposure for the HTTP route', async () => {
192+
server = await createDevServer(defineTestDef({
193+
async setup(ctx) {
194+
await ctx.rpc.sharedState.get('hidden:state', { initialValue: { value: true } })
195+
},
196+
}), {
197+
host: '127.0.0.1',
198+
port: 0,
199+
mcp: { exposeSharedState: false },
200+
})
201+
const client = new Client(
202+
{ name: 'test-client', version: '0.0.0' },
203+
{ versionNegotiation: { mode: 'auto' } },
204+
)
205+
try {
206+
await client.connect(originTransport(server))
207+
const resources = await client.listResources()
208+
const tools = await client.listTools()
209+
expect(resources.resources).toEqual([])
210+
expect(tools.tools.map(tool => tool.name)).not.toContain('devframe_state_read')
211+
}
212+
finally {
213+
await client.close()
214+
}
215+
})
216+
98217
it('answers a bare GET with 405 (no session lifecycle)', async () => {
99218
const started = await boot()
100219
// Stateless serving has no session stream to open — the SDK answers a

0 commit comments

Comments
 (0)