Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/core/src/mcp/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>",
])
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tool/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 4 additions & 20 deletions packages/core/src/tool/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ type CollectedFiles = {
interface Registration {
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly namespace?: string
}

export const create = (registrations: ReadonlyMap<string, Registration>) => {
const runtime = (
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({
Expand All @@ -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 })
}
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/tool/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down Expand Up @@ -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,
},
Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }>>()
Expand Down Expand Up @@ -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")
Expand All @@ -205,7 +214,7 @@ const registryLayer = Layer.effect(
registration: {
tool: entry.tool,
name: entry.name,
group: entry.group,
namespace: entry.namespace,
codemode,
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ describe("MCP errors", () => {
})

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

Expand Down
8 changes: 4 additions & 4 deletions packages/core/test/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ describe("PluginV2", () => {
}),
)

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

expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
"plain",
"context_7_look_up",
"context7_look_up",
"execute",
])
}),
Expand Down
11 changes: 11 additions & 0 deletions packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ const constant = (text: string) =>
})

describe("ToolRegistry", () => {
it.effect("rejects invalid dotted namespaces", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const error = yield* service.register({ echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)

expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.materialize()).definitions).toEqual([])
}),
)

it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
Expand Down
47 changes: 47 additions & 0 deletions packages/core/test/tool-execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,50 @@ test("execute preserves successful results with visible unhandled rejections", a
},
])
})

test("execute supports callable namespace tools", async () => {
const callable = Tool.make({
description: "Administer Slack",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("admin"),
})
const child = Tool.make({
description: "Create a Slack resource",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("created"),
})
const execute = ExecuteTool.create(
new Map([
["slack_admin", { tool: callable, name: "admin", namespace: "slack" }],
["slack_admin_create", { tool: child, name: "create", namespace: "slack.admin" }],
]),
)
const result = await Effect.runPromise(
Tool.settle(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
),
)

expect(result.structured).toEqual({
toolCalls: [
{ tool: "slack.admin", status: "completed" },
{ tool: "slack.admin.create", status: "completed" },
],
})
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
})
16 changes: 11 additions & 5 deletions packages/plugin/src/v2/effect/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,24 @@ export const validateName = (name: string) =>
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))

export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, group?: string) =>
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, namespace?: string) =>
Object.entries(tools).map(([name, tool]) => {
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_")
return {
key: parent === undefined ? normalized : `${parent}_${normalized}`,
key: namespace === undefined ? normalized : `${namespace.replaceAll(".", "_")}_${normalized}`,
name: normalized,
group: parent,
namespace,
tool,
}
})

export const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }),
)

export const withPermission = <T extends AnyTool>(
tool: T,
permission: string,
Expand Down Expand Up @@ -288,7 +294,7 @@ export interface ToolExecuteAfterEvent {
}

export interface RegisterOptions {
readonly group?: string
readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
Expand Down
Loading