toolnexus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -268,6 +268,29 @@ Task persistence is a pluggable `TaskStore` — in-memory default, `"file:<dir>"
268
268
  it **remembers**: a message's A2A `contextId` keys the conversation through `client.ask`, so a
269
269
  peer's turns in the same context carry history via the client's `ConversationStore`.
270
270
 
271
+ ## Serve as an MCP server (be a gateway)
272
+
273
+ The inbound mirror of A2A: expose your **whole toolkit as an MCP server** so any MCP client — an IDE,
274
+ another agent, a remote host — can call its tools. Point toolnexus at N MCP servers + skills + your
275
+ own functions, then re-expose the union as **one** MCP server. Unlike A2A (which advertises skills and
276
+ runs the client loop), the MCP client *is* the LLM host, so each `tools/call` dispatches straight to
277
+ `Tool.execute` — no `client`, no tasks, no store.
278
+
279
+ ```ts
280
+ // streamable-HTTP — an embeddable MCP server mounted at POST /mcp, beside any A2A routes:
281
+ const srv = await tk.serve("127.0.0.1:0", {
282
+ mcp: { name: "my-gateway" /*, tools: ["echo"] // subset; omit ⇒ every toolkit tool */ },
283
+ onCall: (ev) => console.error(ev.name, ev.ms, ev.isError),
284
+ })
285
+ console.log(srv.url + "/mcp") // connect any MCP client here
286
+ await srv.stop()
287
+ ```
288
+
289
+ `tools/list` advertises every toolkit tool (name **verbatim**, `inputSchema` = the tool's parameters);
290
+ `mcp.tools` narrows the surface. The `mcp` profile can also live in the config file as a top-level
291
+ **`mcpServer`** block (singular — distinct from the client-side `mcpServers`). (Transport is
292
+ streamable-HTTP; a stdio transport for local clients like Claude Desktop is a planned follow-up.)
293
+
271
294
  ## Bring your own loop
272
295
 
273
296
  Don't want the host loop? Use the schema adapters and execute calls yourself:
@@ -309,7 +332,7 @@ a sixth.
309
332
  | `tk.skillsPrompt()` | system-prompt skill catalog |
310
333
  | `tk.mcpStatus()` | per-server connection status |
311
334
  | `tk.toOpenAI()` / `toAnthropic()` / `toGemini()` | provider tool schemas |
312
- | `tk.serve(addr, { client, a2a?, onTask? })` | serve the toolkit as an A2A agent → `ServeHandle` |
335
+ | `tk.serve(addr, { client?, a2a?, onTask?, mcp?, onCall? })` | serve the toolkit over HTTP — A2A agent and/or streamable-HTTP MCP server (`/mcp`) → `ServeHandle` |
313
336
  | `tk.addAgent(cardUrl \| Agent)` | fetch a remote agent's card and register its skills as tools |
314
337
  | `tk.close()` | disconnect MCP servers |
315
338
  | `defineTool` / `httpTool` / `agent` | build native / HTTP / A2A tools |
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./native.js";
6
6
  export * from "./http.js";
7
7
  export * from "./a2a.js";
8
8
  export * from "./serve.js";
9
+ export * from "./mcpserve.js";
9
10
  export * from "./adapters.js";
10
11
  export * from "./toolkit.js";
11
12
  export * from "./client.js";
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export * from "./native.js";
6
6
  export * from "./http.js";
7
7
  export * from "./a2a.js";
8
8
  export * from "./serve.js";
9
+ export * from "./mcpserve.js";
9
10
  export * from "./adapters.js";
10
11
  export * from "./toolkit.js";
11
12
  export * from "./client.js";
package/dist/mcp.js CHANGED
@@ -32,9 +32,9 @@ export function parseMcpConfig(input) {
32
32
  if (wrapped !== undefined)
33
33
  return wrapped;
34
34
  // Bare map of servers — but strip sibling top-level config keys (builtins/
35
- // agents/a2a) so they are not mistaken for MCP servers when no wrapper key
36
- // is present.
37
- const { builtins: _builtins, agents: _agents, a2a: _a2a, ...servers } = raw;
35
+ // agents/a2a/mcpServer) so they are not mistaken for MCP servers when no
36
+ // wrapper key is present.
37
+ const { builtins: _builtins, agents: _agents, a2a: _a2a, mcpServer: _mcpServer, ...servers } = raw;
38
38
  return servers;
39
39
  }
40
40
  /**
@@ -0,0 +1,47 @@
1
+ /**
2
+ * MCP — INBOUND: serve a toolkit as an MCP server (SPEC §7C).
3
+ *
4
+ * The inbound mirror of the A2A `serve` (§7B). Where A2A advertises the toolkit's
5
+ * **skills** and fulfils a Task through the whole client loop, MCP advertises the
6
+ * toolkit's **unified tools** (every source — mcp · skill · native · http ·
7
+ * builtin · a2a) and dispatches each `tools/call` straight to `Tool.execute`. The
8
+ * calling MCP client *is* the LLM host, so there is **no client, no Task, and no
9
+ * TaskStore** — just `tools/list` + `tools/call` over the toolkit registry.
10
+ *
11
+ * Transport: **streamable-HTTP**, mounted at `POST /mcp` on the `serve(addr, …)`
12
+ * server alongside the A2A routes (see serve.ts), built on the same
13
+ * `@modelcontextprotocol/sdk` the client side already uses.
14
+ */
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import type { Tool, ToolSource } from "./types.js";
17
+ /** Opt-in MCP serve profile — the toolkit is exposed as an MCP server. */
18
+ export interface MCPServeConfig {
19
+ /** Advertised server name (initialize `serverInfo.name`). Default "toolnexus". */
20
+ name?: string;
21
+ /** Advertised server version. Default "0.1.0". */
22
+ version?: string;
23
+ /** Subset of tool names to expose; omit ⇒ all. Unknown names are ignored. */
24
+ tools?: string[];
25
+ }
26
+ /** Surfaced on each inbound `tools/call`. */
27
+ export interface OnCallEvent {
28
+ name: string;
29
+ source: ToolSource;
30
+ ms: number;
31
+ isError: boolean;
32
+ }
33
+ export type OnCall = (ev: OnCallEvent) => void | Promise<void>;
34
+ /**
35
+ * The tools this profile exposes: every toolkit tool, filtered to `cfg.tools`
36
+ * when set (unknown names in the filter are ignored, never an error).
37
+ */
38
+ export declare function exposedMcpTools(tools: Tool[], cfg?: MCPServeConfig): Tool[];
39
+ /**
40
+ * Build a low-level MCP `Server` exposing `tools` as MCP tools. `tools/list`
41
+ * maps each Tool's name (verbatim — already sanitized at registration),
42
+ * description, and inputSchema; `tools/call` dispatches to `Tool.execute` and
43
+ * maps the `ToolResult` to a `CallToolResult` (`output` → text, `isError`
44
+ * propagates). A fresh server is cheap to build, so the HTTP path makes one per
45
+ * request (stateless).
46
+ */
47
+ export declare function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnCall): Server;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * MCP — INBOUND: serve a toolkit as an MCP server (SPEC §7C).
3
+ *
4
+ * The inbound mirror of the A2A `serve` (§7B). Where A2A advertises the toolkit's
5
+ * **skills** and fulfils a Task through the whole client loop, MCP advertises the
6
+ * toolkit's **unified tools** (every source — mcp · skill · native · http ·
7
+ * builtin · a2a) and dispatches each `tools/call` straight to `Tool.execute`. The
8
+ * calling MCP client *is* the LLM host, so there is **no client, no Task, and no
9
+ * TaskStore** — just `tools/list` + `tools/call` over the toolkit registry.
10
+ *
11
+ * Transport: **streamable-HTTP**, mounted at `POST /mcp` on the `serve(addr, …)`
12
+ * server alongside the A2A routes (see serve.ts), built on the same
13
+ * `@modelcontextprotocol/sdk` the client side already uses.
14
+ */
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
17
+ const DEFAULT_NAME = "toolnexus";
18
+ const DEFAULT_VERSION = "0.1.0";
19
+ /**
20
+ * The tools this profile exposes: every toolkit tool, filtered to `cfg.tools`
21
+ * when set (unknown names in the filter are ignored, never an error).
22
+ */
23
+ export function exposedMcpTools(tools, cfg) {
24
+ if (!cfg?.tools)
25
+ return tools;
26
+ const wanted = new Set(cfg.tools);
27
+ return tools.filter((t) => wanted.has(t.name));
28
+ }
29
+ /**
30
+ * Build a low-level MCP `Server` exposing `tools` as MCP tools. `tools/list`
31
+ * maps each Tool's name (verbatim — already sanitized at registration),
32
+ * description, and inputSchema; `tools/call` dispatches to `Tool.execute` and
33
+ * maps the `ToolResult` to a `CallToolResult` (`output` → text, `isError`
34
+ * propagates). A fresh server is cheap to build, so the HTTP path makes one per
35
+ * request (stateless).
36
+ */
37
+ export function buildMcpServer(tools, cfg, onCall) {
38
+ const byName = new Map(tools.map((t) => [t.name, t]));
39
+ const server = new Server({ name: cfg?.name ?? DEFAULT_NAME, version: cfg?.version ?? DEFAULT_VERSION }, { capabilities: { tools: {} } });
40
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
41
+ tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
42
+ }));
43
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
44
+ const tool = byName.get(req.params.name);
45
+ if (!tool)
46
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`);
47
+ const started = Date.now();
48
+ // The call is a single synchronous tool invocation honoring the MCP
49
+ // request's cancellation. An execute() throw becomes an isError result —
50
+ // never a crash.
51
+ let result;
52
+ try {
53
+ result = await tool.execute(req.params.arguments ?? {}, { signal: extra.signal });
54
+ }
55
+ catch (e) {
56
+ result = { output: e instanceof Error ? e.message : String(e), isError: true };
57
+ }
58
+ if (onCall) {
59
+ try {
60
+ await onCall({ name: tool.name, source: tool.source, ms: Date.now() - started, isError: result.isError });
61
+ }
62
+ catch {
63
+ // Host callback errors are isolated.
64
+ }
65
+ }
66
+ return { content: [{ type: "text", text: result.output }], isError: result.isError };
67
+ });
68
+ return server;
69
+ }
package/dist/serve.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { type Tool } from "./types.js";
1
2
  import type { SkillInfo } from "./skill.js";
