Skip to content

ops-mcp Debugging Runbook

Operators-only. For end-user docs see apps/ops-mcp/INTEGRATION.md and docs/getting-started/connect-claude-code-to-ops-mcp.md.

Terminal window
# Health + metadata + AS resolution
pnpm mcp:doctor # staging
pnpm mcp:doctor --env production
# Full auth probe (provide a token)
pnpm mcp:doctor --token mcps_xxx --env production
MCP_OPS_TOKEN=eyJ... pnpm mcp:doctor --env production

Every 401 from ops-mcp includes three diagnostic headers:

HeaderPurpose
X-Ordermatic-Request-IdPer-request 16-byte hex. Quote this in tickets — it’s the join key into Worker logs.
X-Ordermatic-Auth-Decline-ReasonStable reason code (see table below).
WWW-AuthenticateRFC 6750 challenge incl. resource_metadata URL for OAuth discovery.
ReasonCauseFix
missing_headerNo Authorization headerSend Authorization: Bearer <token>
empty_tokenHeader present but Bearer followed by empty stringSame
unknown_tokenService token: hash not in DB, OR revoked. OAuth: shouldn’t fire (sig errors first)Re-issue / re-authenticate
token_expiredService: expires_at <= now(). OAuth: JWT exp pastRe-issue (service) / Claude Code auto-refreshes (OAuth)
token_revokedOAuth: revoked:<jti> sentinel found in KVRe-authenticate
signature_invalidOAuth: JWT signature didn’t verify against current+previous JWKSLikely key rotation issue OR forged token. Investigate.
denylist_check_unavailableKV binding missing OR KV read erroredCheck Cloudflare KV namespace binding; check status.cloudflare.com
database_unavailableService path only: Neon connection failedCheck Neon status; check DATABASE_URL secret on Worker
internal_errorUnexpected — open an incidentCheck logs by request_id
Terminal window
# From Better Stack / Cloudflare Logs, search for the request_id
# Worker logs include: request_id, user_id, org_id, tool_name, decline_reason
# Local tail (dev only):
cd apps/ops-mcp && pnpm exec wrangler tail --env staging
  1. Webapp UI: /sys-admin/mcp-tokens → New Token
  2. Select org, service name (ops-mcp), env, scopes (mcp:read and/or mcp:write), expiry
  3. Copy the displayed mcps_… token IMMEDIATELY — not stored in plaintext
-- DB-side revoke (single session)
UPDATE mcp_oauth_sessions
SET revoked_at = now(), revoked_reason = 'admin'
WHERE id = '<session-uuid>';

After the DB write, also write the KV sentinel (otherwise Worker won’t see the revoke until JWKS cache refresh, ~5min). Either:

  • Trigger the webapp’s revoke endpoint (it does both)
  • Manually wrangler kv key put --binding MCP_OAUTH_DENYLIST --env <env> revoked:<jti> 1 --ttl 2592000
  1. Generate new ES256 keypair:
    Terminal window
    openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out new.pem
  2. Set new key as OPS_MCP_JWT_PRIVATE_KEY_CURRENT in Infisical
  3. Move the OLD _CURRENT value to OPS_MCP_JWT_PRIVATE_KEY_PREVIOUS
  4. Redeploy webapp — JWKS now publishes BOTH kids
  5. Wait access_token_ttl + skew (~20 min) — all old tokens expire
  6. Remove _PREVIOUS from Infisical
  7. Redeploy webapp — JWKS publishes only the new kid

Create the KV denylist namespace (initial setup)

Section titled “Create the KV denylist namespace (initial setup)”
Terminal window
cd apps/ops-mcp
pnpm exec wrangler kv namespace create MCP_OAUTH_DENYLIST --env staging
# Copy the returned `id` into wrangler.toml under [[env.staging.kv_namespaces]]
pnpm exec wrangler kv namespace create MCP_OAUTH_DENYLIST --env production
# Same for production block

If a user’s OAuth session can’t refresh and they can’t re-auth (e.g. Clerk issue mid-incident), issue a short-lived (24h) service token:

/sys-admin/mcp-tokens
Service name: ops-mcp
Subject: <user-email> # for audit
Scopes: mcp:read mcp:write
Expires: now + 24h
Description: "OAuth break-glass for <user> — incident <ticket>"

Hand the raw token to the user with explicit “delete this after the incident” instructions. Log the issuance in the incident ticket.

”Every OAuth request 401s with denylist_check_unavailable

Section titled “”Every OAuth request 401s with denylist_check_unavailable””

Worker can’t reach the KV namespace. Check:

  1. wrangler.toml has the namespace bound under [[env.<env>.kv_namespaces]] with a non-placeholder id
  2. wrangler deploy succeeded after the namespace was created
  3. wrangler kv key list --binding MCP_OAUTH_DENYLIST --env <env> returns (it should be empty if no revokes have happened, but the call shouldn’t error)

“Service tokens work but OAuth tokens 401 with signature_invalid

Section titled ““Service tokens work but OAuth tokens 401 with signature_invalid””

Key mismatch. Check:

  1. Webapp OPS_MCP_JWT_PRIVATE_KEY_CURRENT is set per env
  2. Webapp MCP_OAUTH_ISSUER env var matches what’s in the AS metadata
  3. Worker WEBAPP_AS_BASE_URL env var matches webapp’s deployed origin
  4. https://app.ordermatic.co/.well-known/jwks.json is reachable from the Worker
  5. JWT’s kid matches one of the JWKS-published keys

”First /mcp call after deploy is slow (>3s)”

Section titled “”First /mcp call after deploy is slow (>3s)””

Cold-start JWKS fetch. Mitigations:

  • KV cache (5min) — second isolate boot fast
  • Bundled fallback JWKS (Eng FIX E5) — TODO if not in current build
  • Cloud Run min-instances=1 on webapp OAuth route

Logs queries (Better Stack / Cloudflare Logs)

Section titled “Logs queries (Better Stack / Cloudflare Logs)”
# All 401s in the last hour with their decline reason
service:"ops-mcp" status:401 | group_by(auth_decline_reason)
# Errors for a specific user
service:"ops-mcp" user_id:"user_xxx" status:>=400
# Reused refresh tokens (should be near zero)
service:"webapp" message:"refresh-token reuse detected"
# Clerk webhook → MCP session revocations
service:"webapp" message:"revokeMcpSessions" reason:*
  • Worker auth issues: ping #platform-eng with the request_id
  • KV outage: check status.cloudflare.com, fallback to issuing service tokens
  • Clerk outage: webapp OAuth flow blocked; existing tokens still work until expiry
  • Neon outage: service-token path AND DB-backed tools (audit/email logs) fail