toolnexus 0.15.0 → 0.17.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/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"
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
- return { output: joinTextContent(result.content), isError: false, metadata: { server } }
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/translate.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { Toolkit } from "./toolkit.js"
17
+ import { encodeParts, inboundPart, type ContentPart } from "./content.js"
17
18
  import type { Usage } from "./client.js"
18
19
 
19
20
  /** An OpenAI-shaped chat request, handed over verbatim (§11). */
@@ -187,9 +188,14 @@ export function openAIMessagesToAnthropic(messages: any[]): { messages: any[]; s
187
188
  }
188
189
  default: {
189
190
  flush()
191
+ // A `content` array has its text parts concatenated and its NON-text parts translated
192
+ // into Anthropic's block shape (§11). Six ports used to pass a text-empty array through
193
+ // raw and undocumented; one specified mapping replaces that, so nothing is flattened
194
+ // away and nothing is dropped.
195
+ const blocks = contentPartBlocks(m.content)
196
+ if (blocks) { out.push({ role: "user", content: blocks }); break }
190
197
  const s = contentText(m.content)
191
198
  if (s) out.push({ role: "user", content: s })
192
- else if (Array.isArray(m.content) && m.content.length) out.push({ role: "user", content: m.content })
193
199
  }
194
200
  }
195
201
  }
@@ -197,6 +203,24 @@ export function openAIMessagesToAnthropic(messages: any[]): { messages: any[]; s
197
203
  return { messages: out, system: systemParts.join("\n\n") }
198
204
  }
199
205
 
206
+ /**
207
+ * Translate an OpenAI-shaped `content` array into Anthropic blocks — but ONLY when it
208
+ * actually carries something non-text; an all-text array still concatenates, so the
209
+ * common case is byte-identical. Both spellings are accepted: a literal `ContentPart`
210
+ * and the provider-native block that same part encodes to.
211
+ */
212
+ function contentPartBlocks(content: unknown): any[] | undefined {
213
+ if (!Array.isArray(content) || content.length === 0) return undefined
214
+ const parts: ContentPart[] = []
215
+ for (const raw of content) {
216
+ const p = inboundPart(raw)
217
+ if (!p) return undefined // an array we do not understand is left to contentText, as before
218
+ parts.push(p)
219
+ }
220
+ if (parts.every((p) => p.type === "text")) return undefined
221
+ return encodeParts(parts, { style: "anthropic", provenance: "attached" })
222
+ }
223
+
200
224
  /**
201
225
  * Converts an OpenAI `tools` array into Anthropic tool declarations. Entries that are
202
226
  * already provider-native pass through; anything unrecognized is skipped.
package/src/types.ts CHANGED
@@ -10,11 +10,20 @@ export type JSONSchema = {
10
10
  [k: string]: unknown
11
11
  }
12
12
 
13
+ import type { ContentPart } from "./content.js"
14
+
13
15
  export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom"
14
16
 
15
17
  export interface ToolResult {
16
18
  output: string
17
19
  isError: boolean
20
+ /**
21
+ * §1B non-text output. `output` stays required and remains what the transcript,
22
+ * compaction, token estimation and any text-only provider see; `parts` is the extra
23
+ * channel a screenshot / PDF / audio clip travels on. Absent ⇒ byte-identical to a
24
+ * pre-0.17 result, which is why it is optional rather than a fourth required field.
25
+ */
26
+ parts?: ContentPart[]
18
27
  metadata?: Record<string, unknown>
19
28
  }
20
29