Skip to content

Build a ContentPart — attach a file, image, or bytes to a prompt

Rust · crate toolnexus · SPEC §1B

impl ContentPart {
pub fn text(s: impl Into<String>) -> Self;
// Edge constructors — every one reads and base64-encodes NOW, so a path or an
// unread stream never enters a ContentPart. A failure is a PartError, returned.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, PartError>; // mime from the fixed extension table
pub fn from_bytes(raw: impl AsRef<[u8]>, mime_type: &str) -> Result<Self, PartError>;
pub fn from_reader(r: impl std::io::Read, mime_type: &str) -> Result<Self, PartError>; // drained now; not closed
pub fn from_url(url: &str, mime_type: &str) -> Result<Self, PartError>; // data: URL -> {mime_type, data}; else kept as url
}
pub struct PartError {
pub kind: &'static str, // extension | read | mime | dataurl
pub msg: String,
}
// ClientOptions.on_unsupported_part: String // "" (provenance rule) | "error" | "text"

ContentPart::text and four edge constructors turn a string, a path, raw bytes, any std::io::Read, or a URL into the one wire shape every part collapses to — ContentPart: {kind, text, mime_type, data, url, name} (wire keys type/mimeType). They are the write half; ContentPart itself is the shape a prompt, a tool result, or an MCP response actually carries.

Every constructor reads and base64-encodes at construction time — never lazily, never on a retained handle — so a ContentPart can always be serialized, persisted, or replayed without re-touching whatever it was built from. Unlike Go, which defers a construction error onto the part, Rust returns it: each constructor is a Result<ContentPart, PartError>, so ? handles it at the call site.

Reach for from_path for the common case — a file already on disk (png/jpg/jpeg/gif/webp/pdf/mp3/wav; any other extension is a PartError naming it, never a guess). Reach for from_bytes when you already hold the bytes (a generated screenshot, a downloaded payload) and don’t want a temp file. Reach for from_reader for a stream — a File, a Cursor, an include_bytes! slice — without writing the read loop yourself (the reader is drained now and left to you to close). Reach for from_url for a remote image/file/audio URL, or a data: URL you want normalized into {mime_type, data} at construction rather than carried as a string.

1. The smallest useful call — attach a file from disk

Section titled “1. The smallest useful call — attach a file from disk”
use toolnexus::ContentPart;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ".pdf" resolves to a `file` part, the one type from_path names from the path
// (`name` is not set for image/audio).
let path = std::env::temp_dir().join(format!("note-{}.pdf", std::process::id()));
std::fs::write(&path, b"%PDF-1.4 fake pdf bytes")?;
let p = ContentPart::from_path(&path)?;
std::fs::remove_file(&path)?;
assert_eq!(p.kind, "file");
assert_eq!(p.mime_type, "application/pdf");
assert_eq!(p.name, path.file_name().unwrap().to_string_lossy());
// Already read and encoded: deleting the file changed nothing.
assert_eq!(ContentPart::from_bytes(b"%PDF-1.4 fake pdf bytes", "application/pdf")?.data, p.data);
println!("ok: {} ({}) {} b64 chars", p.name, p.mime_type, p.data.len());
Ok(())
}

2. The realistic case — from_bytes for generated data, from_url for a data: URL, one prompt

Section titled “2. The realistic case — from_bytes for generated data, from_url for a data: URL, one prompt”
use toolnexus::ContentPart;
use toolnexus::client::Prompt;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A PNG signature, generated in memory — no filesystem round trip needed.
let png: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let screenshot = ContentPart::from_bytes(png, "image/png")?;
// Same bytes, arriving as a data: URL — from_url normalizes it to the identical part.
let data_url = format!("data:image/png;base64,{}", screenshot.data);
let from_url = ContentPart::from_url(&data_url, "")?;
assert_eq!(screenshot, from_url, "a data: URL decodes to the same bytes as from_bytes");
let prompt: Prompt = vec![ContentPart::text("what's in this screenshot?"), screenshot].into();
let Prompt::Parts(parts) = &prompt else { panic!("a multimodal prompt") };
assert_eq!(parts.len(), 2);
println!("ok: {} parts, same image two ways", parts.len());
Ok(())
}

3. The full surface — from_reader for an embedded asset, and the unsupported-part provenance rule

Section titled “3. The full surface — from_reader for an embedded asset, and the unsupported-part provenance rule”
use std::io::Cursor;
use toolnexus::client::{ClientError, ClientOptions, Style, create_client};
use toolnexus::{BuiltinsConfig, ContentPart, ToolkitOptions, create_toolkit};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Stands in for an asset compiled into the binary with include_bytes!.
static REPORT: &[u8] = b"%PDF-1.4";
let report = ContentPart::from_reader(Cursor::new(REPORT), "application/pdf")?;
assert_eq!((report.kind.as_str(), report.mime_type.as_str()), ("file", "application/pdf"));
// A missing extension is refused, by name, with a typed kind.
let err = ContentPart::from_path("README").unwrap_err();
assert_eq!(err.kind, "extension");
// An ATTACHED part the style cannot represent (Anthropic has no audio block)
// fails before any HTTP call: port 9 on loopback is never contacted.
let tk = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), ..Default::default() }).await?;
let client = create_client(ClientOptions {
base_url: "http://127.0.0.1:9".into(),
style: Style::Anthropic,
model: "m".into(),
api_key: "test-key".into(),
retries: -1,
..Default::default()
});
let audio = ContentPart::from_bytes(b"fakeaudio", "audio/mpeg")?;
let res = client.run(vec![ContentPart::text("listen"), audio], &tk).await;
match res {
Err(ClientError::Part(msg)) => {
assert!(msg.contains("\"audio\""), "names the part type: {msg}");
println!("ok: {} bytes embedded; attached audio refused: {msg}", REPORT.len());
}
other => panic!("expected ClientError::Part, got {other:?}"),
}
Ok(())
}
  • 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.
  • ContentPart — The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path.
  • ToolContext — Optional per-call context handed to execute: cancellation, identity, and host-supplied state.