Skip to main content
Glama
README.md
# Instrument the old path before you delete it

[![CI](https://github.com/jagreehal/mcp-legacy-lane/actions/workflows/ci.yml/badge.svg)](https://github.com/jagreehal/mcp-legacy-lane/actions/workflows/ci.yml)

[MCP](https://modelcontextprotocol.io) is an open protocol for LLM apps to call tools over JSON-RPC.

Kent's homework: you have a legacy path still wired in, and you have not counted whether anyone still uses it. Measure before you delete.

This repo runs one [dual-era](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) MCP server and a SQLite ledger that survives a restart. You keep the fallback while any legacy operation remains.

The [versioning](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) page names the two lanes. **Legacy** is `2025-11-25` and earlier: the session starts with `initialize`. **Modern** is [`2026-07-28`](https://modelcontextprotocol.io/specification/2026-07-28) and later: each request carries version and identity in `_meta`.

## Count completed work

A reconnecting client changes the request count. Five legacy tool calls cost fifteen requests if the client opens a new connection each time, and seven if it holds one connection. The legacy handshake spends `initialize` and `notifications/initialized` once per connection, so a reconnecting client looks like heavy legacy traffic. Each row is the same five tool calls per lane:

| Five tool calls per lane | Legacy requests | Modern requests | Legacy share |
|---|---:|---:|---:|
| Client reconnects for each call | 15 | 10 | 60.0% |
| Client holds one connection | 7 | 6 | 53.8% |
| **Completed operations** | **5** | **5** | **50.0%** |

You count 5 and 5 on the operations row. The request rows differ by six points because the client chose to reconnect. `recommend()` reads the operations row. Count the unit that still matches after a transport rewrite or a client that changes how it connects.

## Run it

Node 24 or newer. You store the ledger with `node:sqlite`.

Start the collector first, then the server, then generate traffic:

```bash
pnpm install

pnpm collector   # http://127.0.0.1:3000, OTLP (the metric export format) and agent tools
pnpm server      # http://127.0.0.1:8787, dual-era MCP server
pnpm traffic     # legacy and modern tool calls, plus DCR and CIMD hits
```

DCR is [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#dynamic-client-registration): the client POSTs metadata to `/register` on each connect. Deprecated in `2026-07-28`. CIMD is [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents): the client id is an HTTPS URL to static metadata, the replacement for DCR.

Then read the two stores:

```bash
pnpm report      # verdict from .data/migration-evidence.db
pnpm proof       # lane series from the collector
```

Wait six seconds after `pnpm traffic` before `pnpm proof` so the scrape lands.

The first run prints this:

```text
Migration readiness: MCP protocol lanes
=======================================
window            : 7 days
active days       : 1/7 required
legacy operations : 5
modern operations : 3
total operations  : 8/100 required
legacy %          : 62.50%
raw requests      : 15 legacy / 6 modern
legacy methods    : initialize x5, notifications/initialized x5, tools/call x5
legacy clients    : legacy-dashboard@0.9.4
modern clients    : modern-agent@2.1.0
auth DCR          : 2 attempts (1 success, 1 failure)
auth CIMD         : 3 attempts (2 success, 1 failure)

recommendation    : keep_both

A legacy client completed an operation. Keep the fallback and check next week.
Ask these clients to upgrade: legacy-dashboard@0.9.4.
```

`pnpm traffic` sends five legacy clients and three modern ones, the mix Kent describes a few weeks after a spec release. Each client reconnects for its one call, so the requests row reads 15 against 6. The report names `legacy-dashboard@0.9.4`, so you know who to email.

## The policy

Defaults: a seven-day window, seven active traffic days, 100 completed operations.

| Evidence | Verdict |
|---|---|
| No operations | `no_traffic` |
| One or more legacy operations | `keep_both` |
| Zero legacy operations, sample below the guards | `collect_more_data` |
| Zero legacy operations, both guards passed | `safe_to_plan_removal` |

`recommend()` returns `keep_both` after one legacy operation, even against two hundred modern ones. That call still belongs to a client. Modern volume does not tell you whether anyone still needs the old path.

### Deprecation window

The [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated) lists DCR, roots, sampling, and logging as deprecated in `2026-07-28`. Earliest removal is the first revision on or after `2027-07-28`. A clean local report does not move that date. Clients that follow the spec still have the promised window.

## Ask your agent

Leave the collector and server running. [`.mcp.json`](.mcp.json) points `autotel` at `http://127.0.0.1:3000/mcp` and `migration` at `http://127.0.0.1:8787/mcp`. `migrationStatus` reads the durable window.

> List `mcp.protocol.lane.operations` in autotel and compare the `lane=legacy` and `lane=modern` series. Call `migrationStatus` on the migration server. Can we remove the 2025-11-25 fallback? Cite the operation counts, active days and policy guardrails.

Run that prompt on a weekly cron and you get Kent's answer.

## Signals

| Signal | Storage | Purpose |
|---|---|---|
| `mcp.protocol.lane.requests` | OTLP | Request volume, one series per `lane` and `mcp_method` |
| `mcp.protocol.lane.operations` | OTLP | Comparable operations, one series per `lane` |
| `mcp.auth.registration.attempts` | OTLP | DCR and CIMD attempts, one series per `mode` and `outcome` |
| `mcp.protocol.legacy.days_since_last_operation` | OTLP | Days of silence on the old lane, `-1` when it never ran |
| `.data/migration-evidence.db` | SQLite | The report window, client names, across restarts |

`list_metrics` returns one series per attribute set, so the agent groups by `lane`. You need `autotel-mcp` 0.5.1 or newer for those attributes to survive ingest.

Kent averages 125 DCR registrations per user because each reconnect writes another record. A CIMD client writes none. Count both modes and you can see when DCR has gone quiet.

## Decisions to copy

**Count volume, then measure silence.** Read the counters for how much legacy traffic arrived. You need the last arrival to decide on removal. `mcp.protocol.legacy.days_since_last_operation` reads the ledger when the collector scrapes, so you do not recompute it on the request path. Alert when the gauge crosses 30.

**Put method names on the metric. Put client names in the ledger.** `mcp_method` is a label, so each new value creates another series, and an unauthenticated caller chooses the value. `STANDARD_METHODS` in `factory.ts` is an allow-list: anything unrecognised records as `unknown`. Client names have no bound, so they go to SQLite. One more name costs a row.

**Keep both guards.** A minimum sample stops you approving removal on a quiet afternoon. A minimum of active days stops you approving it on one busy Tuesday that missed the weekly batch job.

**Serve both eras without a session.** `createMcpHandler` runs with `legacy: 'stateless'`. One factory serves both eras and `ctx.era` names the lane. Any instance can answer any request, so you write the counter. You do not need a session table or sticky routing. A stateful fallback would have you instrument sessions. The report would then depend on the load balancer.

## Client names per era

A modern request names its caller. A legacy request names the caller once, during `initialize`.

| | Legacy (`2025-11-25` and earlier) | Modern (`2026-07-28`) |
|---|---|---|
| Method name | JSON-RPC body | `MCP-Method` header |
| `clientInfo` | `initialize` only | `_meta` on every request |

The modern envelope repeats `clientInfo` in `params._meta` on every request, so any instance can serve it. The legacy lane puts the name on the handshake. A stateless server has nowhere to keep it for the tool call that follows. `factory.ts` reads both. The report can name `legacy-dashboard@0.9.4` because that name arrived on `initialize`, not on the tool call that gets counted.

You can route a gateway on `MCP-Method` because the request describes itself. You attribute traffic without a session for that reason too.

`activeDays` buckets by UTC date, so a client whose users work US evenings can land in two buckets. The auth routes write DCR and CIMD attempts. They mint no tokens and fetch no CIMD documents.

## Configuration

| Variable | Effect |
|---|---|
| `MIGRATION_EVIDENCE_PATH` | Ledger location |
| `MIGRATION_WINDOW_DAYS` | Report window |
| `MIGRATION_RETENTION_DAYS` | Ledger retention |
| `MIGRATION_MIN_OPERATIONS` | Sample-size guard |
| `MIGRATION_MIN_ACTIVE_DAYS` | Active-days guard |

`GET /metrics/lanes?windowDays=30` takes the same window, up to 90 days.

To see the ledger survive a restart: run traffic, stop the server, start it again, run `pnpm report`. The counts remain.

`pnpm collector` keeps telemetry in `autotel.db` for 30 days. The server keeps evidence in `.data/migration-evidence.db` for 90. Git ignores both.

## Dual-era server

The server is dual-era because `createMcpHandler` runs with `legacy: 'stateless'`, and `ctx.era` names the lane you count. See the [changelog](https://modelcontextprotocol.io/specification/2026-07-28) for the rest of the `2026-07-28` change set.

`autotel-mcp-instrumentation` adds spans and duration histograms on both sides. You answer the removal question from the counters and the ledger.

## Code map

| File | Responsibility |
|---|---|
| `src/server/factory.ts` | Dual-era tools; reads era, method and client per request |
| `src/server/serve.ts` | Parses the body once, shares it with the factory |
| `src/telemetry/legacy-metrics.ts` | OTel counters and windowed snapshots |
| `src/telemetry/evidence-store.ts` | Indexed SQLite ledger, plus a memory store for tests |
| `src/report/recommend.ts` | The guards and the report copy |
| `src/server/oauth-routes.ts` | DCR and CIMD attempt evidence |
| `src/proof/autotel-proof.ts` | Reads the lane series back out of OTLP |
| `src/client/generate-traffic.ts` | Two named clients, one per lane |

## Verify

```bash
pnpm typecheck
pnpm test
```

Thirteen tests cover the ratio bias, window filtering, restart persistence, auth
outcomes, client attribution, legacy method counts and both removal guards.