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
44 changes: 42 additions & 2 deletions docs/content/1.guide/15.agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,47 @@ ctx.agent.registerResource({
})
```

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.
Devframe assigns `devframe://resource/<encoded-id>` by default. Set `uri` to expose another URI. `read` runs for every MCP read.

## Registering a resource template

RFC 6570 templates describe resources whose URI contains variables. Devframe passes the concrete URI and parsed variables to `read`.

```ts
const processLogs = ctx.agent.registerResource({
id: 'process-logs',
uriTemplate: 'devframe://resource/processes/{processId}/logs/{path}',
name: 'Process log',
mimeType: 'text/plain',
read: (_uri, variables) => ({
text: readProcessLog(String(variables.processId), String(variables.path)),
}),
})

processEvents.on('log-changed', ({ processId, path }) => {
processLogs.notifyUpdated(`devframe://resource/processes/${processId}/logs/${path}`)
})
```

MCP exposes dynamic templates through `resources/templates/list`. Callers read a concrete matching URI through `resources/read`; dynamic entries stay out of `resources/list`.

## Publishing resource updates

Resource handles publish invalidations after their underlying value changes:

```ts
const buildResource = ctx.agent.registerResource({
id: 'live-build',
name: 'Live build',
read: () => ({ json: currentBuild() }),
})

buildEvents.on('changed', () => buildResource.notifyUpdated())
```

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

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.

## Starting the MCP server

Expand Down Expand Up @@ -135,7 +175,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, an RFC 6570 URI template, 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/8.references/3.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,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 template handle `notifyUpdated` | concrete resource URI |

### RPC 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,46 @@
import type { AgentResourceVariables } from '../../../../types/agent'
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 },
})
const fixed = ctx.agent.registerResource({
id: 'status',
uri: 'https://example.com/status',
name: 'Status',
read: () => ({ json: { status: 'ok' } }),
})
const ignored = ctx.agent.registerResource({
id: 'ignored',
name: 'Ignored',
read: () => ({ json: { ignored: true } }),
})
const artifact = ctx.agent.registerResource({
id: 'artifact',
uriTemplate: 'devframe://resource/artifacts/{artifactId}',
name: 'Artifact',
read: (_uri: URL, variables: AgentResourceVariables) => ({ json: variables }),
})
ctx.agent.registerTool({
id: 'increment-state',
description: 'Increment the fixture state.',
handler: () => {
state.mutate(value => void (value.count += 1))
fixed.notifyUpdated()
artifact.notifyUpdated('devframe://resource/artifacts/42')
ignored.notifyUpdated()
},
})
},
}

await createMcpServer(definition, { transport: 'stdio' })
191 changes: 186 additions & 5 deletions packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { StartedServer } from '../../../node/instance-shell'
import type { AgentResourceVariables } from '../../../types/agent'
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 @@ -62,26 +63,40 @@ describe('mcp adapter (streamable http route)', () => {

// A native MCP client must send a (loopback) Origin so the route's gate —
// which rejects Origin-less requests — accepts it.
function originTransport(started: StartedServer): StreamableHTTPClientTransport {
function originTransport(
started: StartedServer,
onRequest?: (request: Request) => void | Promise<void>,
): StreamableHTTPClientTransport {
return new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`), {
requestInit: { headers: { origin: started.origin } },
...(onRequest
? {
fetch: async (input, init) => {
const request = new Request(input, init)
await onRequest(request.clone())
return fetch(request)
},
}
: {}),
})
}

