Skip to content

Commit d7a2fbb

Browse files
committed
feat(agent): derive MCP-safe wire names from tool ids; address review findings
- Auto tool-name convention: internal ids stay colon-namespaced (devframe:<area>:<fn>, devframes:plugin:<slug>:<fn>, command ids); the MCP boundary derives the wire name via toAgentToolName (chars outside [a-zA-Z0-9_-] -> '_', <=128) so every client's tool-name pattern is satisfied. Calls resolve back to ids; collisions keep the first registration and warn (DF0047). Documented in the agent-native guide; exported from devframe/node. - Validator neutrality: the git plugin's agent schemas now use devframe/utils/simple-schema; valibot leaves its runtime deps. - Coded diagnostics for the remaining ad-hoc throws: DF0048 (unknown shared-state key), DF0049-DF0051 (connector call errors) — each with a docs page; the connector projects Diagnostic code/fix/docs into its structured error payload. - Dedupe: one __connection.json probe primitive (probeDevframeOrigin) behind registry liveness checks and the connector's --port probe; one shared coerceAgentPositionalArgs behind the agent host's RPC bridge and the hub's command tools (explicit wrap/drop fallback). - connect.ts: IndexedInstance derives from DevframeInstanceRecord; the lazy SDK seam is typed (ConnectSdk). - Plan 031 accuracy: record carries origin (not host), actual DF codes, wire-name note; plans/README.md row updated.
1 parent 5f42d44 commit d7a2fbb

29 files changed

Lines changed: 688 additions & 295 deletions

‎docs/adapters/mcp.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ The `devframe` bin ships an MCP **connector** — a thin discovery + proxy serve
8585
}
8686
```
8787

88-
It exposes two gateway tools:
88+
It exposes two gateway tools (the wire names of the `devframe:connect:*` ids — see [tool ids and wire names](/guide/agent-native#tool-ids-and-wire-names)):
8989

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.
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/errors/DF0047.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0047: Agent Tool Wire-Name Collision
6+
7+
## Message
8+
9+
> Agent tool "`{id}`" is hidden from the MCP surface: its wire name "`{name}`" collides with the tool "`{existing}`".
10+
11+
## Cause
12+
13+
MCP clients constrain tool names to `^[a-zA-Z0-9_-]{1,128}$`, so the MCP adapter derives each tool's wire name from its id (runs of characters outside `[a-zA-Z0-9_-]` become a single `_`). Two registered ids sanitized to the same wire name — e.g. `demo:greet` and `demo_greet`. The first registration keeps the name; the later tool is hidden from `tools/list`.
14+
15+
## Example
16+
17+
```ts
18+
ctx.agent.registerTool({ id: 'demo:greet', description: '…', handler })
19+
ctx.agent.registerTool({ id: 'demo_greet', description: '…', handler }) // hidden: same wire name
20+
```
21+
22+
## Fix
23+
24+
Rename one of the two ids so they sanitize to distinct wire names. Namespaced ids (`devframes:plugin:<slug>:<fn>`, `devframe:<area>:<fn>`) collide only when they differ solely in separator characters.
25+
26+
## Source
27+
28+
- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — the `tools/list` handler reports this once per hidden tool when deduplicating wire names.

‎docs/errors/DF0048.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0048: Unknown Shared-State Key
6+
7+
## Message
8+
9+
> Unknown shared-state key "`{key}`".
10+
11+
## Cause
12+
13+
The built-in `devframe_state_read` MCP tool was called with a `key` that is not among the shared-state keys the host publishes (or that the `exposeSharedState` filter allows). The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct.
14+
15+
## Example
16+
17+
```ts
18+
// The host publishes only `my-plugin:counter`; an agent calls:
19+
// devframe_state_read({ key: 'my-plugin:cuonter' }) → DF0048
20+
```
21+
22+
## Fix
23+
24+
Call the `devframe_state_read` tool without arguments to list the available keys, then retry with one of them.
25+
26+
## Source
27+
28+
- [`packages/devframe/src/adapters/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/mcp/build-server.ts) — `readStateResult()` throws this when the requested key is absent from the filtered key list.

‎docs/errors/DF0049.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0049: Connector Call Requires Port and Tool
6+
7+
## Message
8+
9+
> The devframe_connect_call-tool tool requires { port: number, tool: string }.
10+
11+
## Cause
12+
13+
The `devframe connect` gateway tool `devframe_connect_call-tool` was invoked without a numeric `port` or a string `tool` name — the two fields that identify which instance to dial and which of its tools to call. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct.
14+
15+
## Example
16+
17+
```ts
18+
// devframe_connect_call-tool({ tool: 'devframe_state_read' }) → DF0049 (missing port)
19+
```
20+
21+
## Fix
22+
23+
Call `devframe_connect_list-instances` first — its result carries each instance's `port` and tool names — then retry with both fields.
24+
25+
## Source
26+
27+
- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when the gateway arguments fail validation.

