Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 6714f74433 feat(plugin): flat tool draft with namespace
Effect draft.add accepts flat tool objects matching Promise ergonomics.
Migrate the tool grouping concept fully to namespace: rename MCP's group
helper/registration to namespace and thread namespace through the flat
Effect and Promise draft declarations.
2026-07-13 00:14:06 -05:00
13 changed files with 157 additions and 88 deletions
+1 -1
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>",
])
+6 -20
View File
@@ -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) => {
+5 -1
View File
@@ -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 } : {}),
}),
}),
),
),
+2 -2
View File
@@ -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:
+8 -8
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>) => {
@@ -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>> = {}
+10 -14
View File
@@ -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* () {
@@ -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: {
@@ -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
}),
+3 -3
View File
@@ -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 }>>()
@@ -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")
@@ -148,7 +148,7 @@ const registryLayer = Layer.effect(
registration: {
tool: entry.tool,
name: entry.name,
group: entry.group,
namespace: entry.namespace,
codemode,
},
},
+5 -18
View File
@@ -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"),
},
})
+39 -4
View File
@@ -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
@@ -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),
})
@@ -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
+1 -1
View File
@@ -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,
+5 -4
View File
@@ -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.
+68 -4
View File
@@ -123,6 +123,17 @@ export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
return makeTyped(config)
}
/**
* Split a flat tool declaration into its registration name, opaque Tool value,
* and registration options. Narrowing on `jsonSchema` selects the matching
* constructor, so no cast is needed to build the Tool from the flat union.
*/
export function fromFlat(flat: FlatDefinition<any, any, any> | FlatDynamicDefinition) {
const { name, namespace, codemode, ...config } = flat
const tool = "jsonSchema" in config ? makeDynamic(config) : makeTyped(config)
return { name, tool, options: { namespace, codemode } satisfies RegisterOptions }
}
function makeTyped<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
@@ -212,14 +223,14 @@ 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, "_")
const parent = namespace?.replace(/[^a-zA-Z0-9_-]/g, "_")
return {
key: parent === undefined ? normalized : `${parent}_${normalized}`,
name: normalized,
group: parent,
namespace: parent,
tool,
}
})
@@ -271,15 +282,68 @@ export interface ToolExecuteAfterEvent {
}
export interface RegisterOptions {
readonly group?: string
/** Dotted CodeMode path prefix, e.g. "slack.admin". */
readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
export type FlatDefinition<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: Config<Input, Output, Structured>["toStructuredOutput"]
readonly execute: Config<Input, Output, Structured>["execute"]
readonly toModelOutput?: Config<Input, Output, Structured>["toModelOutput"]
}
export type FlatDynamicDefinition = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: DynamicConfig["execute"]
}
export interface ToolDraft {
add<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(tool: FlatDefinition<Input, Output, Structured>): void
add(tool: FlatDynamicDefinition): void
add(name: string, tool: AnyTool, options?: RegisterOptions): void
}
export type Registration = {
readonly name: string
readonly tool: AnyTool
readonly options?: RegisterOptions
}
/** Run a draft callback and collect the tools it declared, in registration order. */
export function fromDraft(callback: (draft: ToolDraft) => void) {
const registrations: Array<Registration> = []
const add = (
nameOrTool: string | FlatDefinition<any, any, any> | FlatDynamicDefinition,
tool?: AnyTool,
options?: RegisterOptions,
) =>
registrations.push(typeof nameOrTool === "string" ? { name: nameOrTool, tool: tool!, options } : fromFlat(nameOrTool))
callback({ add })
return registrations
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
+4 -8
View File
@@ -16,7 +16,8 @@ export type Definition<
Structured extends SchemaType<any> = Output,
> = {
readonly name: string
readonly options?: RegisterOptions
readonly namespace?: string
readonly codemode?: boolean
readonly description: string
readonly input: Input
readonly output: Output
@@ -37,7 +38,8 @@ export type Definition<
export type DynamicDefinition = {
readonly name: string
readonly options?: RegisterOptions
readonly namespace?: string
readonly codemode?: boolean
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
@@ -67,12 +69,6 @@ export interface ToolExecuteAfterEvent {
outputPaths?: ReadonlyArray<string>
}
export interface RegisterOptions {
readonly group?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
export interface ToolDraft {
add<
Input extends SchemaType<any>,