Invoice & Receipt Extraction MCP Server: Connect One or Build Your Own

Connect a hosted MCP server for invoice and receipt extraction, or build your own TypeScript server. Compare the two paths and return structured JSON to your agent.

Published
Updated
Reading Time
10 min
Topics:
API & Developer IntegrationMCPAI agentstool calling

If you need an MCP server for invoice or receipt extraction, you have two practical paths. Connect the hosted Invoice Data Extraction server when you want an agent to handle documents now. Build your own TypeScript server when you need to own the transport, define a narrower tool boundary, or put different business logic around the extraction API.

Reading invoices and receipts into structured data is a different job from creating invoices, sending them, or querying a billing account, which is what most tools listed as invoice MCP servers do. In either path, MCP gives the assistant a standard way to discover and call a document extraction tool instead of relying on custom tool-calling code for each client.


Connect the Hosted Invoice and Receipt Extraction MCP Server

The hosted server is available at https://mcp.invoicedataextraction.com/mcp over streamable HTTP. It uses your Invoice Data Extraction API key as a bearer token. Create an API key in the dashboard, store it in INVOICE_DATA_EXTRACTION_API_KEY, and keep it in your client’s secret store rather than in a conversation or committed configuration file.

For example, Codex connects with this entry in ~/.codex/config.toml:

[mcp_servers.invoice-data-extraction]
url = "https://mcp.invoicedataextraction.com/mcp"
bearer_token_env_var = "INVOICE_DATA_EXTRACTION_API_KEY"

The maintained MCP connection guide includes copyable configurations for Claude Code, Codex, Cursor, Hermes, OpenClaw, and Gemini CLI. Every account includes 50 free pages each month, with no card required.

Once connected, an agent can upload invoice or receipt files, submit extraction instructions, wait for the result, answer a question if the extraction stops to ask, and read the rows as structured JSON. The result keeps Review Needed warnings and failed-page reporting beside the data. The agent can also request an XLSX, CSV, or JSON download instead of reading the rows directly.

The same server handles both document types. For an invoice, you might ask for invoice number, date, vendor name, line items, tax, and total. For receipts, a request such as “Extract merchant name, date, total, tax, and categorize by expense type” produces receipt-shaped rows without requiring a separate receipt MCP server.

Connect or Build Your Own?

DecisionConnect the hosted serverBuild your own server
RuntimeUse a remote endpointRun and maintain the server
Tool surfaceUse the complete upload, extraction, question, result, and download flowExpose only the tools and business logic your workflow needs
Best fitFastest route to invoice and receipt extractionMaximum control over transport, backend, and surrounding workflow

Connecting is the shorter route when the standard extraction loop already fits. Building remains useful when the MCP tool itself needs to enforce custom rules, combine extraction with another system, or present a deliberately narrower interface to the agent.


Structured Extraction vs. Raw OCR Wrappers

The backend behind an MCP tool determines whether the assistant receives usable data or a block of text it must interpret again on every call.

A raw OCR wrapper returns recognized text without semantic labels. The assistant still has to decide which string is the invoice number, where the line-item table begins, and whether an amount is a subtotal, tax, or final total. That interpretation is repeated for every document and every downstream question.

A document extraction API returns named fields and rows. For an invoice, those might include invoice number, vendor name, invoice date, line-item descriptions, quantities, unit prices, tax, and total. Receipt rows can carry merchant, date, expense category, tax, and total. With typed JSON enabled, amounts arrive as numbers, yes/no fields as booleans, and empty cells as null.

The difference matters as soon as an agent has to act on the result. It can sum a Total Amount column, compare vendors, or pass line-item rows into another tool without first reconstructing structure from OCR text. The product also keeps the checks that make the rows safe to use: a panel of AI agents has to agree on every value; a value or a row the panel cannot agree on is flagged as Review Needed; and a page that fails is reported, never skipped in silence. The guide to extracting structured JSON from invoices goes deeper into the output choices.


Build a Custom TypeScript MCP Server

The example below builds a local stdio server with the current official Model Context Protocol TypeScript SDK and the invoice extraction API. A host such as Claude Desktop or an IDE launches the process and calls its extract_financial_document tool.

Project Setup

Use Node.js 20 or later and an ESM project:

mkdir mcp-invoice-server && cd mcp-invoice-server
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server @invoicedataextraction/sdk zod tsx
mkdir src

The Invoice Data Extraction SDK accepts local file paths. Because this tool receives base64-encoded file content, the handler writes one temporary file, passes its path to the SDK, and removes it when the call ends.

Define the Tool and Extraction Handler

Create src/index.ts:

import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import InvoiceDataExtraction from "@invoicedataextraction/sdk";
import { writeFile, rm, mkdtemp } from "node:fs/promises";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
import * as z from "zod/v4";

const apiKey = process.env.INVOICE_DATA_EXTRACTION_API_KEY;
if (!apiKey) {
  throw new Error("INVOICE_DATA_EXTRACTION_API_KEY is required");
}

const extractionClient = new InvoiceDataExtraction({ api_key: apiKey });

function createServer(): McpServer {
  const server = new McpServer({
    name: "invoice-receipt-extraction",
    version: "1.0.0",
  });

  server.registerTool(
    "extract_financial_document",
    {
      description:
        "Extract structured rows from an invoice or receipt file. " +
        "Returns typed JSON with Review Needed warnings and failed-page details.",
      inputSchema: z.object({
        file_content: z
          .string()
          .describe("Base64-encoded PDF, JPG, JPEG, or PNG file content"),
        filename: z
          .string()
          .min(1)
          .describe("Original filename with its extension"),
        prompt: z
          .string()
          .optional()
          .describe("Fields and formatting rules for the output rows"),
        output_structure: z
          .enum(["per_invoice", "per_line_item"])
          .default("per_invoice"),
      }),
    },
    async ({ file_content, filename, prompt, output_structure }) => {
      const tempDir = await mkdtemp(join(tmpdir(), "mcp-extraction-"));

      try {
        const tempFilePath = join(tempDir, basename(filename));
        await writeFile(tempFilePath, Buffer.from(file_content, "base64"));

        const status = await extractionClient.extract({
          files: [tempFilePath],
          prompt:
            prompt ??
            "Extract the document date, supplier or merchant, line items, tax, and total",
          output_structure,
          json_typed_values: true,
          polling: {
            wait_seconds: 30,
            timeout_ms: 120000,
          },
        });

        if (status.status !== "completed") {
          return {
            content: [
              { type: "text", text: JSON.stringify(status, null, 2) },
            ],
            isError: true,
          };
        }

        const page = await extractionClient.getResults({
          extraction_id: status.extraction_id,
          limit: 1000,
        });

        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(
                {
                  extraction_id: page.extraction_id,
                  columns: page.columns,
                  rows: page.rows,
                  review_needed: page.review_needed,
                  pages: {
                    failed_count: page.pages.failed_count,
                    failed: page.pages.failed,
                    failure_reasons: page.pages.failure_reasons,
                  },
                  has_more: page.has_more,
                  next_offset: page.next_offset,
                },
                null,
                2,
              ),
            },
          ],
        };
      } catch (error: unknown) {
        const sdkError = error as Error & {
          body?: {
            error?: {
              code?: string;
              message?: string;
              retryable?: boolean;
              details?: unknown;
            };
          };
        };
        const detail = sdkError.body?.error;

        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(
                {
                  error: {
                    code: detail?.code ?? "EXTRACTION_ERROR",
                    message: detail?.message ?? sdkError.message,
                    retryable: detail?.retryable ?? false,
                    details: detail?.details ?? null,
                  },
                },
                null,
                2,
              ),
            },
          ],
          isError: true,
        };
      } finally {
        await rm(tempDir, { recursive: true, force: true });
      }
    },
  );

  server.registerTool(
    "get_extraction_results_page",
    {
      description:
        "Read another page of rows from a completed extraction.",
      inputSchema: z.object({
        extraction_id: z.string().uuid(),
        offset: z.number().int().min(0),
      }),
    },
    async ({ extraction_id, offset }) => {
      try {
        const page = await extractionClient.getResults({
          extraction_id,
          offset,
          limit: 1000,
        });

        return {
          content: [
            { type: "text", text: JSON.stringify(page, null, 2) },
          ],
        };
      } catch (error: unknown) {
        const sdkError = error as Error & {
          body?: {
            error?: {
              code?: string;
              message?: string;
              retryable?: boolean;
              details?: unknown;
            };
          };
        };
        const detail = sdkError.body?.error;

        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(
                {
                  error: {
                    code: detail?.code ?? "RESULTS_ERROR",
                    message: detail?.message ?? sdkError.message,
                    retryable: detail?.retryable ?? false,
                    details: detail?.details ?? null,
                  },
                },
                null,
                2,
              ),
            },
          ],
          isError: true,
        };
      }
    },
  );

  return server;
}

void serveStdio(createServer);
console.error("Invoice and receipt extraction MCP server running on stdio");

This handler separates the extraction status from the extracted rows. extract(...) uploads the file, submits the task, waits for a terminal status, and returns that status. Only after a completed status does getResults(...) return the rows. The response also carries the warnings and failed-page details an agent needs before relying on the data.

The extraction tool returns the first 1,000 rows. When has_more is true, the agent calls get_extraction_results_page with the same extraction_id and next_offset. For a tool that always consumes every row inside the server, use iterateResults(...) instead.

Run the server with:

npx tsx src/index.ts

Because stdout is the stdio protocol channel, application logs must go to stderr. The final console.error is safe; a console.log in this process can corrupt MCP messages.

Register the command in your client. A local client configuration has this shape:

{
  "mcpServers": {
    "invoice-receipt-extraction": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/src/index.ts"]
    }
  }
}

Set INVOICE_DATA_EXTRACTION_API_KEY in the environment that launches the client. Do not put the key in the checked-in configuration.


Production Details That Matter

Authentication

The extraction API uses a bearer-token API key. Read it from INVOICE_DATA_EXTRACTION_API_KEY when the server starts and fail immediately if it is missing. The Node SDK quickstart covers the same authentication setup outside MCP, and the live Node SDK documentation is the source of truth for method signatures and response fields.

Task Failures and SDK Errors

Two failure paths need different handling:

  • If the extraction task reaches status: "failed", extract(...) returns that status. The failure is in status.error; it is not thrown.
  • If upload, submission, polling, validation, or a network request fails before a terminal task result exists, the SDK throws a normal JavaScript Error. The API error envelope, when present, is on error.body.

That is why the handler checks status.status before reading rows and also has a catch block. Returning isError: true gives the calling agent a failure it can explain or act on without crashing the MCP process.

Held Status Requests and Timeouts

The SDK does not use frequent interval polling by default. It asks the API to hold each status request for up to 30 seconds and sends another request only if the extraction is still processing. The wait_seconds setting can be 0 to 45; 0 switches to ordinary interval polling. timeout_ms limits the total wait and throws SDK_TIMEOUT_ERROR when that limit is reached, although the extraction may still be running.

The example uses a two-minute ceiling for an interactive tool call. A production server should choose its limit around the MCP client’s own timeout and provide a separate status tool when callers need to resume longer jobs.

Rate Limits and Larger Workflows

The API permits 30 extraction submissions and 120 status checks per minute per API key. The SDK retries rate-limited and transient internal errors, but sustained batch traffic still needs deliberate concurrency and backoff. Result reads have their own limit of 60 requests per minute.

If a custom server grows beyond one extraction tool, add capabilities because the workflow needs them: a status tool for resumable jobs, a separate balance check before large batches, or a tool that combines extracted rows with an internal approval system. The hosted MCP server already exposes the standard upload, run, question, result, download, balance, list, and cancellation operations, so rebuilding those only makes sense when the custom boundary changes the workflow.

For a complete agentic accounts-payable flow around the extraction tool, see the Claude Agent SDK and custom Skills workflow. If you prefer native Python tools rather than MCP, the OpenAI Agents SDK AP automation guide covers function tools, handoffs, and guardrails. The extraction API quickstart shows the same upload-to-results flow without an MCP layer.

The result is the same architectural choice in two forms: connect the hosted invoice and receipt extraction MCP server for the complete workflow, or build a focused TypeScript tool when owning the server creates real value.

Extract invoice data to Excel with AI

Upload your invoices, say what you need in plain words, and download the spreadsheet.

A panel of AI agents has to agree on every value
What the panel cannot agree on is flagged as Review Needed
50 free pages every month, no subscription
Any layout, any language, scans and phone photos
Numbers come through as numbers and dates as dates
Files encrypted and deleted within 48 hours
Continue Reading