History

feat: add MCP tool exposure modes with discover-first support

Rhys Sullivan committed 8 months ago

Commit 002331f1 parent 54cb04811 files · +977 −27Browse files

Unified · Split

11 files
  1. .env.example +5 −0
  2. v2/apps/convex/convex/mcp.ts +48 −0
  3. v2/apps/pm/src/main.ts +15 −1
  4. v2/apps/pm/src/mcp-handler.ts +56 −2
  5. v2/packages/engine/src/execute-tool-description.ts +205 −0
  6. v2/packages/engine/src/index.test.ts +204 −0
  7. v2/packages/engine/src/index.ts +10 −0
  8. v2/packages/engine/src/source-tool-registry.test.ts +164 −2
  9. v2/packages/engine/src/source-tool-registry.ts +252 −14
  10. v2/packages/engine/src/tool-registry.ts +14 −7
  11. v2/packages/mcp-gateway/src/server.ts +4 −1
.env.example+5−0
@@ -68,6 +68,11 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 67 unmodified lines
6868
6969 # ── MCP OAuth for /mcp (optional) ──
7070 # 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
7176
7277 # ── Discord bot (optional — omit to skip bot startup) ──
7378 # DISCORD_BOT_TOKEN=...
v2/apps/convex/convex/mcp.ts+48−0
@@ -1,3 +1,10 @@
1+import {
2+ buildExecuteToolDescription,
3+ defaultExecuteToolDescription,
4+ defaultExecuteToolExposureMode,
5+ parseExecuteToolExposureMode,
6+ type ExecuteToolExposureMode,
7+} from "@executor-v2/engine";
18 import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
29 import { createExecutorRunClient } from "@executor-v2/sdk";
310 import * as Effect from "effect/Effect";
@@ -25,6 +32,35 @@ const readWorkspaceIdFromRequest = (request: Request): string | null => { · 21 unmodified lines
2532 const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string =>
2633 readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId;
2734
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+
2864 const hasWorkspaceAccess = async (
2965 ctx: ActionCtx,
3066 workspaceId: string,
@@ -65,6 +101,9 @@ const hasWorkspaceAccess = async ( · 34 unmodified lines
65101 };
66102
67103 const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);
104+const defaultToolExposureMode =
105+ parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ??
106+ defaultExecuteToolExposureMode;
68107 const runtimeInternal = internal as any;
69108
70109 export const mcpHandler = httpAction(async (ctx, request) => {
@@ -108,6 +147,14 @@ export const mcpHandler = httpAction(async (ctx, request) => { · 37 unmodified lines
108147 }
109148
110149 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);
150+ const toolExposureMode = readToolExposureModeFromRequest(
151+ request,
152+ defaultToolExposureMode,
153+ );
154+ const executeToolDescription = await resolveExecuteToolDescription(
155+ toolExposureMode,
156+ toolRegistry,
157+ );
111158
112159 const runClient = createExecutorRunClient(async (input) => {
113160 const runId = `run_${crypto.randomUUID()}`;
@@ -151,5 +198,6 @@ export const mcpHandler = httpAction(async (ctx, request) => { · 37 unmodified lines
151198 serverName: "executor-v2-convex",
152199 serverVersion: "0.0.0",
153200 runClient,
201+ executeToolDescription,
154202 });
155203 });
v2/apps/pm/src/main.ts+15−1
@@ -12,10 +12,12 @@ import { · 11 unmodified lines
1212 RuntimeAdapterError,
1313 createRunExecutor,
1414 createSourceToolRegistry,
15+ defaultExecuteToolExposureMode,
1516 invokeRuntimeToolCallResult,
1617 makeOpenApiToolProvider,
1718 makeRuntimeAdapterRegistry,
1819 makeToolProviderRegistry,
20+ parseExecuteToolExposureMode,
1921 } from "@executor-v2/engine";
2022 import {
2123 makeLocalSourceStore,
@@ -71,12 +73,21 @@ const readBooleanFlag = (value: string | undefined): boolean => { · 49 unmodified lines
7173 return normalized === "1" || normalized === "true" || normalized === "yes";
7274 };
7375
76+const readConfiguredToolExposureMode = (
77+ value: string | undefined,
78+): "all_tools" | "sources_only" =>
79+ parseExecuteToolExposureMode(value ?? undefined) ??
80+ defaultExecuteToolExposureMode;
81+
7482 const formatRuntimeAdapterError = (error: RuntimeAdapterError): string =>
7583 error.details ? `${error.message}: ${error.details}` : error.message;
7684
7785 const port = parsePort(process.env.PORT);
7886 const workspaceId = readConfiguredWorkspaceId(process.env.PM_WORKSPACE_ID);
7987 const requireToolApprovals = readBooleanFlag(process.env.PM_REQUIRE_TOOL_APPROVALS);
88+const defaultToolExposureMode = readConfiguredToolExposureMode(
89+ process.env.PM_TOOL_EXPOSURE_MODE,
90+);
8091
8192 const pmRuntimeAdapters = [
8293 makeLocalInProcessRuntimeAdapter(),
@@ -182,7 +193,10 @@ const executeRuntimeRun = createPmExecuteRuntimeRun({ · 99 unmodified lines
182193 });
183194
184195 const runExecutor = createRunExecutor(executeRuntimeRun);
185−const handleMcp = createPmMcpHandler(runExecutor.executeRun);
196+const handleMcp = createPmMcpHandler(runExecutor.executeRun, {
197+ toolRegistry,
198+ defaultToolExposureMode,
199+});
186200
187201 const handleToolCallHttp = createPmToolCallHttpHandler((input) =>
188202 Effect.runPromise(
v2/apps/pm/src/mcp-handler.ts+56−2
@@ -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";
19 import {
210 type ExecuteRunInput,
311 type ExecuteRunResult,
@@ -6,17 +14,63 @@ import { · 2 unmodified lines
614 import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
715 import * as Effect from "effect/Effect";
816
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+
951 export const createPmMcpHandler = (
1052 executeRun: (input: ExecuteRunInput) => Effect.Effect<ExecuteRunResult>,
53+ options: PmMcpHandlerOptions,
1154 ): ((request: Request) => Promise<Response>) => {
1255 const runClient = createExecutorRunClient((input) =>
1356 Effect.runPromise(executeRun(input)),
1457 );
1558
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, {
1870 serverName: "executor-v2-pm",
1971 serverVersion: "0.0.0",
2072 runClient,
73+ executeToolDescription,
2174 });
75+ };
2276 };
v2/packages/engine/src/execute-tool-description.ts+205−0
@@ -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+ });
v2/packages/engine/src/index.test.ts+204−0
@@ -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+});
v2/packages/engine/src/index.ts+10−0
@@ -38,11 +38,21 @@ export { · 37 unmodified lines
3838 type ToolRegistryCatalogToolsOutput,
3939 type ToolRegistryDiscoverInput,
4040 type ToolRegistryDiscoverOutput,
41+ type ToolRegistryNamespaceSummary,
4142 type ToolRegistryToolSummary,
4243 } from "./tool-registry";
4344
4445 export { createSourceToolRegistry } from "./source-tool-registry";
4546
47+export {
48+ buildExecuteToolDescription,
49+ defaultExecuteToolDescription,
50+ defaultExecuteToolExposureMode,
51+ parseExecuteToolExposureMode,
52+ type BuildExecuteToolDescriptionOptions,
53+ type ExecuteToolExposureMode,
54+} from "./execute-tool-description";
55+
4656 export {
4757 PersistentToolApprovalPolicyStoreError,
4858 createPersistentToolApprovalPolicy,
v2/packages/engine/src/source-tool-registry.test.ts+164−2
@@ -8,7 +8,10 @@ import { · 7 unmodified lines
88 OpenApi,
99 } from "@effect/platform";
1010 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";
1215 import {
1316 type SourceStore,
1417 type ToolArtifactStore,
@@ -271,9 +274,30 @@ describe("source tool registry", () => { · 256 unmodified lines
271274 },
272275 });
273276
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+
274295 const namespaces = yield* toolRegistry.catalogNamespaces({});
275296 expect(namespaces.total).toBeGreaterThan(0);
276297 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");
277301
278302 const namespace = namespaces.namespaces[0]?.namespace;
279303 if (!namespace) {
@@ -285,7 +309,10 @@ describe("source tool registry", () => { · 5 unmodified lines
285309 });
286310
287311 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+ ]);
289316 }),
290317 );
291318
@@ -382,4 +409,139 @@ describe("source tool registry", () => { · 90 unmodified lines
382409 expect(server.requests).toEqual([]);
383410 }),
384411 );
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+ );
385547 });
v2/packages/engine/src/source-tool-registry.ts+252−14
@@ -81,6 +81,9 @@ const toRuntimeAdapterError = ( · 80 unmodified lines
8181 });
8282
8383 const defaultPendingRetryAfterMs = 1_000;
84+const maxCatalogNamespacesLimit = 5_000;
85+const maxCatalogToolsLimit = 50_000;
86+const maxUnknownToolSuggestions = 3;
8487
8588 const normalizePendingRetryAfterMs = (value: number | undefined): number => {
8689 if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
@@ -198,6 +201,59 @@ const sourceToolPath = ( · 111 unmodified lines
198201 descriptor: CanonicalToolDescriptor,
199202 ): string => `${sourceNamespace(source)}.${descriptor.toolId}`;
200203
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+
201257 const parseJson = (value: string): unknown | null => {
202258 try {
203259 return JSON.parse(value);
@@ -374,6 +430,173 @@ const describeOutput = (value: unknown): string => { · 170 unmodified lines
374430 }
375431 };
376432
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+
377600 const loadSourceEntries = (
378601 options: SourceToolRegistryOptions,
379602 ): Effect.Effect<SourceToolSnapshot, RuntimeAdapterError> =>
@@ -544,23 +767,39 @@ const catalogNamespaces = ( · 164 unmodified lines
544767 snapshot: SourceToolSnapshot,
545768 input: ToolRegistryCatalogNamespacesInput,
546769 ): 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+ >();
549778
550779 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+ });
554790 }
555791
556792 const namespaces = [...grouped.entries()]
557793 .sort((left, right) => left[0].localeCompare(right[0]))
558− .map(([namespace, paths]) => ({
794+ .map(([namespace, value]) => ({
559795 namespace,
560− toolCount: paths.length,
561− samplePaths: [...paths]
796+ toolCount: value.paths.length,
797+ samplePaths: [...value.paths]
562798 .sort((left, right) => left.localeCompare(right))
563799 .slice(0, 3),
800+ source: value.source.name,
801+ sourceKey: value.source.id,
802+ description: describeSource(value.source),
564803 }));
565804
566805 return {
@@ -573,7 +812,7 @@ const catalogTools = ( · 6 unmodified lines
573812 snapshot: SourceToolSnapshot,
574813 input: ToolRegistryCatalogToolsInput,
575814 ): 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));
577816 const query = (input.query ?? "").trim().toLowerCase();
578817 const namespace = (input.namespace ?? "").trim().toLowerCase();
579818 const includeSchemas = input.includeSchemas === true;
@@ -600,22 +839,21 @@ export const createSourceToolRegistry = ( · 20 unmodified lines
600839 callTool: (input: ToolRegistryCallInput) =>
601840 Effect.gen(function* () {
602841 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;
606844
607845 if (!entry) {
608846 return {
609847 ok: false,
610848 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),
612850 } satisfies RuntimeToolCallResult;
613851 }
614852
615853 const approvalRequest: ToolApprovalRequest = {
616854 runId: input.runId,
617855 callId: input.callId,
618− toolPath: input.toolPath,
856+ toolPath: entry.path,
619857 input: normalizeToolCallInput(input.input),
620858 workspaceId: options.workspaceId,
621859 source: entry.source.name,
v2/packages/engine/src/tool-registry.ts+14−7
@@ -53,12 +53,17 @@ export type ToolRegistryDiscoverOutput = { · 52 unmodified lines
5353 refHintTable?: Record<string, string>;
5454 };
5555
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+
5665 export type ToolRegistryCatalogNamespacesOutput = {
57− namespaces: Array<{
58− namespace: string;
59− toolCount: number;
60− samplePaths: Array<string>;
61− }>;
66+ namespaces: Array<ToolRegistryNamespaceSummary>;
6267 total: number;
6368 };
6469
@@ -154,6 +159,8 @@ const toRuntimeAdapterError = ( · 89 unmodified lines
154159 });
155160
156161 const defaultPendingRetryAfterMs = 1_000;
162+const maxCatalogNamespacesLimit = 5_000;
163+const maxCatalogToolsLimit = 50_000;
157164
158165 const normalizePendingRetryAfterMs = (value: number | undefined): number => {
159166 if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
@@ -483,7 +490,7 @@ const inMemoryCatalogNamespaces = ( · 323 unmodified lines
483490 tools: InMemorySandboxToolMap,
484491 input: ToolRegistryCatalogNamespacesInput,
485492 ): 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));
487494 const grouped = new Map<string, Array<string>>();
488495
489496 for (const path of Object.keys(tools)) {
@@ -512,7 +519,7 @@ const inMemoryCatalogTools = ( · 22 unmodified lines
512519 refHintTable: Record<string, string> | undefined,
513520 input: ToolRegistryCatalogToolsInput,
514521 ): 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));
516523 const query = (input.query ?? "").trim().toLowerCase();
517524 const namespace = (input.namespace ?? "").trim().toLowerCase();
518525 const includeSchemas = input.includeSchemas === true;
v2/packages/mcp-gateway/src/server.ts+4−1
@@ -34,6 +34,7 @@ export type McpGatewayOptions = { · 33 unmodified lines
3434 serverName?: string;
3535 serverVersion?: string;
3636 runClient: ExecutorRunClient;
37+ executeToolDescription?: string;
3738 };
3839 const DEFAULT_SERVER_NAME = "executor-v2";
3940 const DEFAULT_SERVER_VERSION = "0.0.0";
@@ -65,7 +66,9 @@ const createMcpServer = (options: McpGatewayOptions): McpServer => { · 25 unmodified lines
6566 mcp.registerTool(
6667 EXECUTE_TOOL_NAME,
6768 {
68− description: "Execute JavaScript against configured runtime",
69+ description:
70+ options.executeToolDescription ??
71+ "Execute JavaScript against configured runtime",
6972 inputSchema: ExecuteToolInputSchema,
7073 },
7174 async (input: ExecuteToolInput) => {