Skip to content

Commit e09761a

Browse files
authored
feat(mcp): add progressive discovery (#44)
1 parent c0ac338 commit e09761a

25 files changed

Lines changed: 588 additions & 86 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@execbox/core": minor
3+
---
4+
5+
Add MCP-compatible tool annotations and make code MCP servers expose progressive search, inspect, and execute tools by default.

docs/src/content/docs/examples.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ npm run examples
1616

1717
## Example index
1818

19-
| Example | What it shows | When to start here |
20-
| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
21-
| [`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. |
22-
| [`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. |
23-
| [`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. |
24-
| [`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. |
19+
| Example | What it shows | When to start here |
20+
| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
21+
| [`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. |
22+
| [`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. |
23+
| [`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. |
24+
| [`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. |
2525

2626
## What to read next
2727

docs/src/content/docs/mcp-integration.md

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,33 @@ const server = await codeMcpServer(
5252

5353
The wrapper server exposes:
5454

55-
| Tool | Purpose |
56-
| ------------------ | ---------------------------------------------------- |
57-
| `mcp_search_tools` | Search the wrapped MCP catalog |
58-
| `mcp_execute_code` | Execute guest JavaScript against the wrapped catalog |
59-
| `mcp_code` | Return the code-execution tool description |
55+
| Tool | Purpose |
56+
| ---------------------- | --------------------------------------------------------- |
57+
| `mcp_search_tools` | Search the wrapped MCP catalog with concise metadata |
58+
| `mcp_get_tool_details` | Inspect schemas and generated types for one selected tool |
59+
| `mcp_execute_code` | Execute guest JavaScript against the wrapped catalog |
60+
61+
The default `codeMcpServer()` mode is progressive: MCP clients can search the
62+
catalog, inspect only the tools they need, then execute code. Use
63+
`mode: "single"` only when a client needs the legacy all-in-one `mcp_code` tool
64+
whose description embeds the full generated namespace. Use `mode: "both"` to
65+
expose the progressive tools and `mcp_code` together.
66+
67+
```ts
68+
const search = await client.callTool({
69+
name: "mcp_search_tools",
70+
arguments: { query: "search docs" },
71+
});
72+
73+
const details = await client.callTool({
74+
name: "mcp_get_tool_details",
75+
arguments: { safeName: "search_docs" },
76+
});
77+
```
78+
79+
`mcp_search_tools` returns only names, descriptions, and annotations.
80+
`mcp_get_tool_details` returns the selected tool's input schema, output schema,
81+
and generated TypeScript declaration.
6082

6183
## Result handling
6284

@@ -79,6 +101,10 @@ provider surface remains the capability boundary:
79101
- close handles returned by `openMcpToolProvider()`
80102
- choose inline or worker-hosted QuickJS separately from the MCP adapter shape
81103

104+
The code-execution tools are annotated as potentially destructive because MCP
105+
tool annotations are static while guest code can call any wrapped tool exposed
106+
through the provider. Search and details tools are annotated read-only.
107+
82108
## Examples
83109

84110
- [`execbox-mcp-provider.ts`](https://github.com/aallam/execbox/blob/main/examples/execbox-mcp-provider.ts)

docs/src/content/docs/providers-and-tools.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ Declare the narrowest useful input and output shapes. They act as the contract
6767
between guest code and host capabilities, and they also improve generated guest
6868
types.
6969

70+
## Tool annotations
71+
72+
Tools can declare MCP-compatible annotations such as `readOnlyHint`,
73+
`destructiveHint`, `idempotentHint`, and `openWorldHint`. Execbox preserves
74+
these hints for discovery surfaces and MCP wrappers, but does not enforce them.
75+
Use annotations to help clients decide what to show or confirm; keep real
76+
authorization in host policy and provider selection.
77+
7078
## Result boundary
7179

7280
Tool inputs and results cross a JSON-compatible boundary. Return plain data such

docs/src/content/docs/security.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ decision. Treat an upstream MCP catalog as host capability, then expose a small
4545
resolved provider to guest code. Keep upstream client ownership, authentication,
4646
and tenant routing in host code.
4747

48+
MCP tool annotations are advisory metadata, not enforcement. Execbox marks
49+
code-execution wrapper tools as potentially destructive because one code string
50+
can call a mix of read-only and write-capable wrapped tools. Keep confirmation
51+
and authorization policy in the downstream MCP host and in the provider surface
52+
you choose to expose.
53+
4854
## Deeper reading
4955

5056
- [Architecture Overview](/architecture/)

examples/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ start with each example.
1919

2020
## Example index
2121

22-
| File | What it shows |
23-
| ------------------------------------------------------ | ------------------------------------------------------------- |
24-
| [`execbox-basic.ts`](./execbox-basic.ts) | Resolve a provider and execute guest code with inline QuickJS |
25-
| [`execbox-worker.ts`](./execbox-worker.ts) | Run the same provider flow with worker-hosted QuickJS |
26-
| [`execbox-mcp-provider.ts`](./execbox-mcp-provider.ts) | Wrap MCP tools into a provider and execute against them |
27-
| [`execbox-mcp-server.ts`](./execbox-mcp-server.ts) | Expose `mcp_search_tools`, `mcp_execute_code`, and `mcp_code` |
22+
| File | What it shows |
23+
| ------------------------------------------------------ | ------------------------------------------------------------------ |
24+
| [`execbox-basic.ts`](./execbox-basic.ts) | Resolve a provider and execute guest code with inline QuickJS |
25+
| [`execbox-worker.ts`](./execbox-worker.ts) | Run the same provider flow with worker-hosted QuickJS |
26+
| [`execbox-mcp-provider.ts`](./execbox-mcp-provider.ts) | Wrap MCP tools into a provider and execute against them |
27+
| [`execbox-mcp-server.ts`](./execbox-mcp-server.ts) | Expose progressive MCP code tools for search, inspect, and execute |
2828

2929
Read [Providers & Tools](https://execbox.aallam.com/providers-and-tools),
3030
[Runtime Choices](https://execbox.aallam.com/runtime-choices), and

examples/execbox-mcp-server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ async function main(): Promise<void> {
6262
name: "mcp_search_tools",
6363
arguments: { query: "search" },
6464
});
65+
const detailsResult = await wrappedClient.callTool({
66+
name: "mcp_get_tool_details",
67+
arguments: { safeName: "search_docs" },
68+
});
6569
const executeResult = await wrappedClient.callTool({
6670
name: "mcp_execute_code",
6771
arguments: {
@@ -73,6 +77,7 @@ async function main(): Promise<void> {
7377
console.log(
7478
JSON.stringify(
7579
{
80+
detailsResult: detailsResult.structuredContent,
7681
executeResult: executeResult.structuredContent,
7782
searchResult: searchResult.structuredContent,
7883
toolNames: tools.tools.map((tool) => tool.name),

packages/core/__tests__/core/resolveProvider.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,32 @@ describe("resolveProvider", () => {
6262
});
6363
});
6464

65+
it("preserves tool annotations on resolved descriptors", () => {
66+
const provider = resolveProvider({
67+
name: "mcp",
68+
tools: {
69+
"delete-document": {
70+
annotations: {
71+
destructiveHint: true,
72+
idempotentHint: false,
73+
openWorldHint: false,
74+
readOnlyHint: false,
75+
title: "Delete document",
76+
},
77+
execute: async () => ({ ok: true }),
78+
},
79+
},
80+
});
81+
82+
expect(provider.tools.delete_document.annotations).toEqual({
83+
destructiveHint: true,
84+
idempotentHint: false,
85+
openWorldHint: false,
86+
readOnlyHint: false,
87+
title: "Delete document",
88+
});
89+
});
90+
6591
it("validates input before calling the original execute function", async () => {
6692
let called = false;
6793

packages/core/__tests__/mcp/mcpAdapters.test.ts

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ function createUpstreamServer(): McpServer {
2727
const registerTool = server.registerTool.bind(server) as unknown as (
2828
name: string,
2929
config: {
30+
annotations?: {
31+
destructiveHint?: boolean;
32+
idempotentHint?: boolean;
33+
openWorldHint?: boolean;
34+
readOnlyHint?: boolean;
35+
title?: string;
36+
};
3037
description?: string;
3138
inputSchema?: unknown;
3239
outputSchema?: unknown;
@@ -37,6 +44,13 @@ function createUpstreamServer(): McpServer {
3744
registerTool(
3845
"search-docs",
3946
{
47+
annotations: {
48+
destructiveHint: false,
49+
idempotentHint: true,
50+
openWorldHint: false,
51+
readOnlyHint: true,
52+
title: "Search docs",
53+
},
4054
description: "Search documentation",
4155
inputSchema: searchDocsInputSchema,
4256
outputSchema: searchDocsOutputSchema,
@@ -97,6 +111,13 @@ describe("MCP adapters", () => {
97111
"search-docs": "search_docs",
98112
explode: "explode",
99113
});
114+
expect(provider.tools.search_docs.annotations).toEqual({
115+
destructiveHint: false,
116+
idempotentHint: true,
117+
openWorldHint: false,
118+
readOnlyHint: true,
119+
title: "Search docs",
120+
});
100121
expect(provider.types).toContain("declare namespace mcp");
101122
expect(provider.types).toContain("Inspect structuredContent first");
102123
expect(provider.types).toContain("structuredContent?: unknown;");
@@ -134,7 +155,7 @@ describe("MCP adapters", () => {
134155
).rejects.toThrow(/openMcpToolProvider/);
135156
});
136157

137-
it("wraps a connected client with both MCP code tools by default", async () => {
158+
it("wraps a connected client with progressive MCP code tools by default", async () => {
138159
const upstreamServer = createUpstreamServer();
139160
const upstreamClient = await connectClient(upstreamServer);
140161
const wrappedServer = await codeMcpServer(
@@ -144,14 +165,36 @@ describe("MCP adapters", () => {
144165
const wrappedClient = await connectClient(wrappedServer);
145166

146167
const tools = await wrappedClient.listTools();
147-
expect(tools.tools.map((tool) => tool.name)).toEqual(
148-
expect.arrayContaining([
149-
"mcp_code",
150-
"mcp_search_tools",
151-
"mcp_execute_code",
152-
]),
153-
);
168+
expect(tools.tools.map((tool) => tool.name)).toEqual([
169+
"mcp_search_tools",
170+
"mcp_get_tool_details",
171+
"mcp_execute_code",
172+
]);
154173
expect(tools.tools.map((tool) => tool.name)).not.toContain("search-docs");
174+
expect(
175+
Object.fromEntries(tools.tools.map((tool) => [tool.name, tool])),
176+
).toMatchObject({
177+
mcp_execute_code: {
178+
annotations: {
179+
destructiveHint: true,
180+
idempotentHint: false,
181+
openWorldHint: true,
182+
readOnlyHint: false,
183+
},
184+
},
185+
mcp_get_tool_details: {
186+
annotations: {
187+
destructiveHint: false,
188+
readOnlyHint: true,
189+
},
190+
},
191+
mcp_search_tools: {
192+
annotations: {
193+
destructiveHint: false,
194+
readOnlyHint: true,
195+
},
196+
},
197+
});
155198

156199
const searchResult = await wrappedClient.callTool({
157200
name: "mcp_search_tools",
@@ -172,10 +215,46 @@ describe("MCP adapters", () => {
172215
) {
173216
throw new Error("Expected structured MCP search payload");
174217
}
175-
expect(searchResult.structuredContent).toHaveProperty("types");
218+
expect(searchResult.structuredContent).not.toHaveProperty("types");
219+
expect(searchResult.structuredContent).not.toHaveProperty("inputSchema");
220+
expect(searchResult.structuredContent).toMatchObject({
221+
tools: [
222+
expect.objectContaining({
223+
annotations: {
224+
destructiveHint: false,
225+
idempotentHint: true,
226+
openWorldHint: false,
227+
readOnlyHint: true,
228+
title: "Search docs",
229+
},
230+
}),
231+
],
232+
});
233+
234+
const detailsResult = await wrappedClient.callTool({
235+
name: "mcp_get_tool_details",
236+
arguments: { safeName: "search_docs" },
237+
});
238+
239+
expect(detailsResult.structuredContent).toMatchObject({
240+
annotations: {
241+
destructiveHint: false,
242+
idempotentHint: true,
243+
openWorldHint: false,
244+
readOnlyHint: true,
245+
title: "Search docs",
246+
},
247+
inputSchema: expect.objectContaining({ type: "object" }),
248+
originalName: "search-docs",
249+
outputSchema: expect.objectContaining({ type: "object" }),
250+
safeName: "search_docs",
251+
});
176252
expect(
177-
(searchResult.structuredContent as { types: string }).types,
178-
).toContain("Inspect structuredContent first");
253+
(detailsResult.structuredContent as { types: string }).types,
254+
).toContain("function search_docs(input:");
255+
expect(
256+
(detailsResult.structuredContent as { types: string }).types,
257+
).not.toContain("function explode(input:");
179258

180259
const executeResult = await wrappedClient.callTool({
181260
name: "mcp_execute_code",
@@ -347,6 +426,14 @@ describe("MCP adapters", () => {
347426

348427
const tools = await wrappedClient.listTools();
349428
expect(tools.tools.map((tool) => tool.name)).toEqual(["mcp_code"]);
429+
expect(tools.tools[0]).toMatchObject({
430+
annotations: {
431+
destructiveHint: true,
432+
idempotentHint: false,
433+
openWorldHint: true,
434+
readOnlyHint: false,
435+
},
436+
});
350437

351438
const executeResult = await wrappedClient.callTool({
352439
name: "mcp_code",

packages/core/etc/execbox-core-mcp.api.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ export function codeMcpServer(source: McpToolSource, options: CodeMcpServerOptio
1515
export interface CodeMcpServerOptions extends CreateMcpToolProviderOptions {
1616
executor: Executor;
1717
maxTextChars?: number;
18-
mode?: "both" | "single" | "split";
18+
mode?: "both" | "progressive" | "single";
1919
names?: {
20+
details?: string;
2021
execute?: string;
2122
search?: string;
2223
single?: string;
@@ -100,6 +101,7 @@ export interface McpToolProviderHandle {
100101
close: () => Promise<void>;
101102
provider: ResolvedToolProvider;
102103
serverInfo?: Implementation;
104+
toolDefinitions: Record<string, McpWrappedToolDefinition>;
103105
}
104106

105107
// @public
@@ -111,11 +113,22 @@ export type McpToolServerSource = {
111113
// @public
112114
export type McpToolSource = McpToolClientSource | McpToolServerSource;
113115

116+
// @public
117+
export interface McpWrappedToolDefinition {
118+
annotations?: ToolAnnotations;
119+
description?: string;
120+
inputSchema?: JsonSchema;
121+
originalName: string;
122+
outputSchema?: JsonSchema;
123+
safeName: string;
124+
}
125+
114126
// @public
115127
export function openMcpToolProvider(source: McpToolSource, options?: CreateMcpToolProviderOptions): Promise<McpToolProviderHandle>;
116128

117129
// @public
118130
export interface ResolvedToolDescriptor {
131+
annotations?: ToolAnnotations;
119132
description?: string;
120133
execute: (input: unknown, context: ToolExecutionContext) => Promise<unknown>;
121134
inputSchema?: JsonSchema;
@@ -133,6 +146,15 @@ export interface ResolvedToolProvider {
133146
types: string;
134147
}
135148

149+
// @public
150+
export interface ToolAnnotations {
151+
destructiveHint?: boolean;
152+
idempotentHint?: boolean;
153+
openWorldHint?: boolean;
154+
readOnlyHint?: boolean;
155+
title?: string;
156+
}
157+
136158
// @public
137159
export interface ToolExecutionContext {
138160
originalToolName: string;

0 commit comments

Comments
 (0)