toolnexus 0.3.1 → 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 +86 -4
- package/dist/client.d.ts +49 -1
- package/dist/client.js +410 -206
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +3 -3
- package/dist/mcpserve.d.ts +47 -0
- package/dist/mcpserve.js +69 -0
- package/dist/serve.d.ts +8 -0
- package/dist/serve.js +42 -2
- package/dist/toolkit.d.ts +6 -1
- package/dist/toolkit.js +17 -2
- package/package.json +1 -1
- package/src/client.ts +401 -177
- package/src/index.ts +1 -0
- package/src/mcp.ts +3 -3
- package/src/mcpserve.ts +95 -0
- package/src/serve.ts +51 -3
- package/src/toolkit.ts +22 -4
package/src/index.ts
CHANGED
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
|
|
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
|
|
package/src/mcpserve.ts
ADDED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
142
|
-
: opts.client
|
|
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
|
|