attach
JavaScript · package toolnexus · SPEC §1B · js/src/content.ts
type AttachSource = string | Uint8Array | ArrayBuffer | ArrayBufferView | Blob
interface AttachOptions { mimeType?: string // required for raw bytes and any unlisted extension name?: string // filename on a `file` part; defaults to the basename maxPartBytes?: number // reject a payload over this many DECODED bytes}
function attach(source: AttachSource, opts?: AttachOptions): Promise<ContentPart>function fromPath(p: string, opts?: AttachOptions): Promise<ContentPart>function fromBytes(bytes: Uint8Array | ArrayBuffer | ArrayBufferView, mimeType: string, opts?: AttachOptions): ContentPartfunction fromBlob(blob: Blob, opts?: AttachOptions): Promise<ContentPart>function fromDataUrl(url: string, opts?: AttachOptions): ContentPartfunction fromUrl(url: string, opts?: AttachOptions): ContentPart
function text(value: string): ContentPartfunction partBytes(part: ContentPart): number // decoded byte length; a url-only part is 0The authoring side of multimodal content: constructors that turn a path, bytes, a blob, a data
URL, or a remote URL into the ContentPart a prompt or tool result carries — the write half of
the read-only ContentPart shape. attach is the one
entry point most callers need — hand it whatever you actually have and get back a path-free part:
| given | becomes |
|---|---|
| a filesystem path | bytes read now, base64d now; mime from the fixed extension table |
native bytes (Uint8Array/Buffer/ArrayBuffer/any ArrayBufferView) |
base64d now; mimeType required |
a Blob / File |
read to bytes now; mime from blob.type, else the extension table via File.name |
data:<mime>;base64,<b64> |
parsed into {mimeType, data} — never stored as a url |
an http(s): URL |
kept as url |
The named constructors (fromPath, fromBytes, fromBlob, fromDataUrl, fromUrl) are what
attach dispatches to internally; call one directly when you already know which shape you have
and want to skip the sniffing, or when you want the specific error a mismatched source would
throw through attach (fromBytes on a string, for instance).
When to use it
Section titled “When to use it”Reach for attach to build a prompt that includes an image, a PDF, or an audio clip alongside
text — pass the result in a ContentPart[] prompt, or return it in ToolResult.parts from a
tool. Reach for a specific from* constructor when you’re writing a helper that only ever
receives one shape of input (e.g. a function that always receives a Buffer from a stream you
already own) and the extra dispatch in attach buys you nothing.
Why this and not the alternative
Section titled “Why this and not the alternative”Raw bytes (Uint8Array/ArrayBuffer/any ArrayBufferView) carry no extension to read, so
mimeType is required for fromBytes and for attach(bytes, …) — it is never sniffed from
the content. An unlisted extension on a path or URL throws a typed ContentPartError with code
"unknown-extension" rather than guessing.
onUnsupportedPart — what happens when a part reaches a style that cannot represent it
Section titled “onUnsupportedPart — what happens when a part reaches a style that cannot represent it”A related, separate concern from construction: once a part is built, sending it depends on
whether the target provider style ("openai" | "anthropic") has a block shape for that
(type, style) pair at all. onUnsupportedPart (a createClient
option, UnsupportedPartMode = "error" | "text") controls the fallback, and the default already
depends on provenance — did the caller attach this part, or did a tool hand it back:
- A part the caller attached to the prompt and the style cannot represent ⇒ errors by
default (a typed
ContentPartError, code"unsupported") before any HTTP call — the caller asked for this exact part, so silently dropping it is the betrayal. - A part a tool derived (returned via
ToolResult.parts) that the style cannot represent ⇒ degrades to a text placeholder by default (e.g.[unsupported audio part (audio/mpeg, 3 bytes)]) and warns once — nobody asked for a part an MCP server happened to volunteer, so failing the whole run over it is a regression, not a safety net.
Setting onUnsupportedPart overrides both defaults uniformly, in either direction — "error"
forces strictness even on a tool-derived part, "text" forces the placeholder even on an
attached one.
Examples
Section titled “Examples”1. A text part and an image part from a path
Section titled “1. A text part and an image part from a path”import assert from "node:assert"import fs from "node:fs"import { attach, text, partBytes, type ContentPart } from "toolnexus"
// The committed golden — the source of truth for these bytes in all seven ports.const golden = fs.readFileSync("examples/media/fixture.png.base64", "utf8").trim()
const caption: ContentPart = text("What colour is the top-left quadrant?")const image = await attach("examples/media/fixture.png")
assert.equal(caption.type, "text")assert.equal(image.type, "image")assert.equal((image as any).mimeType, "image/png") // from the fixed extension table, never sniffedassert.equal((image as any).data, golden)assert.equal((image as any).url, undefined) // exactly one of data / urlassert.equal(partBytes(image), 82) // decoded bytes, not the +33% base64 string
// A prompt is a string OR an ordered list of parts; ordering is semantic to a model.const prompt: ContentPart[] = [caption, image]console.log("ok:", prompt.length, "parts |", (image as any).mimeType, partBytes(image), "bytes")2. The realistic case — every native source normalises to the same shape
Section titled “2. The realistic case — every native source normalises to the same shape”attach takes whatever spelling of the bytes you have — a Blob/File, an ArrayBuffer, a
Buffer, a data: URL — and every one of them normalises to the same {mimeType, data}.
import assert from "node:assert"import fs from "node:fs"import { attach, type ContentPart } from "toolnexus"
const bytes = fs.readFileSync("examples/media/fixture.png")const golden = fs.readFileSync("examples/media/fixture.png.base64", "utf8").trim()
// A File carries its own type and name — both are read at construction.const file = new File([bytes], "fixture.png", { type: "image/png" })// A bare Blob has a type but no name.const blob = new Blob([bytes], { type: "image/png" })// Raw bytes carry no extension, so mimeType is REQUIRED for these two.const fromRawBytes = await attach(bytes, { mimeType: "image/png" })const fromArrayBuffer = await attach( bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), { mimeType: "image/png" },)// Two spellings of the same bytes must not diverge: a data: URL is parsed, not stored.const dataUrl = await attach(`data:image/png;base64,${golden}`)
const parts: ContentPart[] = [await attach(file), await attach(blob), fromRawBytes, fromArrayBuffer, dataUrl]
for (const p of parts) { assert.equal(p.type, "image") assert.equal((p as any).mimeType, "image/png") assert.equal((p as any).data, golden, "every native source normalises to the same base64") for (const leak of ["path", "url", "blob", "stream", "file"]) { assert.equal(leak in (p as any), false, `a part must not carry a ${leak}`) } // Round-trips through a persisted transcript with the file gone. assert.equal(JSON.parse(JSON.stringify(p)).data, golden)}
console.log("ok:", parts.length, "native sources → one shape")3. The full surface — fromDataUrl/fromUrl directly, maxPartBytes, and the refusals
Section titled “3. The full surface — fromDataUrl/fromUrl directly, maxPartBytes, and the refusals”import assert from "node:assert"import { attach, fromDataUrl, fromUrl, fromBytes, ContentPartError } from "toolnexus"
// A named constructor called directly, when you already know the shape.const golden = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAEklEQVR4nGP8z8DwHxUwMDIwAADFBAE8LEMs0AAAAABJRU5ErkJggg=="const p1 = fromDataUrl(`data:image/png;base64,${golden}`)assert.equal(p1.type, "image")assert.equal((p1 as any).data, golden)
// A remote URL is kept as-is — never fetched at construction.const p2 = fromUrl("https://example.test/shot.png")assert.equal((p2 as any).url, "https://example.test/shot.png")assert.equal((p2 as any).data, undefined)
// An unlisted extension is a typed refusal — mime is never guessed.const err = await attach("/tmp/thing.xyz").catch((e) => e as ContentPartError)assert.ok(err instanceof ContentPartError)assert.equal(err.code, "unknown-extension")
// maxPartBytes rejects an over-limit payload at construction — a typed refusal, not a truncation.const bytes = new Uint8Array(2048)const tooBig = (() => { try { fromBytes(bytes, "image/png", { maxPartBytes: 1024 }); return undefined } catch (e) { return e as ContentPartError }})()assert.ok(tooBig instanceof ContentPartError)assert.equal(tooBig.code, "too-large")
console.log("ok:", p1.type, err.code, tooBig.code)See also
Section titled “See also”ContentPart— The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path. The read-side shapeattachbuilds.Tool— The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute.ToolResult— The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension.ToolContext— Optional per-call context handed to execute: cancellation, identity, and host-supplied state.