Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6714f74433 |
@@ -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>",
|
||||
])
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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>> = {}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+5
-4
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user