DEV Community

Lith SEO
Lith SEO

Posted on Originally published at foxyinvoice.com

Point your AI assistant at your invoices — we shipped an MCP server

FoxyInvoice now speaks MCP — the Model Context Protocol. Connect Claude
Desktop, Claude Code, VS Code, or Cursor to your workspace and ask it to
invoice a client in plain English:

"Invoice Globex for 10 hours of consulting at $150/hour, due in 30 days."

→ Created INV-2026-0007 for $1,608.75 ($1,500 + $108.75 CA tax), due
November 7. It's a draft — want me to send it?

Every number in that sentence came from the server, not the model. That's the
design constraint this post hangs off. Here's the whole thing: how auth works
when the client is a robot, why the tools are thin wrappers over the REST
handlers, and the one trick that makes agent retries harmless.


Try it on a real workspace: FoxyInvoice is free to start — create one at foxyinvoice.com/login and redeem founding code U8B4Z8S87X on the Upgrade page for 6 months of Pro, free, no card. Then Settings → AI assistants → Generate token, and ask Claude to invoice someone.

What shipped

A JSON-RPC endpoint at /api/v1/mcp speaking MCP's Streamable HTTP transport,
exposing seven tools:

Tool What it does
list_clients Search clients by name or email
create_client Create a client
list_products List the catalog
list_invoices List invoices, filter by status/client
get_invoice One invoice, all lines, computed totals
create_invoice Create a draft invoice
send_invoice Email a draft to its client

Auth: a robot is not a browser session

The SPA authenticates with short-lived JWTs behind httpOnly cookies. Right for
a browser, wrong for an assistant you configure once — a token that dies every
15 minutes breaks every MCP client config on earth. But a long-lived
credential needs a story for "how do I make it stop".

The answer is personal access tokens — the same shape GitHub chose:

  • Generated in Settings → AI assistants, shown once, stored only as a SHA-256 hash. The database can't leak what it doesn't have.
  • Presented as Authorization: Bearer foxy_… on every call. No cookies, no handshake state — the server is stateless, so any HTTP client that can POST JSON can drive it.
  • Revocable in one click, checked on every request.
  • A token acts as its user — live. There's no permissions snapshot in the token. Every request re-resolves the user's current roles from the database, so disabling a user or changing their roles takes effect on the very next tool call. Nothing to propagate, no cache to invalidate, no "I removed them and their integration still worked" bug class.

JWTs are deliberately not accepted at the MCP endpoint — a short-lived
browser credential pasted into an assistant config would be a support ticket
factory.

Tools are thin, the core is shared

The tempting way to build this is a parallel implementation: tool handlers
that re-do the queries the REST handlers already do. The correct way is to
make the REST handlers thin and share what's underneath. We extracted the
bodies of the client, product, invoice, and send handlers into *_core
functions that take the authenticated user and the request — both surfaces
call them:

REST handler  ─┐
               ├─► clients::create_core(state, auth, body)
MCP tool call ─┘
Enter fullscreen mode Exit fullscreen mode

Which means everything the REST API guarantees, the tools inherit for free,
because it is literally the same code:

  • Permission checks — a token can never do more than its user can.
  • Tenant scoping — every query filters by the token's tenant; cross-tenant probing returns not-found, not data.
  • Server-owned money math — the agent passes qty and unitPrice and nothing else. The server assigns the INV-YYYY-NNNN number, computes per-line tax from the client's jurisdiction and the tenant's nexus rules, rounds per the invoice rules, writes the audit trail. The model never states an amount, because a confidently wrong total on an invoice is not a bug, it's a liability.
  • Quotas — send_invoice counts against the same monthly send limit as the UI.

Sending is a separate tool, on purpose

A draft costs nothing. An email is irreversible. So creating and sending are
different tools with different names, and create_invoice always produces a
Draft — there is no "send too" parameter. The send tool's description tells
the agent to confirm with the user first.

Is a tool description binding? No. But models follow it remarkably well, and
the real backstop is structural: an agent has to make a second, deliberate,
differently-named call to reach a human being's inbox. Accidents need two
mistakes instead of one.

The retry problem, solved with an idempotency key

Agents retry. A dropped connection mid-create_invoice means the model will
try again — and without protection, "invoice the client" becomes two
invoices.

So create_invoice accepts an optional requestId (any UUID the agent picks
and reuses across retries of the same logical create). The server stores it
as the invoice's client-supplied id; a replay with the same requestId
returns the existing invoice instead of inserting a duplicate. First call
creates, retry returns what the first call created.

This is the same mechanism the offline-first SPA already uses — when you
create a client on a plane and it syncs later, a retried sync can't duplicate
it either. One idea, two surfaces.

Errors an agent can act on

MCP separates protocol errors from tool errors, and we lean on that: business
failures — client not found, validation failed, quota exhausted, no
permission — come back as successful tool calls with isError: true and a
plain-language message. The model reads "Client has no email address — cannot
send invoice", tells the user, and offers to fix it. Only genuine protocol
garbage is a JSON-RPC error. The difference is an assistant that recovers by
itself versus one that says "an error occurred".

The protocol, honestly assessed

MCP is young and moving fast, and we took a position: implement the spec's
Streamable HTTP transport statelessly and skip the rest. No session ids, no
server-initiated SSE streams, no stdio. What that buys:

  • The endpoint is documented in one page — initialize negotiation, tools/list, tools/call, and a 401 when the bearer is missing.
  • Statelessness composes with idempotency: any request can die and be retried, because there is no session to lose.
  • Server-side, it's one axum handler in the Rust API — the same codebase weight class as any other endpoint, not a subsystem.

We'll track the spec as it settles. The stateless subset is the part every
client already agrees on.

Try it

If you have a workspace: Settings → AI assistants → Generate token, paste
the one-liner into Claude Code (or the JSON into Claude Desktop), and ask
your assistant to invoice someone.

Full reference (every tool, token lifecycle, wire format):
foxyinvoice.com/docs/mcp
· this post lives at
foxyinvoice.com/blog/mcp-ai-invoices


FoxyInvoice is a live product — free invoicing for freelancers and small
businesses, built by SEOlith. This post is part of
a series documenting the whole build.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.