Development
MCP Server
The read-only MCP server: stdio and loopback HTTP transports, the status/overview/projects/scrollback tools, the HTTP security model, and desktop hosting.
MCP Server
tokenuse ships a read-only Model Context Protocol server so LLM clients (Claude Code, Claude Desktop, Cursor, and anything else that speaks MCP) can ask about local AI-coding spend without reading the raw session logs themselves. It exposes the same numbers as the scriptable CLI summaries — nothing leaves the machine, and there are no platform API keys, proxies, or telemetry involved.
The server has one dispatcher and two transports:
- stdio —
tokenuse mcp. The client spawns the process per session and talks newline-delimited JSON-RPC over stdin/stdout. No network, no listener left behind. - Streamable HTTP —
tokenuse mcp --http, or the desktop app’s Config-page toggle. A loopback-only HTTP listener that runs for as long as its host does, so the desktop app can serve MCP the whole time it is open.
Source:
src/mcp.rs(dispatcher + tools + stdio transport),src/mcp/http.rs(HTTP transport),desktop/src-tauri/src/mcp_http.rs(desktop process-global host).
flowchart LR
subgraph Clients
CC[Claude Code]
CD[Claude Desktop]
CU[Cursor]
CURL[curl / scripts]
end
subgraph Transports
STDIO["stdio loop<br/>mcp::run"]
HTTP["HTTP listener<br/>mcp::http::serve_http"]
end
CC -->|spawn process| STDIO
CD -->|spawn process| STDIO
CC -->|HTTP + bearer| HTTP
CU -->|HTTP + bearer| HTTP
CURL -->|HTTP + bearer| HTTP
STDIO --> CORE["McpServer::handle_line<br/>(transport-agnostic JSON-RPC)"]
HTTP --> CORE
CORE --> CACHE["15-min TTL data cache"]
CACHE --> ARCHIVE["archive::sync_and_load<br/>(archive.db)"]
CORE --> SEARCH["search_transcripts<br/>(scrollback FTS5)"]
SEARCH --> ARCHIVE
The design goal is that McpServer::handle_line(&str) -> Option<Value> is the single point that parses one JSON-RPC message and returns a response (None means “notification, no reply”). Both transports are thin shims that feed it lines and write back its answers, so the tool logic, protocol negotiation, and data cache are shared and only the wire framing differs.
Quick start
stdio (per-session process)
# Register with Claude Code as a command server:
claude mcp add tokenuse -- tokenuse mcp
Or wire it into any client’s config as command tokenuse, args ["mcp"]. Add --real-names to expose real project names instead of pseudonyms.
HTTP from the CLI (foreground)
tokenuse mcp --http # default port 20151
tokenuse mcp --http --port 20200 # override the configured port
tokenuse mcp --http --real-names # combine with real names
# It prints where it is listening, then serves until interrupted:
# MCP HTTP server listening on http://127.0.0.1:20151/mcp
Register the HTTP endpoint — every request needs the bearer token from <config dir>/tokenuse/mcp-token:
claude mcp add --transport http tokenuse http://127.0.0.1:20151/mcp \
--header "Authorization: Bearer $(cat "$HOME/Library/Application Support/tokenuse/mcp-token")"
The config directory is dirs::config_dir()/tokenuse — ~/Library/Application Support/tokenuse on macOS, ~/.config/tokenuse on Linux, %APPDATA%\tokenuse on Windows.
HTTP from the desktop app
Open Config → MCP Server and flip Serve over HTTP. While enabled the app hosts the same endpoint at http://127.0.0.1:<port>/mcp for as long as it is open, restarting it on the next launch. The panel shows the endpoint, running state, and a masked token with Reveal, Copy token, and Copy command (a ready-to-paste claude mcp add …). The desktop endpoint always pseudonymises project names; --real-names is CLI-only.
Protocol
JSON-RPC 2.0. The dispatcher (McpServer::handle_request) handles:
| Method | Behaviour |
|---|---|
initialize |
Negotiates protocolVersion, advertises capabilities.tools, returns serverInfo and the server instructions. |
ping |
Empty result. |
tools/list |
The four tool definitions with input schemas. |
tools/call |
Dispatches to a tool; unknown tool → -32602, tool failure → a successful response with isError: true. |
notifications/* |
No response (returns None). |
| anything else | -32601 method not found. |
Supported protocol revisions (newest first): 2025-06-18, 2025-03-26, 2024-11-05. An unknown requested revision negotiates down to 2025-06-18. JSON-RPC error codes used: -32700 parse error, -32601 method not found, -32602 invalid params.
Tools
All four tools are read-only. Costs are API-equivalent prices formatted in the configured display currency. Every payload is returned both as pretty-printed text (content[0].text) and, for non-error results, as structuredContent for clients that consume typed output.
flowchart TD
T[tools/call] --> S{name}
S -->|status| ST["today + month totals"]
S -->|overview| OV["month: totals, by_tool,<br/>by_activity, models, daily"]
S -->|projects| PR["month per-project spend<br/>(pseudonymised)"]
S -->|scrollback| SB["full-text transcript search<br/>(pseudonymised labels, raw snippets)"]
ST --> D["McpServer::data()<br/>15-min TTL cache"]
OV --> D
PR --> D
SB --> D
SB --> Q["search_transcripts<br/>(own read-only SQLite conn)"]
status
Today’s and this calendar month’s totals. No arguments.
{
"currency": "GBP",
"today": { "cost": "£42.10", "cost_usd": 56.2, "calls": 320, "sessions": 12,
"input_tokens": 812004, "output_tokens": 41233,
"cache_read_tokens": 60540221, "cache_write_tokens": 104322 },
"month": { "cost": "£1997", "cost_usd": 2665.1, "calls": 12405, "sessions": 410, "...": "..." }
}
Each period carries the PeriodTotals fields (cost_usd, calls, sessions, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) plus a cost string formatted in the display currency.
overview
This month with no project or session identifiers: totals, the per-tool split, deterministic activity categories, top models, and the daily trend. No arguments.
{
"period": "This month",
"currency": "GBP",
"totals": { "cost": "£1997", "calls": 12405, "...": "..." },
"by_tool": [ { "tool": "claude-code", "cost": "£1600", "calls": 9000, "...": "..." } ],
"by_activity": [ { "label": "Coding", "cost": "£820", "calls": 4100 } ],
"models": [ { "name": "claude-…", "provider": "Anthropic", "cost": "£1200", "calls": 7000, "cache_rate": 0.86 } ],
"daily": [ { "day": "2026-07-01", "cost": "£63", "calls": 210 } ]
}
projects
This month’s per-project spend, busiest first. No arguments. Project names are pseudonymised (project-<hex>) unless the server was started with --real-names.
{
"period": "This month",
"currency": "GBP",
"pseudonymised": true,
"projects": [
{ "project": "project-0e99caeba693", "cost": "£1339", "sessions": 180,
"avg_per_session": "£7.44", "tool_mix": "claude-code, codex" }
]
}
scrollback
Full-text search across archived session transcripts, grouped per session. Backed by the same search_transcripts FTS5 index the TUI and desktop Scrollback pages use (see Transcript Store And Scrollback Search).
Arguments:
| Argument | Required | Notes |
|---|---|---|
query |
yes | Terms are ANDed; the final term matches by prefix. A blank query is a tool error and never touches the archive. |
project |
no | A real project name, a pseudonym from a previous response, or a raw identity — all resolve to the same underlying project. |
tool |
no | A tool id such as claude-code, codex, cursor, copilot, or gemini. |
limit |
no | 1–50 matching sessions (default 20). |
{
"query": "headroom",
"currency": "GBP",
"pseudonymised": true,
"total_sessions": 9,
"sessions": [
{
"session": "claude-code:d6c26f05-…",
"tool": "claude-code",
"project": "project-8473062ed331",
"last_timestamp": "2026-07-05T09:27:31.468+00:00",
"cost": "£5.87",
"match_count": 84,
"prompt_only": false,
"matches": [
{ "role": "user", "timestamp": "2026-07-05T08:57:25.990+00:00",
"snippet": "…Terminal showing headroom init configuring Claude Code and Codex…" }
]
}
]
}
Project labels follow the projects-tool pseudonymisation, but snippets are raw transcript text — the client is reading its own local sessions. prompt_only: true marks sessions whose only matches came from migration-seeded prompt fallbacks (their source files were gone before the transcript store existed).
HTTP transport internals
The HTTP transport is hand-rolled over std::net::TcpListener — no async runtime, no new dependencies, consistent with the crate’s “std-only” ethos. It binds 127.0.0.1 only and implements just enough of Streamable HTTP to carry JSON-RPC:
POST /mcpwith a JSON body →application/jsonJSON-RPC response, or HTTP 202 with an empty body whenhandle_linereturnsNone(a notification).GET /mcp→ 405 (no server-initiated SSE stream is offered).- Any other path → 404.
- Stateless: no
Mcp-Session-Idis issued. This is the spec’s stateless mode; clients proceed without a session.
Request lifecycle
sequenceDiagram
participant C as Client
participant A as Accept loop
participant W as Connection thread
participant S as Mutex#lt;McpServer#gt;
C->>A: TCP connect
A->>W: spawn thread-per-connection
W->>W: read request line + headers (capped)
W->>W: security gates (host, origin, path, method, auth)
alt any gate fails
W-->>C: 4xx, empty body, Connection: close
else gates pass
W->>W: read Content-Length body (≤ 1 MiB)
W->>S: lock().handle_line(body)
S-->>W: Some(json) or None
alt Some(response)
W-->>C: 200 application/json
else None (notification)
W-->>C: 202 empty
end
end
Each connection is served on its own thread with a 10 s read timeout and Connection: close (no keep-alive state to manage). One shared Arc<Mutex<McpServer>> backs every connection, so the 15-minute data cache is shared and tool calls serialize through the mutex.
Security gates
Security checks run before routing, so a request that fails them learns nothing about what is served. Error responses carry empty bodies (the status line is a protocol constant, not user-facing copy).
flowchart TD
R[request head parsed] --> H{Host is localhost?}
H -->|no| F403[403 Forbidden]
H -->|yes| O{Origin absent or localhost?}
O -->|no| F403
O -->|yes| P{path == /mcp?}
P -->|no| F404[404 Not Found]
P -->|yes| M{method == POST?}
M -->|no| F405["405 + Allow: POST"]
M -->|yes| A{valid Bearer token?}
A -->|no| F401["401 + WWW-Authenticate: Bearer"]
A -->|yes| L{Content-Length present<br/>and not chunked?}
L -->|no| F411[411 Length Required]
L -->|yes| Z{body ≤ 1 MiB?}
Z -->|no| F413[413 Content Too Large]
Z -->|yes| B{body is a JSON array?}
B -->|yes| F400["400 (batching removed in 2025-06-18)"]
B -->|no| OK["handle_line → 200 / 202"]
| Condition | Status |
|---|---|
| Malformed request head | 400 |
Host missing or not localhost / 127.0.0.1 / [::1] |
403 |
Origin present and not localhost |
403 |
Path other than /mcp |
404 |
Method other than POST (incl. GET) |
405 + Allow: POST |
Missing or wrong Authorization: Bearer |
401 + WWW-Authenticate: Bearer |
Transfer-Encoding: chunked or no Content-Length |
411 |
| Body over 1 MiB | 413 |
| Body is a JSON array (batching) | 400 |
| Valid request | 200 (response) or 202 (notification) |
- The
HostandOriginallowlists are the DNS-rebinding defence: a browser page on another origin can send requests to127.0.0.1, but the browser stamps its realOrigin/Host, so those requests are rejected. Non-browser MCP clients simply omitOrigin. - The bearer comparison is length-checked then folded byte-by-byte, so equal-length compares do not short-circuit on the first differing byte.
- Request line is capped at 8 KiB and headers at 16 KiB to bound a hostile head.
Shutdown
TcpListener::accept() blocks, so the handle can’t just drop the listener. Stopping raises an AtomicBool and makes a throwaway self-connect to 127.0.0.1:<port> to unblock the pending accept(); the loop observes the flag and exits, then the handle joins the thread. This is instant and costs zero CPU while idle, and it is safe to call from a Tauri command handler.
flowchart LR
D["handle.shutdown() / drop"] --> SET["flag.store(true)"]
SET --> WAKE["TcpStream::connect self"]
WAKE --> ACC["blocked accept() returns"]
ACC --> CHK{flag set?}
CHK -->|yes| BRK[break loop]
BRK --> JOIN[join thread]
Data freshness
Every tool call goes through McpServer::data(), which keeps an in-memory cache with a 15-minute TTL (matching the dashboard’s background refresh cadence). On a cold or stale cache it runs archive::sync_and_load — an incremental archive sync plus load — under the shared mutex, so the first call after the TTL expires can be slower while it catches the archive up. scrollback additionally opens its own read-only, no-mutex SQLite connection per query (via search_transcripts), so transcript search never contends with the sync writer and can run from any thread.
sequenceDiagram
participant T as tool call
participant D as data()
participant AR as archive.db
T->>D: request cached data
alt cache fresh (< 15 min)
D-->>T: cached Ingested + formatter
else cold or stale
D->>AR: archive::sync_and_load
AR-->>D: Ingested
D->>D: store with loaded_at = now
D-->>T: fresh Ingested + formatter
end
Configuration and files
Settings live in the shared config.json (UserConfig.mcp, src/config.rs):
{
"mcp": {
"http_enabled": false, // desktop toggle state; started at launch when true
"http_port": 20151 // default DEFAULT_MCP_HTTP_PORT; 0 normalises back to the default
}
}
Two files sit in the config directory beside archive.db:
| File | Purpose |
|---|---|
mcp-salt |
Persistent salt for project-name pseudonyms. Stable across restarts so pseudonyms stay linkable within a client, but not derivable without the file. |
mcp-token |
Bearer token for the HTTP endpoint. Created on first use, 0600 on Unix (Windows relies on the per-user profile ACL). Delete the file to rotate the token. |
Pseudonymisation
Project names are hashed with a salted FNV-1a into project-<12 hex>. The salt is generated once from OS entropy (RandomState, no rand dependency) and persisted to mcp-salt, so the same project maps to the same pseudonym across calls and restarts, but a client that has never seen the real names cannot reverse them. --real-names (CLI transports only) disables the mapping; the desktop endpoint always pseudonymises. The scrollback project filter accepts a real label, a pseudonym from a prior response, or a raw identity, so a client can drill from a projects result straight into transcript search.
Note: the token reuses the salt generator.
RandomStateis not a CSPRNG, but 64 hex chars is a 256-bit search space and the only reachable attacker is a local process or browser on loopback. If the endpoint ever leaves localhost, swap in a real CSPRNG.
Desktop integration
The desktop app hosts the HTTP listener through desktop/src-tauri/src/mcp_http.rs, which holds the HttpServerHandle in a process-global static (there is only ever one listener per process, and this keeps snapshot(app: &App) and its ~50 call sites free of a new state field).
flowchart TD
LAUNCH[app launch] --> CHK{config mcp.http_enabled?}
CHK -->|yes| START["mcp_http::start(port)"]
CHK -->|no| IDLE[no listener]
TOGGLE["Config toggle<br/>set_mcp_http_enabled"] --> START
TOGGLE --> STOP["mcp_http::stop()"]
PORT["set_mcp_http_port"] --> RESTART["restart if enabled"]
START --> SERVE["serve_http on 127.0.0.1"]
QUIT[app quit] --> STOP
SERVE --> SNAP["snapshot.mcp_http<br/>{enabled, port, running, endpoint, last_error}"]
REVEAL["reveal_mcp_token<br/>(on demand only)"] --> TOKENFILE[mcp-token]
- Commands:
set_mcp_http_enabled,set_mcp_http_port,reveal_mcp_token. Enabling binds before persisting, so a port conflict returns an error and leaves the saved config disabled (the toggle snaps back on the next 3-second snapshot poll) rather than saving a broken state. - The snapshot carries only
{enabled, port, running, endpoint, last_error}. The token is never in the snapshot —reveal_mcp_tokenfetches it on demand, mirroring the booleans-only subscription-cookie state. - A port already in use surfaces as a clean bind error (
Address already in use), which is exactly what happens if both the CLI and the desktop app try the same port.
Testing
# Unit tests (in-process TcpStream against a serve_http bound on port 0):
cargo test --lib mcp::
# End-to-end against a live endpoint:
tokenuse mcp --http --port 20151 &
TOKEN=$(cat "$HOME/Library/Application Support/tokenuse/mcp-token")
E=http://127.0.0.1:20151/mcp
# initialize
curl -s -X POST "$E" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
# a tool call
curl -s -X POST "$E" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}'
# security gates
curl -i -X POST "$E" -d '{}' # 401
curl -i "$E" -H "Authorization: Bearer $TOKEN" # 405
curl -i -X POST "$E" -H "Host: evil.example" -H "Authorization: Bearer $TOKEN" -d '{}' # 403
The unit tests cover the full gate matrix (401/403/404/405/411/413/400), the 202 notification path, shutdown freeing the port, and the 0600 token file mode. search_transcripts’ own read-only connection means scrollback is safe to exercise concurrently.
Related docs
- Architecture → MCP Server: where the server sits in the wider system.
- Architecture → Transcript Store And Scrollback Search: the FTS5 index behind
scrollback. - TUI usage: the
tokenuse mcpcommand reference. - Desktop app usage: the Config-page MCP Server panel.