2
3
  import type { RunResult } from "./client.js";
3
4
  import type { A2ATask, A2ATaskState, AgentCard } from "./a2a.js";
5
+ import { type MCPServeConfig, type OnCall } from "./mcpserve.js";
4
6
  /** Pluggable Task persistence. The-factory can plug NATS/JetStream the same way. */
5
7
  export interface TaskStore {
6
8
  get(id: string): Promise<A2ATask | undefined>;
@@ -71,6 +73,12 @@ export interface StartServerOptions {
71
73
  * message) keys the conversation so served turns are remembered via the store. */
72
74
  runTask: (text: string, contextId?: string) => Promise<RunResult>;
73
75
  onTask?: OnTask;
76
+ /** When present, mounts a streamable-HTTP MCP server at `/mcp`; absent ⇒ no MCP route. */
77
+ mcp?: MCPServeConfig;
78
+ /** The toolkit's unified tools, exposed by the MCP profile. */
79
+ mcpTools?: Tool[];
80
+ /** Surfaced on each inbound MCP `tools/call`. */
81
+ onCall?: OnCall;
74
82
  }
75
83
  /**
76
84
  * Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
package/dist/serve.js CHANGED
@@ -18,7 +18,9 @@ import http from "node:http";
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
20
  import { randomUUID } from "node:crypto";
21
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
22
  import { sanitize } from "./types.js";
23
+ import { buildMcpServer, exposedMcpTools } from "./mcpserve.js";
22
24
  /** Default in-memory store — Tasks live only for the lifetime of the process. */
