feat(core): add tool namespaces (#37529)

This commit is contained in:
Aiden Cline 2026-07-17 13:14:06 -05:00 committed by GitHub
commit 4bc8faa01c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 98 additions and 39 deletions

View file

@ -17,7 +17,7 @@ type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])

View file

@ -29,7 +29,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
namespace, which flattens direct model names to `<namespace>_<tool>`, and default into CodeMode (`codemode` defaults true;
`codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:

View file

@ -39,7 +39,7 @@ type CollectedFiles = {
interface Registration {
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly namespace?: string
}
export const create = (registrations: ReadonlyMap<string, Registration>) => {
@ -47,7 +47,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) => {
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
const tools: Record<string, Tool.Definition<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
@ -56,24 +56,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
})
if (registration.group === undefined) {
const path = registration.name
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
tools[path] = value
continue
}
const path = registration.name
const namespace = registration.group
const group = tools[namespace]
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
if (group) {
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
group[path] = value
continue
}
const entries: Record<string, Tool.Definition<never>> = {}
entries[path] = value
tools[namespace] = entries
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
tools[path] = value
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}

View file

@ -13,10 +13,11 @@ import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
/**
* Registry group and permission action names for MCP tools.
* Registry namespace and permission action names for MCP tools.
*/
export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) =>
`${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -107,7 +108,7 @@ export const layer = Layer.effectDiscard(
const next = yield* Scope.fork(scope)
yield* Effect.forEach(
groups,
([group, record]) => tools.register(record, { group }),
([server, record]) => tools.register(record, { namespace: namespace(server) }),
{
discard: true,
},

View file

@ -10,7 +10,15 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ExecuteTool } from "./execute"
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
import {
definition,
permission,
registrationEntries,
RegistrationError,
settle,
validateNamespace,
type AnyTool,
} from "./tool"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node"
@ -95,7 +103,7 @@ const registryLayer = Layer.effect(
type Registration = {
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly namespace?: string
readonly codemode: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@ -186,7 +194,8 @@ const registryLayer = Layer.effect(
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group)
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
const entries = registrationEntries(tools, options?.namespace)
if (entries.length === 0) return
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
@ -205,7 +214,7 @@ const registryLayer = Layer.effect(
registration: {
tool: entry.tool,
name: entry.name,
group: entry.group,
namespace: entry.namespace,
codemode,
},
},