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
68 68
69# ── MCP OAuth for /mcp (optional) ──69# ── MCP OAuth for /mcp (optional) ──
70# MCP_AUTHORIZATION_SERVER=https://your-authkit-domain.authkit.app70# 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=...
v2/apps/convex/convex/mcp.ts+48−0
@@ -1,3 +1,10 @@
1import {
2 buildExecuteToolDescription,
3 defaultExecuteToolDescription,
4 defaultExecuteToolExposureMode,
5 parseExecuteToolExposureMode,
6 type ExecuteToolExposureMode,
7} from "@executor-v2/engine";
1import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";8import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
2import { createExecutorRunClient } from "@executor-v2/sdk";9import { createExecutorRunClient } from "@executor-v2/sdk";
3import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
@@ -25,6 +32,35 @@ const readWorkspaceIdFromRequest = (request: Request): string | null => { · 21 unmodified lines
25const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string =>32const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string =>
26 readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId;33 readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId;
27 34
35const 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
48const 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
28const hasWorkspaceAccess = async (64const 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
67const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);103const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);
104const defaultToolExposureMode =
105 parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ??
106 defaultExecuteToolExposureMode;
68const runtimeInternal = internal as any;107const runtimeInternal = internal as any;
69 108
70export const mcpHandler = httpAction(async (ctx, request) => {109export 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});
v2/apps/pm/src/main.ts+15−1
@@ -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";
20import {22import {
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
76const readConfiguredToolExposureMode = (
77 value: string | undefined,
78): "all_tools" | "sources_only" =>
79 parseExecuteToolExposureMode(value ?? undefined) ??
80 defaultExecuteToolExposureMode;
81
74const formatRuntimeAdapterError = (error: RuntimeAdapterError): string =>82const formatRuntimeAdapterError = (error: RuntimeAdapterError): string =>
75 error.details ? `${error.message}: ${error.details}` : error.message;83 error.details ? `${error.message}: ${error.details}` : error.message;
76 84
77const port = parsePort(process.env.PORT);85const port = parsePort(process.env.PORT);
78const workspaceId = readConfiguredWorkspaceId(process.env.PM_WORKSPACE_ID);86const workspaceId = readConfiguredWorkspaceId(process.env.PM_WORKSPACE_ID);
79const requireToolApprovals = readBooleanFlag(process.env.PM_REQUIRE_TOOL_APPROVALS);87const requireToolApprovals = readBooleanFlag(process.env.PM_REQUIRE_TOOL_APPROVALS);
88const defaultToolExposureMode = readConfiguredToolExposureMode(
89 process.env.PM_TOOL_EXPOSURE_MODE,
90);
80 91
81const pmRuntimeAdapters = [92const pmRuntimeAdapters = [
82 makeLocalInProcessRuntimeAdapter(),93 makeLocalInProcessRuntimeAdapter(),
@@ -182,7 +193,10 @@ const executeRuntimeRun = createPmExecuteRuntimeRun({ · 99 unmodified lines
182});193});
183 194
184const runExecutor = createRunExecutor(executeRuntimeRun);195const runExecutor = createRunExecutor(executeRuntimeRun);
185const handleMcp = createPmMcpHandler(runExecutor.executeRun);196const handleMcp = createPmMcpHandler(runExecutor.executeRun, {
197 toolRegistry,
198 defaultToolExposureMode,
199});
186 200
187const handleToolCallHttp = createPmToolCallHttpHandler((input) =>201const handleToolCallHttp = createPmToolCallHttpHandler((input) =>
188 Effect.runPromise(202 Effect.runPromise(
v2/apps/pm/src/mcp-handler.ts+56−2
@@ -1,3 +1,11 @@
1import {
2 buildExecuteToolDescription,
3 defaultExecuteToolDescription,
4 defaultExecuteToolExposureMode,
5 parseExecuteToolExposureMode,
6 type ExecuteToolExposureMode,
7 type ToolRegistry,
8} from "@executor-v2/engine";
1import {9import {
2 type ExecuteRunInput,10 type ExecuteRunInput,
3 type ExecuteRunResult,11 type ExecuteRunResult,
@@ -6,17 +14,63 @@ import { · 2 unmodified lines
6import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";14import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
7import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
8 16
17export type PmMcpHandlerOptions = {
18 toolRegistry: ToolRegistry;
19 defaultToolExposureMode?: ExecuteToolExposureMode;
20};
21
22const 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
35const 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
9export const createPmMcpHandler = (51export 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};
v2/packages/engine/src/execute-tool-description.ts+205−0
@@ -0,0 +1,205 @@
1import * as Effect from "effect/Effect";
2
3import type { RuntimeAdapterError } from "./runtime-adapters";
4import type {
5 ToolRegistry,
6 ToolRegistryNamespaceSummary,
7 ToolRegistryToolSummary,
8} from "./tool-registry";
9
10export type ExecuteToolExposureMode = "all_tools" | "sources_only";
11
12export type BuildExecuteToolDescriptionOptions = {
13 toolRegistry: ToolRegistry;
14 mode: ExecuteToolExposureMode;
15 namespaceLimit?: number;
16 toolLimitPerNamespace?: number;
17};
18
19export const defaultExecuteToolExposureMode: ExecuteToolExposureMode =
20 "sources_only";
21
22export const defaultExecuteToolDescription =
23 "Execute JavaScript against configured runtime";
24
25const defaultDiscoveryNamespaceLimit = 200;
26const maxNamespaceDescriptionRows = 5_000;
27const maxToolDescriptionRows = 50_000;
28
29const normalizeMode = (value: string): string => value.trim().toLowerCase();
30
31export 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
61const quote = (value: string): string => JSON.stringify(value);
62
63const 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
75const 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
83const 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
99const 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
106const 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
126const 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
154const normalizeNamespaces = (
155 namespaces: ReadonlyArray<ToolRegistryNamespaceSummary>,
156): Array<ToolRegistryNamespaceSummary> =>
157 [...namespaces].sort((left, right) => left.namespace.localeCompare(right.namespace));
158
159export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 fetchOpenApiDocument,
4 makeSourceManagerService,
5} from "@executor-v2/management-api";
6import {
7 type SourceStore,
8 type ToolArtifactStore,
9} from "@executor-v2/persistence-ports";
10import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc";
11import { SourceSchema, type Source, type ToolArtifact } from "@executor-v2/schema";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Schema from "effect/Schema";
15
16import {
17 buildExecuteToolDescription,
18 createRuntimeRunClient,
19 createSourceToolRegistry,
20 createStaticToolRegistry,
21 invokeRuntimeToolCallResult,
22 makeOpenApiToolProvider,
23 makeToolProviderRegistry,
24} from "./index";
25
26const decodeSource = Schema.decodeUnknownSync(SourceSchema);
27
28describe("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
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
44export { createSourceToolRegistry } from "./source-tool-registry";45export { createSourceToolRegistry } from "./source-tool-registry";
45 46
47export {
48 buildExecuteToolDescription,
49 defaultExecuteToolDescription,
50 defaultExecuteToolExposureMode,
51 parseExecuteToolExposureMode,
52 type BuildExecuteToolDescriptionOptions,
53 type ExecuteToolExposureMode,
54} from "./execute-tool-description";
55
46export {56export {
47 PersistentToolApprovalPolicyStoreError,57 PersistentToolApprovalPolicyStoreError,
48 createPersistentToolApprovalPolicy,58 createPersistentToolApprovalPolicy,
v2/packages/engine/src/source-tool-registry.test.ts+164−2
@@ -8,7 +8,10 @@ import { · 7 unmodified lines
8 OpenApi,8 OpenApi,
9} from "@effect/platform";9} from "@effect/platform";
10import { describe, expect, it } from "@effect/vitest";10import { describe, expect, it } from "@effect/vitest";
11import { makeSourceManagerService } from "@executor-v2/management-api";11import {
12 fetchOpenApiDocument,
13 makeSourceManagerService,
14} from "@executor-v2/management-api";
12import {15import {
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});
v2/packages/engine/src/source-tool-registry.ts+252−14
@@ -81,6 +81,9 @@ const toRuntimeAdapterError = ( · 80 unmodified lines
81 });81 });
82 82
83const defaultPendingRetryAfterMs = 1_000;83const defaultPendingRetryAfterMs = 1_000;
84const maxCatalogNamespacesLimit = 5_000;
85const maxCatalogToolsLimit = 50_000;
86const maxUnknownToolSuggestions = 3;
84 87
85const normalizePendingRetryAfterMs = (value: number | undefined): number => {88const 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
204const describeSource = (source: Source): string => {
205 const kind = source.kind.toUpperCase();
206 return `${kind} source at ${source.endpoint}`;
207};
208
209const normalizeToolPathForLookup = (path: string): string =>
210 path
211 .trim()
212 .toLowerCase()
213 .replace(/[^a-z0-9]+/g, ".")
214 .replace(/\.+/g, ".")
215 .replace(/^\.|\.$/g, "");
216
217const 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
201const parseJson = (value: string): unknown | null => {257const 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
433type SourceToolResolution = {
434 entry: SourceToolEntry | null;
435 suggestions: Array<string>;
436};
437
438const 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
456const 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
501const 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
585const 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
377const loadSourceEntries = (600const 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,
v2/packages/engine/src/tool-registry.ts+14−7
@@ -53,12 +53,17 @@ export type ToolRegistryDiscoverOutput = { · 52 unmodified lines
53 refHintTable?: Record<string, string>;53 refHintTable?: Record<string, string>;
54};54};
55 55
56export type ToolRegistryNamespaceSummary = {
57 namespace: string;
58 toolCount: number;
59 samplePaths: Array<string>;
60 source?: string;
61 sourceKey?: string;
62 description?: string;
63};
64
56export type ToolRegistryCatalogNamespacesOutput = {65export 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
156const defaultPendingRetryAfterMs = 1_000;161const defaultPendingRetryAfterMs = 1_000;
162const maxCatalogNamespacesLimit = 5_000;
163const maxCatalogToolsLimit = 50_000;
157 164
158const normalizePendingRetryAfterMs = (value: number | undefined): number => {165const 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;
v2/packages/mcp-gateway/src/server.ts+4−1
@@ -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};
38const DEFAULT_SERVER_NAME = "executor-v2";39const DEFAULT_SERVER_NAME = "executor-v2";
39const DEFAULT_SERVER_VERSION = "0.0.0";40const 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) => {