Unified · Split
| @@ -68,6 +68,11 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 67 unmodified lines | |||
| 68 | 68 | ||
| 69 | # ── MCP OAuth for /mcp (optional) ── | 69 | # ── MCP OAuth for /mcp (optional) ── |
| 70 | # MCP_AUTHORIZATION_SERVER=https://your-authkit-domain.authkit.app | 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 | # ── Discord bot (optional — omit to skip bot startup) ── | 77 | # ── Discord bot (optional — omit to skip bot startup) ── |
| 73 | # DISCORD_BOT_TOKEN=... | 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 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; | 8 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; |
| 2 | import { createExecutorRunClient } from "@executor-v2/sdk"; | 9 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 3 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| @@ -25,6 +32,35 @@ const readWorkspaceIdFromRequest = (request: Request): string | null => { · 21 unmodified lines | |||
| 25 | const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string => | 32 | const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string => |
| 26 | readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId; | 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 | const hasWorkspaceAccess = async ( | 64 | const hasWorkspaceAccess = async ( |
| 29 | ctx: ActionCtx, | 65 | ctx: ActionCtx, |
| 30 | workspaceId: string, | 66 | workspaceId: string, |
| @@ -65,6 +101,9 @@ const hasWorkspaceAccess = async ( · 34 unmodified lines | |||
| 65 | }; | 101 | }; |
| 66 | 102 | ||
| 67 | const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); | 103 | const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); |
| 104 | const defaultToolExposureMode = | ||
| 105 | parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ?? | ||
| 106 | defaultExecuteToolExposureMode; | ||
| 68 | const runtimeInternal = internal as any; | 107 | const runtimeInternal = internal as any; |
| 69 | 108 | ||
| 70 | export const mcpHandler = httpAction(async (ctx, request) => { | 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 | const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId); | 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 | const runClient = createExecutorRunClient(async (input) => { | 159 | const runClient = createExecutorRunClient(async (input) => { |
| 113 | const runId = `run_${crypto.randomUUID()}`; | 160 | const runId = `run_${crypto.randomUUID()}`; |
| @@ -151,5 +198,6 @@ export const mcpHandler = httpAction(async (ctx, request) => { · 37 unmodified lines | |||
| 151 | serverName: "executor-v2-convex", | 198 | serverName: "executor-v2-convex", |
| 152 | serverVersion: "0.0.0", | 199 | serverVersion: "0.0.0", |
| 153 | runClient, | 200 | runClient, |
| 201 | executeToolDescription, | ||
| 154 | }); | 202 | }); |
| 155 | }); | 203 | }); |
| @@ -12,10 +12,12 @@ import { · 11 unmodified lines | |||
| 12 | RuntimeAdapterError, | 12 | RuntimeAdapterError, |
| 13 | createRunExecutor, | 13 | createRunExecutor, |
| 14 | createSourceToolRegistry, | 14 | createSourceToolRegistry, |
| 15 | defaultExecuteToolExposureMode, | ||
| 15 | invokeRuntimeToolCallResult, | 16 | invokeRuntimeToolCallResult, |
| 16 | makeOpenApiToolProvider, | 17 | makeOpenApiToolProvider, |
| 17 | makeRuntimeAdapterRegistry, | 18 | makeRuntimeAdapterRegistry, |
| 18 | makeToolProviderRegistry, | 19 | makeToolProviderRegistry, |
| 20 | parseExecuteToolExposureMode, | ||
| 19 | } from "@executor-v2/engine"; | 21 | } from "@executor-v2/engine"; |
| 20 | import { | 22 | import { |
| 21 | makeLocalSourceStore, | 23 | makeLocalSourceStore, |
| @@ -71,12 +73,21 @@ const readBooleanFlag = (value: string | undefined): boolean => { · 49 unmodified lines | |||
| 71 | return normalized === "1" || normalized === "true" || normalized === "yes"; | 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 | const formatRuntimeAdapterError = (error: RuntimeAdapterError): string => | 82 | const formatRuntimeAdapterError = (error: RuntimeAdapterError): string => |
| 75 | error.details ? `${error.message}: ${error.details}` : error.message; | 83 | error.details ? `${error.message}: ${error.details}` : error.message; |
| 76 | 84 | ||
| 77 | const port = parsePort(process.env.PORT); | 85 | const port = parsePort(process.env.PORT); |
| 78 | const workspaceId = readConfiguredWorkspaceId(process.env.PM_WORKSPACE_ID); | 86 | const workspaceId = readConfiguredWorkspaceId(process.env.PM_WORKSPACE_ID); |
| 79 | const requireToolApprovals = readBooleanFlag(process.env.PM_REQUIRE_TOOL_APPROVALS); | 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 | const pmRuntimeAdapters = [ | 92 | const pmRuntimeAdapters = [ |
| 82 | makeLocalInProcessRuntimeAdapter(), | 93 | makeLocalInProcessRuntimeAdapter(), |
| @@ -182,7 +193,10 @@ const executeRuntimeRun = createPmExecuteRuntimeRun({ · 99 unmodified lines | |||
| 182 | }); | 193 | }); |
| 183 | 194 | ||
| 184 | const runExecutor = createRunExecutor(executeRuntimeRun); | 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 | const handleToolCallHttp = createPmToolCallHttpHandler((input) => | 201 | const handleToolCallHttp = createPmToolCallHttpHandler((input) => |
| 188 | Effect.runPromise( | 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 | import { | 9 | import { |
| 2 | type ExecuteRunInput, | 10 | type ExecuteRunInput, |
| 3 | type ExecuteRunResult, | 11 | type ExecuteRunResult, |
| @@ -6,17 +14,63 @@ import { · 2 unmodified lines | |||
| 6 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; | 14 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; |
| 7 | import * as Effect from "effect/Effect"; | 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 | export const createPmMcpHandler = ( | 51 | export const createPmMcpHandler = ( |
| 10 | executeRun: (input: ExecuteRunInput) => Effect.Effect<ExecuteRunResult>, | 52 | executeRun: (input: ExecuteRunInput) => Effect.Effect<ExecuteRunResult>, |
| 53 | options: PmMcpHandlerOptions, | ||
| 11 | ): ((request: Request) => Promise<Response>) => { | 54 | ): ((request: Request) => Promise<Response>) => { |
| 12 | const runClient = createExecutorRunClient((input) => | 55 | const runClient = createExecutorRunClient((input) => |
| 13 | Effect.runPromise(executeRun(input)), | 56 | Effect.runPromise(executeRun(input)), |
| 14 | ); | 57 | ); |
| 15 | 58 | ||
| 16 | return (request: Request) => | 59 | const defaultMode = |
| 17 | handleMcpHttpRequest(request, { | 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 | serverName: "executor-v2-pm", | 70 | serverName: "executor-v2-pm", |
| 19 | serverVersion: "0.0.0", | 71 | serverVersion: "0.0.0", |
| 20 | runClient, | 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 | type ToolRegistryCatalogToolsOutput, | 38 | type ToolRegistryCatalogToolsOutput, |
| 39 | type ToolRegistryDiscoverInput, | 39 | type ToolRegistryDiscoverInput, |
| 40 | type ToolRegistryDiscoverOutput, | 40 | type ToolRegistryDiscoverOutput, |
| 41 | type ToolRegistryNamespaceSummary, | ||
| 41 | type ToolRegistryToolSummary, | 42 | type ToolRegistryToolSummary, |
| 42 | } from "./tool-registry"; | 43 | } from "./tool-registry"; |
| 43 | 44 | ||
| 44 | export { createSourceToolRegistry } from "./source-tool-registry"; | 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 | export { | 56 | export { |
| 47 | PersistentToolApprovalPolicyStoreError, | 57 | PersistentToolApprovalPolicyStoreError, |
| 48 | createPersistentToolApprovalPolicy, | 58 | createPersistentToolApprovalPolicy, |
| @@ -8,7 +8,10 @@ import { · 7 unmodified lines | |||
| 8 | OpenApi, | 8 | OpenApi, |
| 9 | } from "@effect/platform"; | 9 | } from "@effect/platform"; |
| 10 | import { describe, expect, it } from "@effect/vitest"; | 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 | import { | 15 | import { |
| 13 | type SourceStore, | 16 | type SourceStore, |
| 14 | type ToolArtifactStore, | 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 | const namespaces = yield* toolRegistry.catalogNamespaces({}); | 295 | const namespaces = yield* toolRegistry.catalogNamespaces({}); |
| 275 | expect(namespaces.total).toBeGreaterThan(0); | 296 | expect(namespaces.total).toBeGreaterThan(0); |
| 276 | expect(namespaces.namespaces[0]?.samplePaths.length).toBeGreaterThan(0); | 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 | const namespace = namespaces.namespaces[0]?.namespace; | 302 | const namespace = namespaces.namespaces[0]?.namespace; |
| 279 | if (!namespace) { | 303 | if (!namespace) { |
| @@ -285,7 +309,10 @@ describe("source tool registry", () => { · 5 unmodified lines | |||
| 285 | }); | 309 | }); |
| 286 | 310 | ||
| 287 | expect(catalog.results.length).toBeGreaterThan(0); | 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 | expect(server.requests).toEqual([]); | 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 | const defaultPendingRetryAfterMs = 1_000; | 83 | const defaultPendingRetryAfterMs = 1_000; |
| 84 | const maxCatalogNamespacesLimit = 5_000; | ||
| 85 | const maxCatalogToolsLimit = 50_000; | ||
| 86 | const maxUnknownToolSuggestions = 3; | ||
| 84 | 87 | ||
| 85 | const normalizePendingRetryAfterMs = (value: number | undefined): number => { | 88 | const normalizePendingRetryAfterMs = (value: number | undefined): number => { |
| 86 | if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { | 89 | if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { |
| @@ -198,6 +201,59 @@ const sourceToolPath = ( · 111 unmodified lines | |||
| 198 | descriptor: CanonicalToolDescriptor, | 201 | descriptor: CanonicalToolDescriptor, |
| 199 | ): string => `${sourceNamespace(source)}.${descriptor.toolId}`; | 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 | const parseJson = (value: string): unknown | null => { | 257 | const parseJson = (value: string): unknown | null => { |
| 202 | try { | 258 | try { |
| 203 | return JSON.parse(value); | 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 | const loadSourceEntries = ( | 600 | const loadSourceEntries = ( |
| 378 | options: SourceToolRegistryOptions, | 601 | options: SourceToolRegistryOptions, |
| 379 | ): Effect.Effect<SourceToolSnapshot, RuntimeAdapterError> => | 602 | ): Effect.Effect<SourceToolSnapshot, RuntimeAdapterError> => |
| @@ -544,23 +767,39 @@ const catalogNamespaces = ( · 164 unmodified lines | |||
| 544 | snapshot: SourceToolSnapshot, | 767 | snapshot: SourceToolSnapshot, |
| 545 | input: ToolRegistryCatalogNamespacesInput, | 768 | input: ToolRegistryCatalogNamespacesInput, |
| 546 | ): ToolRegistryCatalogNamespacesOutput => { | 769 | ): ToolRegistryCatalogNamespacesOutput => { |
| 547 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); | 770 | const limit = Math.max(1, Math.min(maxCatalogNamespacesLimit, input.limit ?? 50)); |
| 548 | const grouped = new Map<string, Array<string>>(); | 771 | const grouped = new Map< |
| 772 | string, | ||
| 773 | { | ||
| 774 | paths: Array<string>; | ||
| 775 | source: Source; | ||
| 776 | } | ||
| 777 | >(); | ||
| 549 | 778 | ||
| 550 | for (const entry of snapshot.entries) { | 779 | for (const entry of snapshot.entries) { |
| 551 | const paths = grouped.get(entry.namespace) ?? []; | 780 | const existing = grouped.get(entry.namespace); |
| 552 | paths.push(entry.path); | 781 | if (existing) { |
| 553 | grouped.set(entry.namespace, paths); | 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 | const namespaces = [...grouped.entries()] | 792 | const namespaces = [...grouped.entries()] |
| 557 | .sort((left, right) => left[0].localeCompare(right[0])) | 793 | .sort((left, right) => left[0].localeCompare(right[0])) |
| 558 | .map(([namespace, paths]) => ({ | 794 | .map(([namespace, value]) => ({ |
| 559 | namespace, | 795 | namespace, |
| 560 | toolCount: paths.length, | 796 | toolCount: value.paths.length, |
| 561 | samplePaths: [...paths] | 797 | samplePaths: [...value.paths] |
| 562 | .sort((left, right) => left.localeCompare(right)) | 798 | .sort((left, right) => left.localeCompare(right)) |
| 563 | .slice(0, 3), | 799 | .slice(0, 3), |
| 800 | source: value.source.name, | ||
| 801 | sourceKey: value.source.id, | ||
| 802 | description: describeSource(value.source), | ||
| 564 | })); | 803 | })); |
| 565 | 804 | ||
| 566 | return { | 805 | return { |
| @@ -573,7 +812,7 @@ const catalogTools = ( · 6 unmodified lines | |||
| 573 | snapshot: SourceToolSnapshot, | 812 | snapshot: SourceToolSnapshot, |
| 574 | input: ToolRegistryCatalogToolsInput, | 813 | input: ToolRegistryCatalogToolsInput, |
| 575 | ): ToolRegistryCatalogToolsOutput => { | 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 | const query = (input.query ?? "").trim().toLowerCase(); | 816 | const query = (input.query ?? "").trim().toLowerCase(); |
| 578 | const namespace = (input.namespace ?? "").trim().toLowerCase(); | 817 | const namespace = (input.namespace ?? "").trim().toLowerCase(); |
| 579 | const includeSchemas = input.includeSchemas === true; | 818 | const includeSchemas = input.includeSchemas === true; |
| @@ -600,22 +839,21 @@ export const createSourceToolRegistry = ( · 20 unmodified lines | |||
| 600 | callTool: (input: ToolRegistryCallInput) => | 839 | callTool: (input: ToolRegistryCallInput) => |
| 601 | Effect.gen(function* () { | 840 | Effect.gen(function* () { |
| 602 | const snapshot = yield* loadSourceEntries(options); | 841 | const snapshot = yield* loadSourceEntries(options); |
| 603 | const entry = snapshot.entries.find( | 842 | const resolved = resolveSourceToolPath(snapshot, input.toolPath); |
| 604 | (candidate) => candidate.path === input.toolPath, | 843 | const entry = resolved.entry; |
| 605 | ); | ||
| 606 | 844 | ||
| 607 | if (!entry) { | 845 | if (!entry) { |
| 608 | return { | 846 | return { |
| 609 | ok: false, | 847 | ok: false, |
| 610 | kind: "failed", | 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 | } satisfies RuntimeToolCallResult; | 850 | } satisfies RuntimeToolCallResult; |
| 613 | } | 851 | } |
| 614 | 852 | ||
| 615 | const approvalRequest: ToolApprovalRequest = { | 853 | const approvalRequest: ToolApprovalRequest = { |
| 616 | runId: input.runId, | 854 | runId: input.runId, |
| 617 | callId: input.callId, | 855 | callId: input.callId, |
| 618 | toolPath: input.toolPath, | 856 | toolPath: entry.path, |
| 619 | input: normalizeToolCallInput(input.input), | 857 | input: normalizeToolCallInput(input.input), |
| 620 | workspaceId: options.workspaceId, | 858 | workspaceId: options.workspaceId, |
| 621 | source: entry.source.name, | 859 | source: entry.source.name, |
| @@ -53,12 +53,17 @@ export type ToolRegistryDiscoverOutput = { · 52 unmodified lines | |||
| 53 | refHintTable?: Record<string, string>; | 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 | export type ToolRegistryCatalogNamespacesOutput = { | 65 | export type ToolRegistryCatalogNamespacesOutput = { |
| 57 | namespaces: Array<{ | 66 | namespaces: Array<ToolRegistryNamespaceSummary>; |
| 58 | namespace: string; | ||
| 59 | toolCount: number; | ||
| 60 | samplePaths: Array<string>; | ||
| 61 | }>; | ||
| 62 | total: number; | 67 | total: number; |
| 63 | }; | 68 | }; |
| 64 | 69 | ||
| @@ -154,6 +159,8 @@ const toRuntimeAdapterError = ( · 89 unmodified lines | |||
| 154 | }); | 159 | }); |
| 155 | 160 | ||
| 156 | const defaultPendingRetryAfterMs = 1_000; | 161 | const defaultPendingRetryAfterMs = 1_000; |
| 162 | const maxCatalogNamespacesLimit = 5_000; | ||
| 163 | const maxCatalogToolsLimit = 50_000; | ||
| 157 | 164 | ||
| 158 | const normalizePendingRetryAfterMs = (value: number | undefined): number => { | 165 | const normalizePendingRetryAfterMs = (value: number | undefined): number => { |
| 159 | if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { | 166 | if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { |
| @@ -483,7 +490,7 @@ const inMemoryCatalogNamespaces = ( · 323 unmodified lines | |||
| 483 | tools: InMemorySandboxToolMap, | 490 | tools: InMemorySandboxToolMap, |
| 484 | input: ToolRegistryCatalogNamespacesInput, | 491 | input: ToolRegistryCatalogNamespacesInput, |
| 485 | ): ToolRegistryCatalogNamespacesOutput => { | 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 | const grouped = new Map<string, Array<string>>(); | 494 | const grouped = new Map<string, Array<string>>(); |
| 488 | 495 | ||
| 489 | for (const path of Object.keys(tools)) { | 496 | for (const path of Object.keys(tools)) { |
| @@ -512,7 +519,7 @@ const inMemoryCatalogTools = ( · 22 unmodified lines | |||
| 512 | refHintTable: Record<string, string> | undefined, | 519 | refHintTable: Record<string, string> | undefined, |
| 513 | input: ToolRegistryCatalogToolsInput, | 520 | input: ToolRegistryCatalogToolsInput, |
| 514 | ): ToolRegistryCatalogToolsOutput => { | 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 | const query = (input.query ?? "").trim().toLowerCase(); | 523 | const query = (input.query ?? "").trim().toLowerCase(); |
| 517 | const namespace = (input.namespace ?? "").trim().toLowerCase(); | 524 | const namespace = (input.namespace ?? "").trim().toLowerCase(); |
| 518 | const includeSchemas = input.includeSchemas === true; | 525 | const includeSchemas = input.includeSchemas === true; |
| @@ -34,6 +34,7 @@ export type McpGatewayOptions = { · 33 unmodified lines | |||
| 34 | serverName?: string; | 34 | serverName?: string; |
| 35 | serverVersion?: string; | 35 | serverVersion?: string; |
| 36 | runClient: ExecutorRunClient; | 36 | runClient: ExecutorRunClient; |
| 37 | executeToolDescription?: string; | ||
| 37 | }; | 38 | }; |
| 38 | const DEFAULT_SERVER_NAME = "executor-v2"; | 39 | const DEFAULT_SERVER_NAME = "executor-v2"; |
| 39 | const DEFAULT_SERVER_VERSION = "0.0.0"; | 40 | const DEFAULT_SERVER_VERSION = "0.0.0"; |
| @@ -65,7 +66,9 @@ const createMcpServer = (options: McpGatewayOptions): McpServer => { · 25 unmodified lines | |||
| 65 | mcp.registerTool( | 66 | mcp.registerTool( |
| 66 | EXECUTE_TOOL_NAME, | 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 | inputSchema: ExecuteToolInputSchema, | 72 | inputSchema: ExecuteToolInputSchema, |
| 70 | }, | 73 | }, |
| 71 | async (input: ExecuteToolInput) => { | 74 | async (input: ExecuteToolInput) => { |