The expense tracker exposes AI agents two ways. Over the wire, it speaks the
Model Context Protocol over HTTP at
POST /mcp: any MCP client (Claude, OpenAI, etc) can connect, and with OAuth
connecting is just signing in with your account. In the browser, the app
also registers read-only in-page tools with agents that support
WebMCP (Chrome's origin
trial); see WebMCP experiment below.
An agent connected to your account can do what
the web app does, without the form:
- Capture a receipt: drop a photo or PDF into the chat; it runs the same OCR + extraction pipeline as the web app (DeepSeek, falling back to tesseract), reuses the merchant's previous category from your own history, stores the image, and creates the expense.
- Log a drive: give it stops in plain English; it geocodes, routes, and prices the trip at the year's IRS rate.
- Answer spending questions: "how much did I spend on flights last quarter?" gets the exact total from your data.
- Build reports: create/close reports, move expenses into them, export a report PDF.
- Reconcile: paste a bank statement CSV; it finds every charge with no matching receipt (read-only analysis).
Connecting is signing in with your account; there are no API keys. OAuth clients (Claude, OpenAI, etc) do this automatically:
- Point the client at
https://<your-host>/mcp. - The client fetches
/.well-known/oauth-authorization-server, registers itself, and opens your browser. - You sign in with your normal account (or you're already signed in) and click Allow on the consent page.
- The client gets tokens and connects. Consent is remembered, so reconnecting is a one-click approval.
Manage connected apps in Settings → Agents & API (MCP): delete individual access or refresh tokens (kills a session now, or stops the app getting new sessions), or disconnect an app entirely to revoke everything.
Add to your Claude MCP config (.mcp.json in the project, or the user-level
config):
{
"mcpServers": {
"expense": {
"type": "http",
"url": "https://expense.example.com/mcp"
}
}
}Claude performs OAuth discovery automatically: it registers itself and opens your browser for the sign-in flow.
Add the server in the Claude Desktop settings. It registers itself and opens your browser for the sign-in flow; no configuration file needed.
Any MCP client that supports Streamable HTTP + OAuth works. The endpoint
serves both protocol generations from one URL: 2025-era clients (the
initialize handshake, served statelessly) and 2026-07-28 stateless clients
(per-request _meta envelope, server/discover probe). Clients that
negotiate (e.g. the v2 SDK with versionNegotiation: { mode: 'auto' })
succeed either way.
A client can read the server's identity and connection details before it connects, from static documents that need no auth:
| Path | What it is |
|---|---|
/mcp/server-card |
The Server Card (application/mcp-server-card+json), at the location the Server Card extension reserves for a streamable-HTTP endpoint: the name org.labnotes/expense, title, version, description, icons, and one streamable-http remote listing every protocol revision the endpoint speaks. |
/.well-known/mcp/server-card.json |
The same identity in the older SEP-1649 shape (application/json), which is the only card path scanners and shipped clients actually probe. |
/.well-known/ai-catalog.json |
The domain-level AI Catalog (application/ai-catalog+json) with one entry pointing at the card. Start here: the catalog is how a client discovers servers on a domain, and the card is how it learns how to reach one. |
/.well-known/api-catalog |
The RFC 9727 linkset (application/linkset+json) listing the endpoint and pointing at the card as its machine-readable description. |
/auth.md |
Auth.md: how an agent gets access, which methods are supported, what the tokens are, and how access ends. |
/.well-known/oauth-protected-resource (and /mcp) |
RFC 9728 protected resource metadata; the path-aware URL is the one the SDK probes first, and the one the 401 hint carries. |
Every one of them is public by design, caches for an hour, and revalidates
with If-None-Match. HTML responses also carry Link headers naming the API
catalog (rel="api-catalog"), the card (rel="service-desc"), and
/llms.txt (rel="describedby"); a page that has a .md mirror names it too
(rel="alternate"). Nothing is negotiated on Accept: the mirror is its own
URL, so a cache keyed on the URL alone is always right (Vary used to make
that every cache's problem, and the ones that ignore it serve the wrong
body). robots.txt
declares Content-Signal: ai-train=yes, search=yes, ai-input=yes in its
wildcard block and points at the catalog with Agentmap:.
The card's identity is the same one /mcp reports in serverInfo
(server/discover on the modern leg), so a client that read the card and
then connects sees no contradiction. Everything about them lives in
app/lib/mcp-discovery.server.ts, which the runtime also reads, so the two
cannot drift apart.
The tool list is deliberately absent from both cards:
capabilities.tools declares that tools exist without enumerating them,
because app/lib/mcp.server.ts is their only home and a copy would drift.
| Tool | Writes | What it does |
|---|---|---|
capture_receipt |
✓ | Capture a receipt from base64 image/PDF data or a URL; extract merchant/amount/category (reusing the merchant's history), store the image, create the expense. Optional overrides for merchant, amount, currency, category, date, report, description. A non-USD amount converts to USD at the ECB reference rate for the expense date (result carries fx, and the description gains a "Converted from …" note); with no rate available the amount is stored as-is and warning says so. |
log_mileage |
✓ | Log a driving trip from ordered stops (address strings or pre-geocoded {address, lat, lng}); geocodes, routes, and computes distance + amount at the IRS rate for the trip's date and type (business default). One way by default; pass roundTrip: true when the drive returns to its first stop. |
list_expenses |
Query expenses: date range, category, merchant, report, unreported-only, type, limit. Newest first, amounts as decimal strings. | |
expense_summary |
Totals + per-category breakdown for the same filters; this is the "how much did I spend on X?" tool. | |
list_reports |
Reports with expense counts and exact totals. | |
create_report |
✓ | Create a report (fails if the name exists). |
close_report |
✓ | Close (or reopen) a report; closed reports refuse new expenses. |
add_to_report |
✓ | Move an expense into an open report; renames the stored receipt image to the dated convention name. |
export_report |
Render a report as a PDF (same layout as the web export: grouped by category, mileage rows with type/rate/distance, and a "Receipts & routes" appendix with receipt images and a real route map per mileage trip, its date/mileage/amount listed beside it) and return it base64-encoded. | |
list_categories |
The account's categories; use these when categorizing. | |
list_merchants |
Merchant names previously used, most recent first. | |
get_settings |
Home address, the account's named locations (work, hospital, and so on, each as a name plus its address), and the IRS mileage-rate master table (period + type). | |
reconcile |
Match a bank statement against logged expenses: matched pairs (high confidence), statement lines needing review (amount+date match but merchant differs, or ambiguous), unmatched statement lines, and logged receipts with no statement line. Pure analysis; nothing is written. |
Write tools return isError with a clear message when a report is closed
or doesn't exist.
Every tool declares a response schema and answers with both halves: a text
block (what a person reads, and what older clients parse) and the same value
as structuredContent, which the schema describes. On the 2025 wire the SDK
projects the three array-returning list tools down to { result: [ … ] },
because that shape has to be an object there; a 2026-07-28 client gets the
array as-is.
The rules every tool shares are served as the server's instructions (from
the initialize handshake, or server/discover on the 2026-07-28 era)
instead of being repeated in each description: amounts are decimal strings, a
report named in a call must already exist and be open, and an omitted date
means today in UTC, with serverUtcNow in the response telling the client
how to correct for the user's own timezone. Date arguments are ISO
YYYY-MM-DD and are validated as such; a malformed one is refused rather
than silently filtering the wrong range.
reconcile accepts CSV or QFX/OFX text. CSV: header row
(date,description,amount; column names containing "date", "desc",
"amount" are detected) or plain rows in that order; a Debit/Credit column
split is also handled. Dates can be YYYY-MM-DD, MM/DD/YYYY, or a month
name (Aug 3 2026); amounts may include $, commas, and parentheses for
negatives ((12.34)). Matching is date + absolute amount within a small
tolerance (±2 days, $0.50/1%), scored by merchant-token overlap with the
description: exact date + amount + a shared merchant word is "high"; a
close match with a different merchant name, several candidates, or two
statement lines claiming the same expense goes to needsReview; refund /
credit / payment lines and already-reconciled receipts are never
auto-matched. It never writes, dismisses, or deletes anything.
- OAuth only: authorization-code flow with PKCE (S256), per the MCP
authorization spec. There are no API keys; every
/mcprequest carries an OAuth access token (oat_…, 1-hour TTL) obtained by signing in. Refresh tokens (ort_…) live 30 days and rotate on every grant, so a leaked token only works briefly. Only the SHA-256 hashes are stored. - A token only ever reaches its own account; other accounts are fully isolated. Tokens bind to the signing-in user; users in the same account share the connection, other accounts never see it.
- Revocation: Settings → Agents & API lets you delete individual access
or refresh tokens (revoking an access token doesn't kill the refresh token,
per RFC 7009) or disconnect an app, which revokes everything. The next
request gets
401. - The endpoint is fully stateless: every request carries its own OAuth token and is served by a fresh server instance bound to that token's account: there are no sessions, nothing is held between requests, and cold starts cost nothing.
capture_receiptaccepts URLs: the server fetches them (like the existing Nominatim/OSRM/DeepSeek calls); treat credentials as secrets that can read your expenses.
- The endpoint is
maxDuration: 60(same as the draft-image route) because receipt extraction can be slow. - Public origin / proxies: the OAuth metadata derives its issuer from
the request origin, honoring
x-forwarded-proto/x-forwarded-hostwhen the app sits behind a TLS-terminating proxy (e.g. a localhttps://expense.localhostCaddy setup). If that isn't enough, setPUBLIC_URLto the public base URL and every advertised URL (issuer, endpoints, protected-resource metadata) uses it. Without one of these, a proxied client sees the proxy-internalhttp://origin and fails with "Protected resource … does not match expected". - Stateless by design: the v2 SDK's
createMcpHandlerbuilds a fresh server instance per request and holds nothing between them. 2025-era clients are served per request without sessions (legacy: 'stateless'), and 2026-07-28 clients natively. There is no session map, no cold-start session loss, and the endpoint scales horizontally as-is. tools/callresponses are JSON (responseMode: 'json'on the handler); no SSE stream to babysit.- OAuth authorization codes are single-use, 10-minute TTL, and bound to the client, redirect URI, and PKCE challenge. Refresh tokens rotate; the old token dies on every grant.
- The post-deploy smoke check (
GET /api/smoke, gated byCRON_SECRET) runs real MCP round trips in both protocol eras inside the deployed serverless bundle (runMcpSmokeinapp/lib/mcp.server.ts) using an OAuth access token issued straight to the store for a throwaway client that is deleted right after. It catches the SDK or zod being dropped by Vercel's dependency tracer and proves both generations of clients can be served.
# 2025-era handshake (stateless — no session id is issued)
curl -s https://expense.example.com/mcp \
-H "Authorization: Bearer oat_…" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
# list tools (2025-era, no session id needed)
curl -s https://expense.example.com/mcp \
-H "Authorization: Bearer oat_…" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# 2026-07-28 stateless: every request carries the _meta envelope and the
# standard Mcp-Method / Mcp-Name headers, and names its revision in
#
4982
MCP-Protocol-Version too. The transport answers 400 when the header and the
# body envelope disagree, so a hand-written request needs both.
curl -s https://expense.example.com/mcp \
-H "Authorization: Bearer oat_…" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: get_settings" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}},"name":"get_settings","arguments":{}}}'app/routes/mcp.ts: the HTTP endpoint (loader + action →handleMcpRequest).app/lib/mcp-discovery.server.ts: the pre-connection identity plus the discovery documents it builds (the Server Card, the SEP-1649 copy of it, the AI Catalog, and the RFC 9727 API catalog), served byapp/routes/mcp.server-card.tsat/mcp/server-card,app/routes/[.]well-known.mcp.server-card[.]json.ts,app/routes/[.]well-known.ai-catalog[.]json.ts, andapp/routes/[.]well-known.api-catalog.ts. The same identity feeds the runtimeserverInfo, so card and protocol cannot disagree.app/routes/auth[.]md.tsserves the agent auth document, andapp/routes/[.]well-known.oauth-protected-resource.mcp.tsthe path-aware protected resource metadata.app/lib/mcp.server.ts, the MCP server, with the dual-era stateless handler (createMcpHandler), OAuth bearer auth, the 13 tools, and the reconciliation matcher.app/lib/oauth.server.ts, the OAuth authorization server: PKCE (S256), token/code generation + hashing, RFC 8414 metadata, refresh rotation, and client authentication.app/routes/[.]well-known.oauth-authorization-server.ts(+ openid- configuration, oauth-protected-resource): discovery metadata.app/routes/oauth.{register,authorize,token,revoke}.ts(x): the OAuth endpoints;oauth.authorizerenders the consent page.prisma/contract.prisma:ApiTokenand the OAuth models (OAuthClient,OAuthConsent,OAuthCode,OAuthToken).app/routes/settings.tsx→ Agents & API (MCP): connected apps with per-token delete and full disconnect, endpoint URL.app/lib/report-pdf.server.ts: the report PDF builder shared by the web export andexport_report.- Tests:
test/mcp.test.ts(OAuth access tokens + smoke round trip) andtest/oauth.test.ts(discovery, registration, the full PKCE flow through the real consent page, refresh rotation, revocation, user isolation).
Besides the HTTP endpoint, the app registers three read-only tools with
the browser itself (WebMCP, the document.modelContext API in Chrome's
origin trial, Chrome 149+): list_expenses, expense_summary, and
list_reports. A page-embedded agent (browser agent, ChatGPT Desktop) can
then query your expenses in the tab you already have open, with your normal
session; there is no OAuth dance because the tools fetch /api/webmcp/*
same-origin. Writes stay MCP-only while this is experimental.
- Registration:
app/lib/webmcp.ts(no-op where the API is absent); wired inapp/root.tsxfor signed-in users. - Shared code: the tool contract (names, descriptions, filter fields,
schemas, and the response schemas the MCP handlers register as their
outputSchema) lives inapp/lib/expense-read-tools.tsand the implementations (filter, serialize, summarize, limit) inapp/lib/expense-read.server.ts. The MCP handlers and the WebMCP registration are both thin adapters over them, so the two surfaces can't drift apart. - Data:
app/routes/api.webmcp.$resource.ts, a session-authenticated JSON mirror of the three read tools. - Trying it: join the origin trial
(or use a Chrome with the flag), sign in, and in DevTools run
await document.modelContext.getTools(). Execute a tool withdocument.modelContext.executeTool(tool, '{"limit": 5}').