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
35 changes: 32 additions & 3 deletions packages/ai-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
12 changes: 9 additions & 3 deletions packages/ai-mcp/src/client-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
outputSchema?: Record<string, unknown>
}

/**
* 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(
Expand All @@ -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 }
}
Expand All @@ -118,8 +126,6 @@ async function resolveClient(
return { client, ownsClient: true }
}

async function buildTransport(transport: Exclude<McpClientTransport, object>): Promise<unknown>
async function buildTransport(transport: McpClientTransport): Promise<unknown>
async function buildTransport(transport: McpClientTransport): Promise<unknown> {
if (typeof transport === 'string' || transport instanceof URL) {
const url = transport instanceof URL ? transport : new URL(transport)
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/ai-mcp/src/server-from-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading