Guide · Anthropic SDK Advanced Features

MCP Server Anthropic Files API — upload, file_id reuse, PDF handling

Three Files API behaviours catch MCP server authors off-guard: uploaded files are referenced by file_id across as many requests as needed — but they are scoped to your API key, not a specific user or session — any request using your API key can reference any file you've uploaded, so never store sensitive user documents as files without per-user API key isolation; files must be referenced using { type: 'document', source: { type: 'file', file_id: '...' } } in message content — not as a string or URL — passing the file_id as a string in the message text simply sends the ID as text to the model, not the file content; and the API does not deduplicate uploads — uploading the same PDF twice produces two file objects with two different IDs, both billed and both consuming storage quota, so your MCP server must track whether a document is already uploaded before calling anthropic.files.upload().

TL;DR

Upload files with anthropic.beta.files.upload({ file: ... }) (currently in beta). Reference them in messages as { type: 'document', source: { type: 'file', file_id: uploadedFile.id } }. Cache the file_id keyed on a content hash so the same document is never uploaded twice. Delete files when done with anthropic.beta.files.delete(file_id). Files API is most valuable when the same large document is queried multiple times — it saves re-transmitting the document bytes on every call.

Uploading a document and caching the file_id

The Files API accepts PDF, plain text, HTML, Markdown, CSV, and several image formats. The upload returns a file object with an id field — store this ID and reuse it for all subsequent messages that need to reference the same document. The primary benefit is bandwidth: a 500 KB PDF uploaded once can be referenced in dozens of messages without re-transmitting the bytes each time.

import Anthropic from '@anthropic-ai/sdk';
import { createHash } from 'crypto';
import { readFile } from 'fs/promises';
import Database from 'better-sqlite3';
import { z } from 'zod';

const anthropic = new Anthropic({ defaultHeaders: { 'anthropic-beta': 'files-api-2025-04-14' } });
const db = new Database('./data.db');

// Track uploads to avoid re-uploading the same document.
// Key = sha256 of file bytes; value = Anthropic file_id
db.exec(`CREATE TABLE IF NOT EXISTS file_cache (
  content_hash TEXT PRIMARY KEY,
  file_id      TEXT NOT NULL,
  filename     TEXT,
  created_at   INTEGER DEFAULT (unixepoch())
)`);

async function getOrUploadFile(
  content: Buffer,
  filename: string,
  mimeType: string,
): Promise<string> {
  const hash = createHash('sha256').update(content).digest('hex');

  // Check local cache first
  const cached = db.prepare('SELECT file_id FROM file_cache WHERE content_hash = ?').get(hash) as
    | { file_id: string } | undefined;
  if (cached) return cached.file_id;

  // Not cached — upload to Anthropic Files API
  const file = await (anthropic as any).beta.files.upload({
    file: new File([content], filename, { type: mimeType }),
  });

  // Persist the mapping so future calls skip the upload
  db.prepare('INSERT OR IGNORE INTO file_cache (content_hash, file_id, filename) VALUES (?, ?, ?)')
    .run(hash, file.id, filename);

  return file.id;
}

// MCP tool: analyse a PDF document, reusing file_id when the same doc is seen again
server.tool(
  'analyze_document',
  {
    pdf_base64: z.string().describe('Base64-encoded PDF content'),
    filename:   z.string().default('document.pdf'),
    question:   z.string().min(1).max(2_000),
  },
  async ({ pdf_base64, filename, question }) => {
    const pdfBuffer = Buffer.from(pdf_base64, 'base64');
    const file_id = await getOrUploadFile(pdfBuffer, filename, 'application/pdf');

    const response = await (anthropic as any).messages.create({
      model:      'claude-sonnet-4-6',
      max_tokens: 1024,
      messages: [
        {
          role: 'user',
          content: [
            {
              type:   'document',
              source: { type: 'file', file_id },
              // Optional: add cache_control here to also prompt-cache the doc context
              // cache_control: { type: 'ephemeral' },
            },
            { type: 'text', text: question },
          ],
        },
      ],
    });

    const text = response.content.find((b: any) => b.type === 'text')?.text ?? '';
    return { content: [{ type: 'text', text }] };
  }
);

The File constructor is available globally in Node.js 20+. For older Node.js versions, use a Blob with the file bytes: new Blob([content], { type: mimeType }) and pass it with a filename property. The SDK sends a multipart/form-data upload request internally.

Using file_id in messages — correct content block format

Files are referenced using the document content block type with source.type === 'file'. The common mistake is passing the file ID as plain text in the message — the model receives the literal string "file_abc123" instead of the document content.

// CORRECT — references the file content
const correctMessage = {
  role: 'user',
  content: [
    {
      type:   'document',
      source: { type: 'file', file_id: 'file_abc123' },
    },
    { type: 'text', text: 'Summarise the key findings of this document.' },
  ],
};

// INCORRECT — sends the string "file_abc123" as the message
const wrongMessage = {
  role:    'user',
  content: 'Please summarise file_abc123',
};

// ALSO INCORRECT — source.type must be 'file', not 'url' or 'base64' when using file_id
const alsoWrong = {
  role: 'user',
  content: [
    {
      type:   'document',
      source: { type: 'base64', data: 'file_abc123' },  // wrong — this should be actual base64
    },
  ],
};

The document block type works for text-extractable files (PDF, TXT, HTML, MD, CSV). For images (JPEG, PNG, GIF, WEBP), use the image block type with source: { type: 'file', file_id: '...' }. Never mix the two — sending a PDF file_id in an image block returns a 400 error.

Listing and deleting files

Files persist until explicitly deleted or until account quota limits are reached. For an MCP server that processes user-uploaded documents, implement a cleanup tool that deletes files after the session ends to avoid accumulating stale data and incurring storage costs.

// Tool: list all uploaded files for this API key
server.tool(
  'list_uploaded_files',
  { limit: z.number().int().min(1).max(100).default(20) },
  async ({ limit }) => {
    const files = await (anthropic as any).beta.files.list({ limit });
    const summary = files.data.map((f: any) => ({
      id:         f.id,
      filename:   f.filename,
      size_bytes: f.size,
      created_at: f.created_at,
    }));
    return { content: [{ type: 'text', text: JSON.stringify(summary) }] };
  }
);

// Tool: delete a file by ID
server.tool(
  'delete_uploaded_file',
  { file_id: z.string().startsWith('file_') },
  async ({ file_id }) => {
    await (anthropic as any).beta.files.delete(file_id);

    // Also remove from our local cache
    db.prepare('DELETE FROM file_cache WHERE file_id = ?').run(file_id);

    return {
      content: [{ type: 'text', text: JSON.stringify({ deleted: true, file_id }) }],
    };
  }
);

// Automated cleanup: delete files older than 24 hours from our local cache,
// then delete them from Anthropic too.
async function purgeOldFiles(): Promise<void> {
  const cutoff = Math.floor(Date.now() / 1000) - 86_400;  // 24 hours ago
  const stale = db.prepare('SELECT file_id FROM file_cache WHERE created_at < ?')
    .all(cutoff) as Array<{ file_id: string }>;

  for (const { file_id } of stale) {
    try {
      await (anthropic as any).beta.files.delete(file_id);
      db.prepare('DELETE FROM file_cache WHERE file_id = ?').run(file_id);
    } catch {
      // File may already be deleted — ignore 404s
    }
  }
}

Run purgeOldFiles() on a timer (e.g. every 6 hours) to keep your quota usage in check. The Anthropic Files API enforces per-account storage limits — exceeding them causes upload requests to fail with a 413 error. Proactive cleanup avoids hitting this limit in production.

Combining Files API with prompt caching

Files API and prompt caching are complementary: the Files API avoids re-transmitting document bytes over the wire, while prompt caching avoids re-processing those bytes through the model's context on repeated queries. For maximum efficiency on a document that is queried many times, use both together.

// Combine file_id reference with cache_control on the document block
const response = await (anthropic as any).messages.create({
  model:      'claude-sonnet-4-6',
  max_tokens: 512,
  messages: [
    {
      role: 'user',
      content: [
        {
          type:          'document',
          source:        { type: 'file', file_id },
          cache_control: { type: 'ephemeral' },
          // ↑ Both: skip re-transmitting bytes (Files API)
          //         AND skip re-processing in the context window (prompt cache)
        },
        { type: 'text', text: question },
      ],
    },
  ],
});

When a document exceeds 1,024 tokens (virtually all PDFs), adding cache_control to the document block activates prompt caching for the document's token representation. The first call writes the cache entry; subsequent calls with the same file_id and cache_control hit the cache and pay 90% less for those input tokens. The Files API prevents re-upload; the prompt cache prevents re-tokenisation — together they make large-document MCP tools significantly cheaper to operate.