23
25
  export class InMemoryTaskStore {
24
26
  tasks = new Map();
@@ -111,14 +113,23 @@ function readBody(req) {
111
113
  req.on("end", () => resolve(body));
112
114
  });
113
115
  }
116
+ function safeJson(body) {
117
+ try {
118
+ return JSON.parse(body);
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
114
124
  /**
115
125
  * Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
116
126
  * the server answers 404 to everything (a minimal base for now).
117
127
  */
118
128
  export async function startA2AServer(opts) {
119
- const { a2a, skills, runTask, onTask } = opts;
129
+ const { a2a, skills, runTask, onTask, mcp, mcpTools, onCall } = opts;
120
130
  const [host, portStr] = splitAddr(opts.addr);
121
131
  const store = a2a ? resolveStore(a2a.store) : undefined;
132
+ const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : [];
122
133
  const server = http.createServer((req, res) => {
123
134
  const rpcRes = (id, payload) => {
124
135
  res.writeHead(200, { "content-type": "application/json" });
@@ -126,7 +137,36 @@ export async function startA2AServer(opts) {
126
137
  };
127
138
  const rpcOk = (id, result) => rpcRes(id, { result });
128
139
  const rpcErr = (id, code, message) => rpcRes(id, { error: { code, message } });
129
- // No A2A profile ⇒ no routes.
140
+ // No profile at all ⇒ no routes.
141
+ if (!store && !mcp) {
142
+ res.writeHead(404);
143
+ res.end("not found");
144
+ return;
145
+ }
146
+ // MCP streamable-HTTP profile (independent of A2A): a fresh server+transport
147
+ // per request (stateless — sessionIdGenerator undefined). Checked first so
148
+ // `/mcp` is never shadowed by the A2A POST handler.
149
+ if (mcp && req.url && (req.url === "/mcp" || req.url.startsWith("/mcp?"))) {
150
+ void (async () => {
151
+ const body = req.method === "POST" ? await readBody(req) : "";
152
+ const parsed = body ? safeJson(body) : undefined;
153
+ const mcpServer = buildMcpServer(exposedTools, mcp, onCall);
154
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
155
+ res.on("close", () => {
156
+ void transport.close();
157
+ void mcpServer.close();
158
+ });
159
+ await mcpServer.connect(transport);
160
+ await transport.handleRequest(req, res, parsed);
161
+ })().catch(() => {
162
+ if (!res.headersSent) {
163
+ res.writeHead(500);
164
+ res.end("mcp error");
165
+ }
166
+ });
167
+ return;
168
+ }
169
+ // No A2A profile ⇒ no A2A routes (an MCP-only server 404s everything else).
130
170
  if (!a2a || !store) {
131
171
  res.writeHead(404);
132
172
  res.end("not found");
package/dist/toolkit.d.ts CHANGED
@@ -6,6 +6,7 @@ import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./
6
6
  import { type BuiltinsConfig } from "./builtin.js";
7
7
  import { type Agent } from "./a2a.js";
8
8
  import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
9
+ import { type MCPServeConfig, type OnCall } from "./mcpserve.js";
9
10
  import type { Client } from "./client.js";
10
11
  export interface ToolkitOptions {
11
12
  mcpConfig?: string | object;
@@ -21,6 +22,8 @@ export declare class Toolkit {
21
22
  private readonly skill;
22
23
  /** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
23
24
  private readonly a2aConfig;
25
+ /** MCP serve profile read from a top-level `mcpServer` config block. */
26
+ private readonly mcpServerConfig;
24
27
  private byName;
25
28
  private constructor();
26
29
  static create(opts: ToolkitOptions): Promise<Toolkit>;
@@ -43,9 +46,11 @@ export declare class Toolkit {
43
46
  * When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
44
47
  */
45
48
  serve(addr: string, opts: {
46
- client: Client;
49
+ client?: Client;
47
50
  a2a?: A2AConfig;
48
51
  onTask?: OnTask;
52
+ mcp?: MCPServeConfig;
53
+ onCall?: OnCall;
49
54
  }): Promise<ServeHandle>;
50
55
  execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
51
56
  /** Markdown skill catalog for the system prompt. */
package/dist/toolkit.js CHANGED
@@ -8,13 +8,17 @@ export class Toolkit {
8
8
  mcp;
9
9
  skill;
10
10
  a2aConfig;
11
+ mcpServerConfig;
11
12
  byName = new Map();
12
13
  constructor(mcp, skill, builtins, agents, extraTools,
13
14
  /** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
14
- a2aConfig) {
15
+ a2aConfig,
16
+ /** MCP serve profile read from a top-level `mcpServer` config block. */
17
+ mcpServerConfig) {
15
18
  this.mcp = mcp;
16
19
  this.skill = skill;
17
20
  this.a2aConfig = a2aConfig;
21
+ this.mcpServerConfig = mcpServerConfig;
18
22
  // Builtins are the lowest-precedence source: a host extraTools entry with
19
23
  // the same name shadows a builtin (SPEC §4). Drop shadowed builtins up
20
24
  // front, then apply the normal first-wins dedupe for MCP/skill/agents/extras.
@@ -60,7 +64,14 @@ export class Toolkit {
60
64
  if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "a2a" in opts.mcpConfig) {
61
65
  a2aConfig = opts.mcpConfig.a2a;
62
66
  }
63
- return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig);
67
+ // MCP inbound profile: a top-level `mcpServer` (singular) block — distinct
68
+ // from the client-side `mcpServers` block. serve()/serveStdio() prefer their
69
+ // inline `mcp` over this.
70
+ let mcpServerConfig;
71
+ if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "mcpServer" in opts.mcpConfig) {
72
+ mcpServerConfig = opts.mcpConfig.mcpServer;
73
+ }
74
+ return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig, mcpServerConfig);
64
75
  }
65
76
  tools() {
66
77
  return [...this.byName.values()];
@@ -99,6 +110,7 @@ export class Toolkit {
99
110
  */
100
111
  serve(addr, opts) {
101
112
  const a2a = opts.a2a ?? this.a2aConfig;
113
+ const mcp = opts.mcp ?? this.mcpServerConfig;
102
114
  const skills = this.skill ? Object.values(this.skill.skills) : [];
103
115
  return startA2AServer({
104
116
  addr,
@@ -108,6 +120,9 @@ export class Toolkit {
108
120
  ? opts.client.ask(text, { toolkit: this, id: contextId })
109
121
  : opts.client.run(text, { toolkit: this }),
110
122
  onTask: opts.onTask,
123
+ mcp,
124
+ mcpTools: mcp ? this.tools() : undefined,
125
+ onCall: opts.onCall,
111
126
  });
112
127
  }
113
128
  async execute(name, args, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./native.js"
6
6
  export * from "./http.js"
7
7
  export * from "./a2a.js"
8
8
  export * from "./serve.js"
9
+ export * from "./mcpserve.js"
9
10
  export * from "./adapters.js"
10
11
  export * from "./toolkit.js"
11
12
  export * from "./client.js"
package/src/mcp.ts CHANGED
@@ -55,9 +55,9 @@ export function parseMcpConfig(input: string | object): McpConfig {
55
55
  const wrapped = raw.mcpServers ?? raw.servers ?? raw.mcp
56
56
  if (wrapped !== undefined) return wrapped as McpConfig
57
57
  // Bare map of servers — but strip sibling top-level config keys (builtins/
58
- // agents/a2a) so they are not mistaken for MCP servers when no wrapper key
59
- // is present.
60
- const { builtins: _builtins, agents: _agents, a2a: _a2a, ...servers } = raw
58
+ // agents/a2a/mcpServer) so they are not mistaken for MCP servers when no
59
+ // wrapper key is present.
60
+ const { builtins: _builtins, agents: _agents, a2a: _a2a, mcpServer: _mcpServer, ...servers } = raw
61
61
  return servers as McpConfig
62
62
  }
63
63
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * MCP — INBOUND: serve a toolkit as an MCP server (SPEC §7C).
3
+ *
4
+ * The inbound mirror of the A2A `serve` (§7B). Where A2A advertises the toolkit's
5
+ * **skills** and fulfils a Task through the whole client loop, MCP advertises the
6
+ * toolkit's **unified tools** (every source — mcp · skill · native · http ·
7
+ * builtin · a2a) and dispatches each `tools/call` straight to `Tool.execute`. The
8
+ * calling MCP client *is* the LLM host, so there is **no client, no Task, and no
9
+ * TaskStore** — just `tools/list` + `tools/call` over the toolkit registry.
10
+ *
11
+ * Transport: **streamable-HTTP**, mounted at `POST /mcp` on the `serve(addr, …)`
12
+ * server alongside the A2A routes (see serve.ts), built on the same
13
+ * `@modelcontextprotocol/sdk` the client side already uses.
14
+ */
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js"
16
+ import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
17
+ import type { Tool, ToolSource } from "./types.js"
18
+
19
+ const DEFAULT_NAME = "toolnexus"
20
+ const DEFAULT_VERSION = "0.1.0"
21
+
22
+ /** Opt-in MCP serve profile — the toolkit is exposed as an MCP server. */
23
+ export interface MCPServeConfig {
24
+ /** Advertised server name (initialize `serverInfo.name`). Default "toolnexus". */
25
+ name?: string
26
+ /** Advertised server version. Default "0.1.0". */
27
+ version?: string
28
+ /** Subset of tool names to expose; omit ⇒ all. Unknown names are ignored. */
29
+ tools?: string[]
30
+ }
31
+
32
+ /** Surfaced on each inbound `tools/call`. */
33
+ export interface OnCallEvent {
34
+ name: string
35
+ source: ToolSource
36
+ ms: number
37
+ isError: boolean
38
+ }
39
+
40
+ export type OnCall = (ev: OnCallEvent) => void | Promise<void>
41
+
42
+ /**
43
+ * The tools this profile exposes: every toolkit tool, filtered to `cfg.tools`
44
+ * when set (unknown names in the filter are ignored, never an error).
45
+ */
46
+ export function exposedMcpTools(tools: Tool[], cfg?: MCPServeConfig): Tool[] {
47
+ if (!cfg?.tools) return tools
48
+ const wanted = new Set(cfg.tools)
49
+ return tools.filter((t) => wanted.has(t.name))
50
+ }
51
+
52
+ /**
53
+ * Build a low-level MCP `Server` exposing `tools` as MCP tools. `tools/list`
54
+ * maps each Tool's name (verbatim — already sanitized at registration),
55
+ * description, and inputSchema; `tools/call` dispatches to `Tool.execute` and
56
+ * maps the `ToolResult` to a `CallToolResult` (`output` → text, `isError`
57
+ * propagates). A fresh server is cheap to build, so the HTTP path makes one per
58
+ * request (stateless).
59
+ */
60
+ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnCall): Server {
61
+ const byName = new Map(tools.map((t) => [t.name, t]))
62
+ const server = new Server(
63
+ { name: cfg?.name ?? DEFAULT_NAME, version: cfg?.version ?? DEFAULT_VERSION },
64
+ { capabilities: { tools: {} } },
65
+ )
66
+
67
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
68
+ tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
69
+ }))
70
+
71
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
72
+ const tool = byName.get(req.params.name)
73
+ if (!tool) throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`)
74
+ const started = Date.now()
75
+ // The call is a single synchronous tool invocation honoring the MCP
76
+ // request's cancellation. An execute() throw becomes an isError result —
77
+ // never a crash.
78
+ let result
79
+ try {
80
+ result = await tool.execute(req.params.arguments ?? {}, { signal: extra.signal })
81
+ } catch (e) {
82
+ result = { output: e instanceof Error ? e.message : String(e), isError: true }
83
+ }
84
+ if (onCall) {
85
+ try {
86
+ await onCall({ name: tool.name, source: tool.source, ms: Date.now() - started, isError: result.isError })
87
+ } catch {
88
+ // Host callback errors are isolated.
89
+ }
90
+ }
91
+ return { content: [{ type: "text", text: result.output }], isError: result.isError }
92
+ })
93
+
94
+ return server
95
+ }
package/src/serve.ts CHANGED
@@ -18,10 +18,12 @@ import http from "node:http"
18
18
  import fs from "node:fs"
19
19
  import path from "node:path"
20
20
  import { randomUUID } from "node:crypto"
21
- import { sanitize } from "./types.js"
21
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
22
+ import { sanitize, type Tool } from "./types.js"
22
23
  import type { SkillInfo } from "./skill.js"
23
24
  import type { RunResult } from "./client.js"
24
25
  import type { A2AArtifact, A2ATask, A2ATaskState, AgentCard } from "./a2a.js"
26
+ import { buildMcpServer, exposedMcpTools, type MCPServeConfig, type OnCall } from "./mcpserve.js"
25
27
 
26
28
  // ---------------------------------------------------------------------------
27
29
  // TaskStore adapter — pluggable persistence for served Tasks.
@@ -162,6 +164,12 @@ export interface StartServerOptions {
162
164
  * message) keys the conversation so served turns are remembered via the store. */
163
165
  runTask: (text: string, contextId?: string) => Promise<RunResult>
164
166
  onTask?: OnTask
167
+ /** When present, mounts a streamable-HTTP MCP server at `/mcp`; absent ⇒ no MCP route. */
168
+ mcp?: MCPServeConfig
169
+ /** The toolkit's unified tools, exposed by the MCP profile. */
170
+ mcpTools?: Tool[]
171
+ /** Surfaced on each inbound MCP `tools/call`. */
172
+ onCall?: OnCall
165
173
  }
166
174
 
167
175
  /** Extract the concatenated text of a JSON-RPC `SendMessage` message's parts. */
@@ -182,14 +190,23 @@ function readBody(req: http.IncomingMessage): Promise<string> {
182
190
  })
183
191
  }
184
192
 
193
+ function safeJson(body: string): unknown {
194
+ try {
195
+ return JSON.parse(body)
196
+ } catch {
197
+ return undefined
198
+ }
199
+ }
200
+
185
201
  /**
186
202
  * Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
187
203
  * the server answers 404 to everything (a minimal base for now).
188
204
  */
189
205
  export async function startA2AServer(opts: StartServerOptions): Promise<ServeHandle> {
190
- const { a2a, skills, runTask, onTask } = opts
206
+ const { a2a, skills, runTask, onTask, mcp, mcpTools, onCall } = opts
191
207
  const [host, portStr] = splitAddr(opts.addr)
192
208
  const store = a2a ? resolveStore(a2a.store) : undefined
209
+ const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : []
193
210
 
194
211
  const server = http.createServer((req, res) => {
195
212
  const rpcRes = (id: unknown, payload: object) => {
@@ -199,7 +216,38 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
199
216
  const rpcOk = (id: unknown, result: unknown) => rpcRes(id, { result })
200
217
  const rpcErr = (id: unknown, code: number, message: string) => rpcRes(id, { error: { code, message } })
201
218
 
202
- // No A2A profile ⇒ no routes.
219
+ // No profile at all ⇒ no routes.
220
+ if (!store && !mcp) {
221
+ res.writeHead(404)
222
+ res.end("not found")
223
+ return
224
+ }
225
+
226
+ // MCP streamable-HTTP profile (independent of A2A): a fresh server+transport
227
+ // per request (stateless — sessionIdGenerator undefined). Checked first so
228
+ // `/mcp` is never shadowed by the A2A POST handler.
229
+ if (mcp && req.url && (req.url === "/mcp" || req.url.startsWith("/mcp?"))) {
230
+ void (async () => {
231
+ const body = req.method === "POST" ? await readBody(req) : ""
232
+ const parsed = body ? safeJson(body) : undefined
233
+ const mcpServer = buildMcpServer(exposedTools, mcp, onCall)
234
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
235
+ res.on("close", () => {
236
+ void transport.close()
237
+ void mcpServer.close()
238
+ })
239
+ await mcpServer.connect(transport)
240
+ await transport.handleRequest(req, res, parsed)
241
+ })().catch(() => {
242
+ if (!res.headersSent) {
243
+ res.writeHead(500)
244
+ res.end("mcp error")
245
+ }
246
+ })
247
+ return
248
+ }
249
+
250
+ // No A2A profile ⇒ no A2A routes (an MCP-only server 404s everything else).
203
251
  if (!a2a || !store) {
204
252
  res.writeHead(404)
205
253
  res.end("not found")
package/src/toolkit.ts CHANGED
@@ -8,6 +8,7 @@ import { loadSkills, type SkillSource } from "./skill.js"
8
8
  import { selectBuiltins, type BuiltinsConfig } from "./builtin.js"
9
9
  import { agentTools, parseAgentsConfig, type Agent } from "./a2a.js"
10
10
  import { startA2AServer, type A2AConfig, type OnTask, type ServeHandle } from "./serve.js"
11
+ import { type MCPServeConfig, type OnCall } from "./mcpserve.js"
11
12
  import { toAnthropic, toGemini, toOpenAI } from "./adapters.js"
12
13
  import type { Client } from "./client.js"
13
14
 
@@ -31,6 +32,8 @@ export class Toolkit {
31
32
  extraTools: Tool[],
32
33
  /** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
33
34
  private readonly a2aConfig: A2AConfig | undefined,
35
+ /** MCP serve profile read from a top-level `mcpServer` config block. */
36
+ private readonly mcpServerConfig: MCPServeConfig | undefined,
34
37
  ) {
35
38
  // Builtins are the lowest-precedence source: a host extraTools entry with
36
39
  // the same name shadows a builtin (SPEC §4). Drop shadowed builtins up
@@ -87,7 +90,15 @@ export class Toolkit {
87
90
  a2aConfig = (opts.mcpConfig as { a2a?: A2AConfig }).a2a
88
91
  }
89
92
 
90
- return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig)
93
+ // MCP inbound profile: a top-level `mcpServer` (singular) block — distinct
94
+ // from the client-side `mcpServers` block. serve()/serveStdio() prefer their
95
+ // inline `mcp` over this.
96
+ let mcpServerConfig: MCPServeConfig | undefined
97
+ if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "mcpServer" in opts.mcpConfig) {
98
+ mcpServerConfig = (opts.mcpConfig as { mcpServer?: MCPServeConfig }).mcpServer
99
+ }
100
+
101
+ return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig, mcpServerConfig)
91
102
  }
92
103
 
93
104
  tools(): Tool[] {
@@ -129,8 +140,12 @@ export class Toolkit {
129
140
  * peer's turns are remembered through the client's ConversationStore.
130
141
  * When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
131
142
  */
132
- serve(addr: string, opts: { client: Client; a2a?: A2AConfig; onTask?: OnTask }): Promise<ServeHandle> {
143
+ serve(
144
+ addr: string,
145
+ opts: { client?: Client; a2a?: A2AConfig; onTask?: OnTask; mcp?: MCPServeConfig; onCall?: OnCall },
146
+ ): Promise<ServeHandle> {
133
147
  const a2a = opts.a2a ?? this.a2aConfig
148
+ const mcp = opts.mcp ?? this.mcpServerConfig
134
149
  const skills = this.skill ? Object.values(this.skill.skills) : []
135
150
  return startA2AServer({
136
151
  addr,
@@ -138,9 +153,12 @@ export class Toolkit {
138
153
  skills,
139
154
  runTask: (text, contextId) =>
140
155
  contextId
141
- ? opts.client.ask(text, { toolkit: this, id: contextId })
142
- : opts.client.run(text, { toolkit: this }),
156
+ ? opts.client!.ask(text, { toolkit: this, id: contextId })
157
+ : opts.client!.run(text, { toolkit: this }),
143
158
  onTask: opts.onTask,
159
+ mcp,
160
+ mcpTools: mcp ? this.tools() : undefined,
161
+ onCall: opts.onCall,
144
162
  })
145
163
  }
146
164