From 899317b9c6fc2b3dca4ae676987b3fb53232a6af Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 26 Jun 2026 22:50:15 +0300 Subject: [PATCH] docs(ai-mcp): document options/close()/expose modes, tidy internals Code quality + docs pass for @gemstack/ai-mcp: - README: fixed the "expose an Agent as an MCP server external clients" grammar; documented mcpClientTools options (filter/namePrefix/streaming) with an example; explained the close() lifecycle (present only when this call owns the connection); expanded the three expose modes and listed the remaining server options. - index.ts: same grammar fix in the module doc. - client-tools.ts: removed the unused RemoteTool.outputSchema field and a redundant buildTransport overload; documented the RemoteTool/MinimalClient internal interfaces and the deliberate unknown double-cast in resolveClient. - server-from-agent.ts: documented why safeInstructions swallows errors (best-effort optional metadata; pass opts.instructions to bypass). No public API change. Build + 19 tests green. --- packages/ai-mcp/README.md | 35 ++++++++++++++++++++++-- packages/ai-mcp/src/client-tools.ts | 12 ++++++-- packages/ai-mcp/src/index.ts | 2 +- packages/ai-mcp/src/server-from-agent.ts | 7 +++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/ai-mcp/README.md b/packages/ai-mcp/README.md index 62c0527..33d1549 100644 --- a/packages/ai-mcp/README.md +++ b/packages/ai-mcp/README.md @@ -3,7 +3,7 @@ The bridge between [`@gemstack/ai-sdk`](https://github.com/gemstack-land/gemstack/tree/main/packages/ai-sdk) Agents and [Model Context Protocol](https://modelcontextprotocol.io) servers. Two connectors: - **`mcpClientTools(transport, opts?)`** — consume a remote MCP server's tools as Agent tools. -- **`mcpServerFromAgent(AgentClass, opts?)`** — expose an Agent as an MCP server external clients (Claude Desktop, Cursor, etc.) can call. +- **`mcpServerFromAgent(AgentClass, opts?)`** — expose an Agent as an MCP server that external clients (Claude Desktop, Cursor, etc.) can call. This is the **agent bridge** axis of MCP. It depends on `@gemstack/ai-sdk` and is useless without an Agent. It was carved out of `@gemstack/ai-sdk`'s `/mcp` subpath so the optional MCP SDK dependency is declared only by the package that actually needs it. @@ -37,8 +37,31 @@ const tools = await mcpClientTools({ command: 'npx', args: ['some-mcp-server'] } // (c) Already-connected SDK Client (caller owns lifecycle) const tools = await mcpClientTools(myClient) +``` + +Spread the result into your Agent's `tools()`. When this call owns the connection (cases a + b) the returned array carries a `close()` method; call it when the agent is done so the subprocess / HTTP session shuts down cleanly. When you pass your own `Client` (case c) there is no `close()` — you own that lifecycle. + +```ts +class MyAgent extends Agent { + tools() { return [...tools, ...myOwnTools] } // close() is non-enumerable, so it's not iterated +} +// ... later +await tools.close?.() +``` -// Spread into your Agent's tools(). Call tools.close() when done (cases a + b). +**Options** (`mcpClientTools(transport, opts)`): + +| Option | Default | Effect | +|---|---|---| +| `filter` | all tools | `(toolName) => boolean` — drop remote tools you don't want to expose. | +| `namePrefix` | `''` | Prefix every tool name, to avoid collisions when wiring several remote servers. | +| `streaming` | `true` | Forward the remote server's `notifications/progress` as `tool-update` chunks during a run. | + +```ts +const tools = await mcpClientTools('https://api.example.com/mcp', { + filter: (name) => !name.startsWith('internal_'), + namePrefix: 'remote_', +}) ``` ### Expose an Agent as an MCP server @@ -51,7 +74,13 @@ const server = await mcpServerFromAgent(MyAgent) await server.connect(new StdioServerTransport()) ``` -Three exposure modes via `opts.expose`: `'tools'` (default, one MCP tool per `agent.tools()` entry), `'agent'` (one tool that runs the whole agent: `prompt(text) → text`), or `'both'`. +**Exposure modes** via `opts.expose`: + +- `'tools'` (default) — one MCP tool per `agent.tools()` entry. Best for surfacing an agent's toolbox to other MCP clients. +- `'agent'` — a single MCP tool that runs the whole agent (`prompt(text) -> text`). Best for shipping one agent that any MCP client can call. +- `'both'` — the individual tools and the agent prompt-tool, side by side. + +Other options: `name` / `version` (server identity), `instructions` (advertised server instructions; defaults to the agent's `instructions()`), and `agentToolName` (the prompt-tool's name in `'agent'`/`'both'` mode). ## License diff --git a/packages/ai-mcp/src/client-tools.ts b/packages/ai-mcp/src/client-tools.ts index e0f8186..d176f7d 100644 --- a/packages/ai-mcp/src/client-tools.ts +++ b/packages/ai-mcp/src/client-tools.ts @@ -80,13 +80,18 @@ export async function mcpClientTools( // Internals // ───────────────────────────────────────────────────────────────── +/** Tool metadata as returned by a remote MCP server's `listTools()`. */ interface RemoteTool { name: string description?: string inputSchema: Record - outputSchema?: Record } +/** + * The minimal slice of the SDK's `Client` this bridge depends on. Declared + * structurally so the union in {@link McpClientTransport} never forces a hard + * dependency on `@modelcontextprotocol/sdk` at module load. + */ interface MinimalClient { listTools(): Promise<{ tools: unknown[] }> callTool( @@ -101,6 +106,9 @@ async function resolveClient( transport: McpClientTransport, ): Promise<{ client: MinimalClient; ownsClient: boolean }> { // Already a Client instance — duck-type check for `callTool` + `listTools`. + // The `unknown` double-cast is deliberate: `transport` is typed `object` (we + // can't name the SDK's Client type without a hard dependency on it), so we + // narrow structurally and assert to our MinimalClient shape. if (typeof transport === 'object' && transport !== null && 'callTool' in transport && 'listTools' in transport) { return { client: transport as unknown as MinimalClient, ownsClient: false } } @@ -118,8 +126,6 @@ async function resolveClient( return { client, ownsClient: true } } -async function buildTransport(transport: Exclude): Promise -async function buildTransport(transport: McpClientTransport): Promise async function buildTransport(transport: McpClientTransport): Promise { if (typeof transport === 'string' || transport instanceof URL) { const url = transport instanceof URL ? transport : new URL(transport) diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 0c26fb2..04d38ed 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -3,7 +3,7 @@ * Protocol servers. Two connectors: * * - {@link mcpClientTools} — consume a remote MCP server's tools as Agent tools - * - {@link mcpServerFromAgent} — expose an Agent as an MCP server external + * - {@link mcpServerFromAgent} — expose an Agent as an MCP server that external * clients (Claude Desktop, Cursor, etc.) can call * * Requires `@modelcontextprotocol/sdk` at runtime — declared as an optional diff --git a/packages/ai-mcp/src/server-from-agent.ts b/packages/ai-mcp/src/server-from-agent.ts index 851112c..0b4c6d0 100644 --- a/packages/ai-mcp/src/server-from-agent.ts +++ b/packages/ai-mcp/src/server-from-agent.ts @@ -142,6 +142,13 @@ function stringifyResult(result: unknown): string { return String(result) } +/** + * Read the agent's instructions for the server's advertised `instructions`. + * This is best-effort optional metadata: `instructions()` may legitimately + * depend on per-request state that isn't available here, so a throw is treated + * as "no instructions" rather than failing server creation. Pass + * `opts.instructions` explicitly to bypass this. + */ function safeInstructions(agent: Agent): string | undefined { try { const out = agent.instructions()