Skip to content

Commit d1d9373

Browse files
committed
refactor(agent): lazy tool providers + convention-following tool names
- ctx.agent.registerToolProvider(() => AgentToolInput[]): a lazy tool source queried at list/getTool/invoke time — the same on-demand projection applied to agent-flagged RPCs; earlier sources win on id collision; handle.notifyChanged() drives tools/list_changed - hub commands host derives its agent projection through one provider: the commands map is the single source of truth, replacing the registerAgentTools/unregisterAgentTools mirror and its handle map - built-in and connector tool names follow the devframe:<area>:<fn> convention: read_state -> devframe:state:read, devframe_index -> devframe:connect:list-instances, devframe_call -> devframe:connect:call-tool
1 parent cb79c08 commit d1d9373

20 files changed

Lines changed: 292 additions & 127 deletions

File tree

docs/adapters/mcp.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ The `devframe` bin ships an MCP **connector** — a thin discovery + proxy serve
8787

8888
It exposes two gateway tools:
8989

90-
- **`devframe_index`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`.
91-
- **`devframe_call`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint.
90+
- **`devframe:connect:list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`.
91+
- **`devframe:connect:call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint.
9292

9393
Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/<pid>-<port>.json` on boot and removes it on close; readers prune records whose liveness probe fails. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port <n>` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out.
9494

docs/guide/agent-native.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ export default defineDevframe({
6363
})
6464
```
6565

66+
## Deriving tools from other state
67+
68+
When tools derive from state you already maintain — a command registry, a plugin catalog — register a **provider** instead of mirroring registrations. The host queries it at list/invoke time (the same lazy projection it applies to `agent`-flagged RPCs), so your source of truth stays the only copy:
69+
70+
```ts
71+
const handle = ctx.agent.registerToolProvider(() =>
72+
currentCommands()
73+
.filter(command => command.agent)
74+
.map(command => toAgentTool(command)),
75+
)
76+
77+
// After the underlying state changes, nudge connected MCP clients:
78+
handle.notifyChanged() // fires tools/list_changed
79+
```
80+
81+
The hub's commands host uses exactly this to project agent-flagged palette commands.
82+
6683
## Registering a resource
6784

6885
Resources surface readable snapshots of state, identified by URI:
@@ -79,7 +96,7 @@ ctx.agent.registerResource({
7996

8097
Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/<key>`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out.
8198

82-
Shared state is additionally reachable through the built-in **`read_state` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection.
99+
Shared state is additionally reachable through the built-in **`devframe:state:read` tool** — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection.
83100

84101
## Starting the MCP server
85102

examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ export async function minimalNextDevframeHub(
254254

255255
// Serve MCP in-process on the Next app's own origin (the `/_next/mcp`
256256
// shape): the hub's agent surface — agent-flagged commands, plugin tools
257-
// (git status/log/diff, terminals), `read_state` — over the same catch-all
257+
// (git status/log/diff, terminals), `devframe:state:read` — over the same catch-all
258258
// route as the SPAs, no side-car port involved.
259259
const mcpPath = '/__hub/__mcp'
260260
await nextHost.mountMcp(context, mcpPath, {

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -202,28 +202,28 @@ describe('mcp adapter (in-memory)', () => {
202202
}
203203
})
204204

205-
it('exposes shared state through the built-in read_state tool', async () => {
205+
it('exposes shared state through the built-in devframe:state:read tool', async () => {
206206
const { ctx, client, cleanup } = await bootPair()
207207
try {
208208
await ctx.rpc.sharedState.get('my-plugin:counter', {
209209
initialValue: { count: 7 },
210210
})
211211

212212
const listed = await client.listTools()
213-
const tool = listed.tools.find(t => t.name === 'read_state')
213+
const tool = listed.tools.find(t => t.name === 'devframe:state:read')
214214
expect(tool).toBeDefined()
215215
expect(tool!.annotations?.readOnlyHint).toBe(true)
216216

217217
// No key → key list.
218-
const keys = await client.callTool({ name: 'read_state', arguments: {} })
218+
const keys = await client.callTool({ name: 'devframe:state:read', arguments: {} })
219219
expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] })
220220

221221
// With key → the value.
222-
const value = await client.callTool({ name: 'read_state', arguments: { key: 'my-plugin:counter' } })
222+
const value = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'my-plugin:counter' } })
223223
expect(value.structuredContent).toEqual({ key: 'my-plugin:counter', value: { count: 7 } })
224224

225225
// Unknown key → agent-actionable error.
226-
const missing = await client.callTool({ name: 'read_state', arguments: { key: 'nope' } })
226+
const missing = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'nope' } })
227227
expect(missing.isError).toBe(true)
228228
const content = missing.content as Array<{ text: string }>
229229
expect(content[0]!.text).toContain('unknown shared-state key')
@@ -233,7 +233,7 @@ describe('mcp adapter (in-memory)', () => {
233233
}
234234
})
235235

