Skip to content

Commit 4bc8faa

Browse files
authored
feat(core): add tool namespaces (#37529)
1 parent 5d5b33f commit 4bc8faa

10 files changed

Lines changed: 98 additions & 39 deletions

File tree

packages/core/src/mcp/instructions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type Summary = typeof Summary.Type
1717
const entries = (servers: ReadonlyArray<Summary>) =>
1818
servers.flatMap((server) => [
1919
` <server name="${server.server}">`,
20-
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
20+
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
2121
...server.instructions.split("\n").map((line) => ` ${line}`),
2222
" </server>",
2323
])

packages/core/src/tool/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
2929
## Registration
3030

3131
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
32-
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
32+
namespace, which flattens direct model names to `<namespace>_<tool>`, and default into CodeMode (`codemode` defaults true;
3333
`codemode: false` keeps the tool on the provider's native tool list).
3434

3535
Registrations are scoped:

packages/core/src/tool/execute.ts

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ type CollectedFiles = {
3939
interface Registration {
4040
readonly tool: AnyTool
4141
readonly name: string
42-
readonly group?: string
42+
readonly namespace?: string
4343
}
4444

4545
export const create = (registrations: ReadonlyMap<string, Registration>) => {
4646
const runtime = (
4747
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
4848
hooks?: CodeMode.ToolCallHooks,
4949
) => {
50-
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
50+
const tools: Record<string, Tool.Definition<never>> = {}
5151
for (const [name, registration] of registrations) {
5252
const child = definition(name, registration.tool)
5353
const value = Tool.make({
@@ -56,24 +56,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
5656
output: child.outputSchema,
5757
run: (input) => invoke(name, registration, input),
5858
})
59-
if (registration.group === undefined) {
60-
const path = registration.name
61-
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
62-
tools[path] = value
63-
continue
64-
}
65-
const path = registration.name
66-
const namespace = registration.group
67-
const group = tools[namespace]
68-
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
69-
if (group) {
70-
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
71-
group[path] = value
72-
continue
73-
}
74-
const entries: Record<string, Tool.Definition<never>> = {}
75-
entries[path] = value
76-
tools[namespace] = entries
59+
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
60+
tools[path] = value
7761
}
7862
return CodeMode.make<typeof tools>({ tools, ...hooks })
7963
}

packages/core/src/tool/mcp.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import { Tools } from "./tools"
1313
import { ToolRegistry } from "./registry"
1414

1515
/**
16-
* Registry group and permission action names for MCP tools.
16+
* Registry namespace and permission action names for MCP tools.
1717
*/
18-
export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
19-
export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
18+
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
19+
export const name = (server: string, tool: string) =>
20+
`${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
2021

2122
export const layer = Layer.effectDiscard(
2223
Effect.gen(function* () {
@@ -107,7 +108,7 @@ export const layer = Layer.effectDiscard(
107108
const next = yield* Scope.fork(scope)
108109
yield* Effect.forEach(
109110
groups,
110-
([group, record]) => tools.register(record, { group }),
111+
([server, record]) => tools.register(record, { namespace: namespace(server) }),
111112
{
112113
discard: true,
113114
},

packages/core/src/tool/registry.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import { SessionSchema } from "../session/schema"
1010
import { ToolOutputStore } from "../tool-output-store"
1111
import { Wildcard } from "../util/wildcard"
1212
import { ExecuteTool } from "./execute"
13-
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
13+
import {
14+
definition,
15+
permission,
16+
registrationEntries,
17+
RegistrationError,
18+
settle,
19+
validateNamespace,
20+
type AnyTool,
21+
} from "./tool"
1422
import { Tools } from "./tools"
1523
import { ToolHooks } from "./hooks"
1624
import { makeLocationNode } from "../effect/app-node"
@@ -95,7 +103,7 @@ const registryLayer = Layer.effect(
95103
type Registration = {
96104
readonly tool: AnyTool
97105
readonly name: string
98-
readonly group?: string
106+
readonly namespace?: string
99107
readonly codemode: boolean
100108
}
101109
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@@ -186,7 +194,8 @@ const registryLayer = Layer.effect(
186194

187195
return Service.of({
188196
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
189-
const entries = registrationEntries(tools, options?.group)
197+
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
198+
const entries = registrationEntries(tools, options?.namespace)
190199
if (entries.length === 0) return
191200
const codemode = options?.codemode ?? true
192201
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
@@ -205,7 +214,7 @@ const registryLayer = Layer.effect(
205214
registration: {
206215
tool: entry.tool,
207216
name: entry.name,
208-
group: entry.group,
217+
namespace: entry.namespace,
209218
codemode,
210219
},
211220
},

packages/core/test/mcp.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ describe("MCP errors", () => {
268268
})
269269

270270
test("MCP tool names match V1 sanitization", () => {
271+
expect(McpTool.namespace("context 7")).toBe("context_7")
271272
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
272273
})
273274

packages/core/test/plugin.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ describe("PluginV2", () => {
274274
}),
275275
)
276276

277-
it.effect("groups tool names and routes codemode registrations through execute", () =>
277+
it.effect("namespaces tool names and routes codemode registrations through execute", () =>
278278
Effect.gen(function* () {
279279
const plugins = yield* PluginV2.Service
280280
const registry = yield* ToolRegistry.Service
@@ -291,8 +291,8 @@ describe("PluginV2", () => {
291291
ctx.tool
292292
.transform((draft) => {
293293
draft.add("plain", tool("Plain"), { codemode: false })
294-
draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false })
295-
draft.add("search", tool("Search"), { group: "context 7" })
294+
draft.add("look/up", tool("Lookup"), { namespace: "context7", codemode: false })
295+
draft.add("search", tool("Search"), { namespace: "context7" })
296296
})
297297
.pipe(Effect.orDie),
298298
})
@@ -301,7 +301,7 @@ describe("PluginV2", () => {
301301

302302
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
303303
"plain",
304-
"context_7_look_up",
304+
"context7_look_up",
305305
"execute",
306306
])
307307
}),

packages/core/test/session-runner-tool-registry.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ const constant = (text: string) =>
8484
})
8585

8686
describe("ToolRegistry", () => {
87+
it.effect("rejects invalid dotted namespaces", () =>
88+
Effect.gen(function* () {
89+
const service = yield* ToolRegistry.Service
90+
const error = yield* service.register({ echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
91+
92+
expect(error).toBeInstanceOf(Tool.RegistrationError)
93+
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
94+
expect((yield* service.materialize()).definitions).toEqual([])
95+
}),
96+
)
97+
8798
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
8899
Effect.gen(function* () {
89100
const service = yield* ToolRegistry.Service

packages/core/test/tool-execute.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,50 @@ test("execute preserves successful results with visible unhandled rejections", a
4646
},
4747
])
4848
})
49+
50+
test("execute supports callable namespace tools", async () => {
51+
const callable = Tool.make({
52+
description: "Administer Slack",
53+
input: Schema.Struct({}),
54+
output: Schema.String,
55+
execute: () => Effect.succeed("admin"),
56+
})
57+
const child = Tool.make({
58+
description: "Create a Slack resource",
59+
input: Schema.Struct({}),
60+
output: Schema.String,
61+
execute: () => Effect.succeed("created"),
62+
})
63+
const execute = ExecuteTool.create(
64+
new Map([
65+
["slack_admin", { tool: callable, name: "admin", namespace: "slack" }],
66+
["slack_admin_create", { tool: child, name: "create", namespace: "slack.admin" }],
67+
]),
68+
)
69+
const result = await Effect.runPromise(
70+
Tool.settle(
71+
execute,
72+
{
73+
type: "tool-call",
74+
id: "call_execute",
75+
name: "execute",
76+
input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
77+
},
78+
{
79+
sessionID: Session.ID.make("ses_execute"),
80+
agent: Agent.ID.make("build"),
81+
messageID: SessionMessage.ID.make("msg_execute"),
82+
callID: "call_execute",
83+
progress: () => Effect.void,
84+
},
85+
),
86+
)
87+
88+
expect(result.structured).toEqual({
89+
toolCalls: [
90+
{ tool: "slack.admin", status: "completed" },
91+
{ tool: "slack.admin.create", status: "completed" },
92+
],
93+
})
94+
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
95+
})

packages/plugin/src/v2/effect/tool.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,24 @@ export const validateName = (name: string) =>
150150
? Effect.void
151151
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
152152

153-
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, group?: string) =>
153+
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, namespace?: string) =>
154154
Object.entries(tools).map(([name, tool]) => {
155155
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
156-
const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_")
157156
return {
158-
key: parent === undefined ? normalized : `${parent}_${normalized}`,
157+
key: namespace === undefined ? normalized : `${namespace.replaceAll(".", "_")}_${normalized}`,
159158
name: normalized,
160-
group: parent,
159+
namespace,
161160
tool,
162161
}
163162
})
164163

164+
export const validateNamespace = (namespace: string) =>
165+
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
166+
? Effect.void
167+
: Effect.fail(
168+
new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }),
169+
)
170+
165171
export const withPermission = <T extends AnyTool>(
166172
tool: T,
167173
permission: string,
@@ -288,7 +294,7 @@ export interface ToolExecuteAfterEvent {
288294
}
289295

290296
export interface RegisterOptions {
291-
readonly group?: string
297+
readonly namespace?: string
292298
/** Defaults to true. False exposes the tool directly to the provider. */
293299
readonly codemode?: boolean
294300
}

0 commit comments

Comments
 (0)