CtxWindow
GitHub ↗

CtxWindow

Independent, unaffiliated open-source project. ctxwindow is not built, maintained, or endorsed by Anthropic. "Claude" and "Claude Code" are Anthropic's products; ctxwindow reads their publicly documented OpenTelemetry export and MCP protocol, nothing more.

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.

Close and reopen Claude Code afterward, since env vars only load at process startup. Run one prompt, then check "Test my connection" on the page.

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:

  1. claude.ai → Settings → Connectors → Add custom connector.
  2. Paste the MCP server URL. Leave OAuth Client ID/Secret blank; ctxwindow registers itself dynamically per the MCP spec.
  3. claude.ai opens a Google sign-in prompt automatically. Sign in once.
  4. 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
The _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

ToolReturnsR/W
get_session_metricsSession metadata + per-prompt tokens/latency/costRead
get_token_breakdownPer-turn token/latency breakdownRead
get_tool_metricsTool call counts by statusRead
get_agent_traceOrdered tool-call sequence for one sessionRead
get_cost_estimateEstimated cost, one session or a time windowRead
get_recent_sessionsMost recent sessions, newest firstRead
get_context_timelineFull context-window block breakdownRead
record_sessionRecords one agent execution's metricsWrite (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 -1 is 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)

  1. console.cloud.google.com: create or pick a project, then go to APIs & Services → Credentials.
  2. Create Credentials → OAuth client ID → Application type Web application.
  3. Under Authorized JavaScript origins, add http://localhost:8787 for local use, plus your real domain once deployed. Use localhost, not 127.0.0.1: Google Identity Services only reliably honors localhost for the plain-HTTP local-dev exemption. No redirect URI needed.
  4. Copy the Client ID (safe to expose client-side) and set it as GOOGLE_OAUTH_CLIENT_ID.
Switching 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

  1. 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.app one. It rewrites nothing but the Host header and streams responses back untouched, so SSE endpoints work through it.
  2. 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.
  3. Data-access layer (metrics/store.py), one dispatcher in front of three interchangeable backends.
  4. Storage: SQLite, DynamoDB, or Firestore, picked by STORAGE_BACKEND.

Three write paths, one owner-filtered read path

  • A direct Python import of metrics.store for an agent loop running on the same machine.
  • The authenticated record_session MCP tool or POST /api/record-session for anyone else's remote agent.
  • /otlp/v1/{logs,metrics,traces} for Claude Code's and Copilot's own native OpenTelemetry export, parsed by mcp_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 with METRICS_DB_PATH so a container can write to a scratch location like /tmp instead of the repo's data/ 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 from METRICS_TABLE, region from AWS_REGION. Single-table design: partition key session_id, sort key sk distinguishing item type (SESSION, TURN#0000, TOOLCALL#0000). Aggregate reads (recent sessions, aggregate tool metrics) use Scan, 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 at ctxwindow.uk deliberately stays on seeded SQLite instead, so a cold start wipes visitor writes back to the fixture data. A top-level sessions collection with owner and timestamp as directly queryable top-level fields, plus turns, tool_calls, and context_blocks subcollections. 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 account roles/datastore.user. Needs a composite index on (owner ASC, timestamp DESC) on the sessions collection, created ahead of time via the Firestore console or gcloud firestore indexes composite create, rather than relying on the first-query error link in prod. Collection name is overridable with METRICS_FIRESTORE_COLLECTION. To test locally, run gcloud emulators firestore start and set FIRESTORE_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

You probably don't need this. ctxwindow.uk is live, free, and signs you in with Google, and your sessions are yours alone. Running your own copy only makes sense if you want the data on infrastructure you control, or you're changing the server itself.

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:

  1. Run the unit suite; a red suite blocks the deploy.
  2. 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, and GCP_DEPLOY_SA repo variables set.
  3. docker buildx build --platform linux/amd64 and push to Artifact Registry. The image installs from uv.lock with uv sync --frozen, so builds stay reproducible.
  4. gcloud run deploy, then gcloud run services update-traffic --to-latest. The second command is not redundant: deploy alone 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.
  5. Smoke-test /health, then npx wrangler deploy the Worker (needs the CLOUDFLARE_API_TOKEN secret and CLOUDFLARE_ACCOUNT_ID variable) 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

VariableWhat it does
MCP_AUTH_TOKENThe owner token. Unset, the server generates and prints one on startup.
MCP_SERVER_PORTPort the server binds to.
STORAGE_BACKENDsqlite, dynamodb, or firestore. Anything durable in practice.
METRICS_DB_PATHSQLite file path, overriding the repo's data/ dir.
METRICS_TABLE / AWS_REGIONDynamoDB table name (sessions) and region.
AUTH_TABLEDynamoDB table name for the auth-token store (default mcp-context-auth), separate from METRICS_TABLE.
METRICS_FIRESTORE_COLLECTIONFirestore collection name (default sessions).
FIRESTORE_EMULATOR_HOSTPoint the Firestore client at a local emulator.
GOOGLE_OAUTH_CLIENT_IDEnables the real "Sign in with Google" button. Without it, /auth/login still renders and works with the owner token only.
PUBLIC_ORIGINThe 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_ORIGINComma-separated CORS allowlist.
MCP_ALLOWED_HOSTSComma-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_SUBSComma-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_SRCPath 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_MODESet 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 / HOSTBind 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, POSIX sh still 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.json to 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 (TODO in mcp_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.