Skip to content
Closed
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/guidance.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
26 changes: 6 additions & 20 deletions packages/core/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,27 +305,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
tool: {
// The tool domain is scoped registration, not State, so the host adapts the draft to register calls directly.
transform: (callback) =>
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
yield* Effect.sync(() =>
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
}),
)
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
Effect.forEach(
Tool.fromDraft(callback),
({ name, tool, options }) => tools.register({ [name]: tool }, options),
{ discard: true },
).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
hook: (name, callback) => {
if (name === "execute.before") {
return toolHooks.hook.before((event) => {
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/plugin/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ export function fromPromise(plugin: Plugin) {
register(
host.tool.transform((draft) =>
callback({
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
add: (tool: AnyTool) =>
draft.add(tool.name, fromPromiseTool(tool), {
...(tool.namespace !== undefined ? { namespace: tool.namespace } : {}),
...(tool.codemode !== undefined ? { codemode: tool.codemode } : {}),
}),
}),
),
),
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/tool/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ 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;
`codemode: false` keeps the tool on the provider's native tool list).
`namespace`, which flattens native 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
16 changes: 8 additions & 8 deletions packages/core/src/tool/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>) => {
Expand All @@ -56,19 +56,19 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
})
if (registration.group === undefined) {
if (registration.namespace === 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
const namespace = registration.namespace
const branch = tools[namespace]
if (branch && Tool.isDefinition(branch)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
if (branch) {
if (Object.hasOwn(branch, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
branch[path] = value
continue
}
const entries: Record<string, Tool.Definition<never>> = {}
Expand Down
24 changes: 10 additions & 14 deletions packages/core/src/tool/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ 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 All @@ -32,11 +32,11 @@ export const layer = Layer.effectDiscard(
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const groups = new Map<string, Record<string, Tool.AnyTool>>()
const byServer = new Map<string, Record<string, Tool.AnyTool>>()
for (const tool of yield* mcp.tools()) {
const group = groups.get(tool.server) ?? {}
const record = byServer.get(tool.server) ?? {}
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
group[tool.name] = Tool.withPermission(
record[tool.name] = Tool.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema: {
Expand Down Expand Up @@ -102,16 +102,12 @@ export const layer = Layer.effectDiscard(
}),
name(tool.server, tool.name),
)
groups.set(tool.server, group)
byServer.set(tool.server, record)
}
const next = yield* Scope.fork(scope)
yield* Effect.forEach(
groups,
([group, record]) => tools.register(record, { group }),
{
discard: true,
},
).pipe(Scope.provide(next), Effect.orDie)
yield* Effect.forEach(byServer, ([server, record]) => tools.register(record, { namespace: server }), {
discard: true,
}).pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,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 @@ -129,7 +129,7 @@ const registryLayer = Layer.effect(

return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group)
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 @@ -148,7 +148,7 @@ const registryLayer = Layer.effect(
registration: {
tool: entry.tool,
name: entry.name,
group: entry.group,
namespace: entry.namespace,
codemode,
},
},
Expand Down
23 changes: 5 additions & 18 deletions packages/core/test/lib/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,11 @@ export const registerToolPlugin = <R>(plugin: {
const context = host({
tool: {
transform: (callback) =>
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
})
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
Effect.forEach(
Tool.fromDraft(callback),
({ name, tool, options }) => tools.register({ [name]: tool }, options),
{ discard: true },
).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
Expand Down
43 changes: 39 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 @@ -286,13 +286,13 @@ describe("PluginV2", () => {
execute: () => Effect.succeed({ ok: true }),
})
const plugin = EffectPlugin.define({
id: "grouped-tools",
id: "namespaced-tools",
effect: (ctx) =>
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: "context 7", codemode: false })
draft.add("search", tool("Search"), { namespace: "context 7" })
})
.pipe(Effect.orDie),
})
Expand All @@ -307,6 +307,41 @@ describe("PluginV2", () => {
}),
)

it.effect("accepts flat Effect draft declarations with namespace", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugin = EffectPlugin.define({
id: "flat-tools",
effect: (ctx) =>
ctx.tool
.transform((draft) => {
draft.add({
name: "send",
namespace: "slack",
description: "Send a Slack message",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
execute: () => Effect.succeed({ sent: true }),
})
draft.add({
name: "edit",
codemode: false,
description: "Edit a file",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
})
})
.pipe(Effect.orDie),
})

yield* plugins.activate([versioned(plugin)])

expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual(["edit", "execute"])
}),
)

it.effect("fires before/after tool hooks with mutable events around settlement", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/plugin/promise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ describe("fromPromise", () => {
await ctx.tool.transform((tools) => {
tools.add({
name: "hello",
options: { codemode: false },
codemode: false,
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
Expand Down
9 changes: 5 additions & 4 deletions packages/docs/build/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,13 @@ export default Plugin.define({
})
```

Unsupported characters in tool and group names are normalized to underscores.
Unsupported characters in tool and namespace names are normalized to underscores.
The resulting exposed key must begin with a letter and contain at most 64
letters, digits, underscores, or hyphens. Set `options` on the declaration to
configure registration with `{ group, codemode }`:
letters, digits, underscores, or hyphens. Set registration fields on the
declaration or options with `{ namespace, codemode }`:

- `group` prefixes and groups the exposed tool name.
- `namespace` prefixes the exposed tool name (and becomes the CodeMode path
segment).
- `codemode` defaults to `true` and makes the tool available through the
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
provider.
Expand Down
Loading
Loading