toolnexus 0.20.0 → 0.22.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 +40 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +43 -7
- package/dist/agents/compaction.d.ts +19 -0
- package/dist/agents/compaction.js +26 -4
- package/dist/agents/runtime.d.ts +5 -0
- package/dist/agents/runtime.js +13 -2
- package/dist/batteries.d.ts +223 -0
- package/dist/batteries.js +488 -0
- package/dist/builtin.d.ts +16 -0
- package/dist/builtin.js +315 -17
- package/dist/builtin_extras.d.ts +80 -0
- package/dist/builtin_extras.js +248 -0
- package/dist/classifier.d.ts +14 -0
- package/dist/classifier.js +34 -1
- package/dist/client.d.ts +91 -15
- package/dist/client.js +377 -110
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/judge.d.ts +98 -0
- package/dist/judge.js +199 -0
- package/dist/mcp.d.ts +32 -1
- package/dist/mcp.js +184 -6
- package/dist/models.d.ts +20 -0
- package/dist/models.js +46 -0
- package/dist/promptcache.d.ts +21 -0
- package/dist/promptcache.js +59 -0
- package/dist/store.d.ts +32 -0
- package/dist/store.js +131 -0
- package/dist/tooltiers.d.ts +49 -0
- package/dist/tooltiers.js +197 -0
- package/dist/types.d.ts +19 -0
- package/package.json +2 -2
- package/src/agents/agent.ts +39 -8
- package/src/agents/compaction.ts +45 -4
- package/src/agents/runtime.ts +16 -2
- package/src/batteries.ts +614 -0
- package/src/builtin.ts +333 -18
- package/src/builtin_extras.ts +285 -0
- package/src/classifier.ts +36 -1
- package/src/client.ts +415 -115
- package/src/index.ts +31 -0
- package/src/judge.ts +305 -0
- package/src/mcp.ts +204 -7
- package/src/models.ts +56 -0
- package/src/promptcache.ts +63 -0
- package/src/store.ts +142 -0
- package/src/tooltiers.ts +221 -0
- package/src/types.ts +20 -0
package/src/index.ts
CHANGED
|
@@ -14,5 +14,36 @@ export * from "./adapters.js"
|
|
|
14
14
|
export * from "./translate.js"
|
|
15
15
|
export * from "./toolkit.js"
|
|
16
16
|
export * from "./client.js"
|
|
17
|
+
export * from "./store.js"
|
|
17
18
|
export * from "./classifier.js"
|
|
18
19
|
export * as agents from "./agents/index.js"
|
|
20
|
+
// Simple judgments (§8B, add-judge-adapters). `judge.noul/choice/score` are the NAMED builders
|
|
21
|
+
// (the bare `noul/choice/score` above stay the §8B wire builders); the rest is also top-level.
|
|
22
|
+
export * as judge from "./judge.js"
|
|
23
|
+
// Judge batteries (§8B "Batteries", add-judge-batteries): eight `*Classifier` values + latestUserText.
|
|
24
|
+
export * from "./batteries.js"
|
|
25
|
+
export {
|
|
26
|
+
State,
|
|
27
|
+
context,
|
|
28
|
+
questionMap,
|
|
29
|
+
ask,
|
|
30
|
+
gate,
|
|
31
|
+
decide,
|
|
32
|
+
applyGate,
|
|
33
|
+
applyPolicy,
|
|
34
|
+
answersOf,
|
|
35
|
+
staticClassifier,
|
|
36
|
+
Tape,
|
|
37
|
+
DEFAULT_BANDS,
|
|
38
|
+
type NamedQuestion,
|
|
39
|
+
type Band,
|
|
40
|
+
type Bands,
|
|
41
|
+
type Answers,
|
|
42
|
+
type Rule,
|
|
43
|
+
type GateOutcome,
|
|
44
|
+
type Policy,
|
|
45
|
+
} from "./judge.js"
|
|
46
|
+
export * from "./promptcache.js"
|
|
47
|
+
export * from "./tooltiers.js"
|
|
48
|
+
export * from "./models.js"
|
|
49
|
+
export * from "./builtin_extras.js"
|
package/src/judge.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple judgments (SPEC.md §8B "Simple judgments — ask / gate", change `add-judge-adapters`).
|
|
3
|
+
* A thin layer over any `Classifier`: named question builders, a role-carrying `State`,
|
|
4
|
+
* `ask` (answers by name with bands) and `gate` / `Policy` (rules that escalate instead of
|
|
5
|
+
* deciding when unsure). The wire is unchanged — the builders produce exactly the §8B
|
|
6
|
+
* `evaluate(state, questions)` inputs.
|
|
7
|
+
*
|
|
8
|
+
* A gate never authorises; it only declines to decide.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
Classifier,
|
|
12
|
+
Decision,
|
|
13
|
+
type DecisionAnswer,
|
|
14
|
+
type NoulCriteria,
|
|
15
|
+
type Question,
|
|
16
|
+
type RecordedDecision,
|
|
17
|
+
noul as noulQ,
|
|
18
|
+
choice as choiceQ,
|
|
19
|
+
score as scoreQ,
|
|
20
|
+
} from "./classifier.js"
|
|
21
|
+
import type { Request } from "./types.js"
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------- questions
|
|
24
|
+
|
|
25
|
+
/** One named question of an ordered question list. */
|
|
26
|
+
export interface NamedQuestion {
|
|
27
|
+
name: string
|
|
28
|
+
question: Question
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `noul` question with a name. Name the state field it judges in `instructions`. */
|
|
32
|
+
export function noul(name: string, instructions: string, criteria?: NoulCriteria): NamedQuestion {
|
|
33
|
+
return { name, question: noulQ(instructions, criteria) }
|
|
34
|
+
}
|
|
35
|
+
/** `choice` question with a name; `options` maps option -> what picking it MEANS. */
|
|
36
|
+
export function choice(name: string, instructions: string, options: Record<string, string>): NamedQuestion {
|
|
37
|
+
return { name, question: choiceQ(instructions, options) }
|
|
38
|
+
}
|
|
39
|
+
/** `score` question with a name; `levels` is the ordered rubric. */
|
|
40
|
+
export function score(name: string, instructions: string, levels: string[]): NamedQuestion {
|
|
41
|
+
return { name, question: scoreQ(instructions, levels) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ordered list -> the §8B questions map. A duplicate name is an error naming it. */
|
|
45
|
+
export function questionMap(questions: readonly NamedQuestion[]): Record<string, Question> {
|
|
46
|
+
const out: Record<string, Question> = {}
|
|
47
|
+
for (const { name, question } of questions) {
|
|
48
|
+
if (Object.prototype.hasOwnProperty.call(out, name)) throw new Error(`duplicate question name ${JSON.stringify(name)}`)
|
|
49
|
+
out[name] = question
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- state
|
|
55
|
+
|
|
56
|
+
/** `role` next to the data's fields; a non-object value goes under `data`. */
|
|
57
|
+
export function State(role: string, data: unknown): Record<string, unknown> {
|
|
58
|
+
if (data !== null && typeof data === "object" && !Array.isArray(data)) return { role, ...(data as object) }
|
|
59
|
+
return { role, data }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Sugar: `{context, message, ...extra}`. */
|
|
63
|
+
export function context(ctx: string, message: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
64
|
+
return { context: ctx, message, ...extra }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------- bands + answers
|
|
68
|
+
|
|
69
|
+
export type Band = "yes" | "no" | "uncertain"
|
|
70
|
+
export interface Bands {
|
|
71
|
+
low: number
|
|
72
|
+
high: number
|
|
73
|
+
}
|
|
74
|
+
/** Default cut-points; exclusive on the confident side. */
|
|
75
|
+
export const DEFAULT_BANDS: Readonly<Bands> = Object.freeze({ low: 0.3, high: 0.7 })
|
|
76
|
+
|
|
77
|
+
/** One answer from `ask`: the §8B answer plus `band` (noul) or `sure` (choice/score). */
|
|
78
|
+
export type Answer = DecisionAnswer & {
|
|
79
|
+
/** noul only. */
|
|
80
|
+
band?: Band
|
|
81
|
+
/** choice / score only. */
|
|
82
|
+
sure?: boolean
|
|
83
|
+
/** The one number: noul probability, score value, or choice confidence. */
|
|
84
|
+
value(): number
|
|
85
|
+
/** The picked option of a choice answer ("" otherwise). JS keeps §8B's `choice` string FIELD on a
|
|
86
|
+
* choice answer (a method of the same name would shadow it), so this is `pick()`. */
|
|
87
|
+
pick(): string
|
|
88
|
+
}
|
|
89
|
+
export type Answers = Record<string, Answer>
|
|
90
|
+
|
|
91
|
+
function noulBand(p: number, b: Bands): Band {
|
|
92
|
+
return p < b.low ? "no" : p > b.high ? "yes" : "uncertain"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function wrap(a: DecisionAnswer, b: Bands): Answer {
|
|
96
|
+
const extra =
|
|
97
|
+
a.type === "noul"
|
|
98
|
+
? { band: noulBand(a.noul, b) }
|
|
99
|
+
: { sure: a.confidence > b.high && !(a.type === "choice" && a.nearUniform) }
|
|
100
|
+
const value = () => (a.type === "noul" ? a.noul : a.type === "score" ? a.score : a.confidence)
|
|
101
|
+
const pick = () => (a.type === "choice" ? a.choice : "")
|
|
102
|
+
const out = { ...a, ...extra } as Answer
|
|
103
|
+
Object.defineProperty(out, "value", { value, enumerable: false })
|
|
104
|
+
Object.defineProperty(out, "pick", { value: pick, enumerable: false })
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Band / sure every answer of a decision. */
|
|
109
|
+
export function answersOf(decision: Decision, bands: Bands = DEFAULT_BANDS): Answers {
|
|
110
|
+
return Object.fromEntries(Object.entries(decision.answers).map(([k, a]) => [k, wrap(a, bands)]))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Evaluate once; answers by name, each with a band (noul) or `sure` (choice/score). */
|
|
114
|
+
export async function ask(
|
|
115
|
+
classifier: Classifier,
|
|
116
|
+
state: unknown,
|
|
117
|
+
questions: readonly NamedQuestion[],
|
|
118
|
+
bands: Bands = DEFAULT_BANDS,
|
|
119
|
+
): Promise<Answers> {
|
|
120
|
+
return answersOf(await classifier.evaluate(state, questionMap(questions)), bands)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------- gate / policy
|
|
124
|
+
|
|
125
|
+
export interface Rule {
|
|
126
|
+
question: string
|
|
127
|
+
below?: number
|
|
128
|
+
at_least?: number
|
|
129
|
+
is?: string
|
|
130
|
+
action: string
|
|
131
|
+
target?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface GateOutcome {
|
|
135
|
+
action: string
|
|
136
|
+
target: string
|
|
137
|
+
escalated: boolean
|
|
138
|
+
/** Present iff escalated: a §10 `input` Request. */
|
|
139
|
+
request?: Request
|
|
140
|
+
answers: Answers
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface Policy {
|
|
144
|
+
rules: Rule[]
|
|
145
|
+
/** The no-rule-fired action; empty escalates with reason "no rule fired". */
|
|
146
|
+
default?: string
|
|
147
|
+
bands?: Bands
|
|
148
|
+
/** Skip a rule whose answer is uncertain instead of escalating on it. */
|
|
149
|
+
skipUncertain?: boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function unsure(a: Answer): boolean {
|
|
153
|
+
return a.type === "noul" ? a.band === "uncertain" : !a.sure
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function escalate(answers: Answers, id: string, question: string, reason: string, prompt: string): GateOutcome {
|
|
157
|
+
return {
|
|
158
|
+
action: "needs_input",
|
|
159
|
+
target: "",
|
|
160
|
+
escalated: true,
|
|
161
|
+
answers,
|
|
162
|
+
request: { id, kind: "input", prompt, data: { question, reason, answers } },
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function applyRules(answers: Answers, rules: readonly Rule[], skipUncertain: boolean): GateOutcome | undefined {
|
|
167
|
+
for (const [i, r] of rules.entries()) {
|
|
168
|
+
const a = answers[r.question]
|
|
169
|
+
if (!a || unsure(a)) {
|
|
170
|
+
if (a && skipUncertain) continue
|
|
171
|
+
const reason = `${a ? "uncertain" : "missing"} answer "${r.question}"`
|
|
172
|
+
return escalate(
|
|
173
|
+
answers,
|
|
174
|
+
`gate:${i}:${r.question}`,
|
|
175
|
+
r.question,
|
|
176
|
+
reason,
|
|
177
|
+
`Classifier is unsure about "${r.question}" (${reason}). Decide rule ${i} (${r.action}).`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
const key = JSON.stringify(r.question)
|
|
181
|
+
let fired = false
|
|
182
|
+
let misfit = ""
|
|
183
|
+
if (r.is !== undefined) {
|
|
184
|
+
if (a.type === "choice") fired = a.choice === r.is
|
|
185
|
+
else misfit = `is-rule on ${a.type} answer ${key}`
|
|
186
|
+
} else if (a.type === "choice") {
|
|
187
|
+
misfit = `numeric rule on choice answer ${key}`
|
|
188
|
+
} else {
|
|
189
|
+
const v = a.type === "noul" ? a.noul : a.score
|
|
190
|
+
if (r.below !== undefined) fired = v < r.below
|
|
191
|
+
else if (r.at_least !== undefined) fired = v >= r.at_least
|
|
192
|
+
else misfit = `rule on ${key} has no below / at_least / is`
|
|
193
|
+
}
|
|
194
|
+
// A rule that does not fit its answer escalates; it is never skipped.
|
|
195
|
+
if (misfit)
|
|
196
|
+
return escalate(
|
|
197
|
+
answers,
|
|
198
|
+
`gate:${i}:${r.question}`,
|
|
199
|
+
r.question,
|
|
200
|
+
misfit,
|
|
201
|
+
`Rule ${i} (${r.action}) does not fit its answer (${misfit}). Decide.`,
|
|
202
|
+
)
|
|
203
|
+
if (fired) return { action: r.action, target: r.target ?? "", escalated: false, answers }
|
|
204
|
+
}
|
|
205
|
+
return undefined
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Pure half of `gate`: first-match rules; an unsure or missing answer on rule i escalates and wins. */
|
|
209
|
+
export function applyGate(answers: Answers, rules: readonly Rule[]): GateOutcome {
|
|
210
|
+
return applyRules(answers, rules, false) ?? { action: "", target: "", escalated: false, answers }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Pure half of a `Policy`. */
|
|
214
|
+
export function applyPolicy(answers: Answers, policy: Policy): GateOutcome {
|
|
215
|
+
const hit = applyRules(answers, policy.rules, policy.skipUncertain ?? false)
|
|
216
|
+
if (hit) return hit
|
|
217
|
+
if (policy.default) return { action: policy.default, target: "", escalated: false, answers }
|
|
218
|
+
return escalate(answers, "gate:default", "", "no rule fired", "No rule fired. Decide the action.")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Ask, then apply rules; no rule fired -> empty action (use a `Policy` for a declared default). */
|
|
222
|
+
export async function gate(
|
|
223
|
+
classifier: Classifier,
|
|
224
|
+
state: unknown,
|
|
225
|
+
questions: readonly NamedQuestion[],
|
|
226
|
+
rules: readonly Rule[],
|
|
227
|
+
bands: Bands = DEFAULT_BANDS,
|
|
228
|
+
): Promise<GateOutcome> {
|
|
229
|
+
return applyGate(await ask(classifier, state, questions, bands), rules)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Ask, then apply a `Policy` (rules, default, bands, skipUncertain). */
|
|
233
|
+
export async function decide(
|
|
234
|
+
classifier: Classifier,
|
|
235
|
+
state: unknown,
|
|
236
|
+
questions: readonly NamedQuestion[],
|
|
237
|
+
policy: Policy,
|
|
238
|
+
): Promise<GateOutcome> {
|
|
239
|
+
return applyPolicy(await ask(classifier, state, questions, policy.bands ?? DEFAULT_BANDS), policy)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------- static + tape
|
|
243
|
+
|
|
244
|
+
/** One-line `static` classifier from recorded decisions. */
|
|
245
|
+
export function staticClassifier(decisions: RecordedDecision[] | RecordedDecision, model?: string): Classifier {
|
|
246
|
+
return new Classifier({ style: "static", decisions: Array.isArray(decisions) ? decisions : [decisions], model })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The recorded-body form of a Decision, decodable by `static`. */
|
|
250
|
+
function toRaw(d: Decision): Record<string, unknown> {
|
|
251
|
+
const answers: Record<string, unknown> = {}
|
|
252
|
+
for (const [k, a] of Object.entries(d.answers)) {
|
|
253
|
+
if (a.type === "choice") {
|
|
254
|
+
const { nearUniform: _derived, ...rest } = a
|
|
255
|
+
answers[k] = rest
|
|
256
|
+
} else answers[k] = { ...a }
|
|
257
|
+
}
|
|
258
|
+
const usage: Record<string, number> = { input_tokens: d.usage.inputTokens, output_tokens: d.usage.outputTokens }
|
|
259
|
+
if (d.usage.cost !== undefined) usage.cost = d.usage.cost
|
|
260
|
+
return { model: d.model, answers, usage, calibrated: d.calibrated }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Records live decisions keyed by a caller-given call name, and replays them with no network.
|
|
265
|
+
* `tape.classifier(name)` returns a Classifier: recording (live given) or replaying (no live).
|
|
266
|
+
*/
|
|
267
|
+
export class Tape {
|
|
268
|
+
readonly entries: Record<string, RecordedDecision>
|
|
269
|
+
|
|
270
|
+
constructor(
|
|
271
|
+
private readonly live?: Classifier,
|
|
272
|
+
entries: Record<string, RecordedDecision> = {},
|
|
273
|
+
) {
|
|
274
|
+
this.entries = { ...entries }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A replay-only tape from saved entries. */
|
|
278
|
+
static replay(entries: Record<string, RecordedDecision>): Tape {
|
|
279
|
+
return new Tape(undefined, entries)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
classifier(name: string): Classifier {
|
|
283
|
+
const live = this.live
|
|
284
|
+
if (live) {
|
|
285
|
+
return new Classifier({
|
|
286
|
+
style: "custom",
|
|
287
|
+
evaluate: async (state, questions, signal) => {
|
|
288
|
+
const d = await live.evaluate(state, questions, signal)
|
|
289
|
+
this.entries[name] = { state, questions, response: toRaw(d) }
|
|
290
|
+
return d
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
// Replay: resolved when evaluated (a miss fails then, naming the key, and sends nothing).
|
|
295
|
+
return new Classifier({
|
|
296
|
+
style: "custom",
|
|
297
|
+
evaluate: async (state, questions, signal) => {
|
|
298
|
+
const rec = this.entries[name]
|
|
299
|
+
if (!rec) throw new Error(`tape: no recorded decision for call "${name}"`)
|
|
300
|
+
const raw = rec.response as { model?: string }
|
|
301
|
+
return staticClassifier(rec, raw?.model).evaluate(state, questions, signal)
|
|
302
|
+
},
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
10
10
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
11
11
|
import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
|
|
12
12
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
|
13
|
-
import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
|
|
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
15
|
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
@@ -88,12 +88,38 @@ function isEnabled(cfg: ServerConfig): boolean {
|
|
|
88
88
|
export function parseMcpConfig(input: string | object): McpConfig {
|
|
89
89
|
const raw: any = typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input
|
|
90
90
|
const wrapped = raw.mcpServers ?? raw.servers ?? raw.mcp
|
|
91
|
-
if (wrapped !== undefined) return wrapped
|
|
91
|
+
if (wrapped !== undefined) return normalizeServers(wrapped)
|
|
92
92
|
// Bare map of servers — but strip sibling top-level config keys (builtins/
|
|
93
93
|
// agents/a2a/mcpServer) so they are not mistaken for MCP servers when no
|
|
94
94
|
// wrapper key is present.
|
|
95
95
|
const { builtins: _builtins, agents: _agents, a2a: _a2a, mcpServer: _mcpServer, ...servers } = raw
|
|
96
|
-
return servers
|
|
96
|
+
return normalizeServers(servers)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* §2 `command` + `args`. A local server's `command` may be a non-empty string or a non-empty
|
|
101
|
+
* string array; an optional `args` string array is appended. The result is always the argv form
|
|
102
|
+
* (`command: string[]`, no `args`). Spawned with no shell. A remote server ignores `args`.
|
|
103
|
+
*/
|
|
104
|
+
function normalizeServers(servers: Record<string, any>): McpConfig {
|
|
105
|
+
const out: McpConfig = {}
|
|
106
|
+
for (const [name, cfg] of Object.entries(servers ?? {})) {
|
|
107
|
+
if (cfg === null || typeof cfg !== "object" || isRemote(cfg as ServerConfig)) {
|
|
108
|
+
out[name] = cfg
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
const { args, command, ...rest } = cfg as any
|
|
112
|
+
const head: unknown[] | undefined =
|
|
113
|
+
typeof command === "string" ? [command] : Array.isArray(command) ? command : undefined
|
|
114
|
+
if (!head || head.length === 0 || head[0] === "" || head.some((c) => typeof c !== "string")) {
|
|
115
|
+
throw new Error(`mcp server "${name}": invalid command`)
|
|
116
|
+
}
|
|
117
|
+
if (args !== undefined && (!Array.isArray(args) || args.some((a: unknown) => typeof a !== "string"))) {
|
|
118
|
+
throw new Error(`mcp server "${name}": invalid args`)
|
|
119
|
+
}
|
|
120
|
+
out[name] = { ...rest, command: [...(head as string[]), ...((args as string[] | undefined) ?? [])] }
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
/**
|
|
@@ -240,12 +266,16 @@ function convertTool(server: string, def: McpToolDef, client: Client, timeout: n
|
|
|
240
266
|
description: def.description ?? "",
|
|
241
267
|
inputSchema,
|
|
242
268
|
source: "mcp",
|
|
269
|
+
...(mcpHints((def as any).annotations) ? { hints: mcpHints((def as any).annotations) } : {}),
|
|
243
270
|
async execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
|
|
244
271
|
try {
|
|
245
272
|
const result: any = await client.callTool(
|
|
246
273
|
{ name: def.name, arguments: args ?? {} },
|
|
247
274
|
undefined,
|
|
248
|
-
|
|
275
|
+
// The SDK only attaches `_meta.progressToken` when `onprogress` is supplied; without it
|
|
276
|
+
// `resetTimeoutOnProgress` never fires (add-mcp-progress-timeout). Notifications for a
|
|
277
|
+
// token that matches no in-flight request are dropped by the SDK, so they never reset.
|
|
278
|
+
{ onprogress: () => {}, resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
|
|
249
279
|
)
|
|
250
280
|
// Parts are collected BEFORE any branch returns: the isError and structuredContent
|
|
251
281
|
// short-circuits both read `content[]` on the way past, so neither reintroduces the
|
|
@@ -271,6 +301,129 @@ export interface McpSource {
|
|
|
271
301
|
tools: Tool[]
|
|
272
302
|
status: Record<string, McpStatus>
|
|
273
303
|
close(): Promise<void>
|
|
304
|
+
/**
|
|
305
|
+
* `list_mcp_resources` and `read_mcp_resource` bound to this source's connected servers
|
|
306
|
+
* (SPEC §4C). Both read-only. Opt-in: register them on a toolkit. They stop working once the
|
|
307
|
+
* source is closed.
|
|
308
|
+
*/
|
|
309
|
+
resourceTools(): Tool[]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** One server's outcome, reported by `loadMcp`'s `onServer` the moment that server finishes. */
|
|
313
|
+
export interface McpServerResult {
|
|
314
|
+
name: string
|
|
315
|
+
status: McpStatus
|
|
316
|
+
/** This server's converted tools (empty unless connected). Usable until the source closes. */
|
|
317
|
+
tools: Tool[]
|
|
318
|
+
/** Why the server failed (absent when connected or disabled). */
|
|
319
|
+
error?: Error
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* MCP tool annotations → ToolHints (SPEC §4C). No hint fields at all ⇒ undefined (unknown).
|
|
324
|
+
* Defaults follow the MCP spec: readOnlyHint false, destructiveHint and openWorldHint true.
|
|
325
|
+
*/
|
|
326
|
+
export function mcpHints(a: { readOnlyHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean } | undefined): ToolHints | undefined {
|
|
327
|
+
if (!a || (a.readOnlyHint === undefined && a.destructiveHint === undefined && a.openWorldHint === undefined)) return undefined
|
|
328
|
+
const h: ToolHints = { readOnly: a.readOnlyHint ?? false, destructive: a.destructiveHint ?? true, openWorld: a.openWorldHint ?? true }
|
|
329
|
+
if (h.readOnly) h.destructive = false
|
|
330
|
+
return h
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface ServerConn {
|
|
334
|
+
client: Client
|
|
335
|
+
timeout: number
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function resourceTools(servers: Map<string, ServerConn>): Tool[] {
|
|
339
|
+
const ro: ToolHints = { readOnly: true, destructive: false, openWorld: false }
|
|
340
|
+
const names = () => [...servers.keys()].sort()
|
|
341
|
+
const conn = (server: string): ServerConn => {
|
|
342
|
+
const c = servers.get(server)
|
|
343
|
+
if (!c) throw new Error(`no connected MCP server "${server}" (connected: ${names().join(", ")})`)
|
|
344
|
+
return c
|
|
345
|
+
}
|
|
346
|
+
const list: Tool = {
|
|
347
|
+
name: "list_mcp_resources",
|
|
348
|
+
description: "List the resources MCP servers offer (uri, name, description, mimeType). Optionally for one server.",
|
|
349
|
+
inputSchema: {
|
|
350
|
+
type: "object",
|
|
351
|
+
properties: { server: { type: "string", description: "Only this server (default: all)" } },
|
|
352
|
+
additionalProperties: false,
|
|
353
|
+
},
|
|
354
|
+
source: "mcp",
|
|
355
|
+
hints: ro,
|
|
356
|
+
async execute(args, ctx) {
|
|
357
|
+
let targets = names()
|
|
358
|
+
const s = typeof args?.server === "string" ? args.server : ""
|
|
359
|
+
if (s) {
|
|
360
|
+
try {
|
|
361
|
+
conn(s)
|
|
362
|
+
} catch (e) {
|
|
363
|
+
return { output: `list_mcp_resources: ${(e as Error).message}`, isError: true }
|
|
364
|
+
}
|
|
365
|
+
targets = [s]
|
|
366
|
+
}
|
|
367
|
+
const lines: string[] = []
|
|
368
|
+
const items: unknown[] = []
|
|
369
|
+
for (const name of targets) {
|
|
370
|
+
const c = conn(name)
|
|
371
|
+
try {
|
|
372
|
+
const res: any = await c.client.listResources({}, { timeout: c.timeout, signal: ctx?.signal })
|
|
373
|
+
for (const r of res.resources ?? []) {
|
|
374
|
+
let line = `${name} ${r.uri}`
|
|
375
|
+
if (r.name) line += ` — ${r.name}`
|
|
376
|
+
if (r.mimeType) line += ` (${r.mimeType})`
|
|
377
|
+
if (r.description) line += `: ${r.description}`
|
|
378
|
+
lines.push(line)
|
|
379
|
+
items.push({ server: name, uri: r.uri, name: r.name ?? "", mimeType: r.mimeType ?? "", description: r.description ?? "" })
|
|
380
|
+
}
|
|
381
|
+
} catch (e) {
|
|
382
|
+
lines.push(`${name}: error: ${e instanceof Error ? e.message : String(e)}`)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!lines.length) return { output: "No resources.", isError: false, metadata: { count: 0 } }
|
|
386
|
+
return { output: lines.join("\n"), isError: false, metadata: { count: items.length, resources: items } }
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
const read: Tool = {
|
|
390
|
+
name: "read_mcp_resource",
|
|
391
|
+
description: "Read one MCP resource by server and uri. Text comes back as text; binary content is described, not inlined.",
|
|
392
|
+
inputSchema: {
|
|
393
|
+
type: "object",
|
|
394
|
+
properties: {
|
|
395
|
+
server: { type: "string", description: "The MCP server name" },
|
|
396
|
+
uri: { type: "string", description: "The resource uri" },
|
|
397
|
+
},
|
|
398
|
+
required: ["server", "uri"],
|
|
399
|
+
additionalProperties: false,
|
|
400
|
+
},
|
|
401
|
+
source: "mcp",
|
|
402
|
+
hints: ro,
|
|
403
|
+
async execute(args, ctx) {
|
|
404
|
+
const server = typeof args?.server === "string" ? args.server : ""
|
|
405
|
+
const uri = typeof args?.uri === "string" ? args.uri : ""
|
|
406
|
+
let c: ServerConn
|
|
407
|
+
try {
|
|
408
|
+
c = conn(server)
|
|
409
|
+
} catch (e) {
|
|
410
|
+
return { output: `read_mcp_resource: ${(e as Error).message}`, isError: true }
|
|
411
|
+
}
|
|
412
|
+
if (!uri) return { output: "read_mcp_resource: uri is required", isError: true }
|
|
413
|
+
try {
|
|
414
|
+
const res: any = await c.client.readResource({ uri }, { timeout: c.timeout, signal: ctx?.signal })
|
|
415
|
+
const parts: string[] = []
|
|
416
|
+
for (const rc of res.contents ?? []) {
|
|
417
|
+
if (typeof rc.text === "string") parts.push(rc.text)
|
|
418
|
+
else if (typeof rc.blob === "string") parts.push(`[binary ${rc.mimeType ?? ""}, ${rc.blob.length} base64 chars]`)
|
|
419
|
+
}
|
|
420
|
+
return { output: parts.join("\n"), isError: false, metadata: { server, uri } }
|
|
421
|
+
} catch (e) {
|
|
422
|
+
return { output: `read_mcp_resource: ${e instanceof Error ? e.message : String(e)}`, isError: true }
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
return [list, read]
|
|
274
427
|
}
|
|
275
428
|
|
|
276
429
|
/** Create a fresh (not-yet-connected) client, with the elicitation bridge attached if a waitFor
|
|
@@ -318,19 +471,44 @@ async function connectTransport(client: Client, cfg: ServerConfig): Promise<void
|
|
|
318
471
|
* whole load promptly on parent cancellation. */
|
|
319
472
|
export async function loadMcp(
|
|
320
473
|
input: string | object,
|
|
321
|
-
opts?: {
|
|
474
|
+
opts?: {
|
|
475
|
+
waitFor?: (request: Request) => Promise<Answer>
|
|
476
|
+
signal?: AbortSignal
|
|
477
|
+
/**
|
|
478
|
+
* Called once per server the moment it is connected, failed or found disabled (SPEC §4C),
|
|
479
|
+
* so a host can register a fast server's tools while a slow one is still connecting.
|
|
480
|
+
* Calls are serialized (an async callback is awaited before the next one runs); the load
|
|
481
|
+
* still resolves only after every server and every callback has finished.
|
|
482
|
+
*/
|
|
483
|
+
onServer?: (result: McpServerResult) => void | Promise<void>
|
|
484
|
+
},
|
|
322
485
|
): Promise<McpSource> {
|
|
323
486
|
const config = parseMcpConfig(input)
|
|
324
487
|
const tools: Tool[] = []
|
|
325
488
|
const status: Record<string, McpStatus> = {}
|
|
326
489
|
const clients: Client[] = []
|
|
490
|
+
const servers = new Map<string, ServerConn>()
|
|
327
491
|
const waitFor = opts?.waitFor
|
|
328
492
|
const signal = opts?.signal
|
|
493
|
+
const onServer = opts?.onServer
|
|
494
|
+
let reports: Promise<void> = Promise.resolve()
|
|
495
|
+
let reportError: unknown
|
|
496
|
+
const report = (r: McpServerResult) => {
|
|
497
|
+
if (!onServer) return
|
|
498
|
+
reports = reports.then(async () => {
|
|
499
|
+
try {
|
|
500
|
+
await onServer(r)
|
|
501
|
+
} catch (e) {
|
|
502
|
+
reportError ??= e
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
}
|
|
329
506
|
|
|
330
|
-
|
|
507
|
+
const loading = Promise.all(
|
|
331
508
|
Object.entries(config).map(async ([name, cfg]) => {
|
|
332
509
|
if (!isEnabled(cfg)) {
|
|
333
510
|
status[name] = "disabled"
|
|
511
|
+
report({ name, status: "disabled", tools: [] })
|
|
334
512
|
return
|
|
335
513
|
}
|
|
336
514
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
|
|
@@ -341,19 +519,37 @@ export async function loadMcp(
|
|
|
341
519
|
await raceTimeout(connectTransport(client, cfg), timeout, signal)
|
|
342
520
|
const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal)
|
|
343
521
|
const kept = applyToolsFilter(name, defs, (cfg as { tools?: Record<string, boolean> }).tools)
|
|
344
|
-
|
|
522
|
+
const converted = kept.map((def) => convertTool(name, def, client, timeout))
|
|
523
|
+
tools.push(...converted)
|
|
345
524
|
clients.push(client)
|
|
525
|
+
servers.set(name, { client, timeout })
|
|
346
526
|
status[name] = "connected"
|
|
527
|
+
report({ name, status: "connected", tools: converted })
|
|
347
528
|
} catch (e) {
|
|
348
529
|
status[name] = "failed"
|
|
349
530
|
await client.close().catch(() => {})
|
|
350
531
|
// Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
|
|
351
532
|
// within budget isolates just this server.
|
|
352
533
|
if (signal?.aborted) throw e
|
|
534
|
+
report({ name, status: "failed", tools: [], error: e instanceof Error ? e : new Error(String(e)) })
|
|
353
535
|
console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
354
536
|
}
|
|
355
537
|
}),
|
|
356
538
|
)
|
|
539
|
+
try {
|
|
540
|
+
await loading
|
|
541
|
+
} catch (e) {
|
|
542
|
+
// Parent cancellation: close every client, including ones whose tools onServer handed out.
|
|
543
|
+
await reports
|
|
544
|
+
await Promise.all(clients.map((c) => c.close().catch(() => {})))
|
|
545
|
+
throw e
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
await reports
|
|
549
|
+
if (reportError) {
|
|
550
|
+
await Promise.all(clients.map((c) => c.close().catch(() => {})))
|
|
551
|
+
throw reportError
|
|
552
|
+
}
|
|
357
553
|
|
|
358
554
|
return {
|
|
359
555
|
tools,
|
|
@@ -361,6 +557,7 @@ export async function loadMcp(
|
|
|
361
557
|
async close() {
|
|
362
558
|
await Promise.all(clients.map((c) => c.close().catch(() => {})))
|
|
363
559
|
},
|
|
560
|
+
resourceTools: () => resourceTools(servers),
|
|
364
561
|
}
|
|
365
562
|
}
|
|
366
563
|
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* listModels (SPEC §4C): the models a provider offers, for model switching. One GET per page,
|
|
3
|
+
* no retries. Same key rules as the client (apiKey, apiKeyEnv, or the host's own env var).
|
|
4
|
+
*/
|
|
5
|
+
import { resolveKey, type ClientOptions } from "./client.js"
|
|
6
|
+
|
|
7
|
+
/** One model a provider lists. */
|
|
8
|
+
export interface ModelInfo {
|
|
9
|
+
id: string
|
|
10
|
+
/** The provider's display name, when it gives one. */
|
|
11
|
+
name?: string
|
|
12
|
+
/** Context window in tokens when the provider reports it (OpenRouter does). */
|
|
13
|
+
contextLength?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function getJson(f: typeof fetch, url: string, headers: Record<string, string>, signal?: AbortSignal): Promise<any> {
|
|
17
|
+
const res = await f(url, { method: "GET", headers, signal })
|
|
18
|
+
const text = await res.text()
|
|
19
|
+
if (!res.ok) throw new Error(`list models: HTTP ${res.status}: ${text.slice(0, 300)}`)
|
|
20
|
+
return JSON.parse(text)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* List the models at `opts.baseUrl` in `opts.style`: openai `GET {base}/models`; anthropic
|
|
25
|
+
* `GET {base}/v1/models`, following `has_more`/`last_id`. Sorted by id.
|
|
26
|
+
*/
|
|
27
|
+
export async function listModels(
|
|
28
|
+
opts: Pick<ClientOptions, "baseUrl" | "style" | "apiKey" | "apiKeyEnv" | "headers" | "fetch">,
|
|
29
|
+
ctx?: { signal?: AbortSignal },
|
|
30
|
+
): Promise<ModelInfo[]> {
|
|
31
|
+
const key = resolveKey(opts)
|
|
32
|
+
const f = opts.fetch ?? fetch
|
|
33
|
+
const base = opts.baseUrl.replace(/\/+$/, "")
|
|
34
|
+
const out: ModelInfo[] = []
|
|
35
|
+
if (opts.style === "anthropic") {
|
|
36
|
+
const endpoint = base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`
|
|
37
|
+
let after = ""
|
|
38
|
+
for (let page = 0; page < 100; page++) {
|
|
39
|
+
const url = `${endpoint}?limit=1000${after ? `&after_id=${encodeURIComponent(after)}` : ""}`
|
|
40
|
+
const body = await getJson(f, url, { "x-api-key": key, "anthropic-version": "2023-06-01", ...opts.headers }, ctx?.signal)
|
|
41
|
+
for (const m of body?.data ?? []) out.push({ id: m.id, ...(m.display_name ? { name: m.display_name } : {}) })
|
|
42
|
+
if (!body?.has_more || !body?.last_id) break
|
|
43
|
+
after = body.last_id
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
const body = await getJson(f, `${base}/models`, { Authorization: `Bearer ${key}`, ...opts.headers }, ctx?.signal)
|
|
47
|
+
for (const m of body?.data ?? []) {
|
|
48
|
+
out.push({
|
|
49
|
+
id: m.id,
|
|
50
|
+
...(m.name ? { name: m.name } : {}),
|
|
51
|
+
...(typeof m.context_length === "number" && m.context_length > 0 ? { contextLength: m.context_length } : {}),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
56
|
+
}
|