Skip to content

Commit 4f3f436

Browse files
committed
refactor(devframe): simplify MCP resource lifecycle
1 parent 1c42729 commit 4f3f436

10 files changed

Lines changed: 115 additions & 182 deletions

File tree

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

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -80,25 +80,21 @@ const handle = ctx.agent.registerToolProvider(() =>
8080
handle.notifyChanged() // fires tools/list_changed
8181
```
8282

83-
## Registering fixed resources
83+
## Registering a resource
8484

8585
Readable snapshots by URI:
8686

8787
```ts
88-
const sessionResource = ctx.agent.registerResource({
88+
ctx.agent.registerResource({
8989
id: 'current-session',
90-
uri: 'rolldown://session/current', // optional
9190
name: 'Current Rolldown session',
9291
description: 'Markdown snapshot of the active build session.',
9392
mimeType: 'text/markdown',
94-
read: uri => ({ text: renderMarkdown(currentSession, uri) }),
93+
read: () => ({ text: renderMarkdown(currentSession) }),
9594
})
96-
97-
// Notify subscribed MCP clients after the content changes.
98-
sessionResource.notifyUpdated()
9995
```
10096

101-
Without `uri`, Devframe assigns `devframe://resource/<encoded-id>`. `read` runs for every MCP read and receives the requested URI. A zero-argument reader remains valid.
97+
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`.
10298

10399
## Registering resource templates
104100

@@ -127,21 +123,21 @@ logsResource.notifyUpdated('rolldown://logs/worker')
127123

128124
MCP exposes templates through `resources/templates/list`. When `list` is present, its concrete entries also appear in `resources/list`.
129125

130-
## Resource subscriptions
126+
## Updating a subscribed resource
131127

132128
`subscribe` and `unsubscribe` follow the MCP resource lifecycle. Devframe calls them once per URI and MCP connection, and releases active subscriptions when the connection, registration, or provider goes away.
133129

134130
```ts
135-
ctx.agent.registerResource({
131+
const buildResource = ctx.agent.registerResource({
136132
id: 'live-build',
137133
name: 'Live build',
138134
read: () => ({ json: currentBuild() }),
139-
subscribe: uri => buildEvents.watch(uri.toString()),
140-
unsubscribe: uri => buildEvents.unwatch(uri.toString()),
135+
subscribe: uri => buildEvents.retain(uri, () => buildResource.notifyUpdated()),
136+
unsubscribe: uri => buildEvents.release(uri),
141137
})
142138
```
143139

144-
The callbacks manage the producer listener. They do not send content. Call the registration handle's `notifyUpdated()` method after a change; subscribed clients receive `resources/updated` and can read the current value.
140+
The producer owns its listener and any reference counting across MCP connections. `notifyUpdated()` sends no content. It tells subscribed clients to read the current value.
145141

146142
## Deriving resources from other state
147143

@@ -161,7 +157,7 @@ resources.notifyChanged() // resources/list_changed
161157
resources.notifyUpdated('dataset://builds/active') // resources/updated for subscribers
162158
```
163159

164-
Direct registrations win over providers. Earlier providers win over later providers, and exact fixed URIs win over templates.
160+
Direct registrations win over providers. Earlier providers win over later providers, and exact resource URIs win over templates.
165161

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

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ describe('mcp adapter (in-memory)', () => {
234234
}
235235
})
236236

237-
it('reads fixed resources from their explicit URI', async () => {
237+
it('reads resources from their explicit URI', async () => {
238238
const { ctx, client, cleanup } = await bootPair()
239239
try {
240240
const read = vi.fn((uri: URL) => ({ json: { uri: uri.toString() } }))
@@ -302,7 +302,7 @@ describe('mcp adapter (in-memory)', () => {
302302
}
303303
})
304304

305-
it('resolves an exact fixed URI before a matching template', async () => {
305+
it('resolves an exact resource URI before a matching template', async () => {
306306
const { ctx, client, cleanup } = await bootPair()
307307
try {
308308
ctx.agent.registerResource({
@@ -578,7 +578,7 @@ describe('mcp adapter (in-memory)', () => {
578578
})
579579

580580
describe('mcp adapter (stdio)', () => {
581-
it('lists, reads, and subscribes to fixed and template resources', async () => {
581+
it('lists, reads, and subscribes to registered and template resources', async () => {
582582
const fixture = fileURLToPath(new URL('./fixtures/resource-stdio-server.ts', import.meta.url))
583583
const transport = new StdioClientTransport({
584584
command: process.execPath,

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

Lines changed: 16 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { toAgentToolName } from 'devframe/utils/agent-tool-name'
1010
import { join } from 'pathe'
1111
import { DEVFRAME_EVENTS } from '../../events'
1212
import { diagnostics } from '../../node/diagnostics'
13-
import { AGENT_RESOURCE_SOURCE } from '../../node/host-agent'
1413
import { formatMcpError, stringifyForMcp } from './stringify'
1514
import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema'
1615

@@ -43,7 +42,7 @@ export interface McpServerHandle {
4342

4443
/**
4544
* Wire an MCP {@link Server} to a devframe context. Returns the server
46-
* plus a disposal function for the subscriptions it sets up. The
45+
* plus an async disposal function for the subscriptions it sets up. The
4746
* transport is the caller's responsibility — `createMcpServer` connects
4847
* stdio; tests can connect an {@link InMemoryTransport} instead.
4948
*
@@ -304,13 +303,7 @@ function registerResourceHandlers(
304303
ctx: DevframeNodeContext,
305304
exposeSharedState: boolean | ((key: string) => boolean),
306305
): () => Promise<void> {
307-
interface Subscription {
308-
resourceId: string
309-
source: object
310-
cleanup: () => void | Promise<void>
311-
}
312-
313-
const subscriptions = new Map<string, Subscription>()
306+
const subscriptions = new Map<string, () => void | Promise<void>>()
314307
let subscriptionOperations = Promise.resolve()
315308
const runSubscriptionOperation = <Result>(operation: () => Promise<Result>): Promise<Result> => {
316309
const result = subscriptionOperations.then(operation)
@@ -404,20 +397,20 @@ function registerResourceHandlers(
404397
throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`)
405398

406399
const cleanup = await ctx.agent.subscribeResource(resource.id, uri)
407-
subscriptions.set(uri, { resourceId: resource.id, source: resource.source, cleanup })
400+
subscriptions.set(uri, cleanup)
408401
return {}
409402
})
410403
})
411404

412405
server.setRequestHandler('resources/unsubscribe', async (request) => {
413406
const { uri } = request.params
414407
return await runSubscriptionOperation(async () => {
415-
const subscription = subscriptions.get(uri)
416-
if (!subscription)
408+
const cleanup = subscriptions.get(uri)
409+
if (!cleanup)
417410
return {}
418411

419412
subscriptions.delete(uri)
420-
await subscription.cleanup()
413+
await cleanup()
421414
return {}
422415
})
423416
})
@@ -430,21 +423,13 @@ function registerResourceHandlers(
430423

431424
const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => {
432425
void runSubscriptionOperation(async () => {
433-
for (const [uri, subscription] of subscriptions) {
426+
for (const [uri, cleanup] of [...subscriptions]) {
427+
subscriptions.delete(uri)
428+
await cleanup()
434429
const resource = resolveAgentResource(ctx, uri)
435-
if (resource?.id === subscription.resourceId && resource.source === subscription.source)
430+
if (!resource)
436431
continue
437-
await subscription.cleanup()
438-
if (!resource) {
439-
subscriptions.delete(uri)
440-
continue
441-
}
442-
const cleanup = await ctx.agent.subscribeResource(resource.id, uri)
443-
subscriptions.set(uri, {
444-
resourceId: resource.id,
445-
source: resource.source,
446-
cleanup,
447-
})
432+
subscriptions.set(uri, await ctx.agent.subscribeResource(resource.id, uri))
448433
}
449434
}).catch(() => { /* ignore subscription cleanup errors during reconciliation */ })
450435
})
@@ -455,36 +440,31 @@ function registerResourceHandlers(
455440
await runSubscriptionOperation(async () => {
456441
const active = [...subscriptions.values()]
457442
subscriptions.clear()
458-
await Promise.all(active.map(subscription => subscription.cleanup()))
443+
await Promise.all(active.map(cleanup => cleanup()))
459444
})
460445
}
461446
}
462447

463448
function resolveAgentResource(
464449
ctx: DevframeNodeContext,
465450
uri: string,
466-
): { id: string, variables: Variables, source: object } | undefined {
451+
): { id: string, variables: Variables } | undefined {
467452
const manifest = ctx.agent.list()
468-
const fixed = manifest.resources.find(resource => resource.uri === uri)
469-
if (fixed)
470-
return { id: fixed.id, variables: {}, source: getResourceSource(fixed) }
453+
const resource = manifest.resources.find(candidate => candidate.uri === uri)
454+
if (resource)
455+
return { id: resource.id, variables: {} }
471456

472457
for (const template of manifest.resourceTemplates) {
473458
const variables = new UriTemplate(template.uriTemplate).match(uri)
474459
if (variables) {
475460
return {
476461
id: template.id,
477462
variables,
478-
source: getResourceSource(template),
479463
}
480464
}
481465
}
482466
}
483467

484-
function getResourceSource(resource: object): object {
485-
return Reflect.get(resource, AGENT_RESOURCE_SOURCE) as object
486-
}
487-
488468
/**
489469
* MCP constrains a tool's `outputSchema` to a JSON Schema of `type:
490470
* "object"` — clients (the SDK included) reject anything else. Non-object

packages/devframe/src/node/__tests__/host-agent.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ describe('devToolsAgentHost', () => {
316316
expect(unsubscribe).toHaveBeenCalledWith(new URL('devframe://resource/live'))
317317
})
318318

319-
it('emits updates through fixed and template handles', () => {
319+
it('emits updates through resource and template handles', () => {
320320
const ctx = createContext()
321321
const updated = vi.fn()
322322
ctx.agent.events.on('agent:resource:updated', updated)

0 commit comments

Comments
 (0)