Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

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 initialize handshake negotiates a protocol version, the server mints an Mcp-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 _meta keys (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 mandatory server/discover RPC (capabilities + serverInfo + instructions + supportedVersions). Results carry resultType: "complete", the server's identity in _meta (io.modelcontextprotocol/serverInfo), and — for CacheableResult methods (server/discover, tools/list, resources/list, resources/read, prompts/list) — ttlMs/cacheScope: "private" freshness hints. The TTL is nonzero in production only — locally ttlMs: 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:

  1. Insights analytics events — $mcp_tool_call and friends, captured for product analytics.
  2. 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.
  3. 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-analytics SDK. 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 on ApiClient.config
  • X-Insights-Mcp-Conversation-Id: <mcpConversationId> — when set on ApiClient.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" },
  ],
}