‎docs/errors/DF0050.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0050: No Devframe Instance on Port
6+
7+
## Message
8+
9+
> No running devframe instance on port `{port}`.
10+
11+
## Cause
12+
13+
The `devframe connect` gateway tool `devframe_connect_call-tool` targeted a port with no live devframe instance behind it — neither the instance registry nor a direct probe of the port found one serving `__connection.json`. The instance may have stopped, restarted on a different port, or never existed. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct.
14+
15+
## Example
16+
17+
```ts
18+
// No dev server on 5199:
19+
// devframe_connect_call-tool({ port: 5199, tool: 'devframe_state_read' }) → DF0050
20+
```
21+
22+
## Fix
23+
24+
Call `devframe_connect_list-instances` for the current instance list and retry with a live port.
25+
26+
## Source
27+
28+
- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when neither the registry nor the port probe finds an instance.

‎docs/errors/DF0051.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0051: Instance Has No MCP Endpoint
6+
7+
## Message
8+
9+
> The devframe instance on port `{port}` has no MCP endpoint.
10+
11+
## Cause
12+
13+
The `devframe connect` gateway tool `devframe_connect_call-tool` targeted a live devframe instance that runs without an MCP route — its `__connection.json` advertises no `mcp` entry, so there is no endpoint to proxy the tool call to. The error crosses the MCP boundary as structured JSON, so the calling agent can self-correct.
14+
15+
## Example
16+
17+
```ts
18+
// The instance on 5173 was started without --mcp:
19+
// devframe_connect_call-tool({ port: 5173, tool: 'devframe_state_read' }) → DF0051
20+
```
21+
22+
## Fix
23+
24+
Restart the instance with the `--mcp` flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.
25+
26+
## Source
27+
28+
- [`packages/devframe/src/cli/connect.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/cli/connect.ts) — `call()` throws this when the targeted instance's record carries `mcp: null`.

‎docs/guide/agent-native.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ export const getSessionSummary = defineRpcFunction({
4343

4444
Agent tools take a single object input. The MCP adapter synthesises `arg0`, `arg1`, … from positional args (`args: [A, B]`); a single object schema (`args: [v.object({ ... })]`) reads better at the agent boundary because property names are self-describing.
4545

46+
## Tool ids and wire names
47+
48+
Every agent tool has two names:
49+
50+
- **The id** — how the tool is registered and invoked inside devframe. Ids are colon-namespaced by convention: `devframes:plugin:<slug>:<fn>` for plugin RPCs, `devframe:<area>:<fn>` for built-ins, and command ids for hub-command-derived tools.
51+
- **The wire name** — what MCP clients see and call. Clients constrain tool names to `^[a-zA-Z0-9_-]{1,128}$`, so the MCP adapter derives the wire name automatically: every run of characters outside `[a-zA-Z0-9_-]` becomes a single `_`, truncated to 128 characters.
52+
53+
```
54+
devframe:state:read → devframe_state_read
55+
devframes:plugin:git:status → devframes_plugin_git_status
56+
my-plugin:summarize → my-plugin_summarize
57+
```
58+
59+
The convention applies uniformly to `agent`-flagged RPCs, tools registered via `registerTool` / `registerToolProvider`, and the hub's command-derived tools — keep registering with namespaced ids and let the boundary derive the name. `toAgentToolName` (from `devframe/node`) computes the mapping when you need to predict a wire name (e.g. in a client config or a test). Calls resolve back to the id at the boundary; two ids that sanitize to the same wire name keep the first registration and hide the later one with a `DF0047` warning.
60+
4661
## Registering a plugin tool
4762

4863
For tools without a matching RPC — say, an on-demand narrative summary — register them directly:
@@ -96,7 +111,7 @@ ctx.agent.registerResource({
96111

97112
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.
98113

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.
114+
Shared state is additionally reachable through the built-in **`devframe:state:read` tool** (wire name `devframe_state_read`) — 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.
100115

101116
## Starting the MCP server
102117

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

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,54 @@ describe('mcp adapter (in-memory)', () => {
9292
}
9393
})
9494

95+
it('advertises colon-namespaced ids under their derived wire name and resolves calls back', async () => {
96+
const { ctx, client, cleanup } = await bootPair()
97+
try {
98+
ctx.agent.registerTool({
99+
id: 'devframes:plugin:demo:greet',
100+
description: 'Say hello.',
101+
safety: 'read',
102+
handler: () => ({ greeting: 'hi' }),
103+
})
104+
105+
const listed = await client.listTools()
106+
const names = listed.tools.map(t => t.name)
107+
expect(names).toContain('devframes_plugin_demo_greet')
108+
expect(names).not.toContain('devframes:plugin:demo:greet')
109+
110+
const result = await client.callTool({ name: 'devframes_plugin_demo_greet', arguments: {} })
111+
const content = result.content as Array<{ type: string, text: string }>
112+
expect(JSON.parse(content[0]!.text)).toEqual({ greeting: 'hi' })
113+
}
114+
finally {
115+
await cleanup()
116+
}
117+
})
118+
119+
it('hides a later tool whose wire name collides with an earlier one', async () => {
120+
const { ctx, client, cleanup } = await bootPair()
121+
try {
122+
ctx.agent.registerTool({
123+
id: 'demo:greet',
124+
description: 'First.',
125+
handler: () => 'first',
126+
})
127+
ctx.agent.registerTool({
128+
id: 'demo_greet',
129+
description: 'Second — sanitizes to the same wire name.',
130+
handler: () => 'second',
131+
})
132+
133+
const listed = await client.listTools()
134+
const matches = listed.tools.filter(t => t.name === 'demo_greet')
135+
expect(matches).toHaveLength(1)
136+
expect(matches[0]!.description).toBe('First.')
137+
}
138+
finally {
139+
await cleanup()
140+
}
141+
})
142+
95143
it('returns text and structured content for a tool with an output schema', async () => {
96144
const { ctx, client, cleanup } = await bootPair()
97145
try {
@@ -233,31 +281,31 @@ describe('mcp adapter (in-memory)', () => {
233281
}
234282
})
235283

236-
it('exposes shared state through the built-in devframe:state:read tool', async () => {
284+
it('exposes shared state through the built-in devframe_state_read tool', async () => {
237285
const { ctx, client, cleanup } = await bootPair()
238286
try {
239287
await ctx.rpc.sharedState.get('my-plugin:counter', {
240288
initialValue: { count: 7 },
241289
})
242290

243291
const listed = await client.listTools()
244-
const tool = listed.tools.find(t => t.name === 'devframe:state:read')
292+
const tool = listed.tools.find(t => t.name === 'devframe_state_read')
245293
expect(tool).toBeDefined()
246294
expect(tool!.annotations?.readOnlyHint).toBe(true)
247295

248296
// No key → key list.
249-
const keys = await client.callTool({ name: 'devframe:state:read', arguments: {} })
297+
const keys = await client.callTool({ name: 'devframe_state_read', arguments: {} })
250298
expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] })
251299

252300
// With key → the value.
253-
const value = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'my-plugin:counter' } })
301+
const value = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'my-plugin:counter' } })
254302
expect(value.structuredContent).toEqual({ key: 'my-plugin:counter', value: { count: 7 } })
255303

256304
// Unknown key → agent-actionable error.
257-
const missing = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'nope' } })
305+
const missing = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'nope' } })
258306
expect(missing.isError).toBe(true)
259307
const content = missing.content as Array<{ text: string }>
260-
expect(content[0]!.text).toContain('unknown shared-state key')
308+
expect(content[0]!.text).toContain('Unknown shared-state key')
261309
}
262310
finally {
263311
await cleanup()
@@ -277,7 +325,7 @@ describe('mcp adapter (in-memory)', () => {
277325
await client.connect(clientTransport)
278326
try {
279327
const listed = await client.listTools()
280-
expect(listed.tools.map(t => t.name)).not.toContain('devframe:state:read')
328+
expect(listed.tools.map(t => t.name)).not.toContain('devframe_state_read')
281329
}
282330
finally {
283331
dispose()
@@ -300,10 +348,10 @@ describe('mcp adapter (in-memory)', () => {
300348
const client = new Client({ name: 'test-client', version: '0.0.0' })
301349
await client.connect(clientTransport)
302350
try {
303-
const keys = await client.callTool({ name: 'devframe:state:read', arguments: {} })
351+
const keys = await client.callTool({ name: 'devframe_state_read', arguments: {} })
304352
expect(keys.structuredContent).toEqual({ keys: ['visible:key'] })
305353

306-
const hidden = await client.callTool({ name: 'devframe:state:read', arguments: { key: 'hidden:key' } })
354+
const hidden = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'hidden:key' } })
307355
expect(hidden.isError).toBe(true)
308356
}
309357
finally {

0 commit comments

Comments
 (0)