236-
it('hides read_state when shared-state exposure is disabled', async () => {
236+
it('hides devframe:state:read when shared-state exposure is disabled', async () => {
237237
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
238238
const { server, dispose } = buildMcpServerFromContext(ctx, {
239239
serverName: 'test',
@@ -246,7 +246,7 @@ describe('mcp adapter (in-memory)', () => {
246246
await client.connect(clientTransport)
247247
try {
248248
const listed = await client.listTools()
249-
expect(listed.tools.map(t => t.name)).not.toContain('read_state')
249+
expect(listed.tools.map(t => t.name)).not.toContain('devframe:state:read')
250250
}
251251
finally {
252252
dispose()
@@ -255,7 +255,7 @@ describe('mcp adapter (in-memory)', () => {
255255
}
256256
})
257257

258-
it('respects the shared-state filter in read_state', async () => {
258+
it('respects the shared-state filter in devframe:state:read', async () => {
259259
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
260260
await ctx.rpc.sharedState.get('visible:key', { initialValue: { n: 1 } })
261261
await ctx.rpc.sharedState.get('hidden:key', { initialValue: { n: 2 } })
@@ -269,10 +269,10 @@ describe('mcp adapter (in-memory)', () => {
269269
const client = new Client({ name: 'test-client', version: '0.0.0' })
270270
await client.connect(clientTransport)
271271
try {
272-
const keys = await client.callTool({ name: 'read_state', arguments: {} })
272+
const keys = await client.callTool({ name: 'devframe:state:read', arguments: {} })
273273
expect(keys.structuredContent).toEqual({ keys: ['visible:key'] })
274274

275-
const hidden = await client.callTool({ name: 'read_state', arguments: { key: 'hidden:key' } })
275+
const hidden = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'hidden:key' } })
276276
expect(hidden.isError).toBe(true)
277277
}
278278
finally {

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,12 @@ export async function createMcpServer(
152152
}
153153

154154
/**
155-
* Name of the built-in shared-state read tool. Tool-shaped access matters
156-
* because many MCP clients only consume tools — the parallel
157-
* `devframe://state/<key>` resource projection stays for the clients that do
158-
* read resources.
155+
* Name of the built-in shared-state read tool — namespaced like every other
156+
* built-in (`devframe:<area>:<fn>`). Tool-shaped access matters because many
157+
* MCP clients only consume tools — the parallel `devframe://state/<key>`
158+
* resource projection stays for the clients that do read resources.
159159
*/
160-
const READ_STATE_TOOL = 'read_state'
160+
const READ_STATE_TOOL = 'devframe:state:read'
161161

162162
function sharedStateFilter(exposeSharedState: boolean | ((key: string) => boolean)): ((key: string) => boolean) | undefined {
163163
if (exposeSharedState === false)
@@ -220,8 +220,8 @@ function registerToolHandlers(
220220
const { name, arguments: args } = request.params
221221
try {
222222
// Built-in shared-state read. A registered agent tool of the same
223-
// name wins (mirroring the list projection above); plugin tools keep
224-
// namespaced ids (`<plugin>:<tool>`), so collisions are deliberate.
223+
// name wins (mirroring the list projection above) — ids are
224+
// namespaced, so a collision is a deliberate override.
225225
if (stateFilter && name === READ_STATE_TOOL && !ctx.agent.getTool(READ_STATE_TOOL)) {
226226
const key = (args as { key?: string } | undefined)?.key
227227
const result = await readStateResult(ctx, stateFilter, key)

packages/devframe/src/cli/connect.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,19 @@ interface IndexedInstance {
3838
hint?: string
3939
}
4040

41-
const INDEX_TOOL = 'devframe_index'
42-
const CALL_TOOL = 'devframe_call'
41+
const INDEX_TOOL = 'devframe:connect:list-instances'
42+
const CALL_TOOL = 'devframe:connect:call-tool'
4343

4444
const MCP_DISABLED_HINT
45-
= 'This instance runs without an MCP route. Restart it with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then call devframe_index again.'
45+
= 'This instance runs without an MCP route. Restart it with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.'
4646

4747
/**
4848
* Start the devframe MCP connector on stdio: a thin discovery + proxy server
4949
* in the shape next-devtools-mcp validated. It exposes two gateway tools —
50-
* `devframe_index` (discover running devframe instances via the instance
51-
* registry and list each one's MCP tools) and `devframe_call` (invoke one
52-
* tool on one instance over its Streamable-HTTP endpoint) — and holds no
53-
* domain knowledge of its own.
50+
* `devframe:connect:list-instances` (discover running devframe instances via
51+
* the instance registry and list each one's MCP tools) and
52+
* `devframe:connect:call-tool` (invoke one tool on one instance over its
53+
* Streamable-HTTP endpoint) — and holds no domain knowledge of its own.
5454
*
5555
* @experimental
5656
*/
@@ -67,18 +67,18 @@ export async function startConnectServer(options: ConnectServerOptions = {}): Pr
6767
{
6868
name: INDEX_TOOL,
6969
title: 'Discover running devframes',
70-
description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available — the result names the instance (id, project root, origin) and the port to pass to devframe_call. Safe to call freely.',
70+
description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available — the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.',
7171
inputSchema: { type: 'object', properties: {} },
7272
annotations: { readOnlyHint: true, destructiveHint: false },
7373
},
7474
{
7575
name: CALL_TOOL,
7676
title: 'Call a devframe tool',
77-
description: 'Invoke one MCP tool on one running devframe instance discovered via devframe_index. Pass the instance\'s port, the tool name, and the tool\'s arguments object.',
77+
description: 'Invoke one MCP tool on one running devframe instance discovered via the list-instances tool. Pass the instance\'s port, the tool name, and the tool\'s arguments object.',
7878
inputSchema: {
7979
type: 'object',
8080
properties: {
81-
port: { type: 'number', description: 'The instance\'s port, from devframe_index.' },
81+
port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' },
8282
tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' },
8383
args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' },
8484
},
@@ -239,7 +239,7 @@ async function call(
239239
args: { port?: number, tool?: string, args?: Record<string, unknown> },
240240
): Promise<unknown> {
241241
if (typeof args.port !== 'number' || typeof args.tool !== 'string') {
242-
throw Object.assign(new Error('devframe_call requires { port: number, tool: string }'), {
242+
throw Object.assign(new Error(`${CALL_TOOL} requires { port: number, tool: string }`), {
243243
fix: `Call ${INDEX_TOOL} to get the port and tool names, then retry.`,
244244
})
245245
}

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,4 +269,57 @@ describe('devToolsAgentHost', () => {
269269
await expect(ctx.agent.read('ghost')).rejects.toThrow(/ghost/)
270270
})
271271
})
272+
273+
describe('registerToolProvider()', () => {
274+
it('queries the provider lazily on list/getTool/invoke', async () => {
275+
const ctx = createContext()
276+
const handler = vi.fn(async (args: unknown) => args)
277+
let exposed = false
278+
ctx.agent.registerToolProvider(() => exposed
279+
? [{ id: 'derived:tool', description: 'Derived.', safety: 'read', handler }]
280+
: [])
281+
282+
// The provider's source of truth changes; no re-registration needed.
283+
expect(ctx.agent.getTool('derived:tool')).toBeUndefined()
284+
exposed = true
285+
expect(ctx.agent.getTool('derived:tool')).toMatchObject({
286+
id: 'derived:tool',
287+
kind: 'tool',
288+
safety: 'read',
289+
})
290+
expect(ctx.agent.list().tools.map(t => t.id)).toEqual(['derived:tool'])
291+
292+
await expect(ctx.agent.invoke('derived:tool', { a: 1 })).resolves.toEqual({ a: 1 })
293+
expect(handler).toHaveBeenCalledWith({ a: 1 })
294+
})
295+
296+
it('earlier sources win on id collision', () => {
297+
const ctx = createContext()
298+
ctx.agent.registerTool({ id: 'shared:id', description: 'Registered.', handler: () => 'plain' })
299+
ctx.agent.registerToolProvider(() => [
300+
{ id: 'shared:id', description: 'Provided.', handler: () => 'provided' },
301+
])
302+
303+
expect(ctx.agent.getTool('shared:id')!.description).toBe('Registered.')
304+
expect(ctx.agent.list().tools.filter(t => t.id === 'shared:id')).toHaveLength(1)
305+
})
306+
307+
it('notifyChanged and unregister fire agent:manifest:changed', () => {
308+
const ctx = createContext()
309+
const manifestHandler = vi.fn()
310+
const handle = ctx.agent.registerToolProvider(() => [])
311+
ctx.agent.events.on('agent:manifest:changed', manifestHandler)
312+
313+
handle.notifyChanged()
314+
expect(manifestHandler).toHaveBeenCalledTimes(1)
315+
316+
handle.unregister()
317+
expect(manifestHandler).toHaveBeenCalledTimes(2)
318+
319+
// After unregistration the handle goes quiet.
320+
handle.notifyChanged()
321+
handle.unregister()
322+
expect(manifestHandler).toHaveBeenCalledTimes(2)
323+
})
324+
})
272325
})

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

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
AgentResourceInput,
88
AgentTool,
99
AgentToolInput,
10+
AgentToolProvider,
11+
AgentToolProviderHandle,
1012
DevframeAgentHostEvents,
1113
DevframeAgentHost as DevframeAgentHostType,
1214
DevframeNodeContext,
@@ -39,6 +41,7 @@ export class DevframeAgentHost implements DevframeAgentHostType {
3941

4042
private readonly tools = new Map<string, RegisteredTool>()
4143
private readonly resources = new Map<string, RegisteredResource>()
44+
private readonly providers = new Set<AgentToolProvider>()
4245
private _rpcUnsubscribe: (() => void) | undefined
4346

4447
constructor(
@@ -72,6 +75,23 @@ export class DevframeAgentHost implements DevframeAgentHostType {
7275
return existed
7376
}
7477

78+
registerToolProvider(provider: AgentToolProvider): AgentToolProviderHandle {
79+
this.providers.add(provider)
80+
this.events.emit('agent:manifest:changed')
81+
82+
const notifyChanged = (): void => {
83+
if (this.providers.has(provider))
84+
this.events.emit('agent:manifest:changed')
85+
}
86+
return {
87+
notifyChanged,
88+
unregister: () => {
89+
if (this.providers.delete(provider))
90+
this.events.emit('agent:manifest:changed')
91+
},
92+
}
93+
}
94+
7595
registerResource(input: AgentResourceInput): AgentHandle {
7696
if (this.resources.has(input.id))
7797
throw diagnostics.DF0016({ id: input.id })
@@ -105,8 +125,19 @@ export class DevframeAgentHost implements DevframeAgentHostType {
105125
const rpcTools = this._collectRpcTools()
106126
const plainTools = Array.from(this.tools.values()).map(t => t.tool)
107127
const resources = Array.from(this.resources.values()).map(r => r.resource)
128+
129+
// Provider tools are queried lazily; earlier sources win on id collision.
130+
const seen = new Set([...rpcTools, ...plainTools].map(t => t.id))
131+
const providerTools: AgentTool[] = []
132+
for (const { tool } of this._collectProviderTools()) {
133+
if (seen.has(tool.id))
134+
continue
135+
seen.add(tool.id)
136+
providerTools.push(tool)
137+
}
138+
108139
return {
109-
tools: [...rpcTools, ...plainTools],
140+
tools: [...rpcTools, ...plainTools, ...providerTools],
110141
resources,
111142
}
112143
}
@@ -115,7 +146,10 @@ export class DevframeAgentHost implements DevframeAgentHostType {
115146
const plain = this.tools.get(id)
116147
if (plain)
117148
return plain.tool
118-
return this._collectRpcTools().find(t => t.id === id)
149+
const rpc = this._collectRpcTools().find(t => t.id === id)
150+
if (rpc)
151+
return rpc
152+
return this._collectProviderTools().find(t => t.tool.id === id)?.tool
119153
}
120154

121155
getResource(id: string): AgentResource | undefined {
@@ -136,6 +170,11 @@ export class DevframeAgentHost implements DevframeAgentHostType {
136170
return await this.context.rpc.invokeLocal(id as any, ...(positional as any))
137171
}
138172

173+
const provided = this._collectProviderTools().find(t => t.tool.id === id)
174+
if (provided) {
175+
return await provided.input.handler(args)
176+
}
177+
139178
throw new Error(`[devframe/agent] tool "${id}" not found`)
140179
}
141180

@@ -178,6 +217,16 @@ export class DevframeAgentHost implements DevframeAgentHostType {
178217
}
179218
}
180219

220+
/** Query every registered provider, projecting inputs to serializable tools. */
221+
private _collectProviderTools(): { input: AgentToolInput, tool: AgentTool }[] {
222+
const out: { input: AgentToolInput, tool: AgentTool }[] = []
223+
for (const provider of this.providers) {
224+
for (const input of provider())
225+
out.push({ input, tool: this._projectTool(input) })
226+
}
227+
return out
228+
}
229+
181230
private _collectRpcTools(): AgentTool[] {
182231
const out: AgentTool[] = []
183232
for (const [name, def] of this.context.rpc.definitions) {

0 commit comments

Comments
 (0)