8000 expense/docs/mcp.md at main · assaf/expense · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
304 lines (264 loc) · 24.7 KB

mcp.md

File metadata and controls

304 lines (264 loc) · 24.7 KB

Expense MCP server

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).

Setup

Connecting is signing in with your account; there are no API keys. OAuth clients (Claude, OpenAI, etc) do this automatically:

  1. Point the client at https://<your-host>/mcp.
  2. The client fetches /.well-known/oauth-authorization-server, registers itself, and opens your browser.
  3. You sign in with your normal account (or you're already signed in) and click Allow on the consent page.
  4. 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.

Claude

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.

Claude Desktop

Add the server in the Claude Desktop settings. It registers itself and opens your browser for the sign-in flow; no configuration file needed.

Anything else

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.

Discovery

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.

Tools

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.

Reconciling a statement

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.

Auth & security

  • OAuth only: authorization-code flow with PKCE (S256), per the MCP authorization spec. There are no API keys; every /mcp request 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_receipt accepts URLs: the server fetches them (like the existing Nominatim/OSRM/DeepSeek calls); treat credentials as secrets that can read your expenses.

Operational notes

  • 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-host when the app sits behind a TLS-terminating proxy (e.g. a local https://expense.localhost Caddy setup). If that isn't enough, set PUBLIC_URL to 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-internal http:// origin and fails with "Protected resource … does not match expected".
  • Stateless by design: the v2 SDK's createMcpHandler builds 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/call responses 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 by CRON_SECRET) runs real MCP round trips in both protocol eras inside the deployed serverless bundle (runMcpSmoke in app/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.

Smoke-testing from a terminal

# 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":{}}}'

How it's built

  • 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 by app/routes/mcp.server-card.ts at /mcp/server-card, app/routes/[.]well-known.mcp.server-card[.]json.ts, app/routes/[.]well-known.ai-catalog[.]json.ts, and app/routes/[.]well-known.api-catalog.ts. The same identity feeds the runtime serverInfo, so card and protocol cannot disagree. app/routes/auth[.]md.ts serves the agent auth document, and app/routes/[.]well-known.oauth-protected-resource.mcp.ts the 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.authorize renders the consent page.
  • prisma/contract.prisma: ApiToken and 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 and export_report.
  • Tests: test/mcp.test.ts (OAuth access tokens + smoke round trip) and test/oauth.test.ts (discovery, registration, the full PKCE flow through the real consent page, refresh rotation, revocation, user isolation).

WebMCP experiment (in-page tools)

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 in app/root.tsx for 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 in app/lib/expense-read-tools.ts and the implementations (filter, serialize, summarize, limit) in app/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 with document.modelContext.executeTool(tool, '{"limit": 5}').
0