it('serves the modern era statelessly and lists agent tools', async () => {
it('serves MCP 2026 statelessly and lists agent tools', async () => {
expect.assertions(5)
const started = await boot()
const transport = originTransport(started)
// Negotiate the 2026-07-28 era via `server/discover`.
// Negotiate MCP 2026-07-28 via `server/discover`.
const client = new Client(
{ name: 'test-client', version: '0.0.0' },
{ versionNegotiation: { mode: 'auto' } },
)
try {
await client.connect(transport)
// Stateless per-request serving: the modern era negotiates no
// Stateless per-request serving: MCP 2026 negotiates no
// `Mcp-Session-Id` — there is no session to key state on.
expect(client.getProtocolEra()).toBe('modern')
expect(transport.sessionId).toBeUndefined()
expect(client.getServerCapabilities()?.resources).toEqual({ listChanged: true, subscribe: true })

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

it('delivers filtered resource updates through an MCP 2026 streaming POST', async () => {
expect.assertions(3)
let notifyBuildUpdated!: () => void
let notifyArtifactUpdated!: () => void
let notifyIgnoredUpdated!: () => void
let updateExistingState!: () => void
let createAndUpdateLateState!: () => Promise<void>
let removeLateState!: () => void
const started = await boot(defineTestDef({
async setup(ctx) {
const build = ctx.agent.registerResource({
id: 'build',
name: 'Build',
read: () => ({ json: { status: 'ok' } }),
})
const ignored = ctx.agent.registerResource({
id: 'ignored',
name: 'Ignored',
read: () => ({ json: { ignored: true } }),
})
const artifact = ctx.agent.registerResource({
id: 'artifact',
uriTemplate: 'devframe://resource/artifacts/{artifactId}',
name: 'Artifact',
read: (_uri: URL, variables: AgentResourceVariables) => ({ json: variables }),
})
const existingState = await ctx.rpc.sharedState.get('build:status', {
initialValue: { revision: 0 },
})
notifyBuildUpdated = build.notifyUpdated
notifyArtifactUpdated = () => artifact.notifyUpdated('devframe://resource/artifacts/42')
notifyIgnoredUpdated = ignored.notifyUpdated
updateExistingState = () => existingState.mutate(value => void (value.revision += 1))
createAndUpdateLateState = async () => {
const lateState = await ctx.rpc.sharedState.get('build:late', {
initialValue: { revision: 0 },
})
lateState.mutate(value => void (value.revision += 1))
}
removeLateState = () => {
ctx.rpc.sharedState.delete('build:late')
}
},
}))
const client = new Client(
{ name: 'test-client', version: '0.0.0' },
{ versionNegotiation: { mode: 'auto' } },
)
const updates: string[] = []
let resourceListChanges = 0
const listenRequestMethods: string[] = []
client.setNotificationHandler('notifications/resources/updated', (notification) => {
updates.push(notification.params.uri)
})
client.setNotificationHandler('notifications/resources/list_changed', () => {
resourceListChanges += 1
})

await client.connect(originTransport(started, async (request) => {
const body = await request.json().catch(() => undefined) as { method?: string } | undefined
if (body?.method === 'subscriptions/listen')
listenRequestMethods.push(request.method)
}))
const subscription = await client.listen({
resourcesListChanged: true,
resourceSubscriptions: [
'devframe://resource/build',
'devframe://resource/artifacts/42',
'devframe://state/build%3Astatus',
'devframe://state/build%3Alate',
],
})
try {
notifyIgnoredUpdated()
notifyBuildUpdated()
notifyArtifactUpdated()
updateExistingState()
await createAndUpdateLateState()
removeLateState()

await vi.waitFor(() => {
if (updates.length !== 4 || resourceListChanges !== 2)
throw new Error('Waiting for resource update and list-change notifications')
})
expect(listenRequestMethods).toEqual(['POST'])
expect(updates).toEqual([
'devframe://resource/build',
'devframe://resource/artifacts/42',
'devframe://state/build%3Astatus',
'devframe://state/build%3Alate',
])
expect(resourceListChanges).toBe(2)
}
finally {
await subscription.close()
await client.close()
}
})

it('keeps MCP 2025 HTTP resource access pull-only', async () => {
expect.assertions(7)
const started = await boot(defineTestDef({
setup(ctx) {
ctx.agent.registerResource({
id: 'build',
name: 'Build',
read: () => ({ json: { status: 'ok' } }),
})
ctx.agent.registerResource({
id: 'artifact',
uriTemplate: 'devframe://resource/artifacts/{artifactId}',
name: 'Artifact',
read: (_uri: URL, variables: AgentResourceVariables) => ({ json: variables }),
})
},
}))
const client = new Client({ name: 'mcp-2025-test-client', version: '0.0.0' })
try {
await client.connect(originTransport(started))
expect(client.getProtocolEra()).toBe('legacy')
expect(client.getServerCapabilities()?.resources).toEqual({ listChanged: true })

const resources = await client.listResources()
expect(resources.resources.map(resource => resource.uri)).toContain('devframe://resource/build')
const templates = await client.listResourceTemplates()
expect(templates.resourceTemplates.map(template => template.uriTemplate)).toEqual([
'devframe://resource/artifacts/{artifactId}',
])
const result = await client.readResource({ uri: 'devframe://resource/build' })
expect(JSON.parse((result.contents[0] as { text: string }).text)).toEqual({ status: 'ok' })
const artifact = await client.readResource({ uri: 'devframe://resource/artifacts/42' })
expect(JSON.parse((artifact.contents[0] as { text: string }).text)).toEqual({ artifactId: '42' })
await expect(client.subscribeResource({ uri: 'devframe://resource/build' })).rejects.toThrow()
}
finally {
await client.close()
}
})

it('can disable implicit shared-state MCP exposure for the HTTP route', async () => {
expect.assertions(2)
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' },
{ versionNegotiation: { mode: 'auto' } },
)
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('answers a bare GET with 405 (no session lifecycle)', async () => {
const started = await boot()
// Stateless serving has no session stream to open — the SDK answers a
Expand Down
Loading
Loading