toolnexus 0.24.0 → 0.25.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 +116 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/client.d.ts +32 -8
- package/dist/client.js +102 -23
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp.d.ts +32 -7
- package/dist/mcp.js +125 -36
- package/dist/mcp2026/auth.d.ts +137 -0
- package/dist/mcp2026/auth.js +446 -0
- package/dist/mcp2026/client.d.ts +48 -0
- package/dist/mcp2026/client.js +160 -0
- package/dist/mcp2026/protocol.d.ts +54 -0
- package/dist/mcp2026/protocol.js +113 -0
- package/dist/mcp2026/skills.d.ts +5 -0
- package/dist/mcp2026/skills.js +25 -0
- package/dist/mcp2026/source.d.ts +86 -0
- package/dist/mcp2026/source.js +372 -0
- package/dist/mcp2026/stdio.d.ts +24 -0
- package/dist/mcp2026/stdio.js +99 -0
- package/dist/mcp2026/toolsearch.d.ts +5 -0
- package/dist/mcp2026/toolsearch.js +58 -0
- package/dist/mcphandler.d.ts +169 -0
- package/dist/mcphandler.js +1145 -0
- package/dist/mcpheader.d.ts +5 -0
- package/dist/mcpheader.js +18 -0
- package/dist/mcpserve.d.ts +24 -15
- package/dist/mcpserve.js +105 -2
- package/dist/mcpserverauth.d.ts +48 -0
- package/dist/mcpserverauth.js +203 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolkit.d.ts +20 -1
- package/dist/toolkit.js +25 -3
- package/dist/types.d.ts +22 -0
- package/package.json +4 -3
- package/src/acp.ts +258 -55
- package/src/client.ts +124 -27
- package/src/index.ts +2 -0
- package/src/mcp.ts +162 -43
- package/src/mcp2026/auth.ts +473 -0
- package/src/mcp2026/client.ts +161 -0
- package/src/mcp2026/protocol.ts +135 -0
- package/src/mcp2026/skills.ts +22 -0
- package/src/mcp2026/source.ts +407 -0
- package/src/mcp2026/stdio.ts +102 -0
- package/src/mcp2026/toolsearch.ts +56 -0
- package/src/mcphandler.ts +1252 -0
- package/src/mcpheader.ts +19 -0
- package/src/mcpserve.ts +115 -2
- package/src/mcpserverauth.ts +216 -0
- package/src/skill.ts +50 -0
- package/src/toolkit.ts +31 -4
- package/src/types.ts +23 -0
package/src/client.ts
CHANGED
|
@@ -388,8 +388,12 @@ export interface RunResult {
|
|
|
388
388
|
/** Events yielded by client.stream(). */
|
|
389
389
|
export type StreamEvent =
|
|
390
390
|
| { type: "text"; delta: string }
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
/** Model reasoning narration (e.g. an ACP agent's `agent_thought_chunk`). Never part of
|
|
392
|
+
* the answer text or the RunResult. */
|
|
393
|
+
| { type: "thought"; delta: string; via?: "acp" }
|
|
394
|
+
/** `via: "acp"` marks a tool the ACP agent ran itself: reported, never executed by the loop. */
|
|
395
|
+
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown>; via?: "call_tool" | "acp" }
|
|
396
|
+
| { type: "tool_result"; id: string; name: string; output: string; isError: boolean; metadata?: Record<string, unknown>; via?: "call_tool" | "acp" }
|
|
393
397
|
| { type: "usage"; usage: Usage }
|
|
394
398
|
| { type: "pending"; request: Request }
|
|
395
399
|
| { type: "done"; result: RunResult }
|
|
@@ -1407,6 +1411,13 @@ export class Client {
|
|
|
1407
1411
|
const payload = line.slice(5).trim()
|
|
1408
1412
|
if (payload === "[DONE]") break
|
|
1409
1413
|
const j = safeParse(payload)
|
|
1414
|
+
// A non-text event from an in-process streaming generate rides in-band so it keeps
|
|
1415
|
+
// its order and arrives as it happens; it goes straight to the consumer and is never
|
|
1416
|
+
// executed. Honoured only on the in-process sentinel URL, never from a real server.
|
|
1417
|
+
if (j && j[IN_PROCESS_SIDE_KEY] && this.opts.baseUrl === IN_PROCESS_BASE_URL) {
|
|
1418
|
+
yield j[IN_PROCESS_SIDE_KEY] as StreamEvent
|
|
1419
|
+
continue
|
|
1420
|
+
}
|
|
1410
1421
|
if (j?.usage) addUsage(usage, j.usage, "openai")
|
|
1411
1422
|
const choice = j?.choices?.[0]
|
|
1412
1423
|
const d = choice?.delta
|
|
@@ -1611,11 +1622,27 @@ export interface InProcessResponse {
|
|
|
1611
1622
|
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }
|
|
1612
1623
|
}
|
|
1613
1624
|
|
|
1625
|
+
/** A streaming in-process model: `emit` each event as it happens — `text` deltas become
|
|
1626
|
+
* the streamed answer as they arrive; any other event (thought, an agent's own tool_call /
|
|
1627
|
+
* tool_result) goes straight to the stream consumer and is never executed by the loop.
|
|
1628
|
+
* Return the whole message when done (its `toolCalls`, if any, are what the loop runs). */
|
|
1629
|
+
export type InProcessGenerateStream = (
|
|
1630
|
+
request: InProcessRequest,
|
|
1631
|
+
emit: (event: StreamEvent) => void,
|
|
1632
|
+
) => InProcessResponse | Promise<InProcessResponse>
|
|
1633
|
+
|
|
1634
|
+
type InProcessGenerate = (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
|
|
1635
|
+
|
|
1614
1636
|
/** Options for {@link createInProcessClient}: every client option EXCEPT the three
|
|
1615
|
-
* that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate
|
|
1637
|
+
* that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate` and/or
|
|
1638
|
+
* `generateStream` (at least one). */
|
|
1616
1639
|
export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
|
|
1617
1640
|
/** Your model. Receives the assembled request, returns one assistant message. */
|
|
1618
|
-
generate
|
|
1641
|
+
generate?: InProcessGenerate
|
|
1642
|
+
/** Your model, streaming for real. With it, `stream()` is incremental; without it a
|
|
1643
|
+
* `stream()` on a plain `generate` is refused. When both are set, `generate` serves
|
|
1644
|
+
* non-streaming calls; alone, it serves them too (with a no-op `emit`). */
|
|
1645
|
+
generateStream?: InProcessGenerateStream
|
|
1619
1646
|
}
|
|
1620
1647
|
|
|
1621
1648
|
/** The sentinel base URL. Never dialled — the transport below answers every request
|
|
@@ -1624,6 +1651,10 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
|
|
|
1624
1651
|
* name can never resolve. */
|
|
1625
1652
|
const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
|
|
1626
1653
|
|
|
1654
|
+
/** The in-band SSE key that carries a non-text stream event from an in-process
|
|
1655
|
+
* streaming generate to the consumer (honoured only on {@link IN_PROCESS_BASE_URL}). */
|
|
1656
|
+
const IN_PROCESS_SIDE_KEY = "toolnexus_event"
|
|
1657
|
+
|
|
1627
1658
|
/**
|
|
1628
1659
|
* Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
|
|
1629
1660
|
* the reusable piece of {@link createInProcessClient} — assembles the wire request,
|
|
@@ -1632,15 +1663,20 @@ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
|
|
|
1632
1663
|
* or the agent runtime's `inProcess` option) goes through this ONE function, so there
|
|
1633
1664
|
* is no second copy of the request/response assembly to drift.
|
|
1634
1665
|
*
|
|
1635
|
-
* Streaming is refused rather than faked — see the thrown
|
|
1666
|
+
* Streaming with only a plain `generate` is refused rather than faked — see the thrown
|
|
1667
|
+
* error. With `generateStream` it streams for real: each text event is written to the
|
|
1668
|
+
* SSE body the moment it is emitted.
|
|
1636
1669
|
*/
|
|
1637
|
-
export function createInProcessFetch(generate
|
|
1638
|
-
if (typeof generate !== "function") {
|
|
1639
|
-
throw new Error("toolnexus: createInProcessFetch requires a `generate` function")
|
|
1670
|
+
export function createInProcessFetch(generate?: InProcessGenerate, generateStream?: InProcessGenerateStream): typeof fetch {
|
|
1671
|
+
if (typeof generate !== "function" && typeof generateStream !== "function") {
|
|
1672
|
+
throw new Error("toolnexus: createInProcessFetch requires a `generate` (or `generateStream`) function")
|
|
1640
1673
|
}
|
|
1641
1674
|
|
|
1642
1675
|
const inProcessFetch: typeof fetch = async (_url, init) => {
|
|
1643
1676
|
const body = JSON.parse(String((init as any)?.body ?? "{}"))
|
|
1677
|
+
const request: InProcessRequest = { messages: body.messages ?? [], tools: body.tools, model: body.model, body }
|
|
1678
|
+
|
|
1679
|
+
if (body.stream && generateStream) return inProcessStream(generateStream, request)
|
|
1644
1680
|
|
|
1645
1681
|
// A generate() returns a whole answer, so a stream would be one chunk pretending
|
|
1646
1682
|
// to be many. Content and delta-count assertions cannot tell that from a real
|
|
@@ -1652,25 +1688,11 @@ export function createInProcessFetch(generate: (request: InProcessRequest) => In
|
|
|
1652
1688
|
)
|
|
1653
1689
|
}
|
|
1654
1690
|
|
|
1655
|
-
const answer = await generate({
|
|
1656
|
-
messages: body.messages ?? [],
|
|
1657
|
-
tools: body.tools,
|
|
1658
|
-
model: body.model,
|
|
1659
|
-
body,
|
|
1660
|
-
})
|
|
1691
|
+
const answer = generate ? await generate(request) : await generateStream!(request, () => {})
|
|
1661
1692
|
|
|
1662
1693
|
const message: Record<string, any> = { role: "assistant" }
|
|
1663
1694
|
if (answer.toolCalls && answer.toolCalls.length > 0) {
|
|
1664
|
-
message.tool_calls = answer.toolCalls
|
|
1665
|
-
id: c.id ?? `call_${i}`,
|
|
1666
|
-
type: "function",
|
|
1667
|
-
function: {
|
|
1668
|
-
name: c.name,
|
|
1669
|
-
// A string is already encoded; anything else is encoded here so a model
|
|
1670
|
-
// author never has to think about the wire.
|
|
1671
|
-
arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
|
|
1672
|
-
},
|
|
1673
|
-
}))
|
|
1695
|
+
message.tool_calls = wireToolCalls(answer.toolCalls, false)
|
|
1674
1696
|
} else {
|
|
1675
1697
|
message.content = answer.content ?? ""
|
|
1676
1698
|
}
|
|
@@ -1694,6 +1716,77 @@ export function createInProcessFetch(generate: (request: InProcessRequest) => In
|
|
|
1694
1716
|
return inProcessFetch
|
|
1695
1717
|
}
|
|
1696
1718
|
|
|
1719
|
+
function wireToolCalls(calls: InProcessToolCall[], indexed: boolean): any[] {
|
|
1720
|
+
return calls.map((c, i) => ({
|
|
1721
|
+
...(indexed ? { index: i } : {}),
|
|
1722
|
+
id: c.id ?? `call_${i}`,
|
|
1723
|
+
type: "function",
|
|
1724
|
+
function: {
|
|
1725
|
+
name: c.name,
|
|
1726
|
+
// A string is already encoded; anything else is encoded here so a model
|
|
1727
|
+
// author never has to think about the wire.
|
|
1728
|
+
arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
|
|
1729
|
+
},
|
|
1730
|
+
}))
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/** Runs a streaming generate and answers with an OpenAI SSE body written AS EVENTS ARRIVE:
|
|
1734
|
+
* text → a content delta; any other event → an in-band side chunk for the consumer; then
|
|
1735
|
+
* (tool calls as one delta | the content as one delta when no text was emitted), the
|
|
1736
|
+
* finish_reason chunk, usage, and [DONE]. */
|
|
1737
|
+
function inProcessStream(generateStream: InProcessGenerateStream, request: InProcessRequest): Response {
|
|
1738
|
+
const enc = new TextEncoder()
|
|
1739
|
+
let open = true
|
|
1740
|
+
const sse = new ReadableStream<Uint8Array>({
|
|
1741
|
+
start(controller) {
|
|
1742
|
+
const chunk = (v: unknown) => {
|
|
1743
|
+
if (open) controller.enqueue(enc.encode(`data: ${JSON.stringify(v)}\n\n`))
|
|
1744
|
+
}
|
|
1745
|
+
let sawText = false
|
|
1746
|
+
void (async () => {
|
|
1747
|
+
try {
|
|
1748
|
+
const answer = await generateStream(request, (ev) => {
|
|
1749
|
+
if (!ev || typeof ev !== "object") return
|
|
1750
|
+
if (ev.type === "text") {
|
|
1751
|
+
if (ev.delta) {
|
|
1752
|
+
sawText = true
|
|
1753
|
+
chunk({ choices: [{ index: 0, delta: { content: ev.delta } }] })
|
|
1754
|
+
}
|
|
1755
|
+
return
|
|
1756
|
+
}
|
|
1757
|
+
chunk({ [IN_PROCESS_SIDE_KEY]: ev })
|
|
1758
|
+
})
|
|
1759
|
+
let finish = "stop"
|
|
1760
|
+
if (answer.toolCalls && answer.toolCalls.length > 0) {
|
|
1761
|
+
chunk({ choices: [{ index: 0, delta: { tool_calls: wireToolCalls(answer.toolCalls, true) } }] })
|
|
1762
|
+
finish = "tool_calls"
|
|
1763
|
+
} else if (!sawText && answer.content) {
|
|
1764
|
+
chunk({ choices: [{ index: 0, delta: { content: answer.content } }] })
|
|
1765
|
+
}
|
|
1766
|
+
chunk({ choices: [{ index: 0, delta: {}, finish_reason: finish }] })
|
|
1767
|
+
const u = answer.usage
|
|
1768
|
+
if (u) {
|
|
1769
|
+
const p = u.promptTokens ?? 0, c = u.completionTokens ?? 0
|
|
1770
|
+
chunk({ choices: [], usage: { prompt_tokens: p, completion_tokens: c, total_tokens: u.totalTokens || p + c } })
|
|
1771
|
+
}
|
|
1772
|
+
if (open) controller.enqueue(enc.encode("data: [DONE]\n\n"))
|
|
1773
|
+
open = false
|
|
1774
|
+
controller.close()
|
|
1775
|
+
} catch (e) {
|
|
1776
|
+
if (open) {
|
|
1777
|
+
open = false
|
|
1778
|
+
controller.error(e)
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
})()
|
|
1782
|
+
},
|
|
1783
|
+
cancel() {
|
|
1784
|
+
open = false
|
|
1785
|
+
},
|
|
1786
|
+
})
|
|
1787
|
+
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } })
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1697
1790
|
/**
|
|
1698
1791
|
* A client backed by a model running IN THIS PROCESS — no server, no socket, and no
|
|
1699
1792
|
* HTTP types to construct.
|
|
@@ -1704,11 +1797,15 @@ export function createInProcessFetch(generate: (request: InProcessRequest) => In
|
|
|
1704
1797
|
* The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
|
|
1705
1798
|
* is just that adapter wired into a `Client`.
|
|
1706
1799
|
*
|
|
1707
|
-
* Streaming is refused rather than faked
|
|
1800
|
+
* Streaming is refused rather than faked for a plain `generate`; with `generateStream`
|
|
1801
|
+
* it streams for real — see {@link createInProcessFetch}.
|
|
1708
1802
|
*/
|
|
1709
1803
|
export function createInProcessClient(opts: InProcessOptions): Client {
|
|
1710
|
-
const { generate, ...rest } = opts
|
|
1711
|
-
|
|
1804
|
+
const { generate, generateStream, ...rest } = opts
|
|
1805
|
+
if (typeof generate !== "function" && typeof generateStream !== "function") {
|
|
1806
|
+
throw new Error("toolnexus: createInProcessClient requires a `generate` (or `generateStream`) function")
|
|
1807
|
+
}
|
|
1808
|
+
const inProcessFetch = createInProcessFetch(generate, generateStream)
|
|
1712
1809
|
|
|
1713
1810
|
return new Client({
|
|
1714
1811
|
// Retries default to ZERO here, unlike a network client. There is no wire, so
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ export * from "./a2a.js"
|
|
|
10
10
|
export * from "./acp.js"
|
|
11
11
|
export * from "./serve.js"
|
|
12
12
|
export * from "./mcpserve.js"
|
|
13
|
+
export * from "./mcphandler.js"
|
|
14
|
+
export * from "./mcpserverauth.js"
|
|
13
15
|
export * from "./adapters.js"
|
|
14
16
|
export * from "./translate.js"
|
|
15
17
|
export * from "./toolkit.js"
|
package/src/mcp.ts
CHANGED
|
@@ -12,6 +12,11 @@ import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcont
|
|
|
12
12
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
|
13
13
|
import { type JSONSchema, type Tool, type ToolContext, type ToolHints, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
|
|
14
14
|
import { type ContentPart, summarizePart } from "./content.js"
|
|
15
|
+
import type { SkillDef } from "./skill.js"
|
|
16
|
+
import { probe, newOAuth, defaultTokenStore, type AuthConfig, type ModernServer, type Load2026Options } from "./mcp2026/source.js"
|
|
17
|
+
import { PendingError, type OAuth, type TokenStore } from "./mcp2026/auth.js"
|
|
18
|
+
export { Token, MemoryTokenStore, type TokenStore } from "./mcp2026/auth.js"
|
|
19
|
+
export type { AuthConfig } from "./mcp2026/source.js"
|
|
15
20
|
|
|
16
21
|
// ---------------------------------------------------------------------------
|
|
17
22
|
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
@@ -56,6 +61,10 @@ export interface LocalServer {
|
|
|
56
61
|
* builtins/skills filter (nil/empty ⇒ all; ≥1 true ⇒ allowlist; only-false ⇒ drop-list; unknown
|
|
57
62
|
* ⇒ ignore+warn). Applied to the listed defs before sanitize/prefix. */
|
|
58
63
|
tools?: Record<string, boolean>
|
|
64
|
+
/** §2.8 lazy tool search: expose `<server>_search_tools` + `<server>_call_tool` instead of every tool. */
|
|
65
|
+
toolSearch?: boolean
|
|
66
|
+
/** §2.8 what this server is for (shown by search_tools). */
|
|
67
|
+
description?: string
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
export interface RemoteServer {
|
|
@@ -67,6 +76,11 @@ export interface RemoteServer {
|
|
|
67
76
|
timeout?: number
|
|
68
77
|
/** §2 Gap 7. Per-server tool allowlist (see LocalServer.tools). */
|
|
69
78
|
tools?: Record<string, boolean>
|
|
79
|
+
/** §2.8 lazy tool search (see LocalServer.toolSearch). */
|
|
80
|
+
toolSearch?: boolean
|
|
81
|
+
description?: string
|
|
82
|
+
/** §2A OAuth 2.1 client settings. Absent ⇒ a 401 still triggers the flow (CIMD if the AS supports it). */
|
|
83
|
+
auth?: AuthConfig
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
export type ServerConfig = LocalServer | RemoteServer
|
|
@@ -254,6 +268,23 @@ async function paginateTools(client: Client, timeout: number): Promise<McpToolDe
|
|
|
254
268
|
throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`)
|
|
255
269
|
}
|
|
256
270
|
|
|
271
|
+
/** §2 `tools/call` result → ToolResult (shared by the 2026 path and the frozen fallback). */
|
|
272
|
+
export function mapCallToolResult(result: any, server: string): ToolResult {
|
|
273
|
+
// Parts are collected BEFORE any branch returns: the isError and structuredContent
|
|
274
|
+
// short-circuits both read `content[]` on the way past, so neither reintroduces the
|
|
275
|
+
// silent drop this mapping exists to remove (§2).
|
|
276
|
+
const parts = mcpContentToParts(result.content)
|
|
277
|
+
const withParts = (r: ToolResult): ToolResult => (parts ? { ...r, parts } : r)
|
|
278
|
+
if (result.isError) {
|
|
279
|
+
return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } })
|
|
280
|
+
}
|
|
281
|
+
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
|
282
|
+
return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } })
|
|
283
|
+
}
|
|
284
|
+
const text = joinTextContent(result.content)
|
|
285
|
+
return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } })
|
|
286
|
+
}
|
|
287
|
+
|
|
257
288
|
function convertTool(server: string, def: McpToolDef, client: Client, timeout: number): Tool {
|
|
258
289
|
const inputSchema: JSONSchema = {
|
|
259
290
|
...(def.inputSchema as object),
|
|
@@ -277,19 +308,7 @@ function convertTool(server: string, def: McpToolDef, client: Client, timeout: n
|
|
|
277
308
|
// token that matches no in-flight request are dropped by the SDK, so they never reset.
|
|
278
309
|
{ onprogress: () => {}, resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
|
|
279
310
|
)
|
|
280
|
-
|
|
281
|
-
// short-circuits both read `content[]` on the way past, so neither reintroduces the
|
|
282
|
-
// silent drop this mapping exists to remove (§2).
|
|
283
|
-
const parts = mcpContentToParts(result.content)
|
|
284
|
-
const withParts = (r: ToolResult): ToolResult => (parts ? { ...r, parts } : r)
|
|
285
|
-
if (result.isError) {
|
|
286
|
-
return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } })
|
|
287
|
-
}
|
|
288
|
-
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
|
289
|
-
return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } })
|
|
290
|
-
}
|
|
291
|
-
const text = joinTextContent(result.content)
|
|
292
|
-
return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } })
|
|
311
|
+
return mapCallToolResult(result, server)
|
|
293
312
|
} catch (e) {
|
|
294
313
|
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } }
|
|
295
314
|
}
|
|
@@ -307,6 +326,8 @@ export interface McpSource {
|
|
|
307
326
|
* source is closed.
|
|
308
327
|
*/
|
|
309
328
|
resourceTools(): Tool[]
|
|
329
|
+
/** §2.7 skills served by 2026 servers (skills extension); the toolkit adds them to the `skill` tool. */
|
|
330
|
+
skills: SkillDef[]
|
|
310
331
|
}
|
|
311
332
|
|
|
312
333
|
/** One server's outcome, reported by `loadMcp`'s `onServer` the moment that server finishes. */
|
|
@@ -331,8 +352,19 @@ export function mcpHints(a: { readOnlyHint?: boolean; destructiveHint?: boolean;
|
|
|
331
352
|
}
|
|
332
353
|
|
|
333
354
|
interface ServerConn {
|
|
334
|
-
|
|
335
|
-
|
|
355
|
+
listResources(signal?: AbortSignal): Promise<any[]>
|
|
356
|
+
readResource(uri: string, signal?: AbortSignal): Promise<any[]>
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sdkConn(client: Client, timeout: number): ServerConn {
|
|
360
|
+
return {
|
|
361
|
+
listResources: async (signal) => ((await client.listResources({}, { timeout, signal })) as any).resources ?? [],
|
|
362
|
+
readResource: async (uri, signal) => ((await client.readResource({ uri }, { timeout, signal })) as any).contents ?? [],
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function modernConn(s: ModernServer): ServerConn {
|
|
367
|
+
return { listResources: (signal) => s.listResources(signal), readResource: (uri, signal) => s.readResource(uri, signal) }
|
|
336
368
|
}
|
|
337
369
|
|
|
338
370
|
function resourceTools(servers: Map<string, ServerConn>): Tool[] {
|
|
@@ -369,8 +401,7 @@ function resourceTools(servers: Map<string, ServerConn>): Tool[] {
|
|
|
369
401
|
for (const name of targets) {
|
|
370
402
|
const c = conn(name)
|
|
371
403
|
try {
|
|
372
|
-
const
|
|
373
|
-
for (const r of res.resources ?? []) {
|
|
404
|
+
for (const r of await c.listResources(ctx?.signal)) {
|
|
374
405
|
let line = `${name} ${r.uri}`
|
|
375
406
|
if (r.name) line += ` — ${r.name}`
|
|
376
407
|
if (r.mimeType) line += ` (${r.mimeType})`
|
|
@@ -411,9 +442,8 @@ function resourceTools(servers: Map<string, ServerConn>): Tool[] {
|
|
|
411
442
|
}
|
|
412
443
|
if (!uri) return { output: "read_mcp_resource: uri is required", isError: true }
|
|
413
444
|
try {
|
|
414
|
-
const res: any = await c.client.readResource({ uri }, { timeout: c.timeout, signal: ctx?.signal })
|
|
415
445
|
const parts: string[] = []
|
|
416
|
-
for (const rc of
|
|
446
|
+
for (const rc of await c.readResource(uri, ctx?.signal)) {
|
|
417
447
|
if (typeof rc.text === "string") parts.push(rc.text)
|
|
418
448
|
else if (typeof rc.blob === "string") parts.push(`[binary ${rc.mimeType ?? ""}, ${rc.blob.length} base64 chars]`)
|
|
419
449
|
}
|
|
@@ -441,15 +471,16 @@ function newMcpClient(waitFor?: (request: Request) => Promise<Answer>): Client {
|
|
|
441
471
|
}
|
|
442
472
|
|
|
443
473
|
/** Connect an existing client's transport (remote streamable-HTTP with SSE fallback, or local stdio). */
|
|
444
|
-
async function connectTransport(client: Client, cfg: ServerConfig): Promise<void> {
|
|
474
|
+
async function connectTransport(client: Client, cfg: ServerConfig, auth?: FallbackAuth): Promise<void> {
|
|
445
475
|
if (isRemote(cfg)) {
|
|
446
476
|
const url = new URL(cfg.url)
|
|
447
477
|
const expanded = expandEnvHeaders(cfg.headers)
|
|
448
478
|
const requestInit = expanded ? { headers: expanded } : undefined
|
|
479
|
+
const fetch = auth ? bearerFetch(cfg.url, auth) : undefined
|
|
449
480
|
try {
|
|
450
|
-
await client.connect(new StreamableHTTPClientTransport(url, { requestInit }))
|
|
481
|
+
await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }))
|
|
451
482
|
} catch {
|
|
452
|
-
await client.connect(new SSEClientTransport(url, { requestInit }))
|
|
483
|
+
await client.connect(new SSEClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }))
|
|
453
484
|
}
|
|
454
485
|
} else {
|
|
455
486
|
const local = cfg as LocalServer
|
|
@@ -466,14 +497,90 @@ async function connectTransport(client: Client, cfg: ServerConfig): Promise<void
|
|
|
466
497
|
}
|
|
467
498
|
}
|
|
468
499
|
|
|
500
|
+
interface FallbackAuth {
|
|
501
|
+
oauth: OAuth
|
|
502
|
+
waitFor?: (request: Request) => Promise<Answer>
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* §2A.1a frozen fallback + OAuth: the SDK transport's fetch attaches the current bearer token
|
|
507
|
+
* (refreshing as needed); a 401/403 challenge runs the same OAuth flow once, then the request is
|
|
508
|
+
* re-sent. A pause is answered through waitFor, else the request fails ("authorization pending").
|
|
509
|
+
*/
|
|
510
|
+
function bearerFetch(resource: string, a: FallbackAuth): typeof globalThis.fetch {
|
|
511
|
+
return async (input: any, init?: any) => {
|
|
512
|
+
for (let attempt = 0; ; attempt++) {
|
|
513
|
+
const headers = new Headers(init?.headers)
|
|
514
|
+
const tok = await a.oauth.bearer(resource)
|
|
515
|
+
if (tok) headers.set("Authorization", "Bearer " + tok)
|
|
516
|
+
const res = await globalThis.fetch(input, { ...init, headers })
|
|
517
|
+
const www = res.headers.get("www-authenticate") ?? ""
|
|
518
|
+
if (attempt > 0 || (res.status !== 401 && res.status !== 403) || !www) return res
|
|
519
|
+
try {
|
|
520
|
+
await a.oauth.challenge(resource, res.status, www, tok, async () => ({}))
|
|
521
|
+
} catch (e) {
|
|
522
|
+
if (!(e instanceof PendingError)) throw e
|
|
523
|
+
if (!a.waitFor) throw new Error("authorization pending")
|
|
524
|
+
const ans = await a.waitFor({ ...e.pending })
|
|
525
|
+
if (!ans?.ok) throw new Error(`authorization denied: ${ans?.reason ?? "cancelled"}`)
|
|
526
|
+
const cb = ans.data?.callbackUrl
|
|
527
|
+
if (typeof cb !== "string") throw new Error("authorization answer carries no data.callbackUrl")
|
|
528
|
+
await a.oauth.resume(e.pending.id, cb)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Options shared by loadMcp / listMcpTools for the 2026 path and OAuth (§2A.1a). */
|
|
535
|
+
export interface McpLoadOptions {
|
|
536
|
+
waitFor?: (request: Request) => Promise<Answer>
|
|
537
|
+
signal?: AbortSignal
|
|
538
|
+
/** Where OAuth tokens live (default: in memory, per load). */
|
|
539
|
+
tokenStore?: TokenStore
|
|
540
|
+
/** How `auto` OAuth mode opens the authorization URL (default: the OS opener). */
|
|
541
|
+
openUrl?: (url: string) => unknown | Promise<unknown>
|
|
542
|
+
/** Final exposed names to drop; on a tool-search server they are also hidden from search/call. */
|
|
543
|
+
disableTools?: string[]
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
type Connected =
|
|
547
|
+
| { kind: "modern"; server: ModernServer }
|
|
548
|
+
| { kind: "sdk"; client: Client; defs: McpToolDef[] }
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Discover first (§2.2): a 2026 server is used directly; an old one (§2.2a) gets the frozen SDK
|
|
552
|
+
* path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
|
|
553
|
+
*/
|
|
554
|
+
async function connectServer(name: string, cfg: ServerConfig, timeout: number, opts: McpLoadOptions, store: TokenStore, sdkClient: () => Client, onSdk: (c: Client) => void): Promise<Connected> {
|
|
555
|
+
const o: Load2026Options = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools }
|
|
556
|
+
const remote = isRemote(cfg)
|
|
557
|
+
const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined
|
|
558
|
+
const p = await probe(
|
|
559
|
+
{
|
|
560
|
+
name, timeout, tools: cfg.tools, toolSearch: cfg.toolSearch === true, description: cfg.description,
|
|
561
|
+
remote: remote ? { url: cfg.url, headers: expandEnvHeaders(cfg.headers), oauth: oauth! } : undefined,
|
|
562
|
+
local: remote ? undefined : {
|
|
563
|
+
argv: (cfg as LocalServer).command,
|
|
564
|
+
env: { ...process.env, ...((cfg as LocalServer).environment ?? (cfg as LocalServer).env ?? {}) },
|
|
565
|
+
cwd: (cfg as LocalServer).cwd,
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
o,
|
|
569
|
+
)
|
|
570
|
+
if (p.kind === "modern") return p
|
|
571
|
+
const client = sdkClient()
|
|
572
|
+
onSdk(client)
|
|
573
|
+
await raceTimeout(connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined), timeout, opts.signal)
|
|
574
|
+
const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal)
|
|
575
|
+
return { kind: "sdk", client, defs }
|
|
576
|
+
}
|
|
577
|
+
|
|
469
578
|
/** Connect to every enabled server, list + convert tools. Failures are isolated.
|
|
470
579
|
* §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
|
|
471
580
|
* whole load promptly on parent cancellation. */
|
|
472
581
|
export async function loadMcp(
|
|
473
582
|
input: string | object,
|
|
474
|
-
opts?: {
|
|
475
|
-
waitFor?: (request: Request) => Promise<Answer>
|
|
476
|
-
signal?: AbortSignal
|
|
583
|
+
opts?: McpLoadOptions & {
|
|
477
584
|
/**
|
|
478
585
|
* Called once per server the moment it is connected, failed or found disabled (SPEC §4C),
|
|
479
586
|
* so a host can register a fast server's tools while a slow one is still connecting.
|
|
@@ -486,8 +593,10 @@ export async function loadMcp(
|
|
|
486
593
|
const config = parseMcpConfig(input)
|
|
487
594
|
const tools: Tool[] = []
|
|
488
595
|
const status: Record<string, McpStatus> = {}
|
|
489
|
-
const clients:
|
|
596
|
+
const clients: { close(): Promise<void> }[] = []
|
|
490
597
|
const servers = new Map<string, ServerConn>()
|
|
598
|
+
const skills: SkillDef[] = []
|
|
599
|
+
const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
|
|
491
600
|
const waitFor = opts?.waitFor
|
|
492
601
|
const signal = opts?.signal
|
|
493
602
|
const onServer = opts?.onServer
|
|
@@ -512,22 +621,29 @@ export async function loadMcp(
|
|
|
512
621
|
return
|
|
513
622
|
}
|
|
514
623
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
|
|
515
|
-
//
|
|
516
|
-
// (its stdio child is killed by close() — never orphaned).
|
|
517
|
-
|
|
624
|
+
// The fallback SDK client is created before its connect so a timed-out/aborted connect
|
|
625
|
+
// still has a closeable handle (its stdio child is killed by close() — never orphaned).
|
|
626
|
+
let client: Client | undefined
|
|
518
627
|
try {
|
|
519
|
-
await
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
628
|
+
const c = await connectServer(name, cfg, timeout, opts ?? {}, store, () => newMcpClient(waitFor), (cl) => (client = cl))
|
|
629
|
+
let converted: Tool[]
|
|
630
|
+
if (c.kind === "modern") {
|
|
631
|
+
converted = c.server.tools()
|
|
632
|
+
skills.push(...c.server.skills)
|
|
633
|
+
clients.push(c.server)
|
|
634
|
+
servers.set(name, modernConn(c.server))
|
|
635
|
+
} else {
|
|
636
|
+
const kept = applyToolsFilter(name, c.defs, (cfg as { tools?: Record<string, boolean> }).tools)
|
|
637
|
+
converted = kept.map((def) => convertTool(name, def, c.client, timeout))
|
|
638
|
+
clients.push(c.client)
|
|
639
|
+
servers.set(name, sdkConn(c.client, timeout))
|
|
640
|
+
}
|
|
523
641
|
tools.push(...converted)
|
|
524
|
-
clients.push(client)
|
|
525
|
-
servers.set(name, { client, timeout })
|
|
526
642
|
status[name] = "connected"
|
|
527
643
|
report({ name, status: "connected", tools: converted })
|
|
528
644
|
} catch (e) {
|
|
529
645
|
status[name] = "failed"
|
|
530
|
-
await client
|
|
646
|
+
await (client as Client | undefined)?.close().catch(() => {})
|
|
531
647
|
// Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
|
|
532
648
|
// within budget isolates just this server.
|
|
533
649
|
if (signal?.aborted) throw e
|
|
@@ -558,6 +674,7 @@ export async function loadMcp(
|
|
|
558
674
|
await Promise.all(clients.map((c) => c.close().catch(() => {})))
|
|
559
675
|
},
|
|
560
676
|
resourceTools: () => resourceTools(servers),
|
|
677
|
+
skills,
|
|
561
678
|
}
|
|
562
679
|
}
|
|
563
680
|
|
|
@@ -579,11 +696,12 @@ export interface McpInventory {
|
|
|
579
696
|
* §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
|
|
580
697
|
* returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
|
|
581
698
|
*/
|
|
582
|
-
export async function listMcpTools(input: string | object, opts?:
|
|
699
|
+
export async function listMcpTools(input: string | object, opts?: McpLoadOptions): Promise<McpInventory> {
|
|
583
700
|
const config = parseMcpConfig(input)
|
|
584
701
|
const toolsByServer: Record<string, ToolInfo[]> = {}
|
|
585
702
|
const status: Record<string, McpStatus> = {}
|
|
586
703
|
const signal = opts?.signal
|
|
704
|
+
const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
|
|
587
705
|
|
|
588
706
|
await Promise.all(
|
|
589
707
|
Object.entries(config).map(async ([name, cfg]) => {
|
|
@@ -592,11 +710,12 @@ export async function listMcpTools(input: string | object, opts?: { signal?: Abo
|
|
|
592
710
|
return
|
|
593
711
|
}
|
|
594
712
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
|
|
595
|
-
//
|
|
596
|
-
|
|
713
|
+
// The fallback client is created before its connect so an aborted/timed-out connect is still closed.
|
|
714
|
+
let client: Client | undefined
|
|
597
715
|
try {
|
|
598
|
-
await
|
|
599
|
-
const defs =
|
|
716
|
+
const c = await connectServer(name, cfg, timeout, { ...opts, waitFor: opts?.waitFor }, store, () => newMcpClient(), (cl) => (client = cl))
|
|
717
|
+
const defs: any[] = c.kind === "modern" ? c.server.rawDefs() : c.defs
|
|
718
|
+
if (c.kind === "modern") await c.server.close().catch(() => {})
|
|
600
719
|
toolsByServer[name] = defs.map((d) => ({
|
|
601
720
|
name: d.name,
|
|
602
721
|
description: d.description ?? "",
|
|
@@ -609,10 +728,10 @@ export async function listMcpTools(input: string | object, opts?: { signal?: Abo
|
|
|
609
728
|
status[name] = "connected"
|
|
610
729
|
} catch (e) {
|
|
611
730
|
status[name] = "failed"
|
|
612
|
-
if (signal?.aborted) { await client
|
|
731
|
+
if (signal?.aborted) { await (client as Client | undefined)?.close().catch(() => {}); throw e }
|
|
613
732
|
console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
614
733
|
} finally {
|
|
615
|
-
await client
|
|
734
|
+
await (client as Client | undefined)?.close().catch(() => {})
|
|
616
735
|
}
|
|
617
736
|
}),
|
|
618
737
|
)
|