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/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 +0 -14
- package/dist/agents/runtime.d.ts +4 -3
- package/dist/agents/runtime.js +8 -17
- package/dist/builtin.js +24 -3
- package/dist/client.d.ts +59 -4
- package/dist/client.js +113 -8
- package/dist/content.d.ts +158 -0
- package/dist/content.js +379 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp.d.ts +9 -0
- package/dist/mcp.js +59 -4
- package/dist/mcpserve.js +27 -1
- 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 +2 -2
- package/src/agents/agent.ts +2 -1
- package/src/agents/compaction.ts +39 -1
- package/src/agents/loop.ts +5 -4
- package/src/agents/runtime.ts +13 -5
- package/src/builtin.ts +23 -3
- package/src/client.ts +182 -16
- package/src/content.ts +448 -0
- package/src/index.ts +2 -0
- package/src/mcp.ts +55 -4
- package/src/mcpserve.ts +26 -3
- 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
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/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
|
|