The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
13 KiB
MCP Integration Architecture
This directory contains Insights's MCP (Model Context Protocol) server. The protocol is served by the Hono runtime (Node, deployed to our k8s clusters). A thin Cloudflare Worker sits in front of it as a stateless edge router that terminates OAuth, validates tokens, and proxies /mcp traffic to the regional Hono deployment.
Overview
flowchart TB
subgraph Worker["Cloudflare Worker (index.ts)"]
W1["Stateless - new instance per request"]
W2["Handles OAuth metadata endpoints (RFC 8414, RFC 9728)"]
W3["Validates tokens and resolves the user's region"]
W4["Wide logging via middleware"]
end
subgraph Hono["Hono runtime (Node / k8s)"]
H1["Serves the MCP protocol (tools, prompts, resources)"]
H2["Per-user session state in Redis (keyed by token hash)"]
H3["Tracks analytics events to Insights"]
end
Worker -->|"proxyToHono(): forwards /mcp to<br/>mcp.{us,eu}.hanzo.ai"| Hono
The Worker no longer serves the protocol itself — an earlier iteration ran a stateful Cloudflare Durable Object (mcp.ts) for this, but that has been removed in favor of always proxying to Hono.
Protocol dialects: legacy stateful and 2026-07-28 stateless
The Hono dispatcher serves both MCP dialects side by side (src/lib/stateless-protocol.ts holds the shared constants):
- Legacy (≤2025-11-25): the
initializehandshake negotiates a protocol version, the server mints anMcp-Session-Id, and clients echo it on subsequent requests. Unchanged wire shape. - Stateless (2026-07-28, SEP-2575): no handshake and no protocol-level sessions. Each request self-describes via reserved
_metakeys (io.modelcontextprotocol/protocolVersion,.../clientInfo,.../clientCapabilities— the first is the dialect switch, all three are mandatory and missing ones are rejected with-32602+ HTTP 400); capability discovery happens through the mandatoryserver/discoverRPC (capabilities +serverInfo+ instructions +supportedVersions). Results carryresultType: "complete", the server's identity in_meta(io.modelcontextprotocol/serverInfo), and — forCacheableResultmethods (server/discover,tools/list,resources/list,resources/read,prompts/list) —ttlMs/cacheScope: "private"freshness hints. The TTL is nonzero in production only — locallyttlMs: 0(the spec-compliant "don't cache") keeps SDK client response caches from serving stale results while iterating on tool definitions.
A request's dialect is detected per request from the _meta protocol-version key or a modern MCP-Protocol-Version header. Only modern versions (2026-07-28+) are valid there — legacy versions are implemented solely behind the initialize handshake, so server/discover advertises modern versions only, and a legacy or unknown _meta version is rejected with UnsupportedProtocolVersionError (-32022 on HTTP 400, with the spec's machine-readable data.supported/data.requested payload). Modern requests must also carry SEP-2243's operation headers — MCP-Protocol-Version (mirroring _meta), Mcp-Method (mirroring the body method), and Mcp-Name (mirroring params.name/params.uri on tools/call, prompts/get, resources/read) — so intermediaries can route without parsing bodies; a missing or contradicting header is rejected with HeaderMismatch (-32020) + HTTP 400 before dispatch. Modern messages are also barred from JSON-RPC arrays (-32600 + 400; batching was removed from the protocol in 2025-06-18), and RPCs the modern dialect removed (initialize, ping) answer method-not-found on HTTP 404. Legacy clients are untouched by all of this: header-free requests — including ones sending a legacy MCP-Protocol-Version value such as 2025-06-18 — keep the exact pre-existing wire behavior (HTTP 200 with errors in the JSON-RPC body). Client identity for analytics is read from the initialize body for legacy clients and from per-request _meta for stateless clients. Stateless requests never mint or echo Mcp-Session-Id — cross-request correlation for that traffic relies on mcpConversationId (see below).
File Structure
src/
├── index.ts # Worker entry point: OAuth, routing, /mcp proxy
├── proxy.ts # Region resolution + reverse proxy to the Hono runtime
├── hono/ # The Hono runtime that actually serves the MCP protocol
└── lib/ # Shared helpers (caching, analytics, logging, …)
Key Concepts
Worker → Hono communication
The Worker resolves the caller's region (from cache/KV or by probing both regions) and reverse-proxies the request to the matching Hono deployment:
// In Worker (index.ts)
if (url.pathname.startsWith('/mcp')) {
const region = await resolveProxyRegion(token, ctx.props.userHash, env.MCP_KV)
return proxyToHono(request, region)
}
RequestProperties (the parsed headers and query params for a request) is defined in src/lib/request-properties.ts and shared by both runtimes.
Per-User State
The Hono runtime keeps per-user session state (active project/organization, region, distinctId) in Redis, namespaced by userHash — a PBKDF2 hash of the API token (see src/lib/utils), ensuring:
- Isolation: Users can't access each other's cached data (different prefix)
- Persistence: Region and distinctId survive across requests
- Deterministic: Same token always produces the same hash/prefix
- Secure: Tokens can't be reversed from the hash
Wide Logging Pattern
Instead of scattered log statements, we accumulate data into a single log object and emit once at the end:
const log = new RequestLogger()
log.extend({ route: url.pathname })
log.extend({ region: effectiveRegion })
// ... handle request ...
log.emit(response.status) // Single log with all data + duration
This produces one structured JSON log per request, making it easier to query in observability tools.
Tracking and observability
There are three independent layers that emit signals about each MCP request:
- Insights analytics events —
$mcp_tool_calland friends, captured for product analytics. - Outbound API headers — propagated when the MCP server calls Insights's Django backend, so backend log lines and OTLP spans can correlate with the originating MCP request.
- Wide structured logs — single JSON record per request from the Worker itself (see Wide Logging Pattern above).
$mcp_tool_call event paths
The canonical event is $mcp_tool_call.
The legacy unprefixed mcp_tool_call alias is no longer emitted — the transition shim that dual-emitted it through the cutover has been removed (only pre-2026-06-16 history remains under that name).
The path that fires depends on the server mode and on the mcp-insights-analytics-sdk feature flag:
hono/analytics.ts— homegrown Insights capture. Used by the exec-mode wrapper to emit events for inner tool calls. Properties use the bare form:mcp_session_id,mcp_conversation_id,mcp_client_name, etc.lib/mcpcat.ts— legacy MCPcat SDK path. Same bare property names.lib/insights-mcp-analytics.ts— the@hanzo/mcp-analyticsSDK. Property names are$-prefixed ($mcp_session_id,$mcp_conversation_id, …). This is the path most live traffic flows through today.
Adding a new property to events means wiring it into the McpCatIdentityProvider interface and the property-builder in all three emitters, then sourcing the value on requestProperties (or pulling it from another DO-level source).
Three correlation identifiers
Three identifiers travel with each request, each with a different lifecycle and a different consumer:
| Identifier | Source | Where it lands |
|---|---|---|
sessionId (wrapper-app hint) |
?sessionId= query param, set by integrators (setup wizard, sandbox, etc.) |
Resolved to a UUIDv7 via SessionManager.getSessionUuid() and stamped as $session_id / $ai_session_id on events — drives Session Replay and AI observability grouping. Only set when a wrapper supplies it. |
mcpSessionId (transport session) |
Mcp-Session-Id HTTP header, server-minted on initialize per the Streamable-HTTP MCP spec and echoed by clients on every subsequent request |
Stamped on mcp_tool_call events as mcp_session_id / $mcp_session_id, and forwarded to Django as X-Insights-Mcp-Session-Id so backend structlog contextvars + OTLP span attributes (mcp.session_id) can correlate. |
mcpConversationId (agent-echoed conversation) |
The conversation_id arg injected into tool schemas by @hanzo/mcp-analytics when enableConversationId: true. The SDK mints a UUID and asks the agent to echo it on subsequent calls, so it persists across transport reconnects |
Same plumbing as mcpSessionId — stamped on events and forwarded to Django as X-Insights-Mcp-Conversation-Id. |
Crucially, sessionId and mcpSessionId are different concepts and will not match for the same request. The wrapper-app sessionId is only set for a small fraction of traffic (mostly integrator-driven flows); the transport mcpSessionId is on essentially every authenticated request after initialize.
Forwarding session and conversation IDs to Django
When the Worker calls Insights's Django backend (any ApiClient.fetch), the outbound request carries:
X-Insights-Mcp-Session-Id: <mcpSessionId>— when set onApiClient.configX-Insights-Mcp-Conversation-Id: <mcpConversationId>— when set onApiClient.config
On the Django side, per_request_logging_context_middleware reads both headers (sanitized through the existing sanitize_header_value helper), binds them to structlog contextvars (mcp_session_id, mcp_conversation_id), and sets them on the current OTLP span as mcp.session_id / mcp.conversation_id.
The headers are caller-asserted — anyone can spoof them on a request — so backend consumers should treat them as correlation hints, not authoritative identifiers.
This is attribute-based correlation, not distributed-trace span linkage — the Worker emits no OTLP itself and forwards no traceparent, so the Django-rooted span is not a child of any Worker-side span. Tracing backends can correlate after the fact by querying on the attribute, but won't render a cross-service trace tree until Worker-side OTLP export ships.
OAuth Flow
The server implements RFC 9728 (OAuth Protected Resource Metadata) and RFC 8414 (OAuth Authorization Server Metadata):
sequenceDiagram
participant Client
participant MCP as MCP Server
participant Insights as Insights OAuth
Client->>MCP: Connect without token
MCP-->>Client: 401 + WWW-Authenticate header
Client->>MCP: GET /.well-known/oauth-protected-resource/{path}
MCP-->>Client: Authorization server URL (US or EU)
Client->>Insights: OAuth flow
Insights-->>Client: Access token
Client->>MCP: Reconnect with token
MCP-->>Client: MCP protocol ready
Wrangler Configuration
The Worker no longer binds a Durable Object — /mcp is proxied to the Hono runtime. The historical DO migrations remain in wrangler.jsonc as tombstones, with a final deleted_classes migration that retires the class (and its per-user SQLite storage) in Cloudflare:
{
"migrations": [
{ "new_sqlite_classes": ["MyMCP"], "tag": "v1" },
{ "renamed_classes": [{ "from": "MyMCP", "to": "MCP" }], "tag": "v2" },
{ "deleted_classes": ["MCP"], "tag": "v3" },
],
}