Unified · Split
| @@ -68,6 +68,11 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 67 unmodified lines | ||
| 68 | 68 | |
| 69 | 69 | # ── MCP OAuth for /mcp (optional) ── |
| 70 | 70 | # MCP_AUTHORIZATION_SERVER=https://your-authkit-domain.authkit.app |
| 71 | +# Tool exposure mode for MCP execute descriptions: | |
| 72 | +# - sources_only (default): preload source summaries, discover tools lazily | |
| 73 | +# - all_tools: preload every discovered tool path into execute description | |
| 74 | +# PM_TOOL_EXPOSURE_MODE=sources_only | |
| 75 | +# CONVEX_TOOL_EXPOSURE_MODE=sources_only | |
| 71 | 76 | |
| 72 | 77 | # ── Discord bot (optional — omit to skip bot startup) ── |
| 73 | 78 | # DISCORD_BOT_TOKEN=... |
| @@ -1,3 +1,10 @@ | ||
| 1 | +import { | |
| 2 | + buildExecuteToolDescription, | |
| 3 | + defaultExecuteToolDescription, | |
| 4 | + defaultExecuteToolExposureMode, | |
| 5 | + parseExecuteToolExposureMode, | |
| 6 | + type ExecuteToolExposureMode, | |
| 7 | +} from "@executor-v2/engine"; | |
| 1 | 8 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; |
| 2 | 9 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 3 | 10 | import * as Effect from "effect/Effect"; |
| @@ -25,6 +32,35 @@ const readWorkspaceIdFromRequest = (request: Request): string | null => { · 21 unmodified lines | ||
| 25 | 32 | const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string => |
| 26 | 33 | readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId; |
| 27 | 34 | |
| 35 | +const readToolExposureModeFromRequest = ( | |
| 36 | + request: Request, | |
| 37 | + fallbackMode: ExecuteToolExposureMode, | |
| 38 | +): ExecuteToolExposureMode => { | |
| 39 | + const url = new URL(request.url); | |
| 40 | + const rawMode = | |
| 41 | + url.searchParams.get("toolExposureMode") ?? | |
| 42 | + url.searchParams.get("toolContextMode") ?? | |
| 43 | + undefined; | |
| 44 | + | |
| 45 | + return parseExecuteToolExposureMode(rawMode ?? undefined) ?? fallbackMode; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +const resolveExecuteToolDescription = async ( | |
| 49 | + mode: ExecuteToolExposureMode, | |
| 50 | + toolRegistry: ReturnType<typeof createConvexSourceToolRegistry>, | |
| 51 | +): Promise<string> => { | |
| 52 | + try { | |
| 53 | + return await Effect.runPromise( | |
| 54 | + buildExecuteToolDescription({ | |
| 55 | + toolRegistry, | |
| 56 | + mode, | |
| 57 | + }), | |
| 58 | + ); | |
| 59 | + } catch { | |
| 60 | + return defaultExecuteToolDescription; | |
| 61 | + } | |
| 62 | +}; | |
| 63 | + | |
| 28 | 64 | const hasWorkspaceAccess = async ( |
| 29 | 65 | ctx: ActionCtx, |
| 30 | 66 | workspaceId: string, |
| @@ -65,6 +101,9 @@ const hasWorkspaceAccess = async ( · 34 unmodified lines | ||
| 65 | 101 | }; |
| 66 | 102 | |
| 67 | 103 | const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); |
| 104 | +const defaultToolExposureMode = | |
| 105 | + parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ?? | |
| 106 | + defaultExecuteToolExposureMode; | |
| 68 | 107 | const runtimeInternal = internal as any; |
| 69 | 108 | |
| 70 | 109 | export const mcpHandler = httpAction(async (ctx, request) => { |
| @@ -108,6 +147,14 @@ export const mcpHandler = httpAction(async (ctx, request) => { · 37 unmodified lines | ||
| 108 | 147 | } |
| 109 | 148 | |
| 110 | 149 | const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId); |
| 150 | + const toolExposureMode = readToolExposureModeFromRequest( | |
| 151 | + request, | |
| 152 | + defaultToolExposureMode, | |
| 153 | + ); | |
| 154 | + const executeToolDescription = await resolveExecuteToolDescription( | |
| 155 | + toolExposureMode, | |
| 156 | + toolRegistry, | |
| 157 | + ); | |
| 111 | 158 | |
| 112 | 159 | const runClient = createExecutorRunClient(async (input) => { |
| 113 | 160 | const runId = `run_${crypto.randomUUID()}`; |
| @@ -151,5 +198,6 @@ export const mcpHandler = httpAction(async (ctx, request) => { · 37 unmodified lines | ||
| 151 | 198 | serverName: "executor-v2-convex", |
| 152 | 199 | serverVersion: "0.0.0", |
| 153 | 200 | runClient, |
| 201 | + executeToolDescription, | |
| 154 | 202 | }); |
| 155 | 203 | }); |
| @@ -12,10 +12,12 @@ import { · 11 unmodified lines | ||
| 12 | 12 | RuntimeAdapterError, |
| 13 | 13 | createRunExecutor, |
| 14 | 14 | createSourceToolRegistry, |
| 15 | + defaultExecuteToolExposureMode, | |
| 15 | 16 | invokeRuntimeToolCallResult, |
| 16 | 17 | makeOpenApiToolProvider, |
| 17 | 18 | makeRuntimeAdapterRegistry, |
| 18 | 19 | makeToolProviderRegistry, |
| 20 | + parseExecuteToolExposureMode, | |
| 19 | 21 | } from "@executor-v2/engine"; |
| 20 | 22 | import { |
| 21 | 23 | makeLocalSourceStore, |
| @@ -71,12 +73,21 @@ const readBooleanFlag = (value: string | undefined): boolean => { · 49 unmodified lines | ||
| 71 | 73 | return normalized === "1" || normalized === "true" || normalized === "yes"; |
| 72 | 74 | }; |
| 73 | 75 | |
| 76 | +const readConfiguredToolExposureMode = ( | |
| 77 | + value: string | undefined, | |
| 78 | +): "all_tools" | "sources_only" => | |
| 79 | + parseExecuteToolExposureMode(value ?? undefined) ?? | |
| 80 | + defaultExecuteToolExposureMode; | |
| 81 | + | |
| 74 | 82 | const formatRuntimeAdapterError = (error: RuntimeAdapterError): string => |
| 75 | 83 | error.details ? `${error.message}: ${error.details}` : error.message; |
| 76 | 84 | |
| 77 | 85 | const port = parsePort(process.env.PORT); |
| 78 | 86 | const workspaceId = readConfiguredWorkspaceId(process.env.PM_WORKSPACE_ID); |
| 79 | 87 | const requireToolApprovals = readBooleanFlag(process.env.PM_REQUIRE_TOOL_APPROVALS); |
| 88 | +const defaultToolExposureMode = readConfiguredToolExposureMode( | |
| 89 | + process.env.PM_TOOL_EXPOSURE_MODE, | |
| 90 | +); | |
| 80 | 91 | |
| 81 | 92 | const pmRuntimeAdapters = [ |
| 82 | 93 | makeLocalInProcessRuntimeAdapter(), |
| @@ -182,7 +193,10 @@ const executeRuntimeRun = createPmExecuteRuntimeRun({ · 99 unmodified lines | ||
| 182 | 193 | }); |
| 183 | 194 | |
| 184 | 195 | const runExecutor = createRunExecutor(executeRuntimeRun); |
| 185 | −const handleMcp = createPmMcpHandler(runExecutor.executeRun); | |
| 196 | +const handleMcp = createPmMcpHandler(runExecutor.executeRun, { | |
| 197 | + toolRegistry, | |
| 198 | + defaultToolExposureMode, | |
| 199 | +}); | |
| 186 | 200 | |
| 187 | 201 | const handleToolCallHttp = createPmToolCallHttpHandler((input) => |
| 188 | 202 | Effect.runPromise( |
| @@ -1,3 +1,11 @@ | ||
| 1 | +import { | |
| 2 | + buildExecuteToolDescription, | |
| 3 | + defaultExecuteToolDescription, | |
| 4 | + defaultExecuteToolExposureMode, | |
| 5 | + parseExecuteToolExposureMode, | |
| 6 | + type ExecuteToolExposureMode, | |
| 7 | + type ToolRegistry, | |
| 8 | +} from "@executor-v2/engine"; | |
| 1 | 9 | import { |
| 2 | 10 | type ExecuteRunInput, |
| 3 | 11 | type ExecuteRunResult, |
| @@ -6,17 +14,63 @@ import { · 2 unmodified lines | ||
| 6 | 14 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; |
| 7 | 15 | import * as Effect from "effect/Effect"; |
| 8 | 16 | |
| 17 | +export type PmMcpHandlerOptions = { | |
| 18 | + toolRegistry: ToolRegistry; | |
| 19 | + defaultToolExposureMode?: ExecuteToolExposureMode; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +const readToolExposureModeFromRequest = ( | |
| 23 | + request: Request, | |
| 24 | + fallback: ExecuteToolExposureMode, | |
| 25 | +): ExecuteToolExposureMode => { | |
| 26 | + const url = new URL(request.url); | |
| 27 | + const rawMode = | |
| 28 | + url.searchParams.get("toolExposureMode") ?? | |
| 29 | + url.searchParams.get("toolContextMode") ?? | |
| 30 | + undefined; | |
| 31 | + | |
| 32 | + return parseExecuteToolExposureMode(rawMode ?? undefined) ?? fallback; | |
| 33 | +}; | |
| 34 | + | |
| 35 | +const resolveExecuteToolDescription = async ( | |
| 36 | + toolRegistry: ToolRegistry, | |
| 37 | + mode: ExecuteToolExposureMode, | |
| 38 | +): Promise<string> => { | |
| 39 | + try { | |
| 40 | + return await Effect.runPromise( | |
| 41 | + buildExecuteToolDescription({ | |
| 42 | + toolRegistry, | |
| 43 | + mode, | |
| 44 | + }), | |
| 45 | + ); | |
| 46 | + } catch { | |
| 47 | + return defaultExecuteToolDescription; | |
| 48 | + } | |
| 49 | +}; | |
| 50 | + | |
| 9 | 51 | export const createPmMcpHandler = ( |
| 10 | 52 | executeRun: (input: ExecuteRunInput) => Effect.Effect<ExecuteRunResult>, |
| 53 | + options: PmMcpHandlerOptions, | |
| 11 | 54 | ): ((request: Request) => Promise<Response>) => { |
| 12 | 55 | const runClient = createExecutorRunClient((input) => |
| 13 | 56 | Effect.runPromise(executeRun(input)), |
| 14 | 57 | ); |
| 15 | 58 | |
| 16 | − return (request: Request) => | |
| 17 | − handleMcpHttpRequest(request, { | |
| 59 | + const defaultMode = | |
| 60 | + options.defaultToolExposureMode ?? defaultExecuteToolExposureMode; | |
| 61 | + | |
| 62 | + return async (request: Request) => { | |
| 63 | + const mode = readToolExposureModeFromRequest(request, defaultMode); | |
| 64 | + const executeToolDescription = await resolveExecuteToolDescription( | |
| 65 | + options.toolRegistry, | |
| 66 | + mode, | |
| 67 | + ); | |
| 68 | + | |
| 69 | + return handleMcpHttpRequest(request, { | |
| 18 | 70 | serverName: "executor-v2-pm", |
| 19 | 71 | serverVersion: "0.0.0", |
| 20 | 72 | runClient, |
| 73 | + executeToolDescription, | |
| 21 | 74 | }); |
| 75 | + }; | |
| 22 | 76 | }; |
| @@ -0,0 +1,205 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | + | |
| 3 | +import type { RuntimeAdapterError } from "./runtime-adapters"; | |
| 4 | +import type { | |
| 5 | + ToolRegistry, | |
| 6 | + ToolRegistryNamespaceSummary, | |
| 7 | + ToolRegistryToolSummary, | |
| 8 | +} from "./tool-registry"; | |
| 9 | + | |
| 10 | +export type ExecuteToolExposureMode = "all_tools" | "sources_only"; | |
| 11 | + | |
| 12 | +export type BuildExecuteToolDescriptionOptions = { | |
| 13 | + toolRegistry: ToolRegistry; | |
| 14 | + mode: ExecuteToolExposureMode; | |
| 15 | + namespaceLimit?: number; | |
| 16 | + toolLimitPerNamespace?: number; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export const defaultExecuteToolExposureMode: ExecuteToolExposureMode = | |
| 20 | + "sources_only"; | |
| 21 | + | |
| 22 | +export const defaultExecuteToolDescription = | |
| 23 | + "Execute JavaScript against configured runtime"; | |
| 24 | + | |
| 25 | +const defaultDiscoveryNamespaceLimit = 200; | |
| 26 | +const maxNamespaceDescriptionRows = 5_000; | |
| 27 | +const maxToolDescriptionRows = 50_000; | |
| 28 | + | |
| 29 | +const normalizeMode = (value: string): string => value.trim().toLowerCase(); | |
| 30 | + | |
| 31 | +export const parseExecuteToolExposureMode = ( | |
| 32 | + value: string | undefined, | |
| 33 | +): ExecuteToolExposureMode | null => { | |
| 34 | + if (!value) { | |
| 35 | + return null; | |
| 36 | + } | |
| 37 | + | |
| 38 | + const normalized = normalizeMode(value); | |
| 39 | + | |
| 40 | + if ( | |
| 41 | + normalized === "all_tools" || | |
| 42 | + normalized === "all-tools" || | |
| 43 | + normalized === "alltools" || | |
| 44 | + normalized === "all" | |
| 45 | + ) { | |
| 46 | + return "all_tools"; | |
| 47 | + } | |
| 48 | + | |
| 49 | + if ( | |
| 50 | + normalized === "sources_only" || | |
| 51 | + normalized === "sources-only" || | |
| 52 | + normalized === "sourcesonly" || | |
| 53 | + normalized === "sources" | |
| 54 | + ) { | |
| 55 | + return "sources_only"; | |
| 56 | + } | |
| 57 | + | |
| 58 | + return null; | |
| 59 | +}; | |
| 60 | + | |
| 61 | +const quote = (value: string): string => JSON.stringify(value); | |
| 62 | + | |
| 63 | +const namespaceLine = (namespace: ToolRegistryNamespaceSummary): string => { | |
| 64 | + const sourceLabel = namespace.source ? `source=${namespace.source}` : "source=unknown"; | |
| 65 | + const sourceKeyLabel = namespace.sourceKey | |
| 66 | + ? ` sourceKey=${namespace.sourceKey}` | |
| 67 | + : ""; | |
| 68 | + const description = namespace.description | |
| 69 | + ? ` - ${namespace.description}` | |
| 70 | + : ""; | |
| 71 | + | |
| 72 | + return `- ${namespace.namespace} (${sourceLabel}${sourceKeyLabel}, tools=${namespace.toolCount})${description}`; | |
| 73 | +}; | |
| 74 | + | |
| 75 | +const toolLine = (tool: ToolRegistryToolSummary): string => { | |
| 76 | + const sourceLabel = tool.source ? ` source=${tool.source}` : ""; | |
| 77 | + const description = tool.description ? ` - ${tool.description}` : ""; | |
| 78 | + const inputHint = tool.inputHint ? ` | input=${tool.inputHint}` : ""; | |
| 79 | + const outputHint = tool.outputHint ? ` | output=${tool.outputHint}` : ""; | |
| 80 | + return `- ${tool.path}${sourceLabel}${description}${inputHint}${outputHint}`; | |
| 81 | +}; | |
| 82 | + | |
| 83 | +const dedupeToolsByPath = ( | |
| 84 | + tools: ReadonlyArray<ToolRegistryToolSummary>, | |
| 85 | +): Array<ToolRegistryToolSummary> => { | |
| 86 | + const byPath = new Map<string, ToolRegistryToolSummary>(); | |
| 87 | + | |
| 88 | + for (const tool of tools) { | |
| 89 | + if (!byPath.has(tool.path)) { | |
| 90 | + byPath.set(tool.path, tool); | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + return [...byPath.values()].sort((left, right) => | |
| 95 | + left.path.localeCompare(right.path), | |
| 96 | + ); | |
| 97 | +}; | |
| 98 | + | |
| 99 | +const discoveryGuide = [ | |
| 100 | + "Discovery workflow:", | |
| 101 | + `1) const namespaces = await tools.catalog.namespaces({ limit: ${defaultDiscoveryNamespaceLimit} });`, | |
| 102 | + `2) const matches = await tools.discover({ query: "<intent>", limit: 12 });`, | |
| 103 | + "3) call the selected tool path via tools.<namespace>.<...>(input)", | |
| 104 | +].join("\n"); | |
| 105 | + | |
| 106 | +const formatSourcesOnlyDescription = ( | |
| 107 | + namespaces: ReadonlyArray<ToolRegistryNamespaceSummary>, | |
| 108 | +): string => { | |
| 109 | + const sourceLines = namespaces.length | |
| 110 | + ? namespaces.map(namespaceLine).join("\n") | |
| 111 | + : "- No enabled sources are currently available"; | |
| 112 | + | |
| 113 | + return [ | |
| 114 | + defaultExecuteToolDescription, | |
| 115 | + "", | |
| 116 | + "Mode: sources_only", | |
| 117 | + "Only source-level context is preloaded. Discover tool paths at runtime.", | |
| 118 | + "", | |
| 119 | + "Sources:", | |
| 120 | + sourceLines, | |
| 121 | + "", | |
| 122 | + discoveryGuide, | |
| 123 | + ].join("\n"); | |
| 124 | +}; | |
| 125 | + | |
| 126 | +const formatAllToolsDescription = ( | |
| 127 | + namespaces: ReadonlyArray<ToolRegistryNamespaceSummary>, | |
| 128 | + tools: ReadonlyArray<ToolRegistryToolSummary>, | |
| 129 | +): string => { | |
| 130 | + const sourceLines = namespaces.length | |
| 131 | + ? namespaces.map(namespaceLine).join("\n") | |
| 132 | + : "- No enabled sources are currently available"; | |
| 133 | + | |
| 134 | + const toolLines = tools.length | |
| 135 | + ? tools.map(toolLine).join("\n") | |
| 136 | + : "- No tools are currently available"; | |
| 137 | + | |
| 138 | + return [ | |
| 139 | + defaultExecuteToolDescription, | |
| 140 | + "", | |
| 141 | + "Mode: all_tools", | |
| 142 | + "All known tools are preloaded below.", | |
| 143 | + "", | |
| 144 | + "Sources:", | |
| 145 | + sourceLines, | |
| 146 | + "", | |
| 147 | + "Tool paths:", | |
| 148 | + toolLines, | |
| 149 | + "", | |
| 150 | + `Tip: if a path fails, run tools.discover({ query: ${quote("<intent>")} }) for correction hints.`, | |
| 151 | + ].join("\n"); | |
| 152 | +}; | |
| 153 | + | |
| 154 | +const normalizeNamespaces = ( | |
| 155 | + namespaces: ReadonlyArray<ToolRegistryNamespaceSummary>, | |
| 156 | +): Array<ToolRegistryNamespaceSummary> => | |
| 157 | + [...namespaces].sort((left, right) => left.namespace.localeCompare(right.namespace)); | |
| 158 | + | |
| 159 | +export const buildExecuteToolDescription = ( | |
| 160 | + options: BuildExecuteToolDescriptionOptions, | |
| 161 | +): Effect.Effect<string, RuntimeAdapterError> => | |
| 162 | + Effect.gen(function* () { | |
| 163 | + const namespaceLimit = Math.max( | |
| 164 | + 1, | |
| 165 | + Math.min(maxNamespaceDescriptionRows, options.namespaceLimit ?? maxNamespaceDescriptionRows), | |
| 166 | + ); | |
| 167 | + | |
| 168 | + const namespacesOutput = yield* options.toolRegistry.catalogNamespaces({ | |
| 169 | + limit: namespaceLimit, | |
| 170 | + }); | |
| 171 | + | |
| 172 | + const namespaces = normalizeNamespaces(namespacesOutput.namespaces); | |
| 173 | + | |
| 174 | + if (options.mode === "sources_only") { | |
| 175 | + return formatSourcesOnlyDescription(namespaces); | |
| 176 | + } | |
| 177 | + | |
| 178 | + const requestedToolLimit = Math.max( | |
| 179 | + 1, | |
| 180 | + Math.min( | |
| 181 | + maxToolDescriptionRows, | |
| 182 | + options.toolLimitPerNamespace ?? maxToolDescriptionRows, | |
| 183 | + ), | |
| 184 | + ); | |
| 185 | + | |
| 186 | + const toolsByNamespace = yield* Effect.forEach( | |
| 187 | + namespaces, | |
| 188 | + (namespace) => | |
| 189 | + options.toolRegistry.catalogTools({ | |
| 190 | + namespace: namespace.namespace, | |
| 191 | + limit: requestedToolLimit, | |
| 192 | + compact: false, | |
| 193 | + includeSchemas: false, | |
| 194 | + }), | |
| 195 | + { | |
| 196 | + concurrency: 4, | |
| 197 | + }, | |
| 198 | + ); | |
| 199 | + | |
| 200 | + const tools = dedupeToolsByPath( | |
| 201 | + toolsByNamespace.flatMap((result) => result.results), | |
| 202 | + ); | |
| 203 | + | |
| 204 | + return formatAllToolsDescription(namespaces, tools); | |
| 205 | + }); | |
| @@ -0,0 +1,204 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + fetchOpenApiDocument, | |
| 4 | + makeSourceManagerService, | |
| 5 | +} from "@executor-v2/management-api"; | |
| 6 | +import { | |
| 7 | + type SourceStore, | |
| 8 | + type ToolArtifactStore, | |
| 9 | +} from "@executor-v2/persistence-ports"; | |
| 10 | +import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; | |
| 11 | +import { SourceSchema, type Source, type ToolArtifact } from "@executor-v2/schema"; | |
| 12 | +import * as Effect from "effect/Effect"; | |
| 13 | +import * as Option from "effect/Option"; | |
| 14 | +import * as Schema from "effect/Schema"; | |
| 15 | + | |
| 16 | +import { | |
| 17 | + buildExecuteToolDescription, | |
| 18 | + createRuntimeRunClient, | |
| 19 | + createSourceToolRegistry, | |
| 20 | + createStaticToolRegistry, | |
| 21 | + invokeRuntimeToolCallResult, | |
| 22 | + makeOpenApiToolProvider, | |
| 23 | + makeToolProviderRegistry, | |
| 24 | +} from "./index"; | |
| 25 | + | |
| 26 | +const decodeSource = Schema.decodeUnknownSync(SourceSchema); | |
| 27 | + | |
| 28 | +describe("engine public API", () => { | |
| 29 | + it.effect("executes runtime code against tools via createRuntimeRunClient", () => | |
| 30 | + Effect.gen(function* () { | |
| 31 | + const runClient = createRuntimeRunClient({ | |
| 32 | + runtimeAdapter: makeLocalInProcessRuntimeAdapter(), | |
| 33 | + toolRegistry: createStaticToolRegistry({ | |
| 34 | + tools: { | |
| 35 | + "math.add": { | |
| 36 | + description: "Add two numbers", | |
| 37 | + execute: (input: { a: number; b: number }) => ({ | |
| 38 | + total: input.a + input.b, | |
| 39 | + }), | |
| 40 | + }, | |
| 41 | + }, | |
| 42 | + }), | |
| 43 | + }); | |
| 44 | + | |
| 45 | + const result = yield* Effect.tryPromise(() => | |
| 46 | + runClient.execute({ | |
| 47 | + code: "const sum = await tools.math.add({ a: 2, b: 5 }); return { answer: sum.total };", | |
| 48 | + }), | |
| 49 | + ); | |
| 50 | + | |
| 51 | + expect(result.status).toBe("completed"); | |
| 52 | + expect(result.result).toEqual({ answer: 7 }); | |
| 53 | + }), | |
| 54 | + ); | |
| 55 | + | |
| 56 | + it.effect("builds mode-specific execute descriptions from the same registry", () => | |
| 57 | + Effect.gen(function* () { | |
| 58 | + const toolRegistry = createStaticToolRegistry({ | |
| 59 | + tools: { | |
| 60 | + "github.repos.get": { | |
| 61 | + description: "Get a repository", | |
| 62 | + execute: () => ({ ok: true }), | |
| 63 | + }, | |
| 64 | + "slack.messages.send": { | |
| 65 | + description: "Send a message", | |
| 66 | + execute: () => ({ ok: true }), | |
| 67 | + }, | |
| 68 | + }, | |
| 69 | + }); | |
| 70 | + | |
| 71 | + const sourcesOnly = yield* buildExecuteToolDescription({ | |
| 72 | + toolRegistry, | |
| 73 | + mode: "sources_only", | |
| 74 | + }); | |
| 75 | + | |
| 76 | + const allTools = yield* buildExecuteToolDescription({ | |
| 77 | + toolRegistry, | |
| 78 | + mode: "all_tools", | |
| 79 | + }); | |
| 80 | + | |
| 81 | + expect(sourcesOnly).toContain("Mode: sources_only"); | |
| 82 | + expect(sourcesOnly).toContain("Discovery workflow:"); | |
| 83 | + expect(sourcesOnly).not.toContain("github.repos.get"); | |
| 84 | + | |
| 85 | + expect(allTools).toContain("Mode: all_tools"); | |
| 86 | + expect(allTools).toContain("Tool paths:"); | |
| 87 | + expect(allTools).toContain("github.repos.get"); | |
| 88 | + expect(allTools).toContain("slack.messages.send"); | |
| 89 | + }), | |
| 90 | + ); | |
| 91 | + | |
| 92 | + it.effect("routes native discovery calls through invokeRuntimeToolCallResult", () => | |
| 93 | + Effect.gen(function* () { | |
| 94 | + const toolRegistry = createStaticToolRegistry({ | |
| 95 | + tools: { | |
| 96 | + "github.repos.get": { | |
| 97 | + description: "Get repository", | |
| 98 | + execute: () => ({ ok: true }), | |
| 99 | + }, | |
| 100 | + "github.repos.list": { | |
| 101 | + description: "List repositories", | |
| 102 | + execute: () => ({ ok: true }), | |
| 103 | + }, | |
| 104 | + }, | |
| 105 | + }); | |
| 106 | + | |
| 107 | + const result = yield* invokeRuntimeToolCallResult(toolRegistry, { | |
| 108 | + runId: "run_index_test_1", | |
| 109 | + callId: "call_index_test_1", | |
| 110 | + toolPath: "discover", | |
| 111 | + input: { | |
| 112 | + query: "list repos", | |
| 113 | + }, | |
| 114 | + }); | |
| 115 | + | |
| 116 | + expect(result.ok).toBe(true); | |
| 117 | + if (!result.ok) { | |
| 118 | + return; | |
| 119 | + } | |
| 120 | + | |
| 121 | + const value = result.value as { bestPath: string | null; total: number }; | |
| 122 | + expect(value.bestPath).toBe("github.repos.list"); | |
| 123 | + expect(value.total).toBeGreaterThan(0); | |
| 124 | + }), | |
| 125 | + ); | |
| 126 | + | |
| 127 | + it.live("loads Vercel OpenAPI and discovers tools via source registry", () => | |
| 128 | + Effect.gen(function* () { | |
| 129 | + const openApiSpec = yield* Effect.tryPromise(() => | |
| 130 | + fetchOpenApiDocument("https://openapi.vercel.sh/"), | |
| 131 | + ); | |
| 132 | + | |
| 133 | + const source: Source = decodeSource({ | |
| 134 | + id: "src_vercel_index", | |
| 135 | + workspaceId: "ws_local", | |
| 136 | + name: "vercel", | |
| 137 | + kind: "openapi", | |
| 138 | + endpoint: "https://api.vercel.com", | |
| 139 | + status: "connected", | |
| 140 | + enabled: true, | |
| 141 | + configJson: "{}", | |
| 142 | + sourceHash: null, | |
| 143 | + lastError: null, | |
| 144 | + createdAt: Date.now(), | |
| 145 | + updatedAt: Date.now(), | |
| 146 | + }); | |
| 147 | + | |
| 148 | + const sources: Array<Source> = [source]; | |
| 149 | + const sourceStore: SourceStore = { | |
| 150 | + getById: (workspaceId, sourceId) => | |
| 151 | + Effect.succeed( | |
| 152 | + Option.fromNullable( | |
| 153 | + sources.find( | |
| 154 | + (candidate) => | |
| 155 | + candidate.workspaceId === workspaceId && candidate.id === sourceId, | |
| 156 | + ), | |
| 157 | + ), | |
| 158 | + ), | |
| 159 | + listByWorkspace: (workspaceId) => | |
| 160 | + Effect.succeed( | |
| 161 | + sources.filter((candidate) => candidate.workspaceId === workspaceId), | |
| 162 | + ), | |
| 163 | + upsert: () => Effect.void, | |
| 164 | + removeById: () => Effect.succeed(false), | |
| 165 | + }; | |
| 166 | + | |
| 167 | + const artifactsByKey = new Map<string, ToolArtifact>(); | |
| 168 | + const toolArtifactStore: ToolArtifactStore = { | |
| 169 | + getBySource: (workspaceId, sourceId) => | |
| 170 | + Effect.succeed( | |
| 171 | + Option.fromNullable(artifactsByKey.get(`${workspaceId}:${sourceId}`)), | |
| 172 | + ), | |
| 173 | + upsert: (artifact) => | |
| 174 | + Effect.sync(() => { | |
| 175 | + artifactsByKey.set(`${artifact.workspaceId}:${artifact.sourceId}`, artifact); | |
| 176 | + }), | |
| 177 | + }; | |
| 178 | + | |
| 179 | + const sourceManager = makeSourceManagerService(toolArtifactStore); | |
| 180 | + const refreshed = yield* sourceManager.refreshOpenApiArtifact({ | |
| 181 | + source, | |
| 182 | + openApiSpec, | |
| 183 | + }); | |
| 184 | + expect(refreshed.artifact.toolCount).toBeGreaterThan(0); | |
| 185 | + | |
| 186 | + const toolRegistry = createSourceToolRegistry({ | |
| 187 | + workspaceId: source.workspaceId, | |
| 188 | + sourceStore, | |
| 189 | + toolArtifactStore, | |
| 190 | + toolProviderRegistry: makeToolProviderRegistry([makeOpenApiToolProvider()]), | |
| 191 | + }); | |
| 192 | + | |
| 193 | + const discovered = yield* toolRegistry.discover({ | |
| 194 | + query: "vercel", | |
| 195 | + limit: 12, | |
| 196 | + }); | |
| 197 | + | |
| 198 | + expect(discovered.bestPath).not.toBeNull(); | |
| 199 | + expect(discovered.total).toBeGreaterThan(0); | |
| 200 | + expect(discovered.results[0]?.source).toBe("vercel"); | |
| 201 | + }), | |
| 202 | + 120_000, | |
| 203 | + ); | |
| 204 | +}); | |
| @@ -38,11 +38,21 @@ export { · 37 unmodified lines | ||
| 38 | 38 | type ToolRegistryCatalogToolsOutput, |
| 39 | 39 | type ToolRegistryDiscoverInput, |
| 40 | 40 | type ToolRegistryDiscoverOutput, |
| 41 | + type ToolRegistryNamespaceSummary, | |
| 41 | 42 | type ToolRegistryToolSummary, |
| 42 | 43 | } from "./tool-registry"; |
| 43 | 44 | |
| 44 | 45 | export { createSourceToolRegistry } from "./source-tool-registry"; |
| 45 | 46 | |
| 47 | +export { | |
| 48 | + buildExecuteToolDescription, | |
| 49 | + defaultExecuteToolDescription, | |
| 50 | + defaultExecuteToolExposureMode, | |
| 51 | + parseExecuteToolExposureMode, | |
| 52 | + type BuildExecuteToolDescriptionOptions, | |
| 53 | + type ExecuteToolExposureMode, | |
| 54 | +} from "./execute-tool-description"; | |
| 55 | + | |
| 46 | 56 | export { |
| 47 | 57 | PersistentToolApprovalPolicyStoreError, |
| 48 | 58 | createPersistentToolApprovalPolicy, |
| @@ -8,7 +8,10 @@ import { · 7 unmodified lines | ||
| 8 | 8 | OpenApi, |
| 9 | 9 | } from "@effect/platform"; |
| 10 | 10 | import { describe, expect, it } from "@effect/vitest"; |
| 11 | −import { makeSourceManagerService } from "@executor-v2/management-api"; | |
| 11 | +import { | |
| 12 | + fetchOpenApiDocument, | |
| 13 | + makeSourceManagerService, | |
| 14 | +} from "@executor-v2/management-api"; | |
| 12 | 15 | import { |
| 13 | 16 | type SourceStore, |
| 14 | 17 | type ToolArtifactStore, |
| @@ -271,9 +274,30 @@ describe("source tool registry", () => { · 256 unmodified lines | ||
| 271 | 274 | }, |
| 272 | 275 | }); |
| 273 | 276 | |
| 277 | + const autocorrectedPath = bestPath.replace(".", "_").toUpperCase(); | |
| 278 | + const autocorrectedResult = yield* toolRegistry.callTool({ | |
| 279 | + runId: "run_source_registry_1", | |
| 280 | + callId: "call_source_registry_2", | |
| 281 | + toolPath: autocorrectedPath, | |
| 282 | + input: { | |
| 283 | + owner: "octocat", | |
| 284 | + repo: "hello-world", | |
| 285 | + }, | |
| 286 | + }); | |
| 287 | + | |
| 288 | + expect(autocorrectedResult).toMatchObject({ | |
| 289 | + ok: true, | |
| 290 | + value: { | |
| 291 | + status: 200, | |
| 292 | + }, | |
| 293 | + }); | |
| 294 | + | |
| 274 | 295 | const namespaces = yield* toolRegistry.catalogNamespaces({}); |
| 275 | 296 | expect(namespaces.total).toBeGreaterThan(0); |
| 276 | 297 | expect(namespaces.namespaces[0]?.samplePaths.length).toBeGreaterThan(0); |
| 298 | + expect(namespaces.namespaces[0]?.source).toBe("github"); | |
| 299 | + expect(namespaces.namespaces[0]?.sourceKey).toBe("src_github"); | |
| 300 | + expect(namespaces.namespaces[0]?.description).toContain("source at"); | |
| 277 | 301 | |
| 278 | 302 | const namespace = namespaces.namespaces[0]?.namespace; |
| 279 | 303 | if (!namespace) { |
| @@ -285,7 +309,10 @@ describe("source tool registry", () => { · 5 unmodified lines | ||
| 285 | 309 | }); |
| 286 | 310 | |
| 287 | 311 | expect(catalog.results.length).toBeGreaterThan(0); |
| 288 | − expect(server.requests).toEqual(["/repos/octocat/hello-world"]); | |
| 312 | + expect(server.requests).toEqual([ | |
| 313 | + "/repos/octocat/hello-world", | |
| 314 | + "/repos/octocat/hello-world", | |
| 315 | + ]); | |
| 289 | 316 | }), |
| 290 | 317 | ); |
| 291 | 318 | |
| @@ -382,4 +409,139 @@ describe("source tool registry", () => { · 90 unmodified lines | ||
| 382 | 409 | expect(server.requests).toEqual([]); |
| 383 | 410 | }), |
| 384 | 411 | ); |
| 412 | + | |
| 413 | + it.effect("reloads source entries for each registry operation", () => | |
| 414 | + Effect.gen(function* () { | |
| 415 | + const source: Source = decodeSource({ | |
| 416 | + id: "src_dynamic_load", | |
| 417 | + workspaceId: "ws_local", | |
| 418 | + name: "github", | |
| 419 | + kind: "openapi", | |
| 420 | + endpoint: "https://example.test", | |
| 421 | + status: "connected", | |
| 422 | + enabled: true, | |
| 423 | + configJson: "{}", | |
| 424 | + sourceHash: null, | |
| 425 | + lastError: null, | |
| 426 | + createdAt: Date.now(), | |
| 427 | + updatedAt: Date.now(), | |
| 428 | + }); | |
| 429 | + | |
| 430 | + let listByWorkspaceCalls = 0; | |
| 431 | + | |
| 432 | + const sourceStore: SourceStore = { | |
| 433 | + getById: () => Effect.succeed(Option.some(source)), | |
| 434 | + listByWorkspace: () => | |
| 435 | + Effect.sync(() => { | |
| 436 | + listByWorkspaceCalls += 1; | |
| 437 | + return [source]; | |
| 438 | + }), | |
| 439 | + upsert: () => Effect.void, | |
| 440 | + removeById: () => Effect.succeed(false), | |
| 441 | + }; | |
| 442 | + | |
| 443 | + const toolArtifactStore: ToolArtifactStore = { | |
| 444 | + getBySource: () => Effect.succeed(Option.none()), | |
| 445 | + upsert: () => Effect.void, | |
| 446 | + }; | |
| 447 | + | |
| 448 | + const toolRegistry = createSourceToolRegistry({ | |
| 449 | + workspaceId: source.workspaceId, | |
| 450 | + sourceStore, | |
| 451 | + toolArtifactStore, | |
| 452 | + toolProviderRegistry: makeToolProviderRegistry([makeOpenApiToolProvider()]), | |
| 453 | + }); | |
| 454 | + | |
| 455 | + const discovered = yield* toolRegistry.discover({ query: "repo" }); | |
| 456 | + const namespaces = yield* toolRegistry.catalogNamespaces({}); | |
| 457 | + const missing = yield* toolRegistry.callTool({ | |
| 458 | + runId: "run_source_dynamic_1", | |
| 459 | + callId: "call_source_dynamic_1", | |
| 460 | + toolPath: "github.repos.get", | |
| 461 | + }); | |
| 462 | + | |
| 463 | + expect(discovered.total).toBe(0); | |
| 464 | + expect(namespaces.total).toBe(0); | |
| 465 | + expect(missing.ok).toBe(false); | |
| 466 | + expect(listByWorkspaceCalls).toBe(3); | |
| 467 | + }), | |
| 468 | + ); | |
| 469 | + | |
| 470 | + it.live("loads Vercel OpenAPI and discovers tools", () => | |
| 471 | + Effect.gen(function* () { | |
| 472 | + const openApiSpec = yield* Effect.tryPromise(() => | |
| 473 | + fetchOpenApiDocument("https://openapi.vercel.sh/"), | |
| 474 | + ); | |
| 475 | + | |
| 476 | + const source: Source = decodeSource({ | |
| 477 | + id: "src_vercel_live", | |
| 478 | + workspaceId: "ws_local", | |
| 479 | + name: "vercel", | |
| 480 | + kind: "openapi", | |
| 481 | + endpoint: "https://api.vercel.com", | |
| 482 | + status: "connected", | |
| 483 | + enabled: true, | |
| 484 | + configJson: "{}", | |
| 485 | + sourceHash: null, | |
| 486 | + lastError: null, | |
| 487 | + createdAt: Date.now(), | |
| 488 | + updatedAt: Date.now(), | |
| 489 | + }); | |
| 490 | + | |
| 491 | + const sources: Array<Source> = [source]; | |
| 492 | + const sourceStore: SourceStore = { | |
| 493 | + getById: (workspaceId, sourceId) => | |
| 494 | + Effect.succeed( | |
| 495 | + Option.fromNullable( | |
| 496 | + sources.find( | |
| 497 | + (candidate) => | |
| 498 | + candidate.workspaceId === workspaceId && candidate.id === sourceId, | |
| 499 | + ), | |
| 500 | + ), | |
| 501 | + ), | |
| 502 | + listByWorkspace: (workspaceId) => | |
| 503 | + Effect.succeed( | |
| 504 | + sources.filter((candidate) => candidate.workspaceId === workspaceId), | |
| 505 | + ), | |
| 506 | + upsert: () => Effect.void, | |
| 507 | + removeById: () => Effect.succeed(false), | |
| 508 | + }; | |
| 509 | + | |
| 510 | + const artifactsByKey = new Map<string, ToolArtifact>(); | |
| 511 | + const toolArtifactStore: ToolArtifactStore = { | |
| 512 | + getBySource: (workspaceId, sourceId) => | |
| 513 | + Effect.succeed( | |
| 514 | + Option.fromNullable(artifactsByKey.get(`${workspaceId}:${sourceId}`)), | |
| 515 | + ), | |
| 516 | + upsert: (artifact) => | |
| 517 | + Effect.sync(() => { | |
| 518 | + artifactsByKey.set(`${artifact.workspaceId}:${artifact.sourceId}`, artifact); | |
| 519 | + }), | |
| 520 | + }; | |
| 521 | + | |
| 522 | + const sourceManager = makeSourceManagerService(toolArtifactStore); | |
| 523 | + const refresh = yield* sourceManager.refreshOpenApiArtifact({ | |
| 524 | + source, | |
| 525 | + openApiSpec, | |
| 526 | + }); | |
| 527 | + expect(refresh.artifact.toolCount).toBeGreaterThan(0); | |
| 528 | + | |
| 529 | + const toolRegistry = createSourceToolRegistry({ | |
| 530 | + workspaceId: source.workspaceId, | |
| 531 | + sourceStore, | |
| 532 | + toolArtifactStore, | |
| 533 | + toolProviderRegistry: makeToolProviderRegistry([makeOpenApiToolProvider()]), | |
| 534 | + }); | |
| 535 | + | |
| 536 | + const discovered = yield* toolRegistry.discover({ | |
| 537 | + query: "vercel", | |
| 538 | + limit: 12, | |
| 539 | + }); | |
| 540 | + | |
| 541 | + expect(discovered.bestPath).not.toBeNull(); | |
| 542 | + expect(discovered.total).toBeGreaterThan(0); | |
| 543 | + expect(discovered.results[0]?.source).toBe("vercel"); | |
| 544 | + }), | |
| 545 | + 120_000, | |
| 546 | + ); | |
| 385 | 547 | }); |
| @@ -81,6 +81,9 @@ const toRuntimeAdapterError = ( · 80 unmodified lines | ||
| 81 | 81 | }); |
| 82 | 82 | |
| 83 | 83 | const defaultPendingRetryAfterMs = 1_000; |
| 84 | +const maxCatalogNamespacesLimit = 5_000; | |
| 85 | +const maxCatalogToolsLimit = 50_000; | |
| 86 | +const maxUnknownToolSuggestions = 3; | |
| 84 | 87 | |
| 85 | 88 | const normalizePendingRetryAfterMs = (value: number | undefined): number => { |
| 86 | 89 | if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { |
| @@ -198,6 +201,59 @@ const sourceToolPath = ( · 111 unmodified lines | ||
| 198 | 201 | descriptor: CanonicalToolDescriptor, |
| 199 | 202 | ): string => `${sourceNamespace(source)}.${descriptor.toolId}`; |
| 200 | 203 | |
| 204 | +const describeSource = (source: Source): string => { | |
| 205 | + const kind = source.kind.toUpperCase(); | |
| 206 | + return `${kind} source at ${source.endpoint}`; | |
| 207 | +}; | |
| 208 | + | |
| 209 | +const normalizeToolPathForLookup = (path: string): string => | |
| 210 | + path | |
| 211 | + .trim() | |
| 212 | + .toLowerCase() | |
| 213 | + .replace(/[^a-z0-9]+/g, ".") | |
| 214 | + .replace(/\.+/g, ".") | |
| 215 | + .replace(/^\.|\.$/g, ""); | |
| 216 | + | |
| 217 | +const levenshteinDistance = (left: string, right: string): number => { | |
| 218 | + if (left === right) { | |
| 219 | + return 0; | |
| 220 | + } | |
| 221 | + | |
| 222 | + if (left.length === 0) { | |
| 223 | + return right.length; | |
| 224 | + } | |
| 225 | + | |
| 226 | + if (right.length === 0) { | |
| 227 | + return left.length; | |
| 228 | + } | |
| 229 | + | |
| 230 | + const previous = new Array<number>(right.length + 1); | |
| 231 | + const current = new Array<number>(right.length + 1); | |
| 232 | + | |
| 233 | + for (let column = 0; column <= right.length; column += 1) { | |
| 234 | + previous[column] = column; | |
| 235 | + } | |
| 236 | + | |
| 237 | + for (let row = 1; row <= left.length; row += 1) { | |
| 238 | + current[0] = row; | |
| 239 | + | |
| 240 | + for (let column = 1; column <= right.length; column += 1) { | |
| 241 | + const substitutionCost = left[row - 1] === right[column - 1] ? 0 : 1; | |
| 242 | + current[column] = Math.min( | |
| 243 | + previous[column] + 1, | |
| 244 | + current[column - 1] + 1, | |
| 245 | + previous[column - 1] + substitutionCost, | |
| 246 | + ); | |
| 247 | + } | |
| 248 | + | |
| 249 | + for (let column = 0; column <= right.length; column += 1) { | |
| 250 | + previous[column] = current[column]; | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + return previous[right.length] ?? right.length; | |
| 255 | +}; | |
| 256 | + | |
| 201 | 257 | const parseJson = (value: string): unknown | null => { |
| 202 | 258 | try { |
| 203 | 259 | return JSON.parse(value); |
| @@ -374,6 +430,173 @@ const describeOutput = (value: unknown): string => { · 170 unmodified lines | ||
| 374 | 430 | } |
| 375 | 431 | }; |
| 376 | 432 | |
| 433 | +type SourceToolResolution = { | |
| 434 | + entry: SourceToolEntry | null; | |
| 435 | + suggestions: Array<string>; | |
| 436 | +}; | |
| 437 | + | |
| 438 | +const uniquePaths = (paths: ReadonlyArray<string>): Array<string> => { | |
| 439 | + const seen = new Set<string>(); | |
| 440 | + const ordered: Array<string> = []; | |
| 441 | + | |
| 442 | + for (const path of paths) { | |
| 443 | + if (!seen.has(path)) { | |
| 444 | + seen.add(path); | |
| 445 | + ordered.push(path); | |
| 446 | + } | |
| 447 | + | |
| 448 | + if (ordered.length >= maxUnknownToolSuggestions) { | |
| 449 | + break; | |
| 450 | + } | |
| 451 | + } | |
| 452 | + | |
| 453 | + return ordered; | |
| 454 | +}; | |
| 455 | + | |
| 456 | +const suggestSourceToolPaths = ( | |
| 457 | + entries: ReadonlyArray<SourceToolEntry>, | |
| 458 | + requestedPath: string, | |
| 459 | +): Array<string> => { | |
| 460 | + const lowerRequested = requestedPath.toLowerCase(); | |
| 461 | + const normalizedRequested = normalizeToolPathForLookup(requestedPath); | |
| 462 | + | |
| 463 | + const directMatches = entries | |
| 464 | + .map((entry) => entry.path) | |
| 465 | + .filter((path) => { | |
| 466 | + const lowerPath = path.toLowerCase(); | |
| 467 | + if (lowerPath.startsWith(lowerRequested) || lowerPath.includes(lowerRequested)) { | |
| 468 | + return true; | |
| 469 | + } | |
| 470 | + | |
| 471 | + const normalizedPath = normalizeToolPathForLookup(path); | |
| 472 | + return ( | |
| 473 | + normalizedPath.startsWith(normalizedRequested) || | |
| 474 | + normalizedPath.includes(normalizedRequested) | |
| 475 | + ); | |
| 476 | + }) | |
| 477 | + .sort((left, right) => left.localeCompare(right)); | |
| 478 | + | |
| 479 | + if (directMatches.length > 0) { | |
| 480 | + return uniquePaths(directMatches); | |
| 481 | + } | |
| 482 | + | |
| 483 | + const distanceCandidates = entries | |
| 484 | + .map((entry) => ({ | |
| 485 | + path: entry.path, | |
| 486 | + distance: levenshteinDistance(entry.path.toLowerCase(), lowerRequested), | |
| 487 | + })) | |
| 488 | + .sort((left, right) => { | |
| 489 | + if (left.distance !== right.distance) { | |
| 490 | + return left.distance - right.distance; | |
| 491 | + } | |
| 492 | + | |
| 493 | + return left.path.localeCompare(right.path); | |
| 494 | + }) | |
| 495 | + .slice(0, maxUnknownToolSuggestions) | |
| 496 | + .map((candidate) => candidate.path); | |
| 497 | + | |
| 498 | + return uniquePaths(distanceCandidates); | |
| 499 | +}; | |
| 500 | + | |
| 501 | +const resolveSourceToolPath = ( | |
| 502 | + snapshot: SourceToolSnapshot, | |
| 503 | + requestedPath: string, | |
| 504 | +): SourceToolResolution => { | |
| 505 | + const trimmedPath = requestedPath.trim(); | |
| 506 | + if (trimmedPath.length === 0) { | |
| 507 | + return { | |
| 508 | + entry: null, | |
| 509 | + suggestions: [], | |
| 510 | + }; | |
| 511 | + } | |
| 512 | + | |
| 513 | + const exact = snapshot.entries.find((entry) => entry.path === trimmedPath); | |
| 514 | + if (exact) { | |
| 515 | + return { | |
| 516 | + entry: exact, | |
| 517 | + suggestions: [], | |
| 518 | + }; | |
| 519 | + } | |
| 520 | + | |
| 521 | + const lowerRequested = trimmedPath.toLowerCase(); | |
| 522 | + const lowerMatch = snapshot.entries.find( | |
| 523 | + (entry) => entry.path.toLowerCase() === lowerRequested, | |
| 524 | + ); | |
| 525 | + if (lowerMatch) { | |
| 526 | + return { | |
| 527 | + entry: lowerMatch, | |
| 528 | + suggestions: [], | |
| 529 | + }; | |
| 530 | + } | |
| 531 | + | |
| 532 | + const normalizedRequested = normalizeToolPathForLookup(trimmedPath); | |
| 533 | + const normalizedMatches = snapshot.entries.filter( | |
| 534 | + (entry) => normalizeToolPathForLookup(entry.path) === normalizedRequested, | |
| 535 | + ); | |
| 536 | + if (normalizedMatches.length === 1) { | |
| 537 | + return { | |
| 538 | + entry: normalizedMatches[0] ?? null, | |
| 539 | + suggestions: [], | |
| 540 | + }; | |
| 541 | + } | |
| 542 | + | |
| 543 | + if (normalizedMatches.length > 1) { | |
| 544 | + const preferred = [...normalizedMatches].sort((left, right) => { | |
| 545 | + if (left.path.length !== right.path.length) { | |
| 546 | + return left.path.length - right.path.length; | |
| 547 | + } | |
| 548 | + | |
| 549 | + return left.path.localeCompare(right.path); | |
| 550 | + })[0]; | |
| 551 | + | |
| 552 | + return { | |
| 553 | + entry: preferred ?? null, | |
| 554 | + suggestions: [], | |
| 555 | + }; | |
| 556 | + } | |
| 557 | + | |
| 558 | + const closest = snapshot.entries | |
| 559 | + .map((entry) => ({ | |
| 560 | + entry, | |
| 561 | + distance: levenshteinDistance(entry.path.toLowerCase(), lowerRequested), | |
| 562 | + })) | |
| 563 | + .sort((left, right) => { | |
| 564 | + if (left.distance !== right.distance) { | |
| 565 | + return left.distance - right.distance; | |
| 566 | + } | |
| 567 | + | |
| 568 | + return left.entry.path.localeCompare(right.entry.path); | |
| 569 | + })[0]; | |
| 570 | + | |
| 571 | + const maxDistance = Math.max(2, Math.floor(trimmedPath.length * 0.2)); | |
| 572 | + if (closest && closest.distance <= maxDistance) { | |
| 573 | + return { | |
| 574 | + entry: closest.entry, | |
| 575 | + suggestions: [], | |
| 576 | + }; | |
| 577 | + } | |
| 578 | + | |
| 579 | + return { | |
| 580 | + entry: null, | |
| 581 | + suggestions: suggestSourceToolPaths(snapshot.entries, trimmedPath), | |
| 582 | + }; | |
| 583 | +}; | |
| 584 | + | |
| 585 | +const unknownToolPathErrorMessage = ( | |
| 586 | + requestedPath: string, | |
| 587 | + suggestions: ReadonlyArray<string>, | |
| 588 | +): string => { | |
| 589 | + const hintQuery = requestedPath.trim().length > 0 ? requestedPath.trim() : "tool"; | |
| 590 | + const suggestionText = | |
| 591 | + suggestions.length > 0 | |
| 592 | + ? ` Did you mean: ${suggestions.join(", ")}.` | |
| 593 | + : ""; | |
| 594 | + | |
| 595 | + return `Unknown tool path: ${requestedPath}.${suggestionText} Use tools.discover({ query: ${JSON.stringify( | |
| 596 | + hintQuery, | |
| 597 | + )} }) or tools.catalog.tools({ query: ${JSON.stringify(hintQuery)} }) to find available tool paths.`; | |
| 598 | +}; | |
| 599 | + | |
| 377 | 600 | const loadSourceEntries = ( |
| 378 | 601 | options: SourceToolRegistryOptions, |
| 379 | 602 | ): Effect.Effect<SourceToolSnapshot, RuntimeAdapterError> => |
| @@ -544,23 +767,39 @@ const catalogNamespaces = ( · 164 unmodified lines | ||
| 544 | 767 | snapshot: SourceToolSnapshot, |
| 545 | 768 | input: ToolRegistryCatalogNamespacesInput, |
| 546 | 769 | ): ToolRegistryCatalogNamespacesOutput => { |
| 547 | − const limit = Math.max(1, Math.min(200, input.limit ?? 50)); | |
| 548 | − const grouped = new Map<string, Array<string>>(); | |
| 770 | + const limit = Math.max(1, Math.min(maxCatalogNamespacesLimit, input.limit ?? 50)); | |
| 771 | + const grouped = new Map< | |
| 772 | + string, | |
| 773 | + { | |
| 774 | + paths: Array<string>; | |
| 775 | + source: Source; | |
| 776 | + } | |
| 777 | + >(); | |
| 549 | 778 | |
| 550 | 779 | for (const entry of snapshot.entries) { |
| 551 | − const paths = grouped.get(entry.namespace) ?? []; | |
| 552 | − paths.push(entry.path); | |
| 553 | − grouped.set(entry.namespace, paths); | |
| 780 | + const existing = grouped.get(entry.namespace); | |
| 781 | + if (existing) { | |
| 782 | + existing.paths.push(entry.path); | |
| 783 | + continue; | |
| 784 | + } | |
| 785 | + | |
| 786 | + grouped.set(entry.namespace, { | |
| 787 | + paths: [entry.path], | |
| 788 | + source: entry.source, | |
| 789 | + }); | |
| 554 | 790 | } |
| 555 | 791 | |
| 556 | 792 | const namespaces = [...grouped.entries()] |
| 557 | 793 | .sort((left, right) => left[0].localeCompare(right[0])) |
| 558 | − .map(([namespace, paths]) => ({ | |
| 794 | + .map(([namespace, value]) => ({ | |
| 559 | 795 | namespace, |
| 560 | − toolCount: paths.length, | |
| 561 | − samplePaths: [...paths] | |
| 796 | + toolCount: value.paths.length, | |
| 797 | + samplePaths: [...value.paths] | |
| 562 | 798 | .sort((left, right) => left.localeCompare(right)) |
| 563 | 799 | .slice(0, 3), |
| 800 | + source: value.source.name, | |
| 801 | + sourceKey: value.source.id, | |
| 802 | + description: describeSource(value.source), | |
| 564 | 803 | })); |
| 565 | 804 | |
| 566 | 805 | return { |
| @@ -573,7 +812,7 @@ const catalogTools = ( · 6 unmodified lines | ||
| 573 | 812 | snapshot: SourceToolSnapshot, |
| 574 | 813 | input: ToolRegistryCatalogToolsInput, |
| 575 | 814 | ): ToolRegistryCatalogToolsOutput => { |
| 576 | − const limit = Math.max(1, Math.min(200, input.limit ?? 50)); | |
| 815 | + const limit = Math.max(1, Math.min(maxCatalogToolsLimit, input.limit ?? 50)); | |
| 577 | 816 | const query = (input.query ?? "").trim().toLowerCase(); |
| 578 | 817 | const namespace = (input.namespace ?? "").trim().toLowerCase(); |
| 579 | 818 | const includeSchemas = input.includeSchemas === true; |
| @@ -600,22 +839,21 @@ export const createSourceToolRegistry = ( · 20 unmodified lines | ||
| 600 | 839 | callTool: (input: ToolRegistryCallInput) => |
| 601 | 840 | Effect.gen(function* () { |
| 602 | 841 | const snapshot = yield* loadSourceEntries(options); |
| 603 | − const entry = snapshot.entries.find( | |
| 604 | − (candidate) => candidate.path === input.toolPath, | |
| 605 | − ); | |
| 842 | + const resolved = resolveSourceToolPath(snapshot, input.toolPath); | |
| 843 | + const entry = resolved.entry; | |
| 606 | 844 | |
| 607 | 845 | if (!entry) { |
| 608 | 846 | return { |
| 609 | 847 | ok: false, |
| 610 | 848 | kind: "failed", |
| 611 | − error: `Unknown tool path: ${input.toolPath}. Use tools.discover({ query }) or tools.catalog.tools({ namespace }) to find available tool paths.`, | |
| 849 | + error: unknownToolPathErrorMessage(input.toolPath, resolved.suggestions), | |
| 612 | 850 | } satisfies RuntimeToolCallResult; |
| 613 | 851 | } |
| 614 | 852 | |
| 615 | 853 | const approvalRequest: ToolApprovalRequest = { |
| 616 | 854 | runId: input.runId, |
| 617 | 855 | callId: input.callId, |
| 618 | − toolPath: input.toolPath, | |
| 856 | + toolPath: entry.path, | |
| 619 | 857 | input: normalizeToolCallInput(input.input), |
| 620 | 858 | workspaceId: options.workspaceId, |
| 621 | 859 | source: entry.source.name, |
| @@ -53,12 +53,17 @@ export type ToolRegistryDiscoverOutput = { · 52 unmodified lines | ||
| 53 | 53 | refHintTable?: Record<string, string>; |
| 54 | 54 | }; |
| 55 | 55 | |
| 56 | +export type ToolRegistryNamespaceSummary = { | |
| 57 | + namespace: string; | |
| 58 | + toolCount: number; | |
| 59 | + samplePaths: Array<string>; | |
| 60 | + source?: string; | |
| 61 | + sourceKey?: string; | |
| 62 | + description?: string; | |
| 63 | +}; | |
| 64 | + | |
| 56 | 65 | export type ToolRegistryCatalogNamespacesOutput = { |
| 57 | − namespaces: Array<{ | |
| 58 | − namespace: string; | |
| 59 | − toolCount: number; | |
| 60 | − samplePaths: Array<string>; | |
| 61 | − }>; | |
| 66 | + namespaces: Array<ToolRegistryNamespaceSummary>; | |
| 62 | 67 | total: number; |
| 63 | 68 | }; |
| 64 | 69 | |
| @@ -154,6 +159,8 @@ const toRuntimeAdapterError = ( · 89 unmodified lines | ||
| 154 | 159 | }); |
| 155 | 160 | |
| 156 | 161 | const defaultPendingRetryAfterMs = 1_000; |
| 162 | +const maxCatalogNamespacesLimit = 5_000; | |
| 163 | +const maxCatalogToolsLimit = 50_000; | |
| 157 | 164 | |
| 158 | 165 | const normalizePendingRetryAfterMs = (value: number | undefined): number => { |
| 159 | 166 | if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { |
| @@ -483,7 +490,7 @@ const inMemoryCatalogNamespaces = ( · 323 unmodified lines | ||
| 483 | 490 | tools: InMemorySandboxToolMap, |
| 484 | 491 | input: ToolRegistryCatalogNamespacesInput, |
| 485 | 492 | ): ToolRegistryCatalogNamespacesOutput => { |
| 486 | − const limit = Math.max(1, Math.min(200, input.limit ?? 50)); | |
| 493 | + const limit = Math.max(1, Math.min(maxCatalogNamespacesLimit, input.limit ?? 50)); | |
| 487 | 494 | const grouped = new Map<string, Array<string>>(); |
| 488 | 495 | |
| 489 | 496 | for (const path of Object.keys(tools)) { |
| @@ -512,7 +519,7 @@ const inMemoryCatalogTools = ( · 22 unmodified lines | ||
| 512 | 519 | refHintTable: Record<string, string> | undefined, |
| 513 | 520 | input: ToolRegistryCatalogToolsInput, |
| 514 | 521 | ): ToolRegistryCatalogToolsOutput => { |
| 515 | − const limit = Math.max(1, Math.min(200, input.limit ?? 50)); | |
| 522 | + const limit = Math.max(1, Math.min(maxCatalogToolsLimit, input.limit ?? 50)); | |
| 516 | 523 | const query = (input.query ?? "").trim().toLowerCase(); |
| 517 | 524 | const namespace = (input.namespace ?? "").trim().toLowerCase(); |
| 518 | 525 | const includeSchemas = input.includeSchemas === true; |
| @@ -34,6 +34,7 @@ export type McpGatewayOptions = { · 33 unmodified lines | ||
| 34 | 34 | serverName?: string; |
| 35 | 35 | serverVersion?: string; |
| 36 | 36 | runClient: ExecutorRunClient; |
| 37 | + executeToolDescription?: string; | |
| 37 | 38 | }; |
| 38 | 39 | const DEFAULT_SERVER_NAME = "executor-v2"; |
| 39 | 40 | const DEFAULT_SERVER_VERSION = "0.0.0"; |
| @@ -65,7 +66,9 @@ const createMcpServer = (options: McpGatewayOptions): McpServer => { · 25 unmodified lines | ||
| 65 | 66 | mcp.registerTool( |
| 66 | 67 | EXECUTE_TOOL_NAME, |
| 67 | 68 | { |
| 68 | − description: "Execute JavaScript against configured runtime", | |
| 69 | + description: | |
| 70 | + options.executeToolDescription ?? | |
| 71 | + "Execute JavaScript against configured runtime", | |
| 69 | 72 | inputSchema: ExecuteToolInputSchema, |
| 70 | 73 | }, |
| 71 | 74 | async (input: ExecuteToolInput) => { |