toolnexus 0.16.0 → 0.18.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 +46 -0
- package/dist/agents/agent.d.ts +11 -1
- package/dist/agents/agent.js +0 -9
- package/dist/agents/compaction.d.ts +0 -25
- package/dist/agents/compaction.js +41 -2
- package/dist/agents/loop.d.ts +3 -2
- package/dist/agents/loop.js +10 -14
- package/dist/agents/runtime.d.ts +4 -3
- package/dist/agents/runtime.js +8 -17
- package/dist/builtin.js +24 -3
- package/dist/classifier.d.ts +334 -0
- package/dist/classifier.js +630 -0
- package/dist/client.d.ts +40 -7
- package/dist/client.js +39 -33
- package/dist/content.d.ts +158 -0
- package/dist/content.js +379 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +9 -0
- package/dist/mcp.js +59 -4
- package/dist/mcpserve.js +27 -1
- package/dist/retry.d.ts +30 -0
- package/dist/retry.js +55 -0
- package/dist/translate.js +30 -2
- package/dist/types.d.ts +8 -0
- package/dist/wire.d.ts +40 -0
- package/dist/wire.js +104 -0
- package/package.json +5 -3
- package/src/agents/agent.ts +2 -1
- package/src/agents/compaction.ts +39 -1
- package/src/agents/loop.ts +17 -4
- package/src/agents/runtime.ts +13 -5
- package/src/builtin.ts +23 -3
- package/src/classifier.ts +832 -0
- package/src/client.ts +84 -44
- package/src/content.ts +448 -0
- package/src/index.ts +3 -0
- package/src/mcp.ts +55 -4
- package/src/mcpserve.ts +26 -3
- package/src/retry.ts +57 -0
- package/src/translate.ts +25 -1
- package/src/types.ts +9 -0
- package/src/wire.ts +131 -0
package/src/content.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multimodal content parts (SPEC.md §1B, §8A).
|
|
3
|
+
*
|
|
4
|
+
* One `ContentPart` union — `text | image | file | audio` — is how non-text data enters
|
|
5
|
+
* the loop (a prompt), comes back from a tool (`ToolResult.parts`), and survives MCP.
|
|
6
|
+
* A part is **bytes-or-URL, never a path**: a path does not survive a persisted and
|
|
7
|
+
* replayed transcript, nor the MCP / A2A process boundary, so the edge constructors
|
|
8
|
+
* (`attach`, `text`) normalise a path / native bytes / a `data:` URL at construction.
|
|
9
|
+
*
|
|
10
|
+
* Emission lives here too, not in `adapters.ts` (which is tool-*schema* only): each
|
|
11
|
+
* `(style, part.type)` pair has either a defined block shape or an explicit refusal, and
|
|
12
|
+
* the encoded block is checked against a **positive allowlist** before it can reach the
|
|
13
|
+
* wire — because an unknown block type upstream returns HTTP 200 with the content
|
|
14
|
+
* silently discarded, which is the exact bug this module exists to remove.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs/promises"
|
|
17
|
+
import path from "node:path"
|
|
18
|
+
|
|
19
|
+
export type ContentPart =
|
|
20
|
+
| { type: "text"; text: string }
|
|
21
|
+
| { type: "image"; mimeType: string; data?: string; url?: string }
|
|
22
|
+
| { type: "file"; mimeType: string; data?: string; url?: string; name?: string }
|
|
23
|
+
| { type: "audio"; mimeType: string; data?: string; url?: string }
|
|
24
|
+
|
|
25
|
+
/** A prompt is a string or an ordered list of parts — ordering is semantic to a model. */
|
|
26
|
+
export type PromptInput = string | ContentPart[]
|
|
27
|
+
|
|
28
|
+
/** The two provider styles that have a request path (`toGemini` is declarations only). */
|
|
29
|
+
export type PartStyle = "openai" | "anthropic"
|
|
30
|
+
|
|
31
|
+
/** How a part got here. Drives the unsupported-part rule (§8A): the caller asked for an
|
|
32
|
+
* attached part, so silently changing it is the betrayal; nobody asked for a part an MCP
|
|
33
|
+
* server volunteered, so failing the run over it is a regression. */
|
|
34
|
+
export type PartProvenance = "attached" | "derived"
|
|
35
|
+
|
|
36
|
+
/** What to do with a part the style cannot represent; overrides provenance uniformly. */
|
|
37
|
+
export type UnsupportedPartMode = "error" | "text"
|
|
38
|
+
|
|
39
|
+
/** Why a part could not be constructed or sent (§1B / §8A). `code` is the machine-readable half. */
|
|
40
|
+
export class ContentPartError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
message: string,
|
|
43
|
+
readonly code: "source-conflict" | "source-missing" | "unknown-extension" | "too-large" | "unsupported",
|
|
44
|
+
) {
|
|
45
|
+
super(message)
|
|
46
|
+
this.name = "ContentPartError"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The media extension table (§6 `read`) — fixed, shared with the edge constructors,
|
|
52
|
+
* identical in every port. No magic-byte sniffing and no platform mime database:
|
|
53
|
+
* `/etc/mime.types` varies per machine and would break cross-port parity.
|
|
54
|
+
*/
|
|
55
|
+
export const MEDIA_TYPES: Record<string, { mimeType: string; type: "image" | "file" | "audio" }> = {
|
|
56
|
+
png: { mimeType: "image/png", type: "image" },
|
|
57
|
+
jpg: { mimeType: "image/jpeg", type: "image" },
|
|
58
|
+
jpeg: { mimeType: "image/jpeg", type: "image" },
|
|
59
|
+
gif: { mimeType: "image/gif", type: "image" },
|
|
60
|
+
webp: { mimeType: "image/webp", type: "image" },
|
|
61
|
+
pdf: { mimeType: "application/pdf", type: "file" },
|
|
62
|
+
mp3: { mimeType: "audio/mpeg", type: "audio" },
|
|
63
|
+
wav: { mimeType: "audio/wav", type: "audio" },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Lower-cased extension without the dot, or "" when there is none. */
|
|
67
|
+
export function extensionOf(p: string): string {
|
|
68
|
+
const ext = path.extname(p)
|
|
69
|
+
return ext ? ext.slice(1).toLowerCase() : ""
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The table entry for a path/URL, or undefined when the extension is not media. */
|
|
73
|
+
export function mediaTypeFor(p: string): { mimeType: string; type: "image" | "file" | "audio" } | undefined {
|
|
74
|
+
return MEDIA_TYPES[extensionOf(p)]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Part kind for a mime type: `image/*` ⇒ image, `audio/*` ⇒ audio, everything else ⇒ file. */
|
|
78
|
+
export function partTypeForMime(mimeType: string): "image" | "file" | "audio" {
|
|
79
|
+
if (mimeType.startsWith("image/")) return "image"
|
|
80
|
+
if (mimeType.startsWith("audio/")) return "audio"
|
|
81
|
+
return "file"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Decoded byte length of standard, padded base64 — without decoding it (RFC 4648 §4:
|
|
85
|
+
* `floor(len/4)*3 - padCount`). `<bytes>` in every rendered string is a plain integer, never
|
|
86
|
+
* a fraction, so this floors the group count rather than assuming `len` is a multiple of 4. */
|
|
87
|
+
export function base64Bytes(b64: string): number {
|
|
88
|
+
const n = b64.length
|
|
89
|
+
if (n === 0) return 0
|
|
90
|
+
let pad = 0
|
|
91
|
+
if (b64.endsWith("==")) pad = 2
|
|
92
|
+
else if (b64.endsWith("=")) pad = 1
|
|
93
|
+
return Math.floor(n / 4) * 3 - pad
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Decoded byte length of a part's payload; a url-only part carries no bytes here. */
|
|
97
|
+
export function partBytes(part: ContentPart): number {
|
|
98
|
+
if (part.type === "text") return Buffer.byteLength(part.text, "utf8")
|
|
99
|
+
return part.data ? base64Bytes(part.data) : 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** §1B token estimate for a part, from its decoded byte count: `max(85, floor(bytes/750))`.
|
|
103
|
+
* Measured against real providers: image cost is not proportional to bytes, so this is an
|
|
104
|
+
* explicit estimate, not a count — but it is normative and lives with the part logic so a
|
|
105
|
+
* budgeting helper (e.g. `agents.estimateTokens`) cannot silently diverge from it. */
|
|
106
|
+
export const PART_BYTES_PER_TOKEN = 750
|
|
107
|
+
export const MIN_PART_TOKENS = 85
|
|
108
|
+
export function partTokens(bytes: number): number {
|
|
109
|
+
return Math.max(MIN_PART_TOKENS, Math.floor(bytes / PART_BYTES_PER_TOKEN))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* How a part appears in a log line or a §9 event: `{type, mimeType, bytes}` — never `data`.
|
|
114
|
+
* Same rule as never-log-headers: part payloads are user content.
|
|
115
|
+
*/
|
|
116
|
+
export function describePart(part: ContentPart): Record<string, unknown> {
|
|
117
|
+
if (part.type === "text") return { type: "text", bytes: partBytes(part) }
|
|
118
|
+
return { type: part.type, mimeType: part.mimeType, bytes: partBytes(part) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A one-line, payload-free rendering of a part — for `output` text and placeholders. */
|
|
122
|
+
export function summarizePart(part: ContentPart): string {
|
|
123
|
+
return part.type === "text" ? part.text : `${part.type} (${part.mimeType}, ${partBytes(part)} bytes)`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Validate the §1B invariant: exactly one of `data` / `url`. */
|
|
127
|
+
export function validatePart(part: ContentPart, maxPartBytes?: number): ContentPart {
|
|
128
|
+
if (part.type === "text") return part
|
|
129
|
+
const hasData = part.data !== undefined && part.data !== null
|
|
130
|
+
const hasUrl = part.url !== undefined && part.url !== null
|
|
131
|
+
if (hasData && hasUrl) {
|
|
132
|
+
throw new ContentPartError(`content part "${part.type}" carries both data and url — supply exactly one`, "source-conflict")
|
|
133
|
+
}
|
|
134
|
+
if (!hasData && !hasUrl) {
|
|
135
|
+
throw new ContentPartError(`content part "${part.type}" carries neither data nor url — supply exactly one`, "source-missing")
|
|
136
|
+
}
|
|
137
|
+
if (maxPartBytes !== undefined && hasData) {
|
|
138
|
+
const bytes = base64Bytes(part.data!)
|
|
139
|
+
if (bytes > maxPartBytes) {
|
|
140
|
+
throw new ContentPartError(`content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${maxPartBytes}`, "too-large")
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return part
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True when a value looks like a `ContentPart` (as opposed to a provider-native block). */
|
|
147
|
+
export function isContentPart(v: unknown): v is ContentPart {
|
|
148
|
+
if (!v || typeof v !== "object") return false
|
|
149
|
+
const t = (v as any).type
|
|
150
|
+
if (t === "text") return typeof (v as any).text === "string"
|
|
151
|
+
if (t === "image" || t === "file" || t === "audio") return typeof (v as any).mimeType === "string"
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --------------------------------------------------------------------------- //
|
|
156
|
+
// Edge constructors — a path / bytes / a data: URL never enters the part itself.
|
|
157
|
+
// --------------------------------------------------------------------------- //
|
|
158
|
+
|
|
159
|
+
/** A text part. */
|
|
160
|
+
export function text(value: string): ContentPart {
|
|
161
|
+
return { type: "text", text: value }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Options for {@link attach}. */
|
|
165
|
+
export interface AttachOptions {
|
|
166
|
+
/** Overrides the extension table; **required** for raw bytes and for an unlisted extension. */
|
|
167
|
+
mimeType?: string
|
|
168
|
+
/** Filename carried on a `file` part (OpenAI's `file.filename`). Defaults to the basename. */
|
|
169
|
+
name?: string
|
|
170
|
+
/** Reject a payload over this many **decoded** bytes (never the +33% base64 string). */
|
|
171
|
+
maxPartBytes?: number
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Everything the edge constructor accepts (§1B): a path / `data:` / `https:` string, native
|
|
175
|
+
* bytes in any of their JS spellings, or the host's own file object. Whatever comes in, only
|
|
176
|
+
* `mimeType` + base64 `data` (or a `url`) lands in the part — accept broadly, store narrowly. */
|
|
177
|
+
export type AttachSource = string | Uint8Array | ArrayBuffer | ArrayBufferView | Blob
|
|
178
|
+
|
|
179
|
+
/** A `Blob`/`File` — duck-typed, so a Blob from another realm or a polyfill is accepted too. */
|
|
180
|
+
function isBlobLike(v: unknown): v is Blob {
|
|
181
|
+
return !!v && typeof v === "object" && typeof (v as any).arrayBuffer === "function" && !ArrayBuffer.isView(v)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Normalise any native byte spelling to a `Uint8Array` view over the same bytes — an
|
|
185
|
+
* `ArrayBufferView` that is not a `Uint8Array` must be re-viewed, not passed to
|
|
186
|
+
* `Buffer.from`, which would read it as an array of numbers. */
|
|
187
|
+
function toBytes(source: Uint8Array | ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
188
|
+
if (source instanceof Uint8Array) return source
|
|
189
|
+
if (ArrayBuffer.isView(source)) return new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
|
|
190
|
+
return new Uint8Array(source)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The typed §1B refusal for a name/path whose extension is not in the fixed table. */
|
|
194
|
+
function unknownExtension(nameOrPath: string): ContentPartError {
|
|
195
|
+
const ext = extensionOf(nameOrPath)
|
|
196
|
+
return new ContentPartError(
|
|
197
|
+
`no mime type for extension "${ext || "(none)"}" — pass an explicit mimeType (mime is never sniffed)`,
|
|
198
|
+
"unknown-extension",
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The edge constructor: hand it whatever you actually have and get a path-free part.
|
|
204
|
+
*
|
|
205
|
+
* | given | becomes |
|
|
206
|
+
* |---|---|
|
|
207
|
+
* | a filesystem path | bytes read now, base64d now; mime from the fixed extension table |
|
|
208
|
+
* | native bytes (`Uint8Array`/`Buffer`/`ArrayBuffer`/any `ArrayBufferView`) | base64d now; `mimeType` required |
|
|
209
|
+
* | a `Blob` / `File` | read to bytes **now**; mime from `blob.type`, else the extension table via `File.name` |
|
|
210
|
+
* | `data:<mime>;base64,<b64>` | parsed into `{mimeType, data}` — never stored as a url |
|
|
211
|
+
* | an `http(s):` URL | kept as `url` |
|
|
212
|
+
*/
|
|
213
|
+
export async function attach(source: AttachSource, opts: AttachOptions = {}): Promise<ContentPart> {
|
|
214
|
+
if (typeof source === "string") {
|
|
215
|
+
if (source.startsWith("data:")) return fromDataUrl(source, opts)
|
|
216
|
+
if (/^https?:\/\//i.test(source)) return fromUrl(source, opts)
|
|
217
|
+
return fromPath(source, opts)
|
|
218
|
+
}
|
|
219
|
+
if (isBlobLike(source)) return fromBlob(source, opts)
|
|
220
|
+
if (!opts.mimeType) {
|
|
221
|
+
throw new ContentPartError("attach(bytes) requires an explicit mimeType — bytes carry no extension to read", "unknown-extension")
|
|
222
|
+
}
|
|
223
|
+
return fromBytes(toBytes(source), opts.mimeType, opts)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Build a part from a `Blob` or `File`, consuming it **eagerly** — a part holding a half-read
|
|
228
|
+
* stream would not survive the transcript boundary any better than a path does (§1B).
|
|
229
|
+
*
|
|
230
|
+
* Mime comes from `blob.type` when it is non-empty, else from the fixed extension table applied
|
|
231
|
+
* to `File.name`; an explicit `opts.mimeType` beats both. A `File`'s name is carried onto a
|
|
232
|
+
* `file` part unless `opts.name` overrides it.
|
|
233
|
+
*/
|
|
234
|
+
export async function fromBlob(blob: Blob, opts: AttachOptions = {}): Promise<ContentPart> {
|
|
235
|
+
const name = typeof (blob as any).name === "string" ? String((blob as any).name) : undefined
|
|
236
|
+
const mimeType = opts.mimeType || (blob.type || undefined) || (name ? mediaTypeFor(name)?.mimeType : undefined)
|
|
237
|
+
if (!mimeType) throw unknownExtension(name ?? "")
|
|
238
|
+
const bytes = new Uint8Array(await blob.arrayBuffer())
|
|
239
|
+
return finish(partTypeForMime(mimeType), mimeType, { data: Buffer.from(bytes).toString("base64") }, name ? { name, ...opts } : opts)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Build a part from in-memory bytes. `mimeType` is required — bytes carry no extension. */
|
|
243
|
+
export function fromBytes(bytes: Uint8Array | ArrayBuffer | ArrayBufferView, mimeType: string, opts: AttachOptions = {}): ContentPart {
|
|
244
|
+
const data = Buffer.from(toBytes(bytes)).toString("base64")
|
|
245
|
+
return finish(partTypeForMime(mimeType), mimeType, { data }, opts)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Read a file now and base64 it now, so the part never carries the path (§1B). */
|
|
249
|
+
export async function fromPath(p: string, opts: AttachOptions = {}): Promise<ContentPart> {
|
|
250
|
+
const mimeType = opts.mimeType ?? mediaTypeFor(p)?.mimeType
|
|
251
|
+
if (!mimeType) throw unknownExtension(p)
|
|
252
|
+
const bytes = await fs.readFile(p)
|
|
253
|
+
return finish(partTypeForMime(mimeType), mimeType, { data: bytes.toString("base64") }, { name: path.basename(p), ...opts })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Parse `data:<mime>;base64,<b64>` into `{mimeType, data}`, so two spellings of the same
|
|
257
|
+
* bytes cannot diverge downstream. */
|
|
258
|
+
export function fromDataUrl(url: string, opts: AttachOptions = {}): ContentPart {
|
|
259
|
+
const m = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(url)
|
|
260
|
+
if (!m) throw new ContentPartError("malformed data: URL", "source-missing")
|
|
261
|
+
const mimeType = opts.mimeType ?? (m[1] || "application/octet-stream")
|
|
262
|
+
const data = m[2] ? m[3] : Buffer.from(decodeURIComponent(m[3]), "utf8").toString("base64")
|
|
263
|
+
return finish(partTypeForMime(mimeType), mimeType, { data }, opts)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Keep an `http(s):` URL as a url part; mime comes from the table or an explicit override. */
|
|
267
|
+
export function fromUrl(url: string, opts: AttachOptions = {}): ContentPart {
|
|
268
|
+
const mimeType = opts.mimeType ?? mediaTypeFor(new URL(url).pathname)?.mimeType
|
|
269
|
+
if (!mimeType) throw unknownExtension(new URL(url).pathname)
|
|
270
|
+
return finish(partTypeForMime(mimeType), mimeType, { url }, opts)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function finish(
|
|
274
|
+
type: "image" | "file" | "audio",
|
|
275
|
+
mimeType: string,
|
|
276
|
+
source: { data?: string; url?: string },
|
|
277
|
+
opts: AttachOptions,
|
|
278
|
+
): ContentPart {
|
|
279
|
+
const part: any = { type, mimeType, ...source }
|
|
280
|
+
if (type === "file" && opts.name) part.name = opts.name
|
|
281
|
+
return validatePart(part as ContentPart, opts.maxPartBytes)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --------------------------------------------------------------------------- //
|
|
285
|
+
// §8A emission — the positive allowlist, and the unsupported-part rule.
|
|
286
|
+
// --------------------------------------------------------------------------- //
|
|
287
|
+
|
|
288
|
+
/** The ONLY block types that may reach each style's wire. A part encoding to anything
|
|
289
|
+
* else never leaves this module (map-and-hope is how content gets silently discarded). */
|
|
290
|
+
const ALLOWLIST: Record<PartStyle, ReadonlySet<string>> = {
|
|
291
|
+
openai: new Set(["text", "image_url", "file", "input_audio"]),
|
|
292
|
+
anthropic: new Set(["text", "image", "document"]),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** OpenAI's `input_audio.format` is a bare format name, not a mime type. */
|
|
296
|
+
const AUDIO_FORMATS: Record<string, string> = { "audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/wav": "wav", "audio/x-wav": "wav" }
|
|
297
|
+
|
|
298
|
+
const dataUrl = (mimeType: string, data: string) => `data:${mimeType};base64,${data}`
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Encode one part into its provider block, or `undefined` when the style defines no shape
|
|
302
|
+
* for it. Refusals are explicit, never a fall-through: `openai × file+url` (Chat Completions
|
|
303
|
+
* has no URL form for a file) and `anthropic × audio` (the provider defines no audio block).
|
|
304
|
+
*/
|
|
305
|
+
export function encodePart(part: ContentPart, style: PartStyle): Record<string, any> | undefined {
|
|
306
|
+
if (part.type === "text") return { type: "text", text: part.text }
|
|
307
|
+
if (style === "openai") {
|
|
308
|
+
switch (part.type) {
|
|
309
|
+
case "image":
|
|
310
|
+
return { type: "image_url", image_url: { url: part.data ? dataUrl(part.mimeType, part.data) : part.url } }
|
|
311
|
+
case "file":
|
|
312
|
+
// `file_data` REQUIRES the `data:<mime>;base64,` prefix — a bare base64 string is a 400.
|
|
313
|
+
if (!part.data) return undefined
|
|
314
|
+
return { type: "file", file: { filename: part.name ?? "file", file_data: dataUrl(part.mimeType, part.data) } }
|
|
315
|
+
case "audio":
|
|
316
|
+
if (!part.data) return undefined
|
|
317
|
+
return { type: "input_audio", input_audio: { data: part.data, format: AUDIO_FORMATS[part.mimeType] ?? part.mimeType.replace(/^audio\//, "") } }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
switch (part.type) {
|
|
321
|
+
case "image":
|
|
322
|
+
return part.data
|
|
323
|
+
? { type: "image", source: { type: "base64", media_type: part.mimeType, data: part.data } }
|
|
324
|
+
: { type: "image", source: { type: "url", url: part.url } }
|
|
325
|
+
case "file":
|
|
326
|
+
return part.data
|
|
327
|
+
? { type: "document", source: { type: "base64", media_type: part.mimeType, data: part.data } }
|
|
328
|
+
: { type: "document", source: { type: "url", url: part.url } }
|
|
329
|
+
case "audio":
|
|
330
|
+
return undefined // Anthropic defines no audio block — a named refusal, not an oversight.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Context for {@link encodeParts}: which wire, who asked for the part, and the override. */
|
|
335
|
+
export interface EncodeOptions {
|
|
336
|
+
style: PartStyle
|
|
337
|
+
provenance: PartProvenance
|
|
338
|
+
onUnsupportedPart?: UnsupportedPartMode
|
|
339
|
+
maxPartBytes?: number
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const warnedUnsupported = new Set<string>()
|
|
343
|
+
|
|
344
|
+
/** Test seam: forget which unsupported (style, type) pairs have already been warned about. */
|
|
345
|
+
export function resetUnsupportedWarnings(): void {
|
|
346
|
+
warnedUnsupported.clear()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The text a degraded (non-failing) unsupported part leaves behind — never silence. */
|
|
350
|
+
export function unsupportedPlaceholder(part: ContentPart): string {
|
|
351
|
+
return `[unsupported ${part.type} part (${(part as any).mimeType}, ${partBytes(part)} bytes)]`
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Warn once per (style, type, reason) — the same latch the unsupported-block path uses,
|
|
355
|
+
* keyed separately (by `reason`, not by the message text) so an oversize warning and a
|
|
356
|
+
* no-block warning never suppress each other, and a differently-sized oversize part of the
|
|
357
|
+
* same type doesn't re-trigger the latch. */
|
|
358
|
+
function warnUnsupportedOnce(style: PartStyle, type: string, reason: "unsupported" | "too-large", message: string): void {
|
|
359
|
+
const key = `${style}:${type}:${reason}`
|
|
360
|
+
if (warnedUnsupported.has(key)) return
|
|
361
|
+
warnedUnsupported.add(key)
|
|
362
|
+
console.warn(`[toolnexus] ${message}`)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Encode a list of parts for one style, applying the §8A rules:
|
|
367
|
+
* an **attached** part the style cannot represent raises before any HTTP call; a **derived**
|
|
368
|
+
* part degrades to a text placeholder and warns once. `onUnsupportedPart` overrides both.
|
|
369
|
+
* Every emitted block is asserted against the style's allowlist.
|
|
370
|
+
*
|
|
371
|
+
* `maxPartBytes` (§1B) is enforced here too, at assembly, over EVERY part regardless of
|
|
372
|
+
* provenance — a part that arrived from an MCP server never passed through an edge
|
|
373
|
+
* constructor, so a limit it can walk around is not a limit. Going over follows the exact
|
|
374
|
+
* same provenance split as an unsupported block: an attached part errors, a derived one
|
|
375
|
+
* degrades to the placeholder so a remote server still cannot fail the run.
|
|
376
|
+
*/
|
|
377
|
+
export function encodeParts(parts: readonly ContentPart[], opts: EncodeOptions): Record<string, any>[] {
|
|
378
|
+
const mode: UnsupportedPartMode = opts.onUnsupportedPart ?? (opts.provenance === "attached" ? "error" : "text")
|
|
379
|
+
const out: Record<string, any>[] = []
|
|
380
|
+
for (const raw of parts) {
|
|
381
|
+
const part = validatePart(raw)
|
|
382
|
+
if (opts.maxPartBytes !== undefined && part.type !== "text") {
|
|
383
|
+
const bytes = partBytes(part)
|
|
384
|
+
if (bytes > opts.maxPartBytes) {
|
|
385
|
+
if (mode === "error") {
|
|
386
|
+
throw new ContentPartError(
|
|
387
|
+
`content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${opts.maxPartBytes}`,
|
|
388
|
+
"too-large",
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
warnUnsupportedOnce(
|
|
392
|
+
opts.style,
|
|
393
|
+
part.type,
|
|
394
|
+
"too-large",
|
|
395
|
+
`content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${opts.maxPartBytes} — sending a text placeholder`,
|
|
396
|
+
)
|
|
397
|
+
out.push({ type: "text", text: unsupportedPlaceholder(part) })
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const block = encodePart(part, opts.style)
|
|
402
|
+
if (block && ALLOWLIST[opts.style].has(String(block.type))) {
|
|
403
|
+
out.push(block)
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
if (mode === "error") {
|
|
407
|
+
throw new ContentPartError(
|
|
408
|
+
`provider style "${opts.style}" defines no block for a "${part.type}" content part` +
|
|
409
|
+
(part.type !== "text" ? ` (${part.mimeType})` : ""),
|
|
410
|
+
"unsupported",
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
warnUnsupportedOnce(opts.style, part.type, "unsupported", `provider style "${opts.style}" has no block for a "${part.type}" part — sending a text placeholder`)
|
|
414
|
+
out.push({ type: "text", text: unsupportedPlaceholder(part) })
|
|
415
|
+
}
|
|
416
|
+
return out
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Read an inbound OpenAI-shaped content block back into a `ContentPart` (§11). Accepts both
|
|
421
|
+
* a `ContentPart` written literally and the provider-native block the same part encodes to,
|
|
422
|
+
* so a caller's own OpenAI messages translate as faithfully as ours do.
|
|
423
|
+
*/
|
|
424
|
+
export function inboundPart(block: unknown): ContentPart | undefined {
|
|
425
|
+
if (isContentPart(block)) return block
|
|
426
|
+
if (!block || typeof block !== "object") return undefined
|
|
427
|
+
const b = block as any
|
|
428
|
+
if (b.type === "image_url" && b.image_url?.url) {
|
|
429
|
+
const url = String(b.image_url.url)
|
|
430
|
+
try {
|
|
431
|
+
return url.startsWith("data:") ? fromDataUrl(url) : { type: "image", mimeType: mediaTypeFor(new URL(url).pathname)?.mimeType ?? "image/*", url }
|
|
432
|
+
} catch {
|
|
433
|
+
return undefined
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (b.type === "file" && typeof b.file?.file_data === "string") {
|
|
437
|
+
const part = fromDataUrl(String(b.file.file_data)) as any
|
|
438
|
+
part.type = "file"
|
|
439
|
+
if (b.file.filename) part.name = String(b.file.filename)
|
|
440
|
+
return part as ContentPart
|
|
441
|
+
}
|
|
442
|
+
if (b.type === "input_audio" && typeof b.input_audio?.data === "string") {
|
|
443
|
+
const fmt = String(b.input_audio.format ?? "")
|
|
444
|
+
const mimeType = fmt === "mp3" ? "audio/mpeg" : fmt === "wav" ? "audio/wav" : `audio/${fmt || "*"}`
|
|
445
|
+
return { type: "audio", mimeType, data: String(b.input_audio.data) }
|
|
446
|
+
}
|
|
447
|
+
return undefined
|
|
448
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export * from "./content.js"
|
|
2
|
+
export * from "./wire.js"
|
|
1
3
|
export * from "./types.js"
|
|
2
4
|
export * from "./mcp.js"
|
|
3
5
|
export * from "./skill.js"
|
|
@@ -11,4 +13,5 @@ export * from "./adapters.js"
|
|
|
11
13
|
export * from "./translate.js"
|
|
12
14
|
export * from "./toolkit.js"
|
|
13
15
|
export * from "./client.js"
|
|
16
|
+
export * from "./classifier.js"
|
|
14
17
|
export * as agents from "./agents/index.js"
|
package/src/mcp.ts
CHANGED
|
@@ -11,6 +11,7 @@ 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
13
|
import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
|
|
14
|
+
import { type ContentPart, summarizePart } from "./content.js"
|
|
14
15
|
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
@@ -108,6 +109,43 @@ export function expandEnvHeaders(headers?: Record<string, string>): Record<strin
|
|
|
108
109
|
return out
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* §0.4 / §2. Map every non-text `content[]` entry to a `ContentPart`, so nothing an MCP
|
|
114
|
+
* server returns is dropped silently: image ⇒ image, audio ⇒ audio, `resource_link` ⇒
|
|
115
|
+
* `file{url}`, an embedded `resource` carrying a blob ⇒ `file{data}`. An embedded resource
|
|
116
|
+
* carrying *text* is not a part — it belongs in `output`, and {@link joinTextContent}
|
|
117
|
+
* takes it there.
|
|
118
|
+
*/
|
|
119
|
+
export function mcpContentToParts(content: unknown): ContentPart[] | undefined {
|
|
120
|
+
if (!Array.isArray(content)) return undefined
|
|
121
|
+
const parts: ContentPart[] = []
|
|
122
|
+
for (const item of content) {
|
|
123
|
+
const it = item as any
|
|
124
|
+
if (!it || typeof it !== "object") continue
|
|
125
|
+
// `data` / `blob` are base64 STRINGS in the SDK, never bytes — passed through verbatim
|
|
126
|
+
// so no encoder of ours can drift them away from what the server sent.
|
|
127
|
+
if (it.type === "image" && typeof it.data === "string") {
|
|
128
|
+
parts.push({ type: "image", mimeType: String(it.mimeType ?? "application/octet-stream"), data: it.data })
|
|
129
|
+
} else if (it.type === "audio" && typeof it.data === "string") {
|
|
130
|
+
parts.push({ type: "audio", mimeType: String(it.mimeType ?? "application/octet-stream"), data: it.data })
|
|
131
|
+
} else if (it.type === "resource_link" && it.uri) {
|
|
132
|
+
const part: ContentPart = { type: "file", mimeType: String(it.mimeType ?? "application/octet-stream"), url: String(it.uri) }
|
|
133
|
+
if (it.name) (part as any).name = String(it.name)
|
|
134
|
+
parts.push(part)
|
|
135
|
+
} else if (it.type === "resource" && typeof it.resource?.blob === "string") {
|
|
136
|
+
const part: ContentPart = { type: "file", mimeType: String(it.resource.mimeType ?? "application/octet-stream"), data: it.resource.blob }
|
|
137
|
+
if (it.resource.uri) (part as any).name = String(it.resource.uri)
|
|
138
|
+
parts.push(part)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return parts.length ? parts : undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** An image-only result must not answer the model with "". Name what came back instead. */
|
|
145
|
+
function describeMcpParts(parts: ContentPart[] | undefined): string {
|
|
146
|
+
return parts ? parts.map(summarizePart).join("\n") : ""
|
|
147
|
+
}
|
|
148
|
+
|
|
111
149
|
function formatToolErrorContent(content: unknown): string {
|
|
112
150
|
if (!Array.isArray(content)) return "MCP tool returned an error"
|
|
113
151
|
return (
|
|
@@ -127,10 +165,17 @@ function isTextContent(value: unknown): value is { type: "text"; text: string }
|
|
|
127
165
|
)
|
|
128
166
|
}
|
|
129
167
|
|
|
168
|
+
/** An embedded resource carrying text is text: it joins `output` in place, keeping the
|
|
169
|
+
* server's ordering, rather than being discarded with the other non-text entries. */
|
|
170
|
+
function isResourceText(value: unknown): value is { resource: { text: string } } {
|
|
171
|
+
const v = value as any
|
|
172
|
+
return v?.type === "resource" && typeof v?.resource?.text === "string"
|
|
173
|
+
}
|
|
174
|
+
|
|
130
175
|
function joinTextContent(content: unknown): string {
|
|
131
176
|
if (!Array.isArray(content)) return ""
|
|
132
177
|
return content
|
|
133
|
-
.flatMap((item) => (isTextContent(item) ? [item.text] : []))
|
|
178
|
+
.flatMap((item) => (isTextContent(item) ? [item.text] : isResourceText(item) ? [item.resource.text] : []))
|
|
134
179
|
.join("\n")
|
|
135
180
|
}
|
|
136
181
|
|
|
@@ -202,13 +247,19 @@ function convertTool(server: string, def: McpToolDef, client: Client, timeout: n
|
|
|
202
247
|
undefined,
|
|
203
248
|
{ resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
|
|
204
249
|
)
|
|
250
|
+
// Parts are collected BEFORE any branch returns: the isError and structuredContent
|
|
251
|
+
// short-circuits both read `content[]` on the way past, so neither reintroduces the
|
|
252
|
+
// silent drop this mapping exists to remove (§2).
|
|
253
|
+
const parts = mcpContentToParts(result.content)
|
|
254
|
+
const withParts = (r: ToolResult): ToolResult => (parts ? { ...r, parts } : r)
|
|
205
255
|
if (result.isError) {
|
|
206
|
-
return { output: formatToolErrorContent(result.content), isError: true, metadata: { server } }
|
|
256
|
+
return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } })
|
|
207
257
|
}
|
|
208
258
|
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
|
209
|
-
return { output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } }
|
|
259
|
+
return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } })
|
|
210
260
|
}
|
|
211
|
-
|
|
261
|
+
const text = joinTextContent(result.content)
|
|
262
|
+
return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } })
|
|
212
263
|
} catch (e) {
|
|
213
264
|
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } }
|
|
214
265
|
}
|
package/src/mcpserve.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
16
16
|
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
|
|
17
|
-
import type { Tool, ToolSource } from "./types.js"
|
|
17
|
+
import type { Tool, ToolSource, ToolResult } from "./types.js"
|
|
18
|
+
import type { ContentPart } from "./content.js"
|
|
18
19
|
|
|
19
20
|
const DEFAULT_NAME = "toolnexus"
|
|
20
21
|
const DEFAULT_VERSION = "0.1.0"
|
|
@@ -75,7 +76,7 @@ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnC
|
|
|
75
76
|
// The call is a single synchronous tool invocation honoring the MCP
|
|
76
77
|
// request's cancellation. An execute() throw becomes an isError result —
|
|
77
78
|
// never a crash.
|
|
78
|
-
let result
|
|
79
|
+
let result: ToolResult
|
|
79
80
|
try {
|
|
80
81
|
result = await tool.execute(req.params.arguments ?? {}, { signal: extra.signal })
|
|
81
82
|
} catch (e) {
|
|
@@ -88,8 +89,30 @@ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnC
|
|
|
88
89
|
// Host callback errors are isolated.
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
|
-
return { content: [{ type: "text", text: result.output }], isError: result.isError }
|
|
92
|
+
return { content: [{ type: "text", text: result.output }, ...partBlocks(result.parts)], isError: result.isError }
|
|
92
93
|
})
|
|
93
94
|
|
|
94
95
|
return server
|
|
95
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* §7C. A served tool's `ToolResult.parts` become MCP content blocks appended after the
|
|
100
|
+
* text part, in order — image ⇒ image content, audio ⇒ audio content, file ⇒ an embedded
|
|
101
|
+
* resource (a url-backed file keeps its uri, a byte-backed one carries the blob).
|
|
102
|
+
*/
|
|
103
|
+
function partBlocks(parts: ContentPart[] | undefined): any[] {
|
|
104
|
+
if (!parts?.length) return []
|
|
105
|
+
const out: any[] = []
|
|
106
|
+
for (const p of parts) {
|
|
107
|
+
if (p.type === "text") out.push({ type: "text", text: p.text })
|
|
108
|
+
else if (p.type === "image" && p.data) out.push({ type: "image", data: p.data, mimeType: p.mimeType })
|
|
109
|
+
else if (p.type === "audio" && p.data) out.push({ type: "audio", data: p.data, mimeType: p.mimeType })
|
|
110
|
+
else {
|
|
111
|
+
const uri = p.url ?? `file:///${(p as any).name ?? "resource"}`
|
|
112
|
+
const resource: any = { uri, mimeType: p.mimeType }
|
|
113
|
+
if (p.data) resource.blob = p.data
|
|
114
|
+
out.push({ type: "resource", resource })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE §8 retry policy — the retryable-status set and the `Retry-After` rule.
|
|
3
|
+
*
|
|
4
|
+
* INTERNAL to the package. This module is deliberately NOT re-exported from
|
|
5
|
+
* `index.ts`: `client.ts` and `classifier.ts` (§8B) both need the policy, and a
|
|
6
|
+
* second copy is exactly the drift this file exists to prevent — but sharing it
|
|
7
|
+
* is an implementation detail, not npm API. Nothing here is in the parity-checked
|
|
8
|
+
* option surface, and nothing here may be added to `index.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** ~68 years; the widest whole-second count all seven ports represent exactly. */
|
|
12
|
+
const RETRY_AFTER_MAX_SECONDS = 2147483647
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
|
|
16
|
+
* (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
|
|
17
|
+
*
|
|
18
|
+
* `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
|
|
19
|
+
* and `" "` (as 0), which is how this port came to wait for fractional seconds
|
|
20
|
+
* that five of the other six rejected outright. Fractional, signed, HTTP-date and
|
|
21
|
+
* out-of-range values are not delays we can honour, so the caller falls back to
|
|
22
|
+
* backoff rather than guessing. `0` is a real answer, so it returns `0`, not
|
|
23
|
+
* `null`, and callers must use `??` rather than `||`.
|
|
24
|
+
*/
|
|
25
|
+
export function retryAfterMs(raw: string | null | undefined): number | null {
|
|
26
|
+
if (raw == null) return null
|
|
27
|
+
const s = raw.trim()
|
|
28
|
+
if (!/^[0-9]+$/.test(s)) return null
|
|
29
|
+
const secs = Number(s)
|
|
30
|
+
return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The default retryable set: `429` plus the 5xx worth another try — and `529 Overloaded`,
|
|
35
|
+
* which TypeSafe documents as "retry with backoff" and which an enumeration made terminal.
|
|
36
|
+
*
|
|
37
|
+
* It is an ENUMERATION on purpose. "Any 5xx" would sweep in permanently-broken statuses
|
|
38
|
+
* (`501 Not Implemented`, `505 HTTP Version Not Supported`) and change the retry behaviour of
|
|
39
|
+
* every existing host without asking. A backend with its own transient status — a Cloudflare
|
|
40
|
+
* origin answering `520`–`527`, say — opts in declaratively through `retryableStatuses`, which
|
|
41
|
+
* ADDS to this set and cannot remove from it.
|
|
42
|
+
*
|
|
43
|
+
* Shared with §8B's classifier, which adds `408` to it rather than inventing a second policy.
|
|
44
|
+
*/
|
|
45
|
+
const RETRYABLE = new Set([429, 500, 502, 503, 504, 529])
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a status is retryable by default, optionally widened by a host's `retryableStatuses`.
|
|
49
|
+
*
|
|
50
|
+
* `extra` is ADDITIVE: it can only make more statuses retryable, never fewer, so a host cannot
|
|
51
|
+
* accidentally drop `429` and lose `Retry-After` handling with it. It decides the DEFAULT
|
|
52
|
+
* classification only — `onError` still runs afterwards and has the final say on every attempt,
|
|
53
|
+
* so `onError` returning `"fail"` overrides a status the host itself listed here.
|
|
54
|
+
*/
|
|
55
|
+
export function isRetryableStatus(status: number, extra?: readonly number[] | null): boolean {
|
|
56
|
+
return RETRYABLE.has(status) || (extra != null && extra.includes(status))
|
|
57
|
+
}
|