Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-progressive-discovery.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions docs/src/content/docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 31 additions & 5 deletions docs/src/content/docs/mcp-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions docs/src/content/docs/providers-and-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
12 changes: 6 additions & 6 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions examples/execbox-mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ async function main(): Promise<void> {
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: {
Expand All @@ -73,6 +77,7 @@ async function main(): Promise<void> {
console.log(
JSON.stringify(
{
detailsResult: detailsResult.structuredContent,
executeResult: executeResult.structuredContent,
searchResult: searchResult.structuredContent,
toolNames: tools.tools.map((tool) => tool.name),
Expand Down
26 changes: 26 additions & 0 deletions packages/core/__tests__/core/resolveProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
109 changes: 98 additions & 11 deletions packages/core/__tests__/mcp/mcpAdapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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;");
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 23 additions & 1 deletion packages/core/etc/execbox-core-mcp.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -100,6 +101,7 @@ export interface McpToolProviderHandle {
close: () => Promise<void>;
provider: ResolvedToolProvider;
serverInfo?: Implementation;
toolDefinitions: Record<string, McpWrappedToolDefinition>;
}

// @public
Expand All @@ -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<McpToolProviderHandle>;

// @public
export interface ResolvedToolDescriptor {
annotations?: ToolAnnotations;
description?: string;
execute: (input: unknown, context: ToolExecutionContext) => Promise<unknown>;
inputSchema?: JsonSchema;
Expand All @@ -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;
Expand Down
Loading
Loading