From 88970e337b23641bdba6ef284d4a789644c6b672 Mon Sep 17 00:00:00 2001 From: Mouaad Aallam Date: Sat, 20 Jun 2026 12:40:05 +0200 Subject: [PATCH 1/2] feat(mcp): add progressive discovery --- .changeset/mcp-progressive-discovery.md | 5 + docs/src/content/docs/examples.md | 12 +- docs/src/content/docs/mcp-integration.md | 36 ++++- docs/src/content/docs/providers-and-tools.md | 8 + docs/src/content/docs/security.md | 6 + examples/README.md | 12 +- examples/execbox-mcp-server.ts | 5 + .../__tests__/core/resolveProvider.test.ts | 26 ++++ .../core/__tests__/mcp/mcpAdapters.test.ts | 109 +++++++++++-- packages/core/etc/execbox-core-mcp.api.md | 24 ++- .../core/etc/execbox-core-protocol.api.md | 11 ++ packages/core/etc/execbox-core-runtime.api.md | 12 ++ packages/core/etc/execbox-core.api.md | 11 ++ packages/core/src/index.ts | 1 + packages/core/src/mcp/codeMcpServer.ts | 146 +++++++++++++++--- .../core/src/mcp/createMcpToolProvider.ts | 55 ++++++- packages/core/src/mcp/index.ts | 2 + packages/core/src/mcp/mcpWrappedToolTypes.ts | 62 ++++++-- packages/core/src/protocol/index.ts | 1 + packages/core/src/protocol/messages.ts | 19 +++ packages/core/src/provider/resolveProvider.ts | 54 +++++-- packages/core/src/runner.ts | 9 +- packages/core/src/runtime.ts | 1 + packages/core/src/types.ts | 23 +++ .../runWrappedMcpPenetrationSuite.ts | 26 +++- 25 files changed, 589 insertions(+), 87 deletions(-) create mode 100644 .changeset/mcp-progressive-discovery.md diff --git a/.changeset/mcp-progressive-discovery.md b/.changeset/mcp-progressive-discovery.md new file mode 100644 index 0000000..0951471 --- /dev/null +++ b/.changeset/mcp-progressive-discovery.md @@ -0,0 +1,5 @@ +--- +"@execbox/core": minor +--- + +Add MCP-compatible tool annotations and make code MCP servers expose progressive search, inspect, and execute tools by default. diff --git a/docs/src/content/docs/examples.md b/docs/src/content/docs/examples.md index f416308..dfaf190 100644 --- a/docs/src/content/docs/examples.md +++ b/docs/src/content/docs/examples.md @@ -16,12 +16,12 @@ npm run examples ## Example index -| Example | What it shows | When to start here | -| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -| [`execbox-basic.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-basic.ts) | Resolve a provider and execute guest code with QuickJS. | You want the smallest end-to-end example. | -| [`execbox-worker.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-worker.ts) | Run the same provider flow with QuickJS hosted in a worker thread. | You want QuickJS off the main thread without leaving the process. | -| [`execbox-mcp-provider.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-mcp-provider.ts) | Wrap MCP tools into a provider and execute against them. | You want guest code to call upstream MCP tools as code. | -| [`execbox-mcp-server.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-mcp-server.ts) | Expose `mcp_search_tools`, `mcp_execute_code`, and `mcp_code`. | You want downstream MCP clients to execute code against a wrapped tool namespace. | +| Example | What it shows | When to start here | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| [`execbox-basic.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-basic.ts) | Resolve a provider and execute guest code with QuickJS. | You want the smallest end-to-end example. | +| [`execbox-worker.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-worker.ts) | Run the same provider flow with QuickJS hosted in a worker thread. | You want QuickJS off the main thread without leaving the process. | +| [`execbox-mcp-provider.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-mcp-provider.ts) | Wrap MCP tools into a provider and execute against them. | You want guest code to call upstream MCP tools as code. | +| [`execbox-mcp-server.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-mcp-server.ts) | Expose progressive MCP code tools for search, inspect, and execute. | You want downstream MCP clients to execute code against a wrapped tool namespace. | ## What to read next diff --git a/docs/src/content/docs/mcp-integration.md b/docs/src/content/docs/mcp-integration.md index 896e2e6..43a5ab6 100644 --- a/docs/src/content/docs/mcp-integration.md +++ b/docs/src/content/docs/mcp-integration.md @@ -52,11 +52,33 @@ const server = await codeMcpServer( The wrapper server exposes: -| Tool | Purpose | -| ------------------ | ---------------------------------------------------- | -| `mcp_search_tools` | Search the wrapped MCP catalog | -| `mcp_execute_code` | Execute guest JavaScript against the wrapped catalog | -| `mcp_code` | Return the code-execution tool description | +| Tool | Purpose | +| ---------------------- | --------------------------------------------------------- | +| `mcp_search_tools` | Search the wrapped MCP catalog with concise metadata | +| `mcp_get_tool_details` | Inspect schemas and generated types for one selected tool | +| `mcp_execute_code` | Execute guest JavaScript against the wrapped catalog | + +The default `codeMcpServer()` mode is progressive: MCP clients can search the +catalog, inspect only the tools they need, then execute code. Use +`mode: "single"` only when a client needs the legacy all-in-one `mcp_code` tool +whose description embeds the full generated namespace. Use `mode: "both"` to +expose the progressive tools and `mcp_code` together. + +```ts +const search = await client.callTool({ + name: "mcp_search_tools", + arguments: { query: "search docs" }, +}); + +const details = await client.callTool({ + name: "mcp_get_tool_details", + arguments: { safeName: "search_docs" }, +}); +``` + +`mcp_search_tools` returns only names, descriptions, and annotations. +`mcp_get_tool_details` returns the selected tool's input schema, output schema, +and generated TypeScript declaration. ## Result handling @@ -79,6 +101,10 @@ provider surface remains the capability boundary: - close handles returned by `openMcpToolProvider()` - choose inline or worker-hosted QuickJS separately from the MCP adapter shape +The code-execution tools are annotated as potentially destructive because MCP +tool annotations are static while guest code can call any wrapped tool exposed +through the provider. Search and details tools are annotated read-only. + ## Examples - [`execbox-mcp-provider.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-mcp-provider.ts) diff --git a/docs/src/content/docs/providers-and-tools.md b/docs/src/content/docs/providers-and-tools.md index 98fee90..3f066b5 100644 --- a/docs/src/content/docs/providers-and-tools.md +++ b/docs/src/content/docs/providers-and-tools.md @@ -67,6 +67,14 @@ Declare the narrowest useful input and output shapes. They act as the contract between guest code and host capabilities, and they also improve generated guest types. +## Tool annotations + +Tools can declare MCP-compatible annotations such as `readOnlyHint`, +`destructiveHint`, `idempotentHint`, and `openWorldHint`. Execbox preserves +these hints for discovery surfaces and MCP wrappers, but does not enforce them. +Use annotations to help clients decide what to show or confirm; keep real +authorization in host policy and provider selection. + ## Result boundary Tool inputs and results cross a JSON-compatible boundary. Return plain data such diff --git a/docs/src/content/docs/security.md b/docs/src/content/docs/security.md index a5117c4..5175a07 100644 --- a/docs/src/content/docs/security.md +++ b/docs/src/content/docs/security.md @@ -45,6 +45,12 @@ decision. Treat an upstream MCP catalog as host capability, then expose a small resolved provider to guest code. Keep upstream client ownership, authentication, and tenant routing in host code. +MCP tool annotations are advisory metadata, not enforcement. Execbox marks +code-execution wrapper tools as potentially destructive because one code string +can call a mix of read-only and write-capable wrapped tools. Keep confirmation +and authorization policy in the downstream MCP host and in the provider surface +you choose to expose. + ## Deeper reading - [Architecture Overview](/architecture/) diff --git a/examples/README.md b/examples/README.md index 2b5f856..b1d5ca9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,12 +19,12 @@ start with each example. ## Example index -| File | What it shows | -| ------------------------------------------------------ | ------------------------------------------------------------- | -| [`execbox-basic.ts`](./execbox-basic.ts) | Resolve a provider and execute guest code with inline QuickJS | -| [`execbox-worker.ts`](./execbox-worker.ts) | Run the same provider flow with worker-hosted QuickJS | -| [`execbox-mcp-provider.ts`](./execbox-mcp-provider.ts) | Wrap MCP tools into a provider and execute against them | -| [`execbox-mcp-server.ts`](./execbox-mcp-server.ts) | Expose `mcp_search_tools`, `mcp_execute_code`, and `mcp_code` | +| File | What it shows | +| ------------------------------------------------------ | ------------------------------------------------------------------ | +| [`execbox-basic.ts`](./execbox-basic.ts) | Resolve a provider and execute guest code with inline QuickJS | +| [`execbox-worker.ts`](./execbox-worker.ts) | Run the same provider flow with worker-hosted QuickJS | +| [`execbox-mcp-provider.ts`](./execbox-mcp-provider.ts) | Wrap MCP tools into a provider and execute against them | +| [`execbox-mcp-server.ts`](./execbox-mcp-server.ts) | Expose progressive MCP code tools for search, inspect, and execute | Read [Providers & Tools](https://execbox.aallam.com/providers-and-tools), [Runtime Choices](https://execbox.aallam.com/runtime-choices), and diff --git a/examples/execbox-mcp-server.ts b/examples/execbox-mcp-server.ts index 6466d2c..f9c5d8d 100644 --- a/examples/execbox-mcp-server.ts +++ b/examples/execbox-mcp-server.ts @@ -62,6 +62,10 @@ async function main(): Promise { name: "mcp_search_tools", arguments: { query: "search" }, }); + const detailsResult = await wrappedClient.callTool({ + name: "mcp_get_tool_details", + arguments: { safeName: "search_docs" }, + }); const executeResult = await wrappedClient.callTool({ name: "mcp_execute_code", arguments: { @@ -73,6 +77,7 @@ async function main(): Promise { console.log( JSON.stringify( { + detailsResult: detailsResult.structuredContent, executeResult: executeResult.structuredContent, searchResult: searchResult.structuredContent, toolNames: tools.tools.map((tool) => tool.name), diff --git a/packages/core/__tests__/core/resolveProvider.test.ts b/packages/core/__tests__/core/resolveProvider.test.ts index a90ce50..4bfc055 100644 --- a/packages/core/__tests__/core/resolveProvider.test.ts +++ b/packages/core/__tests__/core/resolveProvider.test.ts @@ -62,6 +62,32 @@ describe("resolveProvider", () => { }); }); + it("preserves tool annotations on resolved descriptors", () => { + const provider = resolveProvider({ + name: "mcp", + tools: { + "delete-document": { + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + readOnlyHint: false, + title: "Delete document", + }, + execute: async () => ({ ok: true }), + }, + }, + }); + + expect(provider.tools.delete_document.annotations).toEqual({ + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + readOnlyHint: false, + title: "Delete document", + }); + }); + it("validates input before calling the original execute function", async () => { let called = false; diff --git a/packages/core/__tests__/mcp/mcpAdapters.test.ts b/packages/core/__tests__/mcp/mcpAdapters.test.ts index 804bb40..c0ae5f5 100644 --- a/packages/core/__tests__/mcp/mcpAdapters.test.ts +++ b/packages/core/__tests__/mcp/mcpAdapters.test.ts @@ -27,6 +27,13 @@ function createUpstreamServer(): McpServer { const registerTool = server.registerTool.bind(server) as unknown as ( name: string, config: { + annotations?: { + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + readOnlyHint?: boolean; + title?: string; + }; description?: string; inputSchema?: unknown; outputSchema?: unknown; @@ -37,6 +44,13 @@ function createUpstreamServer(): McpServer { registerTool( "search-docs", { + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + title: "Search docs", + }, description: "Search documentation", inputSchema: searchDocsInputSchema, outputSchema: searchDocsOutputSchema, @@ -97,6 +111,13 @@ describe("MCP adapters", () => { "search-docs": "search_docs", explode: "explode", }); + expect(provider.tools.search_docs.annotations).toEqual({ + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + title: "Search docs", + }); expect(provider.types).toContain("declare namespace mcp"); expect(provider.types).toContain("Inspect structuredContent first"); expect(provider.types).toContain("structuredContent?: unknown;"); @@ -134,7 +155,7 @@ describe("MCP adapters", () => { ).rejects.toThrow(/openMcpToolProvider/); }); - it("wraps a connected client with both MCP code tools by default", async () => { + it("wraps a connected client with progressive MCP code tools by default", async () => { const upstreamServer = createUpstreamServer(); const upstreamClient = await connectClient(upstreamServer); const wrappedServer = await codeMcpServer( @@ -144,14 +165,36 @@ describe("MCP adapters", () => { const wrappedClient = await connectClient(wrappedServer); const tools = await wrappedClient.listTools(); - expect(tools.tools.map((tool) => tool.name)).toEqual( - expect.arrayContaining([ - "mcp_code", - "mcp_search_tools", - "mcp_execute_code", - ]), - ); + expect(tools.tools.map((tool) => tool.name)).toEqual([ + "mcp_search_tools", + "mcp_get_tool_details", + "mcp_execute_code", + ]); expect(tools.tools.map((tool) => tool.name)).not.toContain("search-docs"); + expect( + Object.fromEntries(tools.tools.map((tool) => [tool.name, tool])), + ).toMatchObject({ + mcp_execute_code: { + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + }, + mcp_get_tool_details: { + annotations: { + destructiveHint: false, + readOnlyHint: true, + }, + }, + mcp_search_tools: { + annotations: { + destructiveHint: false, + readOnlyHint: true, + }, + }, + }); const searchResult = await wrappedClient.callTool({ name: "mcp_search_tools", @@ -172,10 +215,46 @@ describe("MCP adapters", () => { ) { throw new Error("Expected structured MCP search payload"); } - expect(searchResult.structuredContent).toHaveProperty("types"); + expect(searchResult.structuredContent).not.toHaveProperty("types"); + expect(searchResult.structuredContent).not.toHaveProperty("inputSchema"); + expect(searchResult.structuredContent).toMatchObject({ + tools: [ + expect.objectContaining({ + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + title: "Search docs", + }, + }), + ], + }); + + const detailsResult = await wrappedClient.callTool({ + name: "mcp_get_tool_details", + arguments: { safeName: "search_docs" }, + }); + + expect(detailsResult.structuredContent).toMatchObject({ + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + title: "Search docs", + }, + inputSchema: expect.objectContaining({ type: "object" }), + originalName: "search-docs", + outputSchema: expect.objectContaining({ type: "object" }), + safeName: "search_docs", + }); expect( - (searchResult.structuredContent as { types: string }).types, - ).toContain("Inspect structuredContent first"); + (detailsResult.structuredContent as { types: string }).types, + ).toContain("function search_docs(input:"); + expect( + (detailsResult.structuredContent as { types: string }).types, + ).not.toContain("function explode(input:"); const executeResult = await wrappedClient.callTool({ name: "mcp_execute_code", @@ -347,6 +426,14 @@ describe("MCP adapters", () => { const tools = await wrappedClient.listTools(); expect(tools.tools.map((tool) => tool.name)).toEqual(["mcp_code"]); + expect(tools.tools[0]).toMatchObject({ + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + }); const executeResult = await wrappedClient.callTool({ name: "mcp_code", diff --git a/packages/core/etc/execbox-core-mcp.api.md b/packages/core/etc/execbox-core-mcp.api.md index 22242af..542ba45 100644 --- a/packages/core/etc/execbox-core-mcp.api.md +++ b/packages/core/etc/execbox-core-mcp.api.md @@ -15,8 +15,9 @@ export function codeMcpServer(source: McpToolSource, options: CodeMcpServerOptio export interface CodeMcpServerOptions extends CreateMcpToolProviderOptions { executor: Executor; maxTextChars?: number; - mode?: "both" | "single" | "split"; + mode?: "both" | "progressive" | "single"; names?: { + details?: string; execute?: string; search?: string; single?: string; @@ -100,6 +101,7 @@ export interface McpToolProviderHandle { close: () => Promise; provider: ResolvedToolProvider; serverInfo?: Implementation; + toolDefinitions: Record; } // @public @@ -111,11 +113,22 @@ export type McpToolServerSource = { // @public export type McpToolSource = McpToolClientSource | McpToolServerSource; +// @public +export interface McpWrappedToolDefinition { + annotations?: ToolAnnotations; + description?: string; + inputSchema?: JsonSchema; + originalName: string; + outputSchema?: JsonSchema; + safeName: string; +} + // @public export function openMcpToolProvider(source: McpToolSource, options?: CreateMcpToolProviderOptions): Promise; // @public export interface ResolvedToolDescriptor { + annotations?: ToolAnnotations; description?: string; execute: (input: unknown, context: ToolExecutionContext) => Promise; inputSchema?: JsonSchema; @@ -133,6 +146,15 @@ export interface ResolvedToolProvider { types: string; } +// @public +export interface ToolAnnotations { + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + readOnlyHint?: boolean; + title?: string; +} + // @public export interface ToolExecutionContext { originalToolName: string; diff --git a/packages/core/etc/execbox-core-protocol.api.md b/packages/core/etc/execbox-core-protocol.api.md index 9b2aabb..ef711ef 100644 --- a/packages/core/etc/execbox-core-protocol.api.md +++ b/packages/core/etc/execbox-core-protocol.api.md @@ -147,6 +147,7 @@ export interface ProviderManifest { // @public export interface ProviderToolManifest { + annotations?: ToolAnnotations; // (undocumented) description?: string; // (undocumented) @@ -157,6 +158,7 @@ export interface ProviderToolManifest { // @public export interface ResolvedToolDescriptor { + annotations?: ToolAnnotations; description?: string; execute: (input: unknown, context: ToolExecutionContext) => Promise; inputSchema?: JsonSchema; @@ -220,6 +222,15 @@ export interface StartedMessage { type: "started"; } +// @public +export interface ToolAnnotations { + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + readOnlyHint?: boolean; + title?: string; +} + // @public export interface ToolCall { // (undocumented) diff --git a/packages/core/etc/execbox-core-runtime.api.md b/packages/core/etc/execbox-core-runtime.api.md index ec78bbb..77092c3 100644 --- a/packages/core/etc/execbox-core-runtime.api.md +++ b/packages/core/etc/execbox-core-runtime.api.md @@ -118,6 +118,7 @@ export interface ProviderManifest { // @public export interface ProviderToolManifest { + annotations?: ToolAnnotations; // (undocumented) description?: string; // (undocumented) @@ -131,6 +132,7 @@ export type ResolvedExecutorRuntimeOptions = Readonly Promise; inputSchema?: JsonSchema; @@ -151,6 +153,15 @@ export interface ResolvedToolProvider { // @public export function resolveExecutorRuntimeOptions(options?: ExecutorRuntimeOptions, overrides?: ExecutorRuntimeOptions): Required; +// @public +export interface ToolAnnotations { + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + readOnlyHint?: boolean; + title?: string; +} + // @public export interface ToolCall { // (undocumented) @@ -172,6 +183,7 @@ export type ToolCallResult = { // @public export interface ToolDescriptor { + annotations?: ToolAnnotations; description?: string; execute: (input: unknown, context: ToolExecutionContext) => Promise | unknown; inputSchema?: ToolSchema; diff --git a/packages/core/etc/execbox-core.api.md b/packages/core/etc/execbox-core.api.md index 9378c83..05e49ef 100644 --- a/packages/core/etc/execbox-core.api.md +++ b/packages/core/etc/execbox-core.api.md @@ -84,6 +84,7 @@ export type JsonSchema = Record; // @public export interface ResolvedToolDescriptor { + annotations?: ToolAnnotations; description?: string; execute: (input: unknown, context: ToolExecutionContext) => Promise; inputSchema?: JsonSchema; @@ -104,8 +105,18 @@ export interface ResolvedToolProvider { // @public export function resolveProvider(provider: ToolProvider): ResolvedToolProvider; +// @public +export interface ToolAnnotations { + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + readOnlyHint?: boolean; + title?: string; +} + // @public export interface ToolDescriptor { + annotations?: ToolAnnotations; description?: string; execute: (input: unknown, context: ToolExecutionContext) => Promise | unknown; inputSchema?: ToolSchema; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e7e9855..54db779 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export type { JsonSchema, ResolvedToolDescriptor, ResolvedToolProvider, + ToolAnnotations, ToolDescriptor, ToolExecutionContext, ToolProvider, diff --git a/packages/core/src/mcp/codeMcpServer.ts b/packages/core/src/mcp/codeMcpServer.ts index dc075d5..ef2106c 100644 --- a/packages/core/src/mcp/codeMcpServer.ts +++ b/packages/core/src/mcp/codeMcpServer.ts @@ -3,12 +3,14 @@ import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; import * as z from "zod"; import type { Executor } from "../executor/executor"; -import type { ResolvedToolProvider } from "../types"; +import type { ResolvedToolProvider, ToolAnnotations } from "../types"; import { openMcpToolProvider, type CreateMcpToolProviderOptions, + type McpWrappedToolDefinition, type McpToolSource, } from "./createMcpToolProvider"; +import { generateMcpWrappedSingleToolTypes } from "./mcpWrappedToolTypes"; /** * Options for exposing wrapped MCP tool execution through an MCP server. @@ -21,9 +23,10 @@ export interface CodeMcpServerOptions extends CreateMcpToolProviderOptions { /** Maximum number of text characters returned in text content blocks. */ maxTextChars?: number; /** Wrapper tool layout to expose on the returned server. */ - mode?: "both" | "single" | "split"; + mode?: "both" | "progressive" | "single"; /** Optional custom names for the wrapper tools. */ names?: { + details?: string; execute?: string; search?: string; single?: string; @@ -31,6 +34,18 @@ export interface CodeMcpServerOptions extends CreateMcpToolProviderOptions { } const DEFAULT_MAX_TEXT_CHARS = 24_000; +const CODE_EXECUTION_TOOL_ANNOTATIONS = { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, +} satisfies ToolAnnotations; +const READ_ONLY_TOOL_ANNOTATIONS = { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, +} satisfies ToolAnnotations; const DEFAULT_MCP_CODE_WRAPPER_SERVER_INFO = { name: "mcp-code-wrapper", version: "0.0.0", @@ -45,19 +60,13 @@ function renderText(value: unknown, maxTextChars: number): string { } function searchTools( - provider: ResolvedToolProvider, + toolDefinitions: Record, + namespace: string, query: string | undefined, limit: number, ): Record { const normalizedQuery = query?.toLowerCase().trim(); - const matches = Object.entries(provider.tools) - .map(([safeName, descriptor]) => ({ - description: descriptor.description, - inputSchema: descriptor.inputSchema, - originalName: descriptor.originalName, - outputSchema: descriptor.outputSchema, - safeName, - })) + const matches = Object.values(toolDefinitions) .filter((tool) => { if (!normalizedQuery) { return true; @@ -67,14 +76,39 @@ function searchTools( (field) => field.toLowerCase().includes(normalizedQuery), ); }) - .slice(0, limit); + .slice(0, limit) + .map((tool) => ({ + annotations: tool.annotations, + description: tool.description, + originalName: tool.originalName, + safeName: tool.safeName, + })); return { - namespace: provider.name, - originalToSafeName: provider.originalToSafeName, - safeToOriginalName: provider.safeToOriginalName, + namespace, tools: matches, - types: provider.types, + }; +} + +function getToolDetails( + provider: ResolvedToolProvider, + toolDefinitions: Record, + safeName: string, +): Record { + const tool = toolDefinitions[safeName]; + + if (!tool) { + throw new Error(`Unknown wrapped MCP tool: ${safeName}`); + } + + return { + annotations: tool.annotations, + description: tool.description, + inputSchema: tool.inputSchema, + originalName: tool.originalName, + outputSchema: tool.outputSchema, + safeName: tool.safeName, + types: generateMcpWrappedSingleToolTypes(provider, safeName), }; } @@ -91,6 +125,7 @@ function registerExecuteTool( const registerTool = server.registerTool.bind(server) as ( toolName: string, config: { + annotations: ToolAnnotations; description: string; inputSchema: Record; }, @@ -104,6 +139,7 @@ function registerExecuteTool( registerTool( name, { + annotations: CODE_EXECUTION_TOOL_ANNOTATIONS, description, inputSchema: { code: z.string(), @@ -125,13 +161,15 @@ function registerExecuteTool( function registerSearchTool( server: McpServer, name: string, - provider: ResolvedToolProvider, + namespace: string, + toolDefinitions: Record, maxTextChars: number, ): void { // Cast required: same rationale as registerExecuteTool above. const registerTool = server.registerTool.bind(server) as ( toolName: string, config: { + annotations: ToolAnnotations; description: string; inputSchema: Record; }, @@ -144,7 +182,8 @@ function registerSearchTool( registerTool( name, { - description: `Search wrapped MCP tools exposed under the ${provider.name} namespace.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + description: `Search wrapped MCP tools exposed under the ${namespace} namespace. Returns concise catalog entries only; call the details tool for schemas.`, inputSchema: { limit: z.number().int().optional(), query: z.string().optional(), @@ -152,7 +191,8 @@ function registerSearchTool( }, async (args: { limit?: number; query?: string }) => { const structuredContent = searchTools( - provider, + toolDefinitions, + namespace, args.query, args.limit ?? 20, ); @@ -166,6 +206,52 @@ function registerSearchTool( ); } +function registerDetailsTool( + server: McpServer, + name: string, + provider: ResolvedToolProvider, + toolDefinitions: Record, + maxTextChars: number, +): void { + // Cast required: same rationale as registerExecuteTool above. + const registerTool = server.registerTool.bind(server) as ( + toolName: string, + config: { + annotations: ToolAnnotations; + description: string; + inputSchema: Record; + }, + handler: (args: { safeName: string }) => Promise<{ + content: Array<{ text: string; type: "text" }>; + structuredContent: Record; + }>, + ) => void; + + registerTool( + name, + { + annotations: READ_ONLY_TOOL_ANNOTATIONS, + description: `Return the full schema and generated TypeScript declaration for one wrapped ${provider.name} MCP tool.`, + inputSchema: { + safeName: z.string(), + }, + }, + async (args: { safeName: string }) => { + const structuredContent = getToolDetails( + provider, + toolDefinitions, + args.safeName, + ); + return { + content: [ + { text: renderText(structuredContent, maxTextChars), type: "text" }, + ], + structuredContent, + }; + }, + ); +} + function attachOwnedClose( server: McpServer, closeOwnedResources: () => Promise, @@ -203,8 +289,9 @@ export async function codeMcpServer( options: CodeMcpServerOptions, ): Promise { const maxTextChars = options.maxTextChars ?? DEFAULT_MAX_TEXT_CHARS; - const mode = options.mode ?? "both"; + const mode = options.mode ?? "progressive"; const names = { + details: options.names?.details ?? "mcp_get_tool_details", execute: options.names?.execute ?? "mcp_execute_code", search: options.names?.search ?? "mcp_search_tools", single: options.names?.single ?? "mcp_code", @@ -221,15 +308,28 @@ export async function codeMcpServer( ); try { - if (mode === "both" || mode === "split") { - registerSearchTool(server, names.search, provider, maxTextChars); + if (mode === "both" || mode === "progressive") { + registerSearchTool( + server, + names.search, + provider.name, + handle.toolDefinitions, + maxTextChars, + ); + registerDetailsTool( + server, + names.details, + provider, + handle.toolDefinitions, + maxTextChars, + ); registerExecuteTool( server, names.execute, provider, options.executor, maxTextChars, - `Execute JavaScript against the wrapped ${provider.name} MCP tool namespace.`, + `Execute JavaScript against the wrapped ${provider.name} MCP tool namespace. Use the search and details tools before writing code.`, ); } diff --git a/packages/core/src/mcp/createMcpToolProvider.ts b/packages/core/src/mcp/createMcpToolProvider.ts index cc3055a..b7a071b 100644 --- a/packages/core/src/mcp/createMcpToolProvider.ts +++ b/packages/core/src/mcp/createMcpToolProvider.ts @@ -4,7 +4,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; import { resolveProvider } from "../provider/resolveProvider"; -import type { ResolvedToolProvider, ToolProvider } from "../types"; +import type { + JsonSchema, + ResolvedToolProvider, + ToolAnnotations, + ToolProvider, +} from "../types"; import { generateMcpWrappedToolTypes } from "./mcpWrappedToolTypes"; /** @@ -60,6 +65,24 @@ export interface CreateMcpToolProviderOptions { clientInfo?: Implementation; } +/** + * Full wrapped MCP tool metadata used by progressive discovery surfaces. + */ +export interface McpWrappedToolDefinition { + /** Optional MCP-compatible behavior hints copied from the upstream tool. */ + annotations?: ToolAnnotations; + /** Optional human-readable description copied from the upstream tool. */ + description?: string; + /** Normalized input schema used for wrapped tool argument validation. */ + inputSchema?: JsonSchema; + /** Original upstream MCP tool name. */ + originalName: string; + /** Upstream output schema for the tool's `structuredContent`, when provided. */ + outputSchema?: JsonSchema; + /** Sanitized tool name visible in guest code. */ + safeName: string; +} + /** * Explicit handle for a wrapped MCP provider and any owned source connections. */ @@ -68,6 +91,8 @@ export interface McpToolProviderHandle { provider: ResolvedToolProvider; /** Best-effort upstream server identity when available. */ serverInfo?: Implementation; + /** Full wrapped MCP tool definitions keyed by safe guest-visible name. */ + toolDefinitions: Record; /** Releases any internal MCP client/server connection opened for the provider. */ close: () => Promise; } @@ -88,6 +113,12 @@ async function closeAll(closers: Array<() => Promise>): Promise { } } +function asJsonSchema(schema: unknown): JsonSchema | undefined { + return typeof schema === "object" && schema !== null + ? (schema as JsonSchema) + : undefined; +} + async function openMcpToolClient( source: McpToolSource, clientInfo: Implementation, @@ -149,6 +180,9 @@ export async function openMcpToolProvider( try { const toolsResponse = await connection.client.listTools(); + const toolsByOriginalName = new Map( + toolsResponse.tools.map((tool) => [tool.name, tool] as const), + ); const provider: ToolProvider = { name: options.namespace ?? "mcp", tools: {}, @@ -156,6 +190,7 @@ export async function openMcpToolProvider( for (const tool of toolsResponse.tools) { provider.tools[tool.name] = { + annotations: tool.annotations, description: tool.description, execute: async (input, context) => { const argumentsObject = @@ -177,6 +212,23 @@ export async function openMcpToolProvider( } const resolvedProvider = resolveProvider(provider); + const toolDefinitions = Object.fromEntries( + Object.entries(resolvedProvider.tools).map(([safeName, descriptor]) => { + const upstreamTool = toolsByOriginalName.get(descriptor.originalName); + + return [ + safeName, + { + annotations: descriptor.annotations, + description: descriptor.description, + inputSchema: descriptor.inputSchema, + originalName: descriptor.originalName, + outputSchema: asJsonSchema(upstreamTool?.outputSchema), + safeName: descriptor.safeName, + }, + ]; + }), + ); return { close: connection.close, @@ -185,6 +237,7 @@ export async function openMcpToolProvider( types: generateMcpWrappedToolTypes(resolvedProvider), }, serverInfo: getMcpToolSourceServerInfo(source), + toolDefinitions, }; } catch (error) { await connection.close().catch(() => {}); diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 015057d..4050e3f 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -12,6 +12,7 @@ export { type McpToolProviderHandle, type McpToolServerSource, type McpToolSource, + type McpWrappedToolDefinition, } from "./createMcpToolProvider"; export type { Executor } from "../executor/executor"; export type { ExecutionOptions, ExecutorRuntimeOptions } from "../runner"; @@ -22,5 +23,6 @@ export type { JsonSchema, ResolvedToolDescriptor, ResolvedToolProvider, + ToolAnnotations, ToolExecutionContext, } from "../types"; diff --git a/packages/core/src/mcp/mcpWrappedToolTypes.ts b/packages/core/src/mcp/mcpWrappedToolTypes.ts index 1f071ad..b18cdbe 100644 --- a/packages/core/src/mcp/mcpWrappedToolTypes.ts +++ b/packages/core/src/mcp/mcpWrappedToolTypes.ts @@ -23,29 +23,57 @@ const MCP_CALL_TOOL_RESULT_TYPE = [ "};", ].join("\n"); +/** + * Generates one wrapped MCP tool declaration exposed to guest code. + */ +export function generateMcpWrappedToolType( + provider: ResolvedToolProvider, + safeName: string, +): string { + const tool = provider.tools[safeName]; + + if (!tool) { + throw new Error(`Unknown wrapped MCP tool: ${safeName}`); + } + + const comment = renderDocComment([ + ...(tool.description ? [tool.description, ""] : []), + "Wrapped MCP tool. Inspect structuredContent first, then fall back to content text items.", + ]); + + return [ + comment, + `function ${safeName}(input: ${schemaToType(tool.inputSchema)}): Promise;`, + ] + .filter(Boolean) + .join("\n"); +} + /** * Generates the wrapped MCP tool namespace declarations exposed to guest code. */ export function generateMcpWrappedToolTypes( provider: ResolvedToolProvider, ): string { - const declarations = [MCP_CALL_TOOL_RESULT_TYPE]; - - for (const [safeName, tool] of Object.entries(provider.tools)) { - const comment = renderDocComment([ - ...(tool.description ? [tool.description, ""] : []), - "Wrapped MCP tool. Inspect structuredContent first, then fall back to content text items.", - ]); - - declarations.push( - [ - comment, - `function ${safeName}(input: ${schemaToType(tool.inputSchema)}): Promise;`, - ] - .filter(Boolean) - .join("\n"), - ); - } + const declarations = [ + MCP_CALL_TOOL_RESULT_TYPE, + ...Object.keys(provider.tools).map((safeName) => + generateMcpWrappedToolType(provider, safeName), + ), + ]; return renderNamespaceDeclaration(provider.name, declarations); } + +/** + * Generates the wrapped MCP tool namespace declaration for one selected tool. + */ +export function generateMcpWrappedSingleToolTypes( + provider: ResolvedToolProvider, + safeName: string, +): string { + return renderNamespaceDeclaration(provider.name, [ + MCP_CALL_TOOL_RESULT_TYPE, + generateMcpWrappedToolType(provider, safeName), + ]); +} diff --git a/packages/core/src/protocol/index.ts b/packages/core/src/protocol/index.ts index 0d7e86a..4ec0505 100644 --- a/packages/core/src/protocol/index.ts +++ b/packages/core/src/protocol/index.ts @@ -42,5 +42,6 @@ export type { JsonSchema, ResolvedToolDescriptor, ResolvedToolProvider, + ToolAnnotations, ToolExecutionContext, } from "../types.ts"; diff --git a/packages/core/src/protocol/messages.ts b/packages/core/src/protocol/messages.ts index 6a92e5e..7985fd7 100644 --- a/packages/core/src/protocol/messages.ts +++ b/packages/core/src/protocol/messages.ts @@ -14,6 +14,24 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } +function isToolAnnotations(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + + return ( + (value.title === undefined || typeof value.title === "string") && + (value.readOnlyHint === undefined || + typeof value.readOnlyHint === "boolean") && + (value.destructiveHint === undefined || + typeof value.destructiveHint === "boolean") && + (value.idempotentHint === undefined || + typeof value.idempotentHint === "boolean") && + (value.openWorldHint === undefined || + typeof value.openWorldHint === "boolean") + ); +} + function isRuntimeOptions(value: unknown): value is ExecutorRuntimeOptions { if (!isRecord(value)) { return false; @@ -41,6 +59,7 @@ function isProviderManifest(value: unknown): value is ProviderManifest { isRecord(tool) && typeof tool.originalName === "string" && typeof tool.safeName === "string" && + (tool.annotations === undefined || isToolAnnotations(tool.annotations)) && (tool.description === undefined || typeof tool.description === "string"), ); } diff --git a/packages/core/src/provider/resolveProvider.ts b/packages/core/src/provider/resolveProvider.ts index fb22265..f832746 100644 --- a/packages/core/src/provider/resolveProvider.ts +++ b/packages/core/src/provider/resolveProvider.ts @@ -1,4 +1,4 @@ -import Ajv, { type AnySchemaObject, type ValidateFunction } from "ajv"; +import Ajv from "ajv"; import { ExecuteFailure, @@ -13,28 +13,64 @@ import type { JsonSchema, ResolvedToolDescriptor, ResolvedToolProvider, + ToolAnnotations, ToolExecutionContext, ToolProvider, TypegenToolDescriptor, } from "../types"; const DEFAULT_PROVIDER_NAME = "codemode"; +type AjvInstance = InstanceType; +type AjvValidateFunction = ReturnType; + function assertValidNamespace(name: string): void { assertValidIdentifier(name, "provider namespace"); } +function cloneToolAnnotations( + annotations: ToolAnnotations | undefined, +): ToolAnnotations | undefined { + if (!annotations) { + return undefined; + } + + const clone: ToolAnnotations = {}; + + if (annotations.title !== undefined) { + clone.title = annotations.title; + } + + if (annotations.readOnlyHint !== undefined) { + clone.readOnlyHint = annotations.readOnlyHint; + } + + if (annotations.destructiveHint !== undefined) { + clone.destructiveHint = annotations.destructiveHint; + } + + if (annotations.idempotentHint !== undefined) { + clone.idempotentHint = annotations.idempotentHint; + } + + if (annotations.openWorldHint !== undefined) { + clone.openWorldHint = annotations.openWorldHint; + } + + return Object.keys(clone).length === 0 ? undefined : clone; +} + function compileValidator( - ajv: Ajv, + ajv: AjvInstance, schema: JsonSchema | undefined, -): ValidateFunction | undefined { - return schema ? ajv.compile(schema as AnySchemaObject) : undefined; +): AjvValidateFunction | undefined { + return schema ? ajv.compile(schema as object) : undefined; } function formatValidationMessage( - ajv: Ajv, + ajv: AjvInstance, phase: "input" | "output", toolName: string, - validator: ValidateFunction, + validator: AjvValidateFunction, ): string { return `Invalid ${phase} for tool ${toolName}: ${ajv.errorsText(validator.errors)}`; } @@ -46,11 +82,10 @@ export function resolveProvider(provider: ToolProvider): ResolvedToolProvider { const name = provider.name ?? DEFAULT_PROVIDER_NAME; assertValidNamespace(name); - // strict: false allows schemas with extra keywords (e.g. Zod-generated $schema) - // that don't conform to the strict JSON Schema vocabulary. + // Keep provider schemas permissive for generated schemas and extension keywords. const ajv = new Ajv({ allErrors: true, - strict: false, + strictKeywords: false, }); const originalToSafeName: Record = {}; @@ -87,6 +122,7 @@ export function resolveProvider(provider: ToolProvider): ResolvedToolProvider { const outputValidator = compileValidator(ajv, outputSchema); resolvedTools[safeName] = { + annotations: cloneToolAnnotations(descriptor.annotations), description: descriptor.description, execute: async ( input: unknown, diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index 5534685..30d1808 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -8,12 +8,18 @@ import { isExecuteFailure, isJsonSerializable, } from "./errors.ts"; -import type { ExecuteError, ResolvedToolProvider } from "./types.ts"; +import type { + ExecuteError, + ResolvedToolProvider, + ToolAnnotations, +} from "./types.ts"; /** * Transport-safe metadata for one exposed tool. */ export interface ProviderToolManifest { + /** Optional MCP-compatible behavior hints used by discovery surfaces. */ + annotations?: ToolAnnotations; description?: string; originalName: string; safeName: string; @@ -93,6 +99,7 @@ export function extractProviderManifests( Object.entries(provider.tools).map(([safeToolName, descriptor]) => [ safeToolName, { + annotations: descriptor.annotations, description: descriptor.description, originalName: descriptor.originalName, safeName: descriptor.safeName, diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 11300e0..cbf8c1a 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -40,6 +40,7 @@ export type { JsonSchema, ResolvedToolDescriptor, ResolvedToolProvider, + ToolAnnotations, ToolDescriptor, ToolExecutionContext, ToolProvider, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5fff957..6a66584 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -49,6 +49,25 @@ export type ExecuteResult = ok: false; }; +/** + * MCP-compatible behavior hints for a tool. + * + * These values are advisory metadata for clients and models. They are not + * enforced by execbox and must not be treated as a security boundary. + */ +export interface ToolAnnotations { + /** Human-readable title for UI display. */ + title?: string; + /** If true, the tool does not modify its environment. */ + readOnlyHint?: boolean; + /** If true, the tool may perform destructive updates to its environment. */ + destructiveHint?: boolean; + /** If true, repeated calls with the same arguments have no additional effect. */ + idempotentHint?: boolean; + /** If true, the tool may interact with external entities outside a closed domain. */ + openWorldHint?: boolean; +} + /** * Context passed to every tool execution. */ @@ -67,6 +86,8 @@ export interface ToolExecutionContext { * Host-side tool definition before provider resolution. */ export interface ToolDescriptor { + /** Optional MCP-compatible behavior hints used by discovery surfaces. */ + annotations?: ToolAnnotations; /** Optional human-readable description used in generated types and docs. */ description?: string; /** Optional input schema validated before the tool is invoked. */ @@ -96,6 +117,8 @@ export interface ToolProvider { * Tool descriptor after validation, sanitization, and execution wrapping. */ export interface ResolvedToolDescriptor { + /** Optional MCP-compatible behavior hints used by discovery surfaces. */ + annotations?: ToolAnnotations; /** Optional human-readable description used in generated types and docs. */ description?: string; /** Normalized JSON Schema validated before the tool is invoked. */ diff --git a/packages/core/test-support/runWrappedMcpPenetrationSuite.ts b/packages/core/test-support/runWrappedMcpPenetrationSuite.ts index 8250538..1bc4ea4 100644 --- a/packages/core/test-support/runWrappedMcpPenetrationSuite.ts +++ b/packages/core/test-support/runWrappedMcpPenetrationSuite.ts @@ -164,8 +164,8 @@ export function runWrappedMcpPenetrationSuite( expect(tools.tools.map((tool) => tool.name)).toEqual( expect.arrayContaining([ - "mcp_code", "mcp_execute_code", + "mcp_get_tool_details", "mcp_search_tools", ]), ); @@ -179,12 +179,24 @@ export function runWrappedMcpPenetrationSuite( ]), ); expect(searchResult.structuredContent).toMatchObject({ - originalToSafeName: { - "1tool": "_1tool", - default: "default_", - "search-docs": "search_docs", - search_docs: "search_docs__2", - }, + tools: expect.arrayContaining([ + expect.objectContaining({ + originalName: "1tool", + safeName: "_1tool", + }), + expect.objectContaining({ + originalName: "default", + safeName: "default_", + }), + expect.objectContaining({ + originalName: "search-docs", + safeName: "search_docs", + }), + expect.objectContaining({ + originalName: "search_docs", + safeName: "search_docs__2", + }), + ]), }); expect(executeResult.structuredContent).toMatchObject({ ok: true, From 6c44e6b159ae417d46f78990981798e26c0f2eb0 Mon Sep 17 00:00:00 2001 From: Mouaad Aallam Date: Sat, 20 Jun 2026 12:46:46 +0200 Subject: [PATCH 2/2] fix(ci): use ajv strict option --- packages/core/src/provider/resolveProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/provider/resolveProvider.ts b/packages/core/src/provider/resolveProvider.ts index f832746..20dbe0f 100644 --- a/packages/core/src/provider/resolveProvider.ts +++ b/packages/core/src/provider/resolveProvider.ts @@ -85,7 +85,7 @@ export function resolveProvider(provider: ToolProvider): ResolvedToolProvider { // Keep provider schemas permissive for generated schemas and extension keywords. const ajv = new Ajv({ allErrors: true, - strictKeywords: false, + strict: false, }); const originalToSafeName: Record = {};