CtxWindow
A drop-in MCP server that gives Claude Code, Bedrock-based agents, and other MCP/LLM clients real per-session cost, token, and tool metrics, plus a full Context Window Explorer, over a real MCP handshake.
Most agent observability tools re-show data your own UI already displays. ctxwindow shows something you normally can't see at all: system prompt, tool specs, reasoning, tool calls and results, and the final answer, in the order they actually entered context. Each block is measured against the model's real context window and marked as either visible to the user or invisible overhead.
Try it in 30 seconds
Live demo: ctxwindow.uk
Seeded with fixture data. Writes made behind Google sign-in don't survive a cold start on the demo, since it runs on SQLite.
Not yet published to PyPI, so run it from source:
git clone https://github.com/sohaibsohail98/mcp-context-inspector
cd mcp-context-inspector && uv run python -m mcp_server.server
With no MCP_AUTH_TOKEN set, the server generates and prints one on startup, using the same trust model as a Jupyter server's printed token.
Installation
Requires Python 3.11+.
git clone https://github.com/sohaibsohail98/mcp-context-inspector
cd mcp-context-inspector
uv sync
Run it locally
export MCP_AUTH_TOKEN=local-test-owner-token
export STORAGE_BACKEND=sqlite
export METRICS_DB_PATH=/tmp/mci-local-test.db
export MCP_SERVER_PORT=8787
uv run python -m mcp_server.server
Open http://localhost:8787 (use localhost, not
127.0.0.1, since Google sign-in needs it) and use
local-test-owner-token as the bearer token for the config it generates.
Run tests with uv run pytest, lint with uv run ruff check .
The one-command setup
Sign in at /auth/login and the page hands you a single command to paste into
a terminal. It writes the MCP connection and telemetry config into your own
~/.claude/settings.json (backed up first, merged, never overwritten):
curl -fsSL https://ctxwindow.uk/setup/install?t=<code> | sh
On Windows the setup page's Windows (PowerShell) tab hands you the equivalent instead:
irm "https://ctxwindow.uk/setup/install?os=windows&t=<code>" | iex
The ?t= code is single-use and short-lived, so your real token is never in
the command itself.
Claude Code CLI, by hand
{
"mcpServers": {
"context-inspector": {
"url": "https://ctxwindow.uk/mcp",
"headers": { "Authorization": "Bearer <your-token>" }
}
}
}
claude.ai (Connectors)
claude.ai's Connectors feature speaks the MCP OAuth flow directly, so there's no token to copy:
- claude.ai → Settings → Connectors → Add custom connector.
- Paste the MCP server URL. Leave OAuth Client ID/Secret blank; ctxwindow registers itself dynamically per the MCP spec.
- claude.ai opens a Google sign-in prompt automatically. Sign in once.
- All 8 tools are now available in any claude.ai chat.
Once connected, ask it things like:
- "What did my last Claude Code session cost?"
- "Show me the tool-call trace for session sess_..."
- "Which of my recent sessions used the most tokens?"
Live telemetry, by hand
Point Claude Code's own OpenTelemetry export at ctxwindow and sessions show up as they happen:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_EXPORTER_OTLP_ENDPOINT=https://ctxwindow.uk/otlp
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <your-token>"
export OTEL_RESOURCE_ATTRIBUTES=service.name=claude-code
export OTEL_METRICS_INCLUDE_SESSION_ID=true
export OTEL_LOGS_INCLUDE_SESSION_ID=true
export OTEL_LOGS_EXPORT_INTERVAL=5000
export OTEL_LOG_RAW_API_BODIES=1 # opt-in: needed for the Context Explorer
The OTEL_RESOURCE_ATTRIBUTES/*_INCLUDE_SESSION_ID lines are what
let ctxwindow recognize the session as Claude Code's at all. Omit them and every session is
silently dropped.
GitHub Copilot exports the same way:
export COPILOT_OTEL_ENABLED=true
export OTEL_EXPORTER_OTLP_ENDPOINT=https://ctxwindow.uk/otlp
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <your-token>"
export COPILOT_OTEL_CAPTURE_CONTENT=true # opt-in: needed for the Context Explorer
_RAW_API_BODIES/_CAPTURE_CONTENT flags are separate
opt-ins because they carry full prompt/response content, not just metrics. When on, captured
bodies pass through a basic redaction layer (email addresses, home-directory paths) before
storage. Treat that as a trust reducer, not comprehensive PII scrubbing.
The 8 MCP tools
| Tool | Returns | R/W |
|---|---|---|
get_session_metrics | Session metadata + per-prompt tokens/latency/cost | Read |
get_token_breakdown | Per-turn token/latency breakdown | Read |
get_tool_metrics | Tool call counts by status | Read |
get_agent_trace | Ordered tool-call sequence for one session | Read |
get_cost_estimate | Estimated cost, one session or a time window | Read |
get_recent_sessions | Most recent sessions, newest first | Read |
get_context_timeline | Full context-window block breakdown | Read |
record_session | Records one agent execution's metrics | Write (append-only) |
Every tool ships explicit MCP annotations (readOnlyHint /
destructiveHint / idempotentHint / openWorldHint),
so a client can auto-approve the seven reads and prompt only for
record_session. Nothing here reaches outside this server's own store
(openWorldHint is always false), and record_session only ever
inserts a new session — it never mutates or deletes one, so it's marked
append-only rather than destructive, and non-idempotent (each call mints a new
session_id).
Plain REST equivalents are exposed under /api/*.
Context Window Explorer
The per-session Context Explorer tab on the dashboard reconstructs a
session's context window as an ordered, categorized list of blocks — system prompt,
tool specs, injected context, user turns, reasoning, tool calls and results, the final
answer — grouped by turn, with a token-proportional strip across the top and a
cumulative token estimate against the model's real window size. It's built from the
context_blocks a session carries (from an OTLP capture with raw bodies on, or
supplied directly in a record_session payload); a session without them still
shows metrics, just not this view.
Filters
A collapsible Filters bar sits above the block list. The category legend (with All / None) toggles whole categories on and off; alongside it:
- Free-text search over each block's content, label, and category, with
<mark>highlighting of the match. - Min tokens slider — hide blocks below a token-estimate threshold.
- Turn range — from / to turn-number inputs (turn
-1is the pre-turn setup blocks). - Errors only — keep just blocks with
status: "error". - Hide redacted — drop blocks with
status: "redacted"(e.g. extended-thinking content Claude Code strips before export).
A summary line reads “Showing X of Y blocks · Z% of tokens” with a
Clear button when any filter is active. The strip, the legend counts, and the row list all
pass through one predicate, so they never disagree. Filter state (everything except the
search text, which resets on reload) persists to localStorage under
mci_ctx_adv_filter.
Auth model
A plain bearer token (the owner token, or a personal token from /auth/login)
works for clients you hand a token to directly, such as Claude Code's MCP config or curl.
claude.ai's Connectors UI instead speaks OAuth 2.1 and mints its own token on sign-in, kept
separate so disconnecting one doesn't invalidate the other.
Every session is attributed to whoever recorded it. A per-user token only ever sees,
queries, and records its own data. There's no way to read or list another user's sessions,
even by guessing an ID. The owner token sees everyone's data, since it's your server.
Revoke someone's access with mcp_server.auth.store.revoke(google_sub) (find
their sub via list_users()). Their existing token stops working
immediately, and already-recorded data stays where it is.
On the browser side, a revoked token is handled as a clean sign-out. When
/auth/login reloads with a stored token that the server no longer accepts, the
page verifies it (a probe request to /api/sessions?limit=1) before minting
anything and, on a 401/403, clears local state and drops back to the sign-in screen rather
than surfacing a raw "unauthorized" error from /setup/issue-install-code. A
network timeout or 5xx on that probe is treated as transient — the browser stays
signed in and retries on its next refresh.
Google sign-in setup (one-time, about 2 minutes)
- console.cloud.google.com: create or pick a project, then go to APIs & Services → Credentials.
- Create Credentials → OAuth client ID → Application type Web application.
-
Under Authorized JavaScript origins, add
http://localhost:8787for local use, plus your real domain once deployed. Uselocalhost, not127.0.0.1: Google Identity Services only reliably honorslocalhostfor the plain-HTTP local-dev exemption. No redirect URI needed. -
Copy the Client ID (safe to expose client-side) and set it as
GOOGLE_OAUTH_CLIENT_ID.
STORAGE_BACKEND on a running deployment starts the auth store
over empty. Every existing token, including your own, stops working until you sign in again.
That's not a bug; the token store is only as durable as its backend.
Architecture
A Cloudflare Worker (a transparent reverse proxy, giving a short permanent public URL) sits in front of a Cloud Run service running the Starlette + MCP server, backed by Firestore (or DynamoDB, or SQLite for local dev).
One data-access layer, three entry points: a direct Python import for your own local agent,
the authenticated MCP tool/REST route for anyone else's remote agent, and
/otlp/v1/{logs,metrics,traces} for Claude Code's/Copilot's own native
OpenTelemetry export.
Four pieces, front to back
-
Cloudflare Worker (
cloudflare-proxy/worker.js): a pass-through reverse proxy that gives the service a short permanent public URL (ctxwindow.uk) instead of the raw.run.appone. It rewrites nothing but theHostheader and streams responses back untouched, so SSE endpoints work through it. -
Starlette + MCP server (
mcp_server/), running on Cloud Run. Serves the MCP handshake at/mcp, plain REST at/api/*, OTLP ingestion at/otlp/v1/*, Google sign-in at/auth/login, the one-line installer at/setup/install, the dashboard, and this docs site at/docs. -
Data-access layer (
metrics/store.py), one dispatcher in front of three interchangeable backends. -
Storage: SQLite, DynamoDB, or Firestore, picked by
STORAGE_BACKEND.
Three write paths, one owner-filtered read path
- A direct Python import of
metrics.storefor an agent loop running on the same machine. - The authenticated
record_sessionMCP tool orPOST /api/record-sessionfor anyone else's remote agent. /otlp/v1/{logs,metrics,traces}for Claude Code's and Copilot's own native OpenTelemetry export, parsed bymcp_server/otlp/into the same turns and tool calls.
The record_session payload
record_session(prompt, model_id, loop_result, owner=None) needs
loop_result shaped like:
{
"trace": [{"tool": "...", "args": {...}, "status": "ok"}, ...],
"turns": [{"input_tokens": int, "output_tokens": int, "latency_ms": int}, ...],
"input_tokens": int, "output_tokens": int, "total_tokens": int, "latency_ms": int,
"context_blocks": [ # optional: omit and you just lose the Explorer, nothing crashes
{"category": "system", "label": "...", "char_count": int, "token_estimate": int, "turn_n": int | None},
...
],
}
context_blocks categories: system, tools,
user, injected, command, reasoning,
tool_call, tool_result, and answer. An optional
"status" field ("error", "redacted") drives
color-coding and the Explorer's filters. Extended-thinking content folds into
reasoning (Claude Code redacts it before export, so there's no separate
thinking block to render).
injected and command exist because Claude Code wraps
machine-generated context around a message before the model sees it, and blanket-tagging
that as user overstated how much of the window the human actually drove.
The OTLP mapper (mcp_server/otlp/common.py) peels these on an exact
fragment boundary: <system-reminder>, <ide_opened_file>,
a whole <session>…</session> block plus its trailing
title-generation instructions, and similar harness wrappers become
injected; slash-command plumbing (<command-name>,
<local-command-stdout>, <bash-input>…) becomes
command. A mid-sentence mention of one of those tag names in genuine prose
is never reclassified.
Token counts are honest, labeled estimates, not exact provider usage. That keeps a single measurement path across every client, including ones that never report usage back.
Letting a friend's own agent record its own data
record_session is also an authenticated MCP tool (and an
/api/record-session REST route), so a friend's agent running anywhere can push
its own sessions in, attributed to them:
import httpx
httpx.post(
"http://<your-host>:8787/api/record-session",
headers={"Authorization": f"Bearer {their_token}"},
json={"prompt": prompt, "model_id": model_id, "loop_result": loop_result},
)
Storage backends
Three interchangeable backends, selected with STORAGE_BACKEND. They expose
identical function signatures, so callers import from metrics/store.py and
never know which one is active.
-
sqlite(default): zero setup, one file on disk, what local dev uses. Path is overridable withMETRICS_DB_PATHso a container can write to a scratch location like/tmpinstead of the repo'sdata/dir. A container's local filesystem doesn't persist, so anything written here is lost on a cold start. That's fine for local dev and for the public demo, and wrong for anything you care about keeping. -
dynamodb: durable, AWS-hosted, for a deployment already living in AWS. Table name fromMETRICS_TABLE, region fromAWS_REGION. Single-table design: partition keysession_id, sort keyskdistinguishing item type (SESSION,TURN#0000,TOOLCALL#0000). Aggregate reads (recent sessions, aggregate tool metrics) useScan, which its own docstring flags as fine at personal-project scale and worth revisiting with a GSI if that stops being true. -
firestore: durable, GCP-hosted, and the recommended choice for a real Cloud Run deployment with sign-in-backed writes, since the service is already on GCP. This is the backend this project's own code treats as its deployed target; the public demo atctxwindow.ukdeliberately stays on seeded SQLite instead, so a cold start wipes visitor writes back to the fixture data. A top-levelsessionscollection withownerandtimestampas directly queryable top-level fields, plusturns,tool_calls, andcontext_blockssubcollections. Because a real query is available here, it avoids the scan-then-filter tradeoff DynamoDB's backend accepts. The client uses Application Default Credentials, which works automatically on Cloud Run via its service account; grant that accountroles/datastore.user. Needs a composite index on(owner ASC, timestamp DESC)on thesessionscollection, created ahead of time via the Firestore console orgcloud firestore indexes composite create, rather than relying on the first-query error link in prod. Collection name is overridable withMETRICS_FIRESTORE_COLLECTION. To test locally, rungcloud emulators firestore startand setFIRESTORE_EMULATOR_HOST; the client library honors it transparently.
The per-user auth token store (mcp_server/auth/store.py) reads the same
STORAGE_BACKEND variable, so sessions and auth always switch together. It needs
the same durability: under SQLite, a token row lost on cold start silently breaks that
user's auth with no error anywhere useful.
Deploying your own
If you do want your own: the deploy path this repo uses is a Cloud Run service built from
the Dockerfile at the repo root, fronted by the Cloudflare Worker in
cloudflare-proxy/. .github/workflows/deploy.yml runs it end to end
on every push to main that touches the server, and is the honest reference for
the real steps:
- Run the unit suite; a red suite blocks the deploy.
-
Authenticate to GCP with Workload Identity Federation, so there are no stored GCP keys in
repo secrets. This needs the
GCP_PROJECT,GCP_WIF_PROVIDER, andGCP_DEPLOY_SArepo variables set. -
docker buildx build --platform linux/amd64and push to Artifact Registry. The image installs fromuv.lockwithuv sync --frozen, so builds stay reproducible. -
gcloud run deploy, thengcloud run services update-traffic --to-latest. The second command is not redundant:deployalone creates a revision but won't move traffic if a split was ever set out-of-band, which pinned production to a stale revision for two days here before it was caught. -
Smoke-test
/health, thennpx wrangler deploythe Worker (needs theCLOUDFLARE_API_TOKENsecret andCLOUDFLARE_ACCOUNT_IDvariable) and smoke-test it through the public hostname.
Forking this means replacing the GCP project, the Artifact Registry path in the workflow's
IMAGE, and cloudflare-proxy/wrangler.toml's ORIGIN and
routes, all of which currently point at this project's own account and domain.
Environment variables
| Variable | What it does |
|---|---|
MCP_AUTH_TOKEN | The owner token. Unset, the server generates and prints one on startup. |
MCP_SERVER_PORT | Port the server binds to. |
STORAGE_BACKEND | sqlite, dynamodb, or firestore. Anything durable in practice. |
METRICS_DB_PATH | SQLite file path, overriding the repo's data/ dir. |
METRICS_TABLE / AWS_REGION | DynamoDB table name (sessions) and region. |
AUTH_TABLE | DynamoDB table name for the auth-token store (default mcp-context-auth), separate from METRICS_TABLE. |
METRICS_FIRESTORE_COLLECTION | Firestore collection name (default sessions). |
FIRESTORE_EMULATOR_HOST | Point the Firestore client at a local emulator. |
GOOGLE_OAUTH_CLIENT_ID | Enables the real "Sign in with Google" button. Without it, /auth/login still renders and works with the owner token only. |
PUBLIC_ORIGIN | The real public origin (e.g. https://ctxwindow.uk), needed because behind the proxy request.base_url reflects Cloud Run's internal origin, not what a real caller used. Every URL ctxwindow generates about itself (OAuth metadata, the install command) needs the real one. |
CHAT_UI_ORIGIN | Comma-separated CORS allowlist. |
MCP_ALLOWED_HOSTS | Comma-separated Host header allowlist for the MCP SDK's DNS-rebinding protection. Getting this wrong causes 421 Invalid Host header on authenticated requests only, so an unauthenticated smoke test won't catch it. |
DEV_MODE_SUBS | Comma-separated Google sub allowlist for developer-mode dashboard features (currently: showing api_tests' synthetic probe sessions, hidden from everyone else by default). Find your own sub via mcp_server.auth.store.list_users(). |
DEMO_SEED_SRC | Path to a prebuilt SQLite demo dataset (demo/metrics.db, built by scripts/seed_demo_db.py). Set alongside a scratch METRICS_DB_PATH and the server copies the seed in on first boot only, which is how the public demo resets itself on a cold start. Leave both unset for a normal deployment. |
CTXWINDOW_DEMO_MODE | Set to 1 only for demo-video capture (scripts/demo_capture.py): enables a fixed fake bearer token against the seeded DB and the ?demo=1 intro animation on /auth/login. Unset in every real deployment. |
PORT / HOST | Bind address, set by the platform (Cloud Run sets PORT). PORT overrides MCP_SERVER_PORT; HOST defaults to 127.0.0.1 (the Dockerfile sets 0.0.0.0). |
Roadmap
Shipped
- Curl-pipeable one-line setup (
/setup/install) - Windows / PowerShell installer variant (
irm …?os=windows | iex, POSIXshstill the default) - Explicit MCP tool annotations on all 8 tools (read-only vs. append-only)
- Advanced filtering in the Context Explorer (search, min-tokens, turn range, errors-only, hide-redacted)
- Per-device token revoke — each browser gets its own token
(
device_tokens), and the dashboard's "Devices & sessions" panel (/auth/devices,/auth/revoke-device) drops one without signing the rest out - Owner-scoped
/otlp/debug+ "Test your connection" panel - Route-enumerating tenant-isolation test
- Firestore backend, and the production move onto it
- Own domain (
ctxwindow.uk) in front of the Worker proxy
Still open, honestly
- Account-wide revoke UX. Per-device revoke is done; the account-wide
auth.store.revoke(kill every token at once) is still admin-only, with no dashboard button. - Publish to PyPI. Install is still git-clone-only.
- Cursor MCP tool access. Worth being precise: Cursor's OTel telemetry export is Enterprise-only with no self-service config surface, so cost and token dashboards for Cursor sessions are not on the table. Pointing
local_setup.py's backup-and-merge logic at~/.cursor/mcp.jsonto expose the MCP tools there is feasible, and would be labeled "tool access," not "Cursor support." - A real per-project settings endpoint. The dashboard's project settings panel is wired to placeholder state (
TODOinmcp_server/routes/auth.py). - Redaction hardening. The OTLP redaction layer catches email addresses and home-directory paths. That is a trust reducer, not comprehensive PII scrubbing, and it's documented that way on purpose.