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/dist/content.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
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
|
+
/** Why a part could not be constructed or sent (§1B / §8A). `code` is the machine-readable half. */
|
|
19
|
+
export class ContentPartError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(message, code) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.name = "ContentPartError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The media extension table (§6 `read`) — fixed, shared with the edge constructors,
|
|
29
|
+
* identical in every port. No magic-byte sniffing and no platform mime database:
|
|
30
|
+
* `/etc/mime.types` varies per machine and would break cross-port parity.
|
|
31
|
+
*/
|
|
32
|
+
export const MEDIA_TYPES = {
|
|
33
|
+
png: { mimeType: "image/png", type: "image" },
|
|
34
|
+
jpg: { mimeType: "image/jpeg", type: "image" },
|
|
35
|
+
jpeg: { mimeType: "image/jpeg", type: "image" },
|
|
36
|
+
gif: { mimeType: "image/gif", type: "image" },
|
|
37
|
+
webp: { mimeType: "image/webp", type: "image" },
|
|
38
|
+
pdf: { mimeType: "application/pdf", type: "file" },
|
|
39
|
+
mp3: { mimeType: "audio/mpeg", type: "audio" },
|
|
40
|
+
wav: { mimeType: "audio/wav", type: "audio" },
|
|
41
|
+
};
|
|
42
|
+
/** Lower-cased extension without the dot, or "" when there is none. */
|
|
43
|
+
export function extensionOf(p) {
|
|
44
|
+
const ext = path.extname(p);
|
|
45
|
+
return ext ? ext.slice(1).toLowerCase() : "";
|
|
46
|
+
}
|
|
47
|
+
/** The table entry for a path/URL, or undefined when the extension is not media. */
|
|
48
|
+
export function mediaTypeFor(p) {
|
|
49
|
+
return MEDIA_TYPES[extensionOf(p)];
|
|
50
|
+
}
|
|
51
|
+
/** Part kind for a mime type: `image/*` ⇒ image, `audio/*` ⇒ audio, everything else ⇒ file. */
|
|
52
|
+
export function partTypeForMime(mimeType) {
|
|
53
|
+
if (mimeType.startsWith("image/"))
|
|
54
|
+
return "image";
|
|
55
|
+
if (mimeType.startsWith("audio/"))
|
|
56
|
+
return "audio";
|
|
57
|
+
return "file";
|
|
58
|
+
}
|
|
59
|
+
/** Decoded byte length of standard, padded base64 — without decoding it (RFC 4648 §4:
|
|
60
|
+
* `floor(len/4)*3 - padCount`). `<bytes>` in every rendered string is a plain integer, never
|
|
61
|
+
* a fraction, so this floors the group count rather than assuming `len` is a multiple of 4. */
|
|
62
|
+
export function base64Bytes(b64) {
|
|
63
|
+
const n = b64.length;
|
|
64
|
+
if (n === 0)
|
|
65
|
+
return 0;
|
|
66
|
+
let pad = 0;
|
|
67
|
+
if (b64.endsWith("=="))
|
|
68
|
+
pad = 2;
|
|
69
|
+
else if (b64.endsWith("="))
|
|
70
|
+
pad = 1;
|
|
71
|
+
return Math.floor(n / 4) * 3 - pad;
|
|
72
|
+
}
|
|
73
|
+
/** Decoded byte length of a part's payload; a url-only part carries no bytes here. */
|
|
74
|
+
export function partBytes(part) {
|
|
75
|
+
if (part.type === "text")
|
|
76
|
+
return Buffer.byteLength(part.text, "utf8");
|
|
77
|
+
return part.data ? base64Bytes(part.data) : 0;
|
|
78
|
+
}
|
|
79
|
+
/** §1B token estimate for a part, from its decoded byte count: `max(85, floor(bytes/750))`.
|
|
80
|
+
* Measured against real providers: image cost is not proportional to bytes, so this is an
|
|
81
|
+
* explicit estimate, not a count — but it is normative and lives with the part logic so a
|
|
82
|
+
* budgeting helper (e.g. `agents.estimateTokens`) cannot silently diverge from it. */
|
|
83
|
+
export const PART_BYTES_PER_TOKEN = 750;
|
|
84
|
+
export const MIN_PART_TOKENS = 85;
|
|
85
|
+
export function partTokens(bytes) {
|
|
86
|
+
return Math.max(MIN_PART_TOKENS, Math.floor(bytes / PART_BYTES_PER_TOKEN));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* How a part appears in a log line or a §9 event: `{type, mimeType, bytes}` — never `data`.
|
|
90
|
+
* Same rule as never-log-headers: part payloads are user content.
|
|
91
|
+
*/
|
|
92
|
+
export function describePart(part) {
|
|
93
|
+
if (part.type === "text")
|
|
94
|
+
return { type: "text", bytes: partBytes(part) };
|
|
95
|
+
return { type: part.type, mimeType: part.mimeType, bytes: partBytes(part) };
|
|
96
|
+
}
|
|
97
|
+
/** A one-line, payload-free rendering of a part — for `output` text and placeholders. */
|
|
98
|
+
export function summarizePart(part) {
|
|
99
|
+
return part.type === "text" ? part.text : `${part.type} (${part.mimeType}, ${partBytes(part)} bytes)`;
|
|
100
|
+
}
|
|
101
|
+
/** Validate the §1B invariant: exactly one of `data` / `url`. */
|
|
102
|
+
export function validatePart(part, maxPartBytes) {
|
|
103
|
+
if (part.type === "text")
|
|
104
|
+
return part;
|
|
105
|
+
const hasData = part.data !== undefined && part.data !== null;
|
|
106
|
+
const hasUrl = part.url !== undefined && part.url !== null;
|
|
107
|
+
if (hasData && hasUrl) {
|
|
108
|
+
throw new ContentPartError(`content part "${part.type}" carries both data and url — supply exactly one`, "source-conflict");
|
|
109
|
+
}
|
|
110
|
+
if (!hasData && !hasUrl) {
|
|
111
|
+
throw new ContentPartError(`content part "${part.type}" carries neither data nor url — supply exactly one`, "source-missing");
|
|
112
|
+
}
|
|
113
|
+
if (maxPartBytes !== undefined && hasData) {
|
|
114
|
+
const bytes = base64Bytes(part.data);
|
|
115
|
+
if (bytes > maxPartBytes) {
|
|
116
|
+
throw new ContentPartError(`content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${maxPartBytes}`, "too-large");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return part;
|
|
120
|
+
}
|
|
121
|
+
/** True when a value looks like a `ContentPart` (as opposed to a provider-native block). */
|
|
122
|
+
export function isContentPart(v) {
|
|
123
|
+
if (!v || typeof v !== "object")
|
|
124
|
+
return false;
|
|
125
|
+
const t = v.type;
|
|
126
|
+
if (t === "text")
|
|
127
|
+
return typeof v.text === "string";
|
|
128
|
+
if (t === "image" || t === "file" || t === "audio")
|
|
129
|
+
return typeof v.mimeType === "string";
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
// --------------------------------------------------------------------------- //
|
|
133
|
+
// Edge constructors — a path / bytes / a data: URL never enters the part itself.
|
|
134
|
+
// --------------------------------------------------------------------------- //
|
|
135
|
+
/** A text part. */
|
|
136
|
+
export function text(value) {
|
|
137
|
+
return { type: "text", text: value };
|
|
138
|
+
}
|
|
139
|
+
/** A `Blob`/`File` — duck-typed, so a Blob from another realm or a polyfill is accepted too. */
|
|
140
|
+
function isBlobLike(v) {
|
|
141
|
+
return !!v && typeof v === "object" && typeof v.arrayBuffer === "function" && !ArrayBuffer.isView(v);
|
|
142
|
+
}
|
|
143
|
+
/** Normalise any native byte spelling to a `Uint8Array` view over the same bytes — an
|
|
144
|
+
* `ArrayBufferView` that is not a `Uint8Array` must be re-viewed, not passed to
|
|
145
|
+
* `Buffer.from`, which would read it as an array of numbers. */
|
|
146
|
+
function toBytes(source) {
|
|
147
|
+
if (source instanceof Uint8Array)
|
|
148
|
+
return source;
|
|
149
|
+
if (ArrayBuffer.isView(source))
|
|
150
|
+
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
|
151
|
+
return new Uint8Array(source);
|
|
152
|
+
}
|
|
153
|
+
/** The typed §1B refusal for a name/path whose extension is not in the fixed table. */
|
|
154
|
+
function unknownExtension(nameOrPath) {
|
|
155
|
+
const ext = extensionOf(nameOrPath);
|
|
156
|
+
return new ContentPartError(`no mime type for extension "${ext || "(none)"}" — pass an explicit mimeType (mime is never sniffed)`, "unknown-extension");
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The edge constructor: hand it whatever you actually have and get a path-free part.
|
|
160
|
+
*
|
|
161
|
+
* | given | becomes |
|
|
162
|
+
* |---|---|
|
|
163
|
+
* | a filesystem path | bytes read now, base64d now; mime from the fixed extension table |
|
|
164
|
+
* | native bytes (`Uint8Array`/`Buffer`/`ArrayBuffer`/any `ArrayBufferView`) | base64d now; `mimeType` required |
|
|
165
|
+
* | a `Blob` / `File` | read to bytes **now**; mime from `blob.type`, else the extension table via `File.name` |
|
|
166
|
+
* | `data:<mime>;base64,<b64>` | parsed into `{mimeType, data}` — never stored as a url |
|
|
167
|
+
* | an `http(s):` URL | kept as `url` |
|
|
168
|
+
*/
|
|
169
|
+
export async function attach(source, opts = {}) {
|
|
170
|
+
if (typeof source === "string") {
|
|
171
|
+
if (source.startsWith("data:"))
|
|
172
|
+
return fromDataUrl(source, opts);
|
|
173
|
+
if (/^https?:\/\//i.test(source))
|
|
174
|
+
return fromUrl(source, opts);
|
|
175
|
+
return fromPath(source, opts);
|
|
176
|
+
}
|
|
177
|
+
if (isBlobLike(source))
|
|
178
|
+
return fromBlob(source, opts);
|
|
179
|
+
if (!opts.mimeType) {
|
|
180
|
+
throw new ContentPartError("attach(bytes) requires an explicit mimeType — bytes carry no extension to read", "unknown-extension");
|
|
181
|
+
}
|
|
182
|
+
return fromBytes(toBytes(source), opts.mimeType, opts);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Build a part from a `Blob` or `File`, consuming it **eagerly** — a part holding a half-read
|
|
186
|
+
* stream would not survive the transcript boundary any better than a path does (§1B).
|
|
187
|
+
*
|
|
188
|
+
* Mime comes from `blob.type` when it is non-empty, else from the fixed extension table applied
|
|
189
|
+
* to `File.name`; an explicit `opts.mimeType` beats both. A `File`'s name is carried onto a
|
|
190
|
+
* `file` part unless `opts.name` overrides it.
|
|
191
|
+
*/
|
|
192
|
+
export async function fromBlob(blob, opts = {}) {
|
|
193
|
+
const name = typeof blob.name === "string" ? String(blob.name) : undefined;
|
|
194
|
+
const mimeType = opts.mimeType || (blob.type || undefined) || (name ? mediaTypeFor(name)?.mimeType : undefined);
|
|
195
|
+
if (!mimeType)
|
|
196
|
+
throw unknownExtension(name ?? "");
|
|
197
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
198
|
+
return finish(partTypeForMime(mimeType), mimeType, { data: Buffer.from(bytes).toString("base64") }, name ? { name, ...opts } : opts);
|
|
199
|
+
}
|
|
200
|
+
/** Build a part from in-memory bytes. `mimeType` is required — bytes carry no extension. */
|
|
201
|
+
export function fromBytes(bytes, mimeType, opts = {}) {
|
|
202
|
+
const data = Buffer.from(toBytes(bytes)).toString("base64");
|
|
203
|
+
return finish(partTypeForMime(mimeType), mimeType, { data }, opts);
|
|
204
|
+
}
|
|
205
|
+
/** Read a file now and base64 it now, so the part never carries the path (§1B). */
|
|
206
|
+
export async function fromPath(p, opts = {}) {
|
|
207
|
+
const mimeType = opts.mimeType ?? mediaTypeFor(p)?.mimeType;
|
|
208
|
+
if (!mimeType)
|
|
209
|
+
throw unknownExtension(p);
|
|
210
|
+
const bytes = await fs.readFile(p);
|
|
211
|
+
return finish(partTypeForMime(mimeType), mimeType, { data: bytes.toString("base64") }, { name: path.basename(p), ...opts });
|
|
212
|
+
}
|
|
213
|
+
/** Parse `data:<mime>;base64,<b64>` into `{mimeType, data}`, so two spellings of the same
|
|
214
|
+
* bytes cannot diverge downstream. */
|
|
215
|
+
export function fromDataUrl(url, opts = {}) {
|
|
216
|
+
const m = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(url);
|
|
217
|
+
if (!m)
|
|
218
|
+
throw new ContentPartError("malformed data: URL", "source-missing");
|
|
219
|
+
const mimeType = opts.mimeType ?? (m[1] || "application/octet-stream");
|
|
220
|
+
const data = m[2] ? m[3] : Buffer.from(decodeURIComponent(m[3]), "utf8").toString("base64");
|
|
221
|
+
return finish(partTypeForMime(mimeType), mimeType, { data }, opts);
|
|
222
|
+
}
|
|
223
|
+
/** Keep an `http(s):` URL as a url part; mime comes from the table or an explicit override. */
|
|
224
|
+
export function fromUrl(url, opts = {}) {
|
|
225
|
+
const mimeType = opts.mimeType ?? mediaTypeFor(new URL(url).pathname)?.mimeType;
|
|
226
|
+
if (!mimeType)
|
|
227
|
+
throw unknownExtension(new URL(url).pathname);
|
|
228
|
+
return finish(partTypeForMime(mimeType), mimeType, { url }, opts);
|
|
229
|
+
}
|
|
230
|
+
function finish(type, mimeType, source, opts) {
|
|
231
|
+
const part = { type, mimeType, ...source };
|
|
232
|
+
if (type === "file" && opts.name)
|
|
233
|
+
part.name = opts.name;
|
|
234
|
+
return validatePart(part, opts.maxPartBytes);
|
|
235
|
+
}
|
|
236
|
+
// --------------------------------------------------------------------------- //
|
|
237
|
+
// §8A emission — the positive allowlist, and the unsupported-part rule.
|
|
238
|
+
// --------------------------------------------------------------------------- //
|
|
239
|
+
/** The ONLY block types that may reach each style's wire. A part encoding to anything
|
|
240
|
+
* else never leaves this module (map-and-hope is how content gets silently discarded). */
|
|
241
|
+
const ALLOWLIST = {
|
|
242
|
+
openai: new Set(["text", "image_url", "file", "input_audio"]),
|
|
243
|
+
anthropic: new Set(["text", "image", "document"]),
|
|
244
|
+
};
|
|
245
|
+
/** OpenAI's `input_audio.format` is a bare format name, not a mime type. */
|
|
246
|
+
const AUDIO_FORMATS = { "audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/wav": "wav", "audio/x-wav": "wav" };
|
|
247
|
+
const dataUrl = (mimeType, data) => `data:${mimeType};base64,${data}`;
|
|
248
|
+
/**
|
|
249
|
+
* Encode one part into its provider block, or `undefined` when the style defines no shape
|
|
250
|
+
* for it. Refusals are explicit, never a fall-through: `openai × file+url` (Chat Completions
|
|
251
|
+
* has no URL form for a file) and `anthropic × audio` (the provider defines no audio block).
|
|
252
|
+
*/
|
|
253
|
+
export function encodePart(part, style) {
|
|
254
|
+
if (part.type === "text")
|
|
255
|
+
return { type: "text", text: part.text };
|
|
256
|
+
if (style === "openai") {
|
|
257
|
+
switch (part.type) {
|
|
258
|
+
case "image":
|
|
259
|
+
return { type: "image_url", image_url: { url: part.data ? dataUrl(part.mimeType, part.data) : part.url } };
|
|
260
|
+
case "file":
|
|
261
|
+
// `file_data` REQUIRES the `data:<mime>;base64,` prefix — a bare base64 string is a 400.
|
|
262
|
+
if (!part.data)
|
|
263
|
+
return undefined;
|
|
264
|
+
return { type: "file", file: { filename: part.name ?? "file", file_data: dataUrl(part.mimeType, part.data) } };
|
|
265
|
+
case "audio":
|
|
266
|
+
if (!part.data)
|
|
267
|
+
return undefined;
|
|
268
|
+
return { type: "input_audio", input_audio: { data: part.data, format: AUDIO_FORMATS[part.mimeType] ?? part.mimeType.replace(/^audio\//, "") } };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
switch (part.type) {
|
|
272
|
+
case "image":
|
|
273
|
+
return part.data
|
|
274
|
+
? { type: "image", source: { type: "base64", media_type: part.mimeType, data: part.data } }
|
|
275
|
+
: { type: "image", source: { type: "url", url: part.url } };
|
|
276
|
+
case "file":
|
|
277
|
+
return part.data
|
|
278
|
+
? { type: "document", source: { type: "base64", media_type: part.mimeType, data: part.data } }
|
|
279
|
+
: { type: "document", source: { type: "url", url: part.url } };
|
|
280
|
+
case "audio":
|
|
281
|
+
return undefined; // Anthropic defines no audio block — a named refusal, not an oversight.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const warnedUnsupported = new Set();
|
|
285
|
+
/** Test seam: forget which unsupported (style, type) pairs have already been warned about. */
|
|
286
|
+
export function resetUnsupportedWarnings() {
|
|
287
|
+
warnedUnsupported.clear();
|
|
288
|
+
}
|
|
289
|
+
/** The text a degraded (non-failing) unsupported part leaves behind — never silence. */
|
|
290
|
+
export function unsupportedPlaceholder(part) {
|
|
291
|
+
return `[unsupported ${part.type} part (${part.mimeType}, ${partBytes(part)} bytes)]`;
|
|
292
|
+
}
|
|
293
|
+
/** Warn once per (style, type, reason) — the same latch the unsupported-block path uses,
|
|
294
|
+
* keyed separately (by `reason`, not by the message text) so an oversize warning and a
|
|
295
|
+
* no-block warning never suppress each other, and a differently-sized oversize part of the
|
|
296
|
+
* same type doesn't re-trigger the latch. */
|
|
297
|
+
function warnUnsupportedOnce(style, type, reason, message) {
|
|
298
|
+
const key = `${style}:${type}:${reason}`;
|
|
299
|
+
if (warnedUnsupported.has(key))
|
|
300
|
+
return;
|
|
301
|
+
warnedUnsupported.add(key);
|
|
302
|
+
console.warn(`[toolnexus] ${message}`);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Encode a list of parts for one style, applying the §8A rules:
|
|
306
|
+
* an **attached** part the style cannot represent raises before any HTTP call; a **derived**
|
|
307
|
+
* part degrades to a text placeholder and warns once. `onUnsupportedPart` overrides both.
|
|
308
|
+
* Every emitted block is asserted against the style's allowlist.
|
|
309
|
+
*
|
|
310
|
+
* `maxPartBytes` (§1B) is enforced here too, at assembly, over EVERY part regardless of
|
|
311
|
+
* provenance — a part that arrived from an MCP server never passed through an edge
|
|
312
|
+
* constructor, so a limit it can walk around is not a limit. Going over follows the exact
|
|
313
|
+
* same provenance split as an unsupported block: an attached part errors, a derived one
|
|
314
|
+
* degrades to the placeholder so a remote server still cannot fail the run.
|
|
315
|
+
*/
|
|
316
|
+
export function encodeParts(parts, opts) {
|
|
317
|
+
const mode = opts.onUnsupportedPart ?? (opts.provenance === "attached" ? "error" : "text");
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const raw of parts) {
|
|
320
|
+
const part = validatePart(raw);
|
|
321
|
+
if (opts.maxPartBytes !== undefined && part.type !== "text") {
|
|
322
|
+
const bytes = partBytes(part);
|
|
323
|
+
if (bytes > opts.maxPartBytes) {
|
|
324
|
+
if (mode === "error") {
|
|
325
|
+
throw new ContentPartError(`content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${opts.maxPartBytes}`, "too-large");
|
|
326
|
+
}
|
|
327
|
+
warnUnsupportedOnce(opts.style, part.type, "too-large", `content part "${part.type}" is ${bytes} decoded bytes, over the maxPartBytes limit of ${opts.maxPartBytes} — sending a text placeholder`);
|
|
328
|
+
out.push({ type: "text", text: unsupportedPlaceholder(part) });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const block = encodePart(part, opts.style);
|
|
333
|
+
if (block && ALLOWLIST[opts.style].has(String(block.type))) {
|
|
334
|
+
out.push(block);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (mode === "error") {
|
|
338
|
+
throw new ContentPartError(`provider style "${opts.style}" defines no block for a "${part.type}" content part` +
|
|
339
|
+
(part.type !== "text" ? ` (${part.mimeType})` : ""), "unsupported");
|
|
340
|
+
}
|
|
341
|
+
warnUnsupportedOnce(opts.style, part.type, "unsupported", `provider style "${opts.style}" has no block for a "${part.type}" part — sending a text placeholder`);
|
|
342
|
+
out.push({ type: "text", text: unsupportedPlaceholder(part) });
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Read an inbound OpenAI-shaped content block back into a `ContentPart` (§11). Accepts both
|
|
348
|
+
* a `ContentPart` written literally and the provider-native block the same part encodes to,
|
|
349
|
+
* so a caller's own OpenAI messages translate as faithfully as ours do.
|
|
350
|
+
*/
|
|
351
|
+
export function inboundPart(block) {
|
|
352
|
+
if (isContentPart(block))
|
|
353
|
+
return block;
|
|
354
|
+
if (!block || typeof block !== "object")
|
|
355
|
+
return undefined;
|
|
356
|
+
const b = block;
|
|
357
|
+
if (b.type === "image_url" && b.image_url?.url) {
|
|
358
|
+
const url = String(b.image_url.url);
|
|
359
|
+
try {
|
|
360
|
+
return url.startsWith("data:") ? fromDataUrl(url) : { type: "image", mimeType: mediaTypeFor(new URL(url).pathname)?.mimeType ?? "image/*", url };
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (b.type === "file" && typeof b.file?.file_data === "string") {
|
|
367
|
+
const part = fromDataUrl(String(b.file.file_data));
|
|
368
|
+
part.type = "file";
|
|
369
|
+
if (b.file.filename)
|
|
370
|
+
part.name = String(b.file.filename);
|
|
371
|
+
return part;
|
|
372
|
+
}
|
|
373
|
+
if (b.type === "input_audio" && typeof b.input_audio?.data === "string") {
|
|
374
|
+
const fmt = String(b.input_audio.format ?? "");
|
|
375
|
+
const mimeType = fmt === "mp3" ? "audio/mpeg" : fmt === "wav" ? "audio/wav" : `audio/${fmt || "*"}`;
|
|
376
|
+
return { type: "audio", mimeType, data: String(b.input_audio.data) };
|
|
377
|
+
}
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mcp.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import { type JSONSchema, type Tool, type McpStatus, type Request, type Answer } from "./types.js";
|
|
3
|
+
import { type ContentPart } from "./content.js";
|
|
3
4
|
/** Map an MCP `elicitation/create` request onto a §10 `Request`. */
|
|
4
5
|
export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
|
|
5
6
|
/** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
|
|
@@ -37,6 +38,14 @@ export declare function parseMcpConfig(input: string | object): McpConfig;
|
|
|
37
38
|
* environment, not the committed config. Values are never logged.
|
|
38
39
|
*/
|
|
39
40
|
export declare function expandEnvHeaders(headers?: Record<string, string>): Record<string, string> | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* §0.4 / §2. Map every non-text `content[]` entry to a `ContentPart`, so nothing an MCP
|
|
43
|
+
* server returns is dropped silently: image ⇒ image, audio ⇒ audio, `resource_link` ⇒
|
|
44
|
+
* `file{url}`, an embedded `resource` carrying a blob ⇒ `file{data}`. An embedded resource
|
|
45
|
+
* carrying *text* is not a part — it belongs in `output`, and {@link joinTextContent}
|
|
46
|
+
* takes it there.
|
|
47
|
+
*/
|
|
48
|
+
export declare function mcpContentToParts(content: unknown): ContentPart[] | undefined;
|
|
40
49
|
export interface McpSource {
|
|
41
50
|
tools: Tool[];
|
|
42
51
|
status: Record<string, McpStatus>;
|
package/dist/mcp.js
CHANGED
|
@@ -10,6 +10,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
10
10
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
11
11
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
12
12
|
import { sanitize } from "./types.js";
|
|
13
|
+
import { summarizePart } from "./content.js";
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
15
16
|
// (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
|
|
@@ -78,6 +79,48 @@ export function expandEnvHeaders(headers) {
|
|
|
78
79
|
}
|
|
79
80
|
return out;
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* §0.4 / §2. Map every non-text `content[]` entry to a `ContentPart`, so nothing an MCP
|
|
84
|
+
* server returns is dropped silently: image ⇒ image, audio ⇒ audio, `resource_link` ⇒
|
|
85
|
+
* `file{url}`, an embedded `resource` carrying a blob ⇒ `file{data}`. An embedded resource
|
|
86
|
+
* carrying *text* is not a part — it belongs in `output`, and {@link joinTextContent}
|
|
87
|
+
* takes it there.
|
|
88
|
+
*/
|
|
89
|
+
export function mcpContentToParts(content) {
|
|
90
|
+
if (!Array.isArray(content))
|
|
91
|
+
return undefined;
|
|
92
|
+
const parts = [];
|
|
93
|
+
for (const item of content) {
|
|
94
|
+
const it = item;
|
|
95
|
+
if (!it || typeof it !== "object")
|
|
96
|
+
continue;
|
|
97
|
+
// `data` / `blob` are base64 STRINGS in the SDK, never bytes — passed through verbatim
|
|
98
|
+
// so no encoder of ours can drift them away from what the server sent.
|
|
99
|
+
if (it.type === "image" && typeof it.data === "string") {
|
|
100
|
+
parts.push({ type: "image", mimeType: String(it.mimeType ?? "application/octet-stream"), data: it.data });
|
|
101
|
+
}
|
|
102
|
+
else if (it.type === "audio" && typeof it.data === "string") {
|
|
103
|
+
parts.push({ type: "audio", mimeType: String(it.mimeType ?? "application/octet-stream"), data: it.data });
|
|
104
|
+
}
|
|
105
|
+
else if (it.type === "resource_link" && it.uri) {
|
|
106
|
+
const part = { type: "file", mimeType: String(it.mimeType ?? "application/octet-stream"), url: String(it.uri) };
|
|
107
|
+
if (it.name)
|
|
108
|
+
part.name = String(it.name);
|
|
109
|
+
parts.push(part);
|
|
110
|
+
}
|
|
111
|
+
else if (it.type === "resource" && typeof it.resource?.blob === "string") {
|
|
112
|
+
const part = { type: "file", mimeType: String(it.resource.mimeType ?? "application/octet-stream"), data: it.resource.blob };
|
|
113
|
+
if (it.resource.uri)
|
|
114
|
+
part.name = String(it.resource.uri);
|
|
115
|
+
parts.push(part);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return parts.length ? parts : undefined;
|
|
119
|
+
}
|
|
120
|
+
/** An image-only result must not answer the model with "". Name what came back instead. */
|
|
121
|
+
function describeMcpParts(parts) {
|
|
122
|
+
return parts ? parts.map(summarizePart).join("\n") : "";
|
|
123
|
+
}
|
|
81
124
|
function formatToolErrorContent(content) {
|
|
82
125
|
if (!Array.isArray(content))
|
|
83
126
|
return "MCP tool returned an error";
|
|
@@ -92,11 +135,17 @@ function isTextContent(value) {
|
|
|
92
135
|
value.type === "text" &&
|
|
93
136
|
typeof value.text === "string");
|
|
94
137
|
}
|
|
138
|
+
/** An embedded resource carrying text is text: it joins `output` in place, keeping the
|
|
139
|
+
* server's ordering, rather than being discarded with the other non-text entries. */
|
|
140
|
+
function isResourceText(value) {
|
|
141
|
+
const v = value;
|
|
142
|
+
return v?.type === "resource" && typeof v?.resource?.text === "string";
|
|
143
|
+
}
|
|
95
144
|
function joinTextContent(content) {
|
|
96
145
|
if (!Array.isArray(content))
|
|
97
146
|
return "";
|
|
98
147
|
return content
|
|
99
|
-
.flatMap((item) => (isTextContent(item) ? [item.text] : []))
|
|
148
|
+
.flatMap((item) => (isTextContent(item) ? [item.text] : isResourceText(item) ? [item.resource.text] : []))
|
|
100
149
|
.join("\n");
|
|
101
150
|
}
|
|
102
151
|
/**
|
|
@@ -166,13 +215,19 @@ function convertTool(server, def, client, timeout) {
|
|
|
166
215
|
async execute(args, ctx) {
|
|
167
216
|
try {
|
|
168
217
|
const result = await client.callTool({ name: def.name, arguments: args ?? {} }, undefined, { resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout });
|
|
218
|
+
// Parts are collected BEFORE any branch returns: the isError and structuredContent
|
|
219
|
+
// short-circuits both read `content[]` on the way past, so neither reintroduces the
|
|
220
|
+
// silent drop this mapping exists to remove (§2).
|
|
221
|
+
const parts = mcpContentToParts(result.content);
|
|
222
|
+
const withParts = (r) => (parts ? { ...r, parts } : r);
|
|
169
223
|
if (result.isError) {
|
|
170
|
-
return { output: formatToolErrorContent(result.content), isError: true, metadata: { server } };
|
|
224
|
+
return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } });
|
|
171
225
|
}
|
|
172
226
|
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
|
173
|
-
return { output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } };
|
|
227
|
+
return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } });
|
|
174
228
|
}
|
|
175
|
-
|
|
229
|
+
const text = joinTextContent(result.content);
|
|
230
|
+
return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } });
|
|
176
231
|
}
|
|
177
232
|
catch (e) {
|
|
178
233
|
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } };
|
package/dist/mcpserve.js
CHANGED
|
@@ -63,7 +63,33 @@ export function buildMcpServer(tools, cfg, onCall) {
|
|
|
63
63
|
// Host callback errors are isolated.
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
-
return { content: [{ type: "text", text: result.output }], isError: result.isError };
|
|
66
|
+
return { content: [{ type: "text", text: result.output }, ...partBlocks(result.parts)], isError: result.isError };
|
|
67
67
|
});
|
|
68
68
|
return server;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* §7C. A served tool's `ToolResult.parts` become MCP content blocks appended after the
|
|
72
|
+
* text part, in order — image ⇒ image content, audio ⇒ audio content, file ⇒ an embedded
|
|
73
|
+
* resource (a url-backed file keeps its uri, a byte-backed one carries the blob).
|
|
74
|
+
*/
|
|
75
|
+
function partBlocks(parts) {
|
|
76
|
+
if (!parts?.length)
|
|
77
|
+
return [];
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const p of parts) {
|
|
80
|
+
if (p.type === "text")
|
|
81
|
+
out.push({ type: "text", text: p.text });
|
|
82
|
+
else if (p.type === "image" && p.data)
|
|
83
|
+
out.push({ type: "image", data: p.data, mimeType: p.mimeType });
|
|
84
|
+
else if (p.type === "audio" && p.data)
|
|
85
|
+
out.push({ type: "audio", data: p.data, mimeType: p.mimeType });
|
|
86
|
+
else {
|
|
87
|
+
const uri = p.url ?? `file:///${p.name ?? "resource"}`;
|
|
88
|
+
const resource = { uri, mimeType: p.mimeType };
|
|
89
|
+
if (p.data)
|
|
90
|
+
resource.blob = p.data;
|
|
91
|
+
out.push({ type: "resource", resource });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
package/dist/translate.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* OpenAI function-calling posture). When toolnexus owns the conversation, use the agent
|
|
13
13
|
* loop with relay tools + `runWithAnswer` (§10) instead.
|
|
14
14
|
*/
|
|
15
|
+
import { encodeParts, inboundPart } from "./content.js";
|
|
15
16
|
/**
|
|
16
17
|
* Maps a provider stop reason onto an OpenAI finish reason. Tool calls win: a turn that
|
|
17
18
|
* emitted any tool call is always `"tool_calls"` to a conforming client.
|
|
@@ -134,17 +135,44 @@ export function openAIMessagesToAnthropic(messages) {
|
|
|
134
135
|
}
|
|
135
136
|
default: {
|
|
136
137
|
flush();
|
|
138
|
+
// A `content` array has its text parts concatenated and its NON-text parts translated
|
|
139
|
+
// into Anthropic's block shape (§11). Six ports used to pass a text-empty array through
|
|
140
|
+
// raw and undocumented; one specified mapping replaces that, so nothing is flattened
|
|
141
|
+
// away and nothing is dropped.
|
|
142
|
+
const blocks = contentPartBlocks(m.content);
|
|
143
|
+
if (blocks) {
|
|
144
|
+
out.push({ role: "user", content: blocks });
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
137
147
|
const s = contentText(m.content);
|
|
138
148
|
if (s)
|
|
139
149
|
out.push({ role: "user", content: s });
|
|
140
|
-
else if (Array.isArray(m.content) && m.content.length)
|
|
141
|
-
out.push({ role: "user", content: m.content });
|
|
142
150
|
}
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
flush();
|
|
146
154
|
return { messages: out, system: systemParts.join("\n\n") };
|
|
147
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Translate an OpenAI-shaped `content` array into Anthropic blocks — but ONLY when it
|
|
158
|
+
* actually carries something non-text; an all-text array still concatenates, so the
|
|
159
|
+
* common case is byte-identical. Both spellings are accepted: a literal `ContentPart`
|
|
160
|
+
* and the provider-native block that same part encodes to.
|
|
161
|
+
*/
|
|
162
|
+
function contentPartBlocks(content) {
|
|
163
|
+
if (!Array.isArray(content) || content.length === 0)
|
|
164
|
+
return undefined;
|
|
165
|
+
const parts = [];
|
|
166
|
+
for (const raw of content) {
|
|
167
|
+
const p = inboundPart(raw);
|
|
168
|
+
if (!p)
|
|
169
|
+
return undefined; // an array we do not understand is left to contentText, as before
|
|
170
|
+
parts.push(p);
|
|
171
|
+
}
|
|
172
|
+
if (parts.every((p) => p.type === "text"))
|
|
173
|
+
return undefined;
|
|
174
|
+
return encodeParts(parts, { style: "anthropic", provenance: "attached" });
|
|
175
|
+
}
|
|
148
176
|
/**
|
|
149
177
|
* Converts an OpenAI `tools` array into Anthropic tool declarations. Entries that are
|
|
150
178
|
* already provider-native pass through; anything unrecognized is skipped.
|
package/dist/types.d.ts
CHANGED
|
@@ -8,10 +8,18 @@ export type JSONSchema = {
|
|
|
8
8
|
additionalProperties?: boolean;
|
|
9
9
|
[k: string]: unknown;
|
|
10
10
|
};
|
|
11
|
+
import type { ContentPart } from "./content.js";
|
|
11
12
|
export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom";
|
|
12
13
|
export interface ToolResult {
|
|
13
14
|
output: string;
|
|
14
15
|
isError: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* §1B non-text output. `output` stays required and remains what the transcript,
|
|
18
|
+
* compaction, token estimation and any text-only provider see; `parts` is the extra
|
|
19
|
+
* channel a screenshot / PDF / audio clip travels on. Absent ⇒ byte-identical to a
|
|
20
|
+
* pre-0.17 result, which is why it is optional rather than a fourth required field.
|
|
21
|
+
*/
|
|
22
|
+
parts?: ContentPart[];
|
|
15
23
|
metadata?: Record<string, unknown>;
|
|
16
24
|
}
|
|
17
25
|
export interface ToolContext {
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical transcript → provider wire messages (SPEC.md §8A).
|
|
3
|
+
*
|
|
4
|
+
* The loop's `messages` array is the **canonical transcript**: a user turn carrying parts
|
|
5
|
+
* holds `ContentPart[]`, and a tool turn carrying parts holds them under a `parts` key.
|
|
6
|
+
* Neither shape is a wire shape. This module is the single place that turns the canonical
|
|
7
|
+
* transcript into what each style actually accepts, so the four loop sites (run/stream ×
|
|
8
|
+
* openai/anthropic) only ever have to *record* parts, never encode them.
|
|
9
|
+
*
|
|
10
|
+
* It is also where the **tool-result relocation rule** lives. Anthropic defines blocks
|
|
11
|
+
* inside `tool_result.content`, so its parts are emitted natively, keyed to their
|
|
12
|
+
* `tool_use_id`. OpenAI's `tool` message rejects an image outright (a hard 400 — "Image URLs
|
|
13
|
+
* are only allowed for messages with role 'user'"), so there every non-text part from every
|
|
14
|
+
* tool result answering one assistant turn is relocated, in tool-call order, into ONE
|
|
15
|
+
* synthetic `user` message emitted immediately after the last tool message. That synthetic
|
|
16
|
+
* message is an adapter artifact: it is built here, on the way out, and never written back
|
|
17
|
+
* to the transcript, the `ConversationStore` or `translate` output — which is what keeps a
|
|
18
|
+
* mid-conversation provider switch free of OpenAI-shaped residue.
|
|
19
|
+
*/
|
|
20
|
+
import type { UnsupportedPartMode } from "./content.js";
|
|
21
|
+
/** Shared shaping options for a wire build. */
|
|
22
|
+
export interface WireOptions {
|
|
23
|
+
onUnsupportedPart?: UnsupportedPartMode;
|
|
24
|
+
maxPartBytes?: number;
|
|
25
|
+
}
|
|
26
|
+
/** The text preceding each relocated part, so the model can attribute it to its call. */
|
|
27
|
+
export declare const relocationHeader: (name: string, id: string) => string;
|
|
28
|
+
/**
|
|
29
|
+
* Build the OpenAI-style wire messages. Tool messages carry `output` plus text parts only;
|
|
30
|
+
* their non-text parts are collected and flushed as one synthetic user message the moment
|
|
31
|
+
* the run of tool messages ends.
|
|
32
|
+
*/
|
|
33
|
+
export declare function toOpenAIWire(messages: any[], opts?: WireOptions): any[];
|
|
34
|
+
/**
|
|
35
|
+
* Build the Anthropic-style wire messages. Nothing is relocated: a `tool_result` block that
|
|
36
|
+
* carries parts gets them natively inside its own `content`, keyed to its `tool_use_id`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function toAnthropicWire(messages: any[], opts?: WireOptions): any[];
|
|
39
|
+
/** Validate every attached part before assembly, so `maxPartBytes` cannot be bypassed. */
|
|
40
|
+
export declare function checkPromptParts(prompt: unknown, opts: WireOptions): void;
|