toolnexus 0.24.0 → 0.26.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 +151 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/classifier.d.ts +189 -31
- package/dist/classifier.js +589 -30
- package/dist/client.d.ts +38 -8
- package/dist/client.js +102 -23
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/judge.d.ts +6 -3
- package/dist/judge.js +13 -1
- 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/serve.js +7 -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/classifier.ts +677 -46
- package/src/client.ts +130 -27
- package/src/index.ts +2 -0
- package/src/judge.ts +19 -4
- 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/serve.ts +7 -0
- package/src/skill.ts +50 -0
- package/src/toolkit.ts +31 -4
- package/src/types.ts +23 -0
package/src/client.ts
CHANGED
|
@@ -227,6 +227,12 @@ export type MetricEvent =
|
|
|
227
227
|
promptTokens: number
|
|
228
228
|
completionTokens: number
|
|
229
229
|
error?: string
|
|
230
|
+
/** L1: how many questions were asked. */
|
|
231
|
+
questions: number
|
|
232
|
+
/** L1: sha256 hex of `canonicalRequest(model, questions)` ("" when unencodable). */
|
|
233
|
+
questionsHash: string
|
|
234
|
+
/** True when the decision came from the classifier cache and no request was sent. */
|
|
235
|
+
cached: boolean
|
|
230
236
|
}
|
|
231
237
|
// `warning`, never `error`: a degenerate-criteria report is advisory, and a consumer that
|
|
232
238
|
// counts "has an error" as a failure must not count this.
|
|
@@ -388,8 +394,12 @@ export interface RunResult {
|
|
|
388
394
|
/** Events yielded by client.stream(). */
|
|
389
395
|
export type StreamEvent =
|
|
390
396
|
| { type: "text"; delta: string }
|
|
391
|
-
|
|
392
|
-
|
|
397
|
+
/** Model reasoning narration (e.g. an ACP agent's `agent_thought_chunk`). Never part of
|
|
398
|
+
* the answer text or the RunResult. */
|
|
399
|
+
| { type: "thought"; delta: string; via?: "acp" }
|
|
400
|
+
/** `via: "acp"` marks a tool the ACP agent ran itself: reported, never executed by the loop. */
|
|
401
|
+
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown>; via?: "call_tool" | "acp" }
|
|
402
|
+
| { type: "tool_result"; id: string; name: string; output: string; isError: boolean; metadata?: Record<string, unknown>; via?: "call_tool" | "acp" }
|
|
393
403
|
| { type: "usage"; usage: Usage }
|
|
394
404
|
| { type: "pending"; request: Request }
|
|
395
405
|
| { type: "done"; result: RunResult }
|
|
@@ -1407,6 +1417,13 @@ export class Client {
|
|
|
1407
1417
|
const payload = line.slice(5).trim()
|
|
1408
1418
|
if (payload === "[DONE]") break
|
|
1409
1419
|
const j = safeParse(payload)
|
|
1420
|
+
// A non-text event from an in-process streaming generate rides in-band so it keeps
|
|
1421
|
+
// its order and arrives as it happens; it goes straight to the consumer and is never
|
|
1422
|
+
// executed. Honoured only on the in-process sentinel URL, never from a real server.
|
|
1423
|
+
if (j && j[IN_PROCESS_SIDE_KEY] && this.opts.baseUrl === IN_PROCESS_BASE_URL) {
|
|
1424
|
+
yield j[IN_PROCESS_SIDE_KEY] as StreamEvent
|
|
1425
|
+
continue
|
|
1426
|
+
}
|
|
1410
1427
|
if (j?.usage) addUsage(usage, j.usage, "openai")
|
|
1411
1428
|
const choice = j?.choices?.[0]
|
|
1412
1429
|
const d = choice?.delta
|
|
@@ -1611,11 +1628,27 @@ export interface InProcessResponse {
|
|
|
1611
1628
|
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }
|
|
1612
1629
|
}
|
|
1613
1630
|
|
|
1631
|
+
/** A streaming in-process model: `emit` each event as it happens — `text` deltas become
|
|
1632
|
+
* the streamed answer as they arrive; any other event (thought, an agent's own tool_call /
|
|
1633
|
+
* tool_result) goes straight to the stream consumer and is never executed by the loop.
|
|
1634
|
+
* Return the whole message when done (its `toolCalls`, if any, are what the loop runs). */
|
|
1635
|
+
export type InProcessGenerateStream = (
|
|
1636
|
+
request: InProcessRequest,
|
|
1637
|
+
emit: (event: StreamEvent) => void,
|
|
1638
|
+
) => InProcessResponse | Promise<InProcessResponse>
|
|
1639
|
+
|
|
1640
|
+
type InProcessGenerate = (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
|
|
1641
|
+
|
|
1614
1642
|
/** Options for {@link createInProcessClient}: every client option EXCEPT the three
|
|
1615
|
-
* that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate
|
|
1643
|
+
* that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate` and/or
|
|
1644
|
+
* `generateStream` (at least one). */
|
|
1616
1645
|
export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
|
|
1617
1646
|
/** Your model. Receives the assembled request, returns one assistant message. */
|
|
1618
|
-
generate
|
|
1647
|
+
generate?: InProcessGenerate
|
|
1648
|
+
/** Your model, streaming for real. With it, `stream()` is incremental; without it a
|
|
1649
|
+
* `stream()` on a plain `generate` is refused. When both are set, `generate` serves
|
|
1650
|
+
* non-streaming calls; alone, it serves them too (with a no-op `emit`). */
|
|
1651
|
+
generateStream?: InProcessGenerateStream
|
|
1619
1652
|
}
|
|
1620
1653
|
|
|
1621
1654
|
/** The sentinel base URL. Never dialled — the transport below answers every request
|
|
@@ -1624,6 +1657,10 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
|
|
|
1624
1657
|
* name can never resolve. */
|
|
1625
1658
|
const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
|
|
1626
1659
|
|
|
1660
|
+
/** The in-band SSE key that carries a non-text stream event from an in-process
|
|
1661
|
+
* streaming generate to the consumer (honoured only on {@link IN_PROCESS_BASE_URL}). */
|
|
1662
|
+
const IN_PROCESS_SIDE_KEY = "toolnexus_event"
|
|
1663
|
+
|
|
1627
1664
|
/**
|
|
1628
1665
|
* Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
|
|
1629
1666
|
* the reusable piece of {@link createInProcessClient} — assembles the wire request,
|
|
@@ -1632,15 +1669,20 @@ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
|
|
|
1632
1669
|
* or the agent runtime's `inProcess` option) goes through this ONE function, so there
|
|
1633
1670
|
* is no second copy of the request/response assembly to drift.
|
|
1634
1671
|
*
|
|
1635
|
-
* Streaming is refused rather than faked — see the thrown
|
|
1672
|
+
* Streaming with only a plain `generate` is refused rather than faked — see the thrown
|
|
1673
|
+
* error. With `generateStream` it streams for real: each text event is written to the
|
|
1674
|
+
* SSE body the moment it is emitted.
|
|
1636
1675
|
*/
|
|
1637
|
-
export function createInProcessFetch(generate
|
|
1638
|
-
if (typeof generate !== "function") {
|
|
1639
|
-
throw new Error("toolnexus: createInProcessFetch requires a `generate` function")
|
|
1676
|
+
export function createInProcessFetch(generate?: InProcessGenerate, generateStream?: InProcessGenerateStream): typeof fetch {
|
|
1677
|
+
if (typeof generate !== "function" && typeof generateStream !== "function") {
|
|
1678
|
+
throw new Error("toolnexus: createInProcessFetch requires a `generate` (or `generateStream`) function")
|
|
1640
1679
|
}
|
|
1641
1680
|
|
|
1642
1681
|
const inProcessFetch: typeof fetch = async (_url, init) => {
|
|
1643
1682
|
const body = JSON.parse(String((init as any)?.body ?? "{}"))
|
|
1683
|
+
const request: InProcessRequest = { messages: body.messages ?? [], tools: body.tools, model: body.model, body }
|
|
1684
|
+
|
|
1685
|
+
if (body.stream && generateStream) return inProcessStream(generateStream, request)
|
|
1644
1686
|
|
|
1645
1687
|
// A generate() returns a whole answer, so a stream would be one chunk pretending
|
|
1646
1688
|
// to be many. Content and delta-count assertions cannot tell that from a real
|
|
@@ -1652,25 +1694,11 @@ export function createInProcessFetch(generate: (request: InProcessRequest) => In
|
|
|
1652
1694
|
)
|
|
1653
1695
|
}
|
|
1654
1696
|
|
|
1655
|
-
const answer = await generate({
|
|
1656
|
-
messages: body.messages ?? [],
|
|
1657
|
-
tools: body.tools,
|
|
1658
|
-
model: body.model,
|
|
1659
|
-
body,
|
|
1660
|
-
})
|
|
1697
|
+
const answer = generate ? await generate(request) : await generateStream!(request, () => {})
|
|
1661
1698
|
|
|
1662
1699
|
const message: Record<string, any> = { role: "assistant" }
|
|
1663
1700
|
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
|
-
}))
|
|
1701
|
+
message.tool_calls = wireToolCalls(answer.toolCalls, false)
|
|
1674
1702
|
} else {
|
|
1675
1703
|
message.content = answer.content ?? ""
|
|
1676
1704
|
}
|
|
@@ -1694,6 +1722,77 @@ export function createInProcessFetch(generate: (request: InProcessRequest) => In
|
|
|
1694
1722
|
return inProcessFetch
|
|
1695
1723
|
}
|
|
1696
1724
|
|
|
1725
|
+
function wireToolCalls(calls: InProcessToolCall[], indexed: boolean): any[] {
|
|
1726
|
+
return calls.map((c, i) => ({
|
|
1727
|
+
...(indexed ? { index: i } : {}),
|
|
1728
|
+
id: c.id ?? `call_${i}`,
|
|
1729
|
+
type: "function",
|
|
1730
|
+
function: {
|
|
1731
|
+
name: c.name,
|
|
1732
|
+
// A string is already encoded; anything else is encoded here so a model
|
|
1733
|
+
// author never has to think about the wire.
|
|
1734
|
+
arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
|
|
1735
|
+
},
|
|
1736
|
+
}))
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** Runs a streaming generate and answers with an OpenAI SSE body written AS EVENTS ARRIVE:
|
|
1740
|
+
* text → a content delta; any other event → an in-band side chunk for the consumer; then
|
|
1741
|
+
* (tool calls as one delta | the content as one delta when no text was emitted), the
|
|
1742
|
+
* finish_reason chunk, usage, and [DONE]. */
|
|
1743
|
+
function inProcessStream(generateStream: InProcessGenerateStream, request: InProcessRequest): Response {
|
|
1744
|
+
const enc = new TextEncoder()
|
|
1745
|
+
let open = true
|
|
1746
|
+
const sse = new ReadableStream<Uint8Array>({
|
|
1747
|
+
start(controller) {
|
|
1748
|
+
const chunk = (v: unknown) => {
|
|
1749
|
+
if (open) controller.enqueue(enc.encode(`data: ${JSON.stringify(v)}\n\n`))
|
|
1750
|
+
}
|
|
1751
|
+
let sawText = false
|
|
1752
|
+
void (async () => {
|
|
1753
|
+
try {
|
|
1754
|
+
const answer = await generateStream(request, (ev) => {
|
|
1755
|
+
if (!ev || typeof ev !== "object") return
|
|
1756
|
+
if (ev.type === "text") {
|
|
1757
|
+
if (ev.delta) {
|
|
1758
|
+
sawText = true
|
|
1759
|
+
chunk({ choices: [{ index: 0, delta: { content: ev.delta } }] })
|
|
1760
|
+
}
|
|
1761
|
+
return
|
|
1762
|
+
}
|
|
1763
|
+
chunk({ [IN_PROCESS_SIDE_KEY]: ev })
|
|
1764
|
+
})
|
|
1765
|
+
let finish = "stop"
|
|
1766
|
+
if (answer.toolCalls && answer.toolCalls.length > 0) {
|
|
1767
|
+
chunk({ choices: [{ index: 0, delta: { tool_calls: wireToolCalls(answer.toolCalls, true) } }] })
|
|
1768
|
+
finish = "tool_calls"
|
|
1769
|
+
} else if (!sawText && answer.content) {
|
|
1770
|
+
chunk({ choices: [{ index: 0, delta: { content: answer.content } }] })
|
|
1771
|
+
}
|
|
1772
|
+
chunk({ choices: [{ index: 0, delta: {}, finish_reason: finish }] })
|
|
1773
|
+
const u = answer.usage
|
|
1774
|
+
if (u) {
|
|
1775
|
+
const p = u.promptTokens ?? 0, c = u.completionTokens ?? 0
|
|
1776
|
+
chunk({ choices: [], usage: { prompt_tokens: p, completion_tokens: c, total_tokens: u.totalTokens || p + c } })
|
|
1777
|
+
}
|
|
1778
|
+
if (open) controller.enqueue(enc.encode("data: [DONE]\n\n"))
|
|
1779
|
+
open = false
|
|
1780
|
+
controller.close()
|
|
1781
|
+
} catch (e) {
|
|
1782
|
+
if (open) {
|
|
1783
|
+
open = false
|
|
1784
|
+
controller.error(e)
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
})()
|
|
1788
|
+
},
|
|
1789
|
+
cancel() {
|
|
1790
|
+
open = false
|
|
1791
|
+
},
|
|
1792
|
+
})
|
|
1793
|
+
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } })
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1697
1796
|
/**
|
|
1698
1797
|
* A client backed by a model running IN THIS PROCESS — no server, no socket, and no
|
|
1699
1798
|
* HTTP types to construct.
|
|
@@ -1704,11 +1803,15 @@ export function createInProcessFetch(generate: (request: InProcessRequest) => In
|
|
|
1704
1803
|
* The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
|
|
1705
1804
|
* is just that adapter wired into a `Client`.
|
|
1706
1805
|
*
|
|
1707
|
-
* Streaming is refused rather than faked
|
|
1806
|
+
* Streaming is refused rather than faked for a plain `generate`; with `generateStream`
|
|
1807
|
+
* it streams for real — see {@link createInProcessFetch}.
|
|
1708
1808
|
*/
|
|
1709
1809
|
export function createInProcessClient(opts: InProcessOptions): Client {
|
|
1710
|
-
const { generate, ...rest } = opts
|
|
1711
|
-
|
|
1810
|
+
const { generate, generateStream, ...rest } = opts
|
|
1811
|
+
if (typeof generate !== "function" && typeof generateStream !== "function") {
|
|
1812
|
+
throw new Error("toolnexus: createInProcessClient requires a `generate` (or `generateStream`) function")
|
|
1813
|
+
}
|
|
1814
|
+
const inProcessFetch = createInProcessFetch(generate, generateStream)
|
|
1712
1815
|
|
|
1713
1816
|
return new Client({
|
|
1714
1817
|
// 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/judge.ts
CHANGED
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
Decision,
|
|
13
13
|
type DecisionAnswer,
|
|
14
14
|
type NoulCriteria,
|
|
15
|
-
type
|
|
15
|
+
type AnyQuestion,
|
|
16
16
|
type RecordedDecision,
|
|
17
|
+
isRawAnswer,
|
|
18
|
+
raw as rawQ,
|
|
17
19
|
noul as noulQ,
|
|
18
20
|
choice as choiceQ,
|
|
19
21
|
score as scoreQ,
|
|
@@ -25,7 +27,7 @@ import type { Request } from "./types.js"
|
|
|
25
27
|
/** One named question of an ordered question list. */
|
|
26
28
|
export interface NamedQuestion {
|
|
27
29
|
name: string
|
|
28
|
-
question:
|
|
30
|
+
question: AnyQuestion
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** `noul` question with a name. Name the state field it judges in `instructions`. */
|
|
@@ -41,9 +43,15 @@ export function score(name: string, instructions: string, levels: string[]): Nam
|
|
|
41
43
|
return { name, question: scoreQ(instructions, levels) }
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/** A raw question with a name: a System One type the library does not model (e.g. `box`), sent
|
|
47
|
+
* as written. Its answer decodes to a raw answer `{type, value}`. */
|
|
48
|
+
export function raw(name: string, type: string, instructions: string, criteria?: unknown): NamedQuestion {
|
|
49
|
+
return { name, question: rawQ(type, instructions, criteria) }
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
/** Ordered list -> the §8B questions map. A duplicate name is an error naming it. */
|
|
45
|
-
export function questionMap(questions: readonly NamedQuestion[]): Record<string,
|
|
46
|
-
const out: Record<string,
|
|
53
|
+
export function questionMap(questions: readonly NamedQuestion[]): Record<string, AnyQuestion> {
|
|
54
|
+
const out: Record<string, AnyQuestion> = {}
|
|
47
55
|
for (const { name, question } of questions) {
|
|
48
56
|
if (Object.prototype.hasOwnProperty.call(out, name)) throw new Error(`duplicate question name ${JSON.stringify(name)}`)
|
|
49
57
|
out[name] = question
|
|
@@ -93,6 +101,13 @@ function noulBand(p: number, b: Bands): Band {
|
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
function wrap(a: DecisionAnswer, b: Bands): Answer {
|
|
104
|
+
if (isRawAnswer(a)) {
|
|
105
|
+
// An unmodelled answer type is never sure: there is no number to band.
|
|
106
|
+
const out = { ...(a as object), sure: false } as Answer
|
|
107
|
+
Object.defineProperty(out, "value", { value: () => 0, enumerable: false })
|
|
108
|
+
Object.defineProperty(out, "pick", { value: () => "", enumerable: false })
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
96
111
|
const extra =
|
|
97
112
|
a.type === "noul"
|
|
98
113
|
? { band: noulBand(a.noul, b) }
|
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
|
)
|