Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion docs/content/1.guide/14.agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,71 @@ ctx.agent.registerResource({
})
```

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`.

## Registering resource templates

Templates describe resources whose URI contains variables. Devframe uses the MCP SDK's URI-template parser and passes the parsed variables to `read`.

```ts
const logsResource = ctx.agent.registerResource({
id: 'process-logs',
uriTemplate: 'rolldown://logs/{process}',
name: 'Process logs',
mimeType: 'text/plain',
list: () => ({
resources: runningProcesses().map(process => ({
uri: `rolldown://logs/${encodeURIComponent(process.name)}`,
name: `${process.name} logs`,
mimeType: 'text/plain',
})),
}),
read: (_uri, variables) => ({
text: readLogs(String(variables.process)),
}),
})

logsResource.notifyUpdated('rolldown://logs/worker')
```

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

## Updating a subscribed resource

`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.

```ts
const buildResource = ctx.agent.registerResource({
id: 'live-build',
name: 'Live build',
read: () => ({ json: currentBuild() }),
subscribe: uri => buildEvents.retain(uri, () => buildResource.notifyUpdated()),
unsubscribe: uri => buildEvents.release(uri),
})
```

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.

## Deriving resources from other state

Resource providers are queried when Devframe lists, resolves, or reads resources. Use them when another registry already owns the definitions.

```ts
const resources = ctx.agent.registerResourceProvider(() =>
currentDatasets().map(dataset => ({
id: `dataset:${dataset.id}`,
uri: `dataset://${dataset.id}`,
name: dataset.name,
read: () => ({ json: dataset.snapshot() }),
})),
)

resources.notifyChanged() // resources/list_changed
resources.notifyUpdated('dataset://builds/active') // resources/updated for subscribers
```

Direct registrations win over providers. Earlier providers win over later providers, and exact resource URIs win over templates.

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.

## Starting the MCP server
Expand Down Expand Up @@ -133,7 +198,7 @@ In `claude_desktop_config.json`:
}
```

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

## Writing descriptions agents act on

Expand Down
3 changes: 2 additions & 1 deletion docs/content/1.guide/20.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ Emitted on `ctx.agent.events`; adapters (e.g. the MCP server) re-publish their m
|---|---|---|
| `agent:manifest:changed` | any tool/resource/provider change | — |
| `agent:tool:registered` / `agent:tool:unregistered` | `registerTool` / `unregisterTool` | `AgentTool` / id |
| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` / id |
| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` or `AgentResourceTemplate` / id |
| `agent:resource:updated` | resource or provider handle `notifyUpdated` | concrete URI |

### Client connection events

Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export function initDevframe(
const mounted = mountMcpHttp(app, context, mcpPath, {
serverName: `${def.id} (devframe)`,
serverVersion: def.version ?? '0.0.0',
exposeSharedState: true,
exposeSharedState: mcpConfig.exposeSharedState ?? true,
allowedOrigins: mcpConfig.allowedOrigins,
})
mcpDispose = mounted.dispose
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { DevframeDefinition } from '../../../../types/devframe'
import { createMcpServer } from '../../build-server'

const definition: DevframeDefinition = {
id: 'resource-stdio-test',
name: 'Resource stdio test',
version: '1.0.0',
packageName: '@devframe/resource-stdio-test',
homepage: 'https://example.com',
description: 'Stdio resource test fixture.',
async setup(ctx) {
const state = await ctx.rpc.sharedState.get('stdio:counter', {
initialValue: { count: 0 },
})
ctx.agent.registerTool({
id: 'increment-state',
description: 'Increment the fixture state.',
handler: () => state.mutate(value => void (value.count += 1)),
})
const fixed = ctx.agent.registerResource({
id: 'status',
uri: 'https://example.com/status',
name: 'Status',
read: uri => ({ json: { uri: uri.toString(), status: 'ok' } }),
subscribe: () => {
setTimeout(() => fixed.notifyUpdated(), 20)
},
})
ctx.agent.registerResource({
id: 'logs',
uriTemplate: 'devframe://logs/{name}',
name: 'Logs',
list: () => ({ resources: [{ uri: 'devframe://logs/app', name: 'App logs' }] }),
read: (_uri: URL, variables: Readonly<Record<string, string | string[]>>) => ({
json: { process: variables.name },
}),
})
},
}

await createMcpServer(definition, { transport: 'stdio' })
107 changes: 106 additions & 1 deletion packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { StartedServer } from '../../../node/instance-shell'
import type { DevframeDefinition } from '../../../types/devframe'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDevServer } from '../../dev'

function defineTestDef(overrides?: Partial<DevframeDefinition>): DevframeDefinition {
Expand Down Expand Up @@ -90,6 +90,111 @@ describe('mcp adapter (streamable http route)', () => {
}
})

it('keeps resource subscriptions session-local and cleans them up on disconnect', async () => {
const subscribe = vi.fn()
let completeCleanup!: () => void
const unsubscribe = vi.fn(() => new Promise<void>((resolve) => {
completeCleanup = resolve
}))
let notifyUpdated: (() => void) | undefined
const started = await boot(defineTestDef({
setup(ctx) {
const handle = ctx.agent.registerResource({
id: 'build-status',
name: 'Build status',
read: () => ({ json: { status: 'ok' } }),
subscribe,
unsubscribe,
})
notifyUpdated = handle.notifyUpdated
},
}))
const transport = originTransport(started)
const client = new Client({ name: 'test-client', version: '0.0.0' })
const notifications: string[] = []
client.setNotificationHandler('notifications/resources/updated', (notification) => {
notifications.push(notification.params.uri)
})

await client.connect(transport)
await client.subscribeResource({ uri: 'devframe://resource/build-status' })
await client.subscribeResource({ uri: 'devframe://resource/build-status' })
expect(subscribe).toHaveBeenCalledOnce()

notifyUpdated!()
await vi.waitFor(() => expect(notifications).toEqual(['devframe://resource/build-status']))

const termination = fetch(`${started.origin}/__mcp`, {
method: 'DELETE',
headers: {
'origin': started.origin,
'mcp-session-id': transport.sessionId!,
},
})
const terminationSettled = vi.fn()
void termination.then(terminationSettled, terminationSettled)
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
await Promise.resolve()
expect(terminationSettled).not.toHaveBeenCalled()

completeCleanup()
const terminationResponse = await termination
expect(terminationResponse.ok).toBe(true)
await client.close()
})

it('pushes subscribed shared-state updates and cleans up on disconnect', async () => {
let updateState: (() => void) | undefined
const started = await boot(defineTestDef({
async setup(ctx) {
const state = await ctx.rpc.sharedState.get('build:status', {
initialValue: { revision: 0 },
})
updateState = () => state.mutate(value => void (value.revision += 1))
},
}))
const transport = originTransport(started)
const client = new Client({ name: 'test-client', version: '0.0.0' })
const notifications: string[] = []
client.setNotificationHandler('notifications/resources/updated', (notification) => {
notifications.push(notification.params.uri)
})

await client.connect(transport)
const uri = 'devframe://state/build%3Astatus'
await client.subscribeResource({ uri })
updateState!()
await vi.waitFor(() => expect(notifications).toEqual([uri]))

await transport.terminateSession()
updateState!()
expect(notifications).toEqual([uri])
await client.close()
})

it('can disable implicit shared-state MCP exposure for the HTTP route', async () => {
server = await createDevServer(defineTestDef({
async setup(ctx) {
await ctx.rpc.sharedState.get('hidden:state', { initialValue: { value: true } })
},
}), {
host: '127.0.0.1',
port: 0,
mcp: { exposeSharedState: false },
})
const client = new Client({ name: 'test-client', version: '0.0.0' })
try {
await client.connect(originTransport(server))
const resources = await client.listResources()
const tools = await client.listTools()
expect(resources.resources).toEqual([])
expect(tools.tools.map(tool => tool.name)).not.toContain('devframe_state_read')
}
finally {
await client.close()
}
})

it('tears the session down on DELETE and rejects reuse of the id', async () => {
const started = await boot()
const url = `${started.origin}/__mcp`
Expand Down
Loading
Loading