@basein/runner 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -1,621 +1,694 @@
1
- # BaseInstRunnerMCP — Design & Implementation Plan
2
-
3
- > A recording MCP **proxy**: it sits between any MCP client (Claude Code, Cursor,
4
- > Codex, an Agent SDK session) and every MCP server that client uses, executes
5
- > each call on the client's behalf, and records the run as a reusable scenario in
6
- > the BaseIn service.
7
- >
8
- > **Status:** design. No code written yet.
9
- > **Relationship to RRepeat:** a separate solution. RRepeat records by *observing*
10
- > a Claude Code session through hooks; BaseInstRunnerMCP records by *being* the
11
- > execution path for MCP. They share a recorder contract and can run side by side.
12
-
13
- ---
14
-
15
- ## 0. Decisions locked
16
-
17
- | # | Decision | Choice | Consequence |
18
- |---|---|---|---|
19
- | D1 | Stack | TypeScript / Node, standalone repo | Ports `McpClient`, `McpBridge`, `RemoteRecorder`, the scope merge and the auth client from RRepeat instead of reinventing them |
20
- | D2 | Scope | MCP proxy **+ companion hook binary** | The only combination that reaches non-MCP tools (`Bash`, `Read`, `Edit`) |
21
- | D3 | Topology | Per-server shadow, **auto-generated** config | Preserves `mcp__<server>__<tool>` names; N proxy processes, one per upstream |
22
- | D4 | v1 scope | **Record only** | Replay/divergence deferred to v2 |
23
- | D5 | Run correlation | **Hook binary owns the run** | One monotonic step counter across built-in and MCP steps |
24
- | D6 | Persistence | BaseIn auth-service HTTP | Reuses `/recordings/*`, tenancy and the savings pipeline |
25
- | D7 | Protocol | **Full transparent passthrough** | Generic bidirectional relay, not a `tools/*` switch |
26
- | D8 | Callers | **Any MCP client** | No assumptions about config paths or tool-name conventions |
27
-
28
- ### 0.1 The tension in D5 + D8, and how it is resolved
29
-
30
- D5 says the hook binary owns run identity. D8 says support any MCP client. Only
31
- Claude Code has hooks. Both cannot hold universally, so run ownership is
32
- **negotiated at proxy startup**, with two tiers:
33
-
34
- | Tier | When | Run owner | What gets recorded |
35
- |---|---|---|---|
36
- | **Tier 1 — Bound** | A BIR control server is discoverable for this cwd (Claude Code or Agent SDK with `bir-hooks` installed) | Control server | Built-ins **and** MCP, one ordered step stream, prompt and final answer included |
37
- | **Tier 2 — Standalone** | No control server found within the discovery window | The proxy itself | MCP calls only. No prompt, no final answer, no built-in steps — a partial scenario, explicitly flagged |
38
-
39
- Tier 2 is not a degraded bug, it is the honest ceiling of what an MCP proxy can
40
- observe. Scenarios record which tier produced them so downstream consumers never
41
- mistake a partial trace for a complete one.
42
-
43
- ---
44
-
45
- ## 1. Goals and non-goals
46
-
47
- **Goals**
48
-
49
- 1. Execute every MCP `tools/call` the client makes, on the client's behalf, and
50
- return the upstream's result **losslessly** — content blocks, `structuredContent`,
51
- `isError`, `_meta` — so the client cannot tell the proxy is there.
52
- 2. Record each call as a step in a BaseIn run: server, tool, arguments, result,
53
- duration, error.
54
- 3. Be transparent for everything that is not `tools/call`: prompts, resources,
55
- logging, completion, progress, cancellation, and the **reverse** direction
56
- (sampling, elicitation, roots, list-changed notifications).
57
- 4. Never be the reason a host session fails. Every failure degrades to passthrough
58
- or to not-recording; none propagates.
59
- 5. Install itself: given a client's existing MCP configuration, produce the
60
- proxied configuration automatically and reversibly.
61
-
62
- **Non-goals for v1**
63
-
64
- - Replay, divergence bundles, steering. (v2 — §12.)
65
- - Intercepting non-MCP tools *without* the hook binary. Structurally impossible.
66
- - Wrapping `claude-in-chrome`. It is `scope: "dynamic"`, present in no config file,
67
- so there is no entry to rewrite.
68
- - Model-side behaviour change. The proxy never edits tool descriptions to steer
69
- selection.
70
-
71
- ---
72
-
73
- ## 2. The structural constraint
74
-
75
- This is the single most important fact in the document, and every design choice
76
- below follows from it.
77
-
78
- **An MCP server sees only MCP traffic addressed to it.** The model does not choose
79
- the proxy — it emits `mcp__chrome-devtools__navigate_page`, and the client routes
80
- that name to whichever process is registered under the key `chrome-devtools`. That
81
- makes interception a *configuration* guarantee, not a prompting one, which is
82
- strong. But its reach is exactly the set of configured MCP servers:
83
-
84
- | Traffic | Proxy sees it? | Why |
85
- |---|---|---|
86
- | `mcp__<wrapped>__*` | **Yes, always** | Routed by config key |
87
- | `mcp__<unwrapped>__*` | No | Client connects to that server directly |
88
- | `Bash`, `Read`, `Edit`, `Grep`, `Glob`, `Task`, `WebFetch` | **No** | Not MCP. Hook binary only |
89
- | `claude-in-chrome` | No | Dynamic scope, no config entry |
90
- | User prompt, model reasoning, final answer | **No** | Not tool traffic. Hook binary only |
91
-
92
- Hence D2. The proxy and the hook binary are two halves of one recorder.
93
-
94
- ---
95
-
96
- ## 3. Architecture
97
-
98
- ```mermaid
99
- graph TB
100
- subgraph host["Host application (Claude Code / Cursor / SDK)"]
101
- M[model loop]
102
- H["hooks (Claude Code only)"]
103
- end
104
-
105
- subgraph bir["BaseInstRunnerMCP"]
106
- C["bir-hooks<br/>control server<br/>:PORT"]
107
- P1["bir-proxy<br/>server: chrome-devtools"]
108
- P2["bir-proxy<br/>server: github"]
109
- end
110
-
111
- U1[chrome-devtools-mcp]
112
- U2["github mcp (http)"]
113
- R[("BaseIn auth-service<br/>/recordings/*")]
114
-
115
- M -->|"mcp__chrome-devtools__*"| P1
116
- M -->|"mcp__github__*"| P2
117
- H -->|"built-in steps, prompt, stop"| C
118
- P1 <-->|stdio JSON-RPC| U1
119
- P2 <-->|http/sse JSON-RPC| U2
120
- P1 -->|"step report + call id"| C
121
- P2 -->|"step report + call id"| C
122
- C -->|ordered run| R
123
- ```
124
-
125
- ### 3.1 Components
126
-
127
- | Component | Process | Responsibility |
128
- |---|---|---|
129
- | `bir-proxy` | one per wrapped upstream, spawned by the host | Bidirectional JSON-RPC relay. Executes `tools/call` against the upstream, reports the step, forwards everything else verbatim |
130
- | `bir-hooks` | one per host session | Claude Code hook receiver **and** control server. Owns run identity and the monotonic step counter. Records built-in tool steps, prompt, final answer |
131
- | `bir` | CLI | `install`, `uninstall`, `status`, `doctor`, `wrap` |
132
- | Recorder | library, inside `bir-hooks` (Tier 1) or `bir-proxy` (Tier 2) | Streams steps to the BaseIn service |
133
-
134
- ### 3.2 Why a control server, rather than each proxy recording directly
135
-
136
- Three proxies recording independently would produce three interleaved step streams
137
- with no shared ordering, and would double-record every call the hook also sees. A
138
- single control server gives one `stepIndex` sequence, one run lifecycle, one HTTP
139
- chain to the recorder, and one place to dedupe. It is the role RRepeat's
140
- `HookServer` already plays, widened to accept proxy reports.
141
-
142
- ---
143
-
144
- ## 4. Solution layout
145
-
146
- ```
147
- BaseInstRunnerMCP/
148
- ├─ package.json # bin: bir, bir-proxy, bir-hooks
149
- ├─ tsconfig.json
150
- ├─ src/
151
- │ ├─ bin/
152
- │ │ ├─ bir-proxy.ts # stdio MCP proxy entry point
153
- │ │ ├─ bir-hooks.ts # hook receiver + control server entry point
154
- │ │ └─ bir.ts # install / status / doctor / wrap
155
- │ ├─ jsonrpc/
156
- │ │ ├─ framing.ts # line-delimited JSON-RPC read/write
157
- │ │ └─ types.ts # Request | Response | Notification
158
- │ ├─ proxy/
159
- │ │ ├─ relay.ts # bidirectional relay, default-forward
160
- │ │ ├─ intercept.ts # the small intercept table
161
- │ │ └─ session.ts # per-proxy lifecycle, tier negotiation
162
- │ ├─ upstream/
163
- │ │ ├─ client.ts # UpstreamClient interface
164
- │ │ ├─ stdio-client.ts # spawn + framing
165
- │ │ └─ http-client.ts # streamable-http and sse
166
- │ ├─ control/
167
- │ │ ├─ server.ts # local HTTP control plane
168
- │ │ ├─ discovery.ts # ~/.baseinstrunner/control/<key>.json
169
- │ │ ├─ correlation.ts # __bir_call_id__ inject/extract/dedupe
170
- │ │ └─ ordering.ts # monotonic stepIndex allocator
171
- │ ├─ record/
172
- │ │ ├─ recorder.ts # Recorder interface
173
- │ │ ├─ remote-recorder.ts # BaseIn HTTP implementation
174
- │ │ ├─ redact.ts # secret scrubbing
175
- │ │ └─ truncate.ts # payload size caps
176
- │ ├─ config/
177
- │ │ ├─ resolve.ts # merged scope resolution per client
178
- │ │ ├─ generate.ts # wrap entries → proxied config
179
- │ │ └─ adapters/
180
- │ │ ├─ claude-code.ts # 3-scope merge, ~/.claude.json + .mcp.json
181
- │ │ └─ generic.ts # explicit --config <path>
182
- │ └─ auth/client.ts # login, token refresh
183
- └─ test/
184
- ├─ fixtures/ # golden JSON-RPC transcripts
185
- ├─ fake-upstream.ts # scriptable MCP server for tests
186
- └─ *.test.ts
187
- ```
188
-
189
- ---
190
-
191
- ## 5. Core contracts
192
-
193
- ```ts
194
- // upstream/client.ts — every upstream transport implements this.
195
- export interface UpstreamClient {
196
- /** Resolves when the handshake completed; rejects if the upstream is unusable. */
197
- readonly ready: Promise<InitializeResult>;
198
- /** Send any request and get the raw JSON-RPC result. No shape assumptions. */
199
- request(method: string, params?: unknown, signal?: AbortSignal): Promise<unknown>;
200
- /** Fire-and-forget notification toward the upstream. */
201
- notify(method: string, params?: unknown): void;
202
- /** Requests and notifications the upstream originates (sampling, elicitation, ...). */
203
- onIncoming(handler: (msg: JsonRpcMessage) => void): void;
204
- close(): void;
205
- }
206
-
207
- // record/recorder.ts — the surface both tiers use. Mirrors RRepeat's RemoteRecorder
208
- // so the BaseIn server needs no new endpoints for v1.
209
- export interface Recorder {
210
- startRun(input: string, metadata?: Record<string, unknown>): string;
211
- recordToolSelected(runId: string, stepIndex: number,
212
- d: { toolName: string; toolInput: string; context?: string }): string;
213
- recordToolResponse(runId: string, stepIndex: number,
214
- d: { toolName: string; toolOutput?: string; toolError?: string }): string;
215
- recordFinalAnswer(runId: string, stepIndex: number, d: { answer: string }): string;
216
- finishRun(runId: string, finalOutput?: string, metrics?: RunMetrics): void;
217
- flush(runId: string): Promise<void>;
218
- }
219
-
220
- // control/correlation.ts
221
- export const BIR_CALL_ID = "__bir_call_id__";
222
-
223
- // The step a proxy reports to the control server.
224
- export interface ProxyStepReport {
225
- callId?: string; // present in Tier 1 when the hook injected one
226
- serverName: string; // config key, e.g. "chrome-devtools"
227
- toolName: string; // upstream-local name, e.g. "navigate_page"
228
- qualifiedName: string; // client-rendered, e.g. "mcp__chrome-devtools__navigate_page"
229
- args: unknown; // post-redaction
230
- result?: unknown; // whole CallToolResult, post-redaction and truncation
231
- isError: boolean;
232
- errorMessage?: string;
233
- startedAt: number; // epoch ms
234
- durationMs: number;
235
- }
236
- ```
237
-
238
- `qualifiedName` is stored **alongside** `serverName` + `toolName`, never instead of
239
- them. `mcp__<server>__<tool>` is Claude Code's rendering; other clients differ.
240
- Storing the parts keeps scenarios portable across hosts (D8).
241
-
242
- ---
243
-
244
- ## 6. Implementation, phase by phase
245
-
246
- Each phase ends in something runnable and testable. Do not start a phase before
247
- its predecessor's acceptance test passes.
248
-
249
- ### Phase 0 — Scaffold
250
-
251
- 1. `npm init`; TypeScript strict, ESM (`"type": "module"`), Node >= 20.
252
- 2. `package.json` bin map:
253
- ```json
254
- { "bir": "dist/bin/bir.js",
255
- "bir-proxy": "dist/bin/bir-proxy.js",
256
- "bir-hooks": "dist/bin/bir-hooks.js" }
257
- ```
258
- 3. Zero runtime dependencies beyond `zod` if you want schema validation. Node's
259
- built-in `fetch`, `child_process` and `http` cover the rest. Resist adding an MCP
260
- SDK — full passthrough means handling unknown methods, which a typed SDK fights.
261
-
262
- **Acceptance:** `npx bir --version` prints.
263
-
264
- ### Phase 1 — Framing and the bidirectional relay
265
-
266
- This is the heart, and it is deliberately dumb.
267
-
268
- 1. `jsonrpc/framing.ts` — read newline-delimited JSON from a stream, write the same.
269
- Handle partial reads, oversized lines, and invalid JSON (respond `-32700`, never
270
- crash).
271
- 2. `proxy/relay.ts` — connect two message ports (host stdio ↔ upstream) and forward
272
- every message in both directions, **by default**.
273
-
274
- The design rule that makes full passthrough tractable:
275
-
276
- > **Default-forward, intercept by exception.** Do not enumerate the protocol.
277
- > Forward any message you have no specific reason to touch. The intercept table
278
- > is three entries long.
279
-
280
- 3. Id handling. Because the relay is 1:1, host ids can pass through unchanged. Keep
281
- a `Map<hostId, { method, startedAt }>` anyway — you need it to time `tools/call`
282
- and to match `notifications/cancelled`. Reserve a disjoint id space (a `bir:`
283
- prefix) for any request the proxy ever originates, so collisions are impossible.
284
-
285
- **Acceptance:** `bir-proxy -- npx -y chrome-devtools-mcp@latest` behaves
286
- byte-identically to running the upstream directly, verified by a golden transcript
287
- in `test/fixtures/`.
288
-
289
- ### Phase 2 — Upstream transports
290
-
291
- 1. `stdio-client.ts` — `spawn(command, args, { env, stdio: ['pipe','pipe','pipe'] })`.
292
- Forward upstream stderr to the proxy's own stderr **prefixed**, never to stdout;
293
- a stray stdout byte corrupts the host's JSON-RPC stream. This is the single most
294
- common way stdio MCP servers break.
295
- 2. `http-client.ts` — streamable-http and sse. Carry `--header k:v` for auth, since
296
- the host's own OAuth handling does not survive a proxy in front of it (§9).
297
- 3. Lifecycle: upstream death is not proxy death. On exit, mark the upstream dead,
298
- answer in-flight requests with a JSON-RPC error, optionally restart once with
299
- backoff, then stay dead and answer `tools/list` with an error rather than exiting.
300
-
301
- **Acceptance:** the Phase 1 transcript test passes against a stdio upstream, an
302
- http upstream, and an upstream killed mid-call.
303
-
304
- ### Phase 3 — The intercept table
305
-
306
- Only three methods are touched. Everything else relays untouched, both directions.
307
-
308
- | Method | Direction | Action |
309
- |---|---|---|
310
- | `initialize` | host → upstream | **Relay, and rewrite as little as possible.** Forward the host's `capabilities` verbatim so the upstream learns whether sampling/elicitation exist. Return the upstream's `capabilities` and `serverInfo` verbatim, so prompts/resources/logging stay advertised. Do **not** hard-code `{ tools: {} }` — that is exactly what makes a proxy silently swallow prompts and resources. |
311
- | `tools/list` | host → upstream | Relay. If Tier 1 correlation is active, apply schema relaxation (§6, Phase 6). Otherwise leave schemas untouched. |
312
- | `tools/call` | host → upstream | Extract and strip `__bir_call_id__`; time the call; relay; report the step; return the upstream result **verbatim**. |
313
-
314
- Everything else — `prompts/*`, `resources/*`, `completion/complete`,
315
- `logging/setLevel`, `ping`, `notifications/cancelled`, `notifications/progress`,
316
- and the reverse-direction `sampling/createMessage`, `elicitation/create`,
317
- `roots/list`, `notifications/*/list_changed`, `notifications/message` — passes
318
- through with no special case. That is the point of D7: unknown methods work
319
- *because* they are never enumerated.
320
-
321
- Two rules that are easy to get wrong:
322
-
323
- - **Never rewrite a `tools/call` result.** Return the upstream object as-is,
324
- including `_meta` and `structuredContent`. Record a *copy*.
325
- - **Never let recording block the response.** Report the step on a fire-and-forget
326
- queue after the result is already on its way to the host.
327
-
328
- **Acceptance:** an upstream exposing prompts and resources is fully usable through
329
- the proxy — `prompts/list` and `resources/read` reach it and return.
330
-
331
- ### Phase 4 — Recorder and auth
332
-
333
- 1. Port RRepeat's `auth/client.ts` (login, `saveCredentials`, refresh-on-401).
334
- 2. Port `RemoteRecorder` unchanged in shape: client-generated `run_`/`step_` uuids,
335
- a per-run ordered promise chain, best-effort sends, one token refresh on 401.
336
- Endpoints, already live: `POST /recordings/runs`,
337
- `POST /recordings/runs/:id/steps`, `PATCH /recordings/runs/:id/steps/:stepId`,
338
- `POST /recordings/runs/:id/finish`.
339
- 3. `redact.ts` — scrub before anything leaves the process. Minimum: values under
340
- keys matching `/(token|secret|password|api[_-]?key|authorization|cookie)/i`,
341
- anything matching a bearer/JWT/`sk-` shape, and `env` blocks. Applied to
342
- arguments *and* results.
343
- 4. `truncate.ts` — cap each payload (suggest 64 KiB) with an explicit
344
- `{ truncated: true, originalBytes }` marker. Base64 image blocks from browser
345
- MCPs will otherwise dominate every run.
346
-
347
- **Acceptance:** a Tier 2 run appears in the BaseIn service with correct steps and
348
- no secret material, verified by a redaction fixture.
349
-
350
- ### Phase 5 — Control plane and the hook binary (Tier 1)
351
-
352
- **Discovery.** The control server writes
353
- `~/.baseinstrunner/control/<sha256(cwd).slice(0,16)>.json`:
354
-
355
- ```json
356
- { "url": "http://127.0.0.1:53411",
357
- "token": "<random 32 bytes, hex>",
358
- "sessionId": "...",
359
- "pid": 12345,
360
- "startedAt": 1756600000000 }
361
- ```
362
-
363
- A proxy resolves it in this order: `BIR_CONTROL_URL` env (injected into generated
364
- config, and the only reliable channel for SDK sessions) → the discovery file for
365
- its cwd → give up. Give-up is bounded: **buffer steps in memory for up to 5 s while
366
- retrying**, then fall to Tier 2 and flush the buffer into a self-owned run. The
367
- window exists because the host may spawn MCP servers before `SessionStart` fires;
368
- the ordering between the two is not guaranteed.
369
-
370
- **Control server HTTP surface** (loopback only, ephemeral port, bearer token from
371
- the discovery file required on every route):
372
-
373
- | Route | Caller | Purpose |
374
- |---|---|---|
375
- | `POST /session/start` | hook | Open the run; returns `runId` |
376
- | `POST /session/prompt` | hook | `UserPromptSubmit` — the run's `input` |
377
- | `POST /tool/pre` | hook | Built-in step opened; returns `{ stepIndex, callId? }` |
378
- | `POST /tool/post` | hook | Built-in step closed with its result |
379
- | `POST /session/stop` | hook | Final answer |
380
- | `POST /session/end` | hook | `finishRun` + flush |
381
- | `POST /proxy/register` | proxy | `{ serverName, pid, cwd }` → `{ runId, sessionId }` |
382
- | `POST /proxy/step` | proxy | A `ProxyStepReport`; allocates `stepIndex` |
383
- | `GET /health` | CLI | `bir doctor` |
384
-
385
- **Ordering.** `ordering.ts` hands out `stepIndex` from one counter shared by hook
386
- and proxy reports. Allocation happens at *report* time, not call time, so
387
- concurrent MCP calls are ordered by completion — deterministic, and honest about
388
- what actually happened.
389
-
390
- **Hook wiring.** `bir-hooks` registers `UserPromptSubmit`, `PreToolUse`,
391
- `PostToolUse`, `PostToolUseFailure`, `Stop`, `StopFailure`, `SessionStart`,
392
- `SessionEnd`. Use `PostToolUseFailure` rather than inferring failure from
393
- `PostToolUse` shape, and `SubagentStart` / `SubagentStop` (which carry `agent_id`)
394
- to attribute subagent steps correctly.
395
-
396
- **Acceptance:** a Claude Code session running one built-in and one MCP tool
397
- produces a single run whose steps are in true execution order.
398
-
399
- ### Phase 6 — Correlation and dedupe
400
-
401
- In Tier 1 a wrapped MCP call is seen **twice**: once by the hook at `PreToolUse`,
402
- once by the proxy at `tools/call`. Without correlation you double-record.
403
-
404
- **Mechanism.** At `PreToolUse` for a wrapped `mcp__*` tool, the hook returns
405
- `permissionDecision: "allow"` with `updatedInput = { ...original, [BIR_CALL_ID]: id }`.
406
- The proxy strips that key before relaying and echoes it on its step report. The
407
- control server merges the two views into one step:
408
-
409
- - the hook contributes identity, `tool_use_id`, ordering position, and the model's
410
- original arguments;
411
- - the proxy contributes the true upstream result, `isError`, `structuredContent`,
412
- and real execution duration.
413
-
414
- This is RRepeat's `RREPEAT_SENTINEL` trick generalised, so it is proven — but
415
- inherit its cost knowingly: **carrying an extra argument requires relaxing the
416
- tool's schema** (`additionalProperties: true` plus the declared key), and that
417
- relaxation is visible to the model on every call, not only recorded ones.
418
- Therefore:
419
-
420
- - Apply relaxation **only** when Tier 1 correlation is active. Tier 2 never relaxes.
421
- - Relax **only** wrapped servers.
422
- - Provide `--no-correlation`, falling back to fingerprint matching —
423
- `(serverName, toolName, hash(args))` inside a 30 s window — for hosts where
424
- schema relaxation is unacceptable. Document it as lossy under identical
425
- concurrent calls.
426
-
427
- **Acceptance:** one MCP call in a Claude Code session produces exactly one step,
428
- carrying both the model's arguments and the upstream's real result.
429
-
430
- ### Phase 7 — Config generation and the CLI
431
-
432
- 1. `config/adapters/claude-code.ts` — merge the three scopes in Claude Code's own
433
- precedence order: **Local** (`~/.claude.json` → `projects[<cwd>].mcpServers`)
434
- beats **Project** (`<cwd>/.mcp.json`) beats **User** (`~/.claude.json` top-level).
435
- Match the local-scope key on a *normalised* path: Claude Code stores forward
436
- slashes on Windows while `process.cwd()` returns backslashes. Getting this wrong
437
- is the number one cause of "the proxy is installed but never runs".
438
- 2. `config/generate.ts` — rewrite each resolved entry in place, keeping the key:
439
- ```json
440
- "chrome-devtools": {
441
- "command": "npx",
442
- "args": ["-y", "-p", "baseinstrunner", "bir-proxy",
443
- "--server-name", "chrome-devtools", "--",
444
- "<original command>", "<original args...>"],
445
- "env": { "BIR_CONTROL_URL": "http://127.0.0.1:53411" }
446
- }
447
- ```
448
- Remote entries become
449
- `bir-proxy --server-name X --url <url> --transport http`.
450
- 3. **Write to the winning scope.** If a local-scope entry shadows the project one,
451
- rewriting `.mcp.json` changes nothing. Detect this and warn loudly.
452
- 4. Reversibility: stash each original entry in a sidecar
453
- (`~/.baseinstrunner/installed.json`) so `bir uninstall` restores exactly.
454
- 5. `bir doctor` — resolve config, list which servers are wrapped, query the control
455
- server's `/health`, and confirm every wrapped name has a *registered* proxy.
456
- Registration at the control server is the guarantee check; do not infer it from
457
- the config file, and do not infer it from `serverInfo` (which must stay the
458
- upstream's, per Phase 3).
459
- 6. `bir wrap -- <cmd>` — manual single-server mode for clients with no adapter.
460
- This is what makes D8 real: the generic path never needs to know where a client
461
- stores its settings.
462
-
463
- **Acceptance:** `bir install` on a project with three MCP servers wraps all three,
464
- `bir doctor` reports three registered proxies, and `bir uninstall` restores the
465
- file byte-for-byte.
466
-
467
- ### Phase 8 — Hardening
468
-
469
- - **Backpressure.** Bound the step queue (suggest 1000). On overflow, drop oldest
470
- and set `lossy: true` on the run. Never block a tool call on the recorder.
471
- - **Degradation matrix** — §10, implemented as explicit branches, each with a test.
472
- - **Startup cost.** N upstreams means N proxy processes *plus* N upstream processes.
473
- Measure session-start latency; if it matters, add `--lazy` to defer the upstream
474
- spawn until the first `tools/list`.
475
- - **Windows.** `spawn` with `shell: false` and explicit `.cmd` resolution for `npx`;
476
- path normalisation in the local-scope key; no assumptions about `$HOME`.
477
-
478
- ---
479
-
480
- ## 7. What a recorded step looks like
481
-
482
- Mapped onto the existing BaseIn endpoints, one MCP call becomes two rows, exactly
483
- as RRepeat records them today:
484
-
485
- ```
486
- POST /recordings/runs/:runId/steps
487
- { id, stepIndex: 7, type: "tool_selected",
488
- toolName: "mcp__chrome-devtools__navigate_page",
489
- toolInput: "{\"url\":\"https://example.com\"}",
490
- context: "<model reasoning, Tier 1 only>" }
491
-
492
- POST /recordings/runs/:runId/steps
493
- { id, stepIndex: 8, type: "tool_response",
494
- toolName: "mcp__chrome-devtools__navigate_page",
495
- toolOutput: "<serialised CallToolResult, redacted, truncated>" }
496
- ```
497
-
498
- Run `metadata` carries what is new, and what BaseIn-side consumers will need:
499
-
500
- ```json
501
- { "recorder": "baseinstrunner",
502
- "tier": "bound",
503
- "host": { "app": "claude-code", "version": "2.1.251" },
504
- "wrappedServers": ["chrome-devtools", "github"],
505
- "lossy": false }
506
- ```
507
-
508
- `tier` is the field that stops a Tier 2 partial trace being mistaken for a
509
- complete one.
510
-
511
- ---
512
-
513
- ## 8. Full-passthrough method map
514
-
515
- For implementers. The relay does not branch on these — the table documents what
516
- must *survive* the relay, and is the basis of the conformance suite.
517
-
518
- **Host → upstream:** `initialize`, `notifications/initialized`, `ping`,
519
- `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`,
520
- `resources/templates/list`, `resources/read`, `resources/subscribe`,
521
- `resources/unsubscribe`, `completion/complete`, `logging/setLevel`,
522
- `notifications/cancelled`, `notifications/progress`,
523
- `notifications/roots/list_changed`.
524
-
525
- **Upstream → host:** `sampling/createMessage`, `elicitation/create`, `roots/list`,
526
- `notifications/message`, `notifications/progress`, `notifications/cancelled`,
527
- `notifications/tools/list_changed`, `notifications/resources/list_changed`,
528
- `notifications/resources/updated`, `notifications/prompts/list_changed`.
529
-
530
- The reverse direction is precisely why a `tools/*`-only bridge is not enough: an
531
- upstream that issues `elicitation/create` and receives no answer **hangs**. Claude
532
- Code surfaces these as the `Elicitation` hook event, so a dropped one is at least
533
- observable.
534
-
535
- ---
536
-
537
- ## 9. Security
538
-
539
- - The proxy sits on the credential path for every wrapped server. For remote
540
- upstreams it holds the headers, because the host's own OAuth flow authenticates
541
- the server it is *configured with* — which is now the proxy. Store nothing; read
542
- from env or the OS keychain at spawn.
543
- - Every argument and result crosses a process boundary and then the network.
544
- Redaction (Phase 4.3) runs before both.
545
- - The control server binds `127.0.0.1` only, on an ephemeral port, and requires the
546
- token from the discovery file on every route. A local port that accepts
547
- unauthenticated step reports is a local exfiltration channel.
548
- - Discovery files are mode `0600`.
549
- - `bir doctor` prints exactly which servers are wrapped, so a proxy can never sit
550
- in the path invisibly.
551
-
552
- ---
553
-
554
- ## 10. Degradation matrix
555
-
556
- The governing rule: **the host session must never fail because of BaseInstRunner.**
557
-
558
- | Failure | Behaviour |
559
- |---|---|
560
- | Control server unreachable at startup | Buffer <= 5 s, then Tier 2; log once to stderr |
561
- | Control server dies mid-session | Proxy switches to Tier 2 for the remainder; run flagged `lossy` |
562
- | Recorder HTTP fails | Drop the send, log, continue. Never retry inline |
563
- | Upstream fails to start | `initialize` still answers; `tools/list` returns an error. The host shows one broken server, not a broken session |
564
- | Upstream dies mid-call | Return `isError: true` with the transport message as content — a tool failure the model can react to, not a protocol error |
565
- | Unknown JSON-RPC method | Forward it. If the upstream rejects, relay the rejection |
566
- | Step queue overflow | Drop oldest, flag the run `lossy` |
567
- | Schema relaxation rejected by host | Disable correlation, fall back to fingerprint matching |
568
-
569
- ---
570
-
571
- ## 11. Testing
572
-
573
- 1. **Transcript identity (the core test).** Record a golden JSON-RPC transcript
574
- against a real upstream directly, then through the proxy. Every host-visible
575
- byte except timing must match. Run it for stdio, http and sse.
576
- 2. **`fake-upstream.ts`** — a scriptable MCP server that can advertise arbitrary
577
- capabilities, emit `notifications/tools/list_changed`, issue
578
- `elicitation/create`, delay, and die on command. Everything below builds on it.
579
- 3. **Passthrough conformance** — one case per row of §8, both directions.
580
- 4. **Correlation** — a fake hook injecting `__bir_call_id__`; assert exactly one
581
- merged step. Then the `--no-correlation` fingerprint path, including the known
582
- ambiguity under identical concurrent calls.
583
- 5. **Config adapter** — fixture `~/.claude.json` + `.mcp.json` pairs covering scope
584
- precedence, the Windows path-normalisation case, remote entries, already-wrapped
585
- entries (idempotent install), and reserved names.
586
- 6. **Redaction** — assert no fixture secret reaches the recorder.
587
- 7. **Integration** — a real Claude Code session against `fake-upstream`, asserting
588
- run shape end to end. The only test that catches host-behaviour drift.
589
-
590
- ---
591
-
592
- ## 12. Milestones
593
-
594
- | Milestone | Phases | Deliverable |
595
- |---|---|---|
596
- | **M1 — Invisible proxy** | 0–2 | `bir-proxy` wraps any upstream with zero observable difference |
597
- | **M2 — Standalone recording** | 3–4 | Tier 2 runs land in BaseIn. Works with any MCP client, today |
598
- | **M3 — Bound recording** | 5–6 | Tier 1: built-ins + MCP, one ordered stream, deduped |
599
- | **M4 — Product** | 7–8 | `bir install / doctor / uninstall`, hardened, documented |
600
- | **v2 — Replay** | R0–R7 | Run a calculated scenario on a similar-meaning match. Designed in [calculatedReplay.md](calculatedReplay.md); the runbook is [calculatedReplayGuide.md](calculatedReplayGuide.md) |
601
-
602
- M2 is the first genuinely useful release, and it does not depend on hooks at all —
603
- ship it before touching the control plane.
604
-
605
- ---
606
-
607
- ## 13. Open questions
608
-
609
- 1. **Does the BaseIn service need `tier` / `lossy` as real columns**, or is run
610
- `metadata` enough for the savings pipeline to exclude partial traces from
611
- baselines? A Tier 2 run has no prompt, so similarity matching on `input` behaves
612
- differently.
613
- 2. **Coexistence with RRepeat.** If both are installed, both record the same session
614
- into the same service. Should `bir-hooks` detect `rrepeat-hooks` and refuse, or
615
- should runs carry a `recorder` discriminator and be deduped server-side? §7's
616
- metadata assumes the latter.
617
- 3. **`--lazy` upstream spawn** — worth it, or are N idle upstream processes
618
- acceptable? Needs a measurement on a realistic five-server config before deciding.
619
- 4. **Fingerprint fallback under concurrency.** Two identical parallel `tools/call`s
620
- are genuinely indistinguishable without a call id. Accept the ambiguity, or make
621
- correlation mandatory for Tier 1?
1
+ # BaseInstRunnerMCP — Design & Implementation Plan
2
+
3
+ > A recording MCP **proxy**: it sits between any MCP client (Claude Code, Cursor,
4
+ > Codex, an Agent SDK session) and every MCP server that client uses, executes
5
+ > each call on the client's behalf, and records the run as a reusable scenario in
6
+ > the BaseIn service.
7
+ >
8
+ > **Status:** the design the runner was built from. Parts of it (the tiers,
9
+ > correlation, the degradation matrix) have since been revised to match the
10
+ > implementation. The production contract — measurements, environment variables,
11
+ > the operations runbook — is [production.md](../production.md).
12
+ > **Relationship to RRepeat:** a separate solution. RRepeat records by *observing*
13
+ > a Claude Code session through hooks; BaseInstRunnerMCP records by *being* the
14
+ > execution path for MCP. They share a recorder contract and can run side by side.
15
+
16
+ ---
17
+
18
+ ## 0. Decisions locked
19
+
20
+ | # | Decision | Choice | Consequence |
21
+ |---|---|---|---|
22
+ | D1 | Stack | TypeScript / Node, standalone repo | Ports `McpClient`, `McpBridge`, `RemoteRecorder`, the scope merge and the auth client from RRepeat instead of reinventing them |
23
+ | D2 | Scope | MCP proxy **+ companion hook binary** | The only combination that reaches non-MCP tools (`Bash`, `Read`, `Edit`) |
24
+ | D3 | Topology | Per-server shadow, **auto-generated** config | Preserves `mcp__<server>__<tool>` names; N proxy processes, one per upstream |
25
+ | D4 | v1 scope | **Record only** | Replay/divergence deferred to v2 |
26
+ | D5 | Run correlation | **Hook binary owns the run** | One monotonic step counter across built-in and MCP steps |
27
+ | D6 | Persistence | BaseIn auth-service HTTP | Reuses `/recordings/*`, tenancy and the savings pipeline |
28
+ | D7 | Protocol | **Full transparent passthrough** | Generic bidirectional relay, not a `tools/*` switch |
29
+ | D8 | Callers | **Any MCP client** | No assumptions about config paths or tool-name conventions |
30
+
31
+ ### 0.1 The tension in D5 + D8, and how it is resolved
32
+
33
+ D5 says the hook binary owns run identity. D8 says support any MCP client. Only
34
+ Claude Code has hooks. Both cannot hold universally, so run ownership is
35
+ **negotiated at proxy startup**, with two tiers and a passthrough that records nothing:
36
+
37
+ | Tier | When | Run owner | What gets recorded |
38
+ |---|---|---|---|
39
+ | **Tier 1 — Bound** | A BIR control server is discoverable for this cwd (Claude Code or Agent SDK with `bir-hooks` installed) | Control server | Built-ins **and** MCP, one ordered step stream, prompt and final answer included |
40
+ | **Tier 2 — Standalone** | No control server found within the discovery window | The proxy itself | MCP calls only. No prompt, no final answer, no built-in steps — a partial scenario, explicitly flagged |
41
+ | **Passthrough** | No control server, for a Claude Code user-scope entry wrapped by `bir install`, in a directory that was never installed and has no recorder | Nobody | Nothing. The proxy relays and uploads nothing |
42
+
43
+ Passthrough exists because a user-scope server is wrapped once, in one project, but
44
+ Claude Code starts it in every project, and Tier 2 there would upload that project's
45
+ tool traffic without anyone having asked. Every other entry — one written by
46
+ `bir install --config`, a project- or local-scope entry, a `bir wrap` entry, one
47
+ without `BIR_CONTROL_URL` — falls to Tier 2 from any directory, and `--standalone`
48
+ forces Tier 2.
49
+
50
+ Tier 2 is not a degraded bug, it is the honest ceiling of what an MCP proxy can
51
+ observe. Scenarios record which tier produced them so downstream consumers never
52
+ mistake a partial trace for a complete one.
53
+
54
+ ---
55
+
56
+ ## 1. Goals and non-goals
57
+
58
+ **Goals**
59
+
60
+ 1. Execute every MCP `tools/call` the client makes, on the client's behalf, and
61
+ return the upstream's result **losslessly** — content blocks, `structuredContent`,
62
+ `isError`, `_meta` — so the client cannot tell the proxy is there.
63
+ 2. Record each call as a step in a BaseIn run: server, tool, arguments, result,
64
+ duration, error.
65
+ 3. Be transparent for everything that is not `tools/call`: prompts, resources,
66
+ logging, completion, progress, cancellation, and the **reverse** direction
67
+ (sampling, elicitation, roots, list-changed notifications).
68
+ 4. Never be the reason a host session fails. Every failure degrades to passthrough
69
+ or to not-recording; none propagates.
70
+ 5. Install itself: given a client's existing MCP configuration, produce the
71
+ proxied configuration automatically and reversibly.
72
+
73
+ **Non-goals for v1**
74
+
75
+ - Replay, divergence bundles, steering. (v2 — §12.)
76
+ - Intercepting non-MCP tools *without* the hook binary. Structurally impossible.
77
+ - Wrapping `claude-in-chrome`. It is `scope: "dynamic"`, present in no config file,
78
+ so there is no entry to rewrite.
79
+ - Model-side behaviour change. The proxy never edits tool descriptions to steer
80
+ selection.
81
+
82
+ ---
83
+
84
+ ## 2. The structural constraint
85
+
86
+ This is the single most important fact in the document, and every design choice
87
+ below follows from it.
88
+
89
+ **An MCP server sees only MCP traffic addressed to it.** The model does not choose
90
+ the proxy — it emits `mcp__chrome-devtools__navigate_page`, and the client routes
91
+ that name to whichever process is registered under the key `chrome-devtools`. That
92
+ makes interception a *configuration* guarantee, not a prompting one, which is
93
+ strong. But its reach is exactly the set of configured MCP servers:
94
+
95
+ | Traffic | Proxy sees it? | Why |
96
+ |---|---|---|
97
+ | `mcp__<wrapped>__*` | **Yes, always** | Routed by config key |
98
+ | `mcp__<unwrapped>__*` | No | Client connects to that server directly |
99
+ | `Bash`, `Read`, `Edit`, `Grep`, `Glob`, `Task`, `WebFetch` | **No** | Not MCP. Hook binary only |
100
+ | `claude-in-chrome` | No | Dynamic scope, no config entry |
101
+ | User prompt, model reasoning, final answer | **No** | Not tool traffic. Hook binary only |
102
+
103
+ Hence D2. The proxy and the hook binary are two halves of one recorder.
104
+
105
+ ---
106
+
107
+ ## 3. Architecture
108
+
109
+ ```mermaid
110
+ graph TB
111
+ subgraph host["Host application (Claude Code / Cursor / SDK)"]
112
+ M[model loop]
113
+ H["hooks (Claude Code only)"]
114
+ end
115
+
116
+ subgraph bir["BaseInstRunnerMCP"]
117
+ C["bir-hooks<br/>control server<br/>:PORT"]
118
+ P1["bir-proxy<br/>server: chrome-devtools"]
119
+ P2["bir-proxy<br/>server: github"]
120
+ end
121
+
122
+ U1[chrome-devtools-mcp]
123
+ U2["github mcp (http)"]
124
+ R[("BaseIn auth-service<br/>/recordings/*")]
125
+
126
+ M -->|"mcp__chrome-devtools__*"| P1
127
+ M -->|"mcp__github__*"| P2
128
+ H -->|"built-in steps, prompt, stop"| C
129
+ P1 <-->|stdio JSON-RPC| U1
130
+ P2 <-->|http/sse JSON-RPC| U2
131
+ P1 -->|"step report + call id"| C
132
+ P2 -->|"step report + call id"| C
133
+ C -->|ordered run| R
134
+ ```
135
+
136
+ ### 3.1 Components
137
+
138
+ | Component | Process | Responsibility |
139
+ |---|---|---|
140
+ | `bir-proxy` | one per wrapped upstream, spawned by the host | Bidirectional JSON-RPC relay. Executes `tools/call` against the upstream, reports the step, forwards everything else verbatim |
141
+ | `bir-hooks` | one per host session | Claude Code hook receiver **and** control server. Owns run identity and the monotonic step counter. Records built-in tool steps, prompt, final answer |
142
+ | `bir` | CLI | `install`, `uninstall`, `status`, `doctor`, `wrap` |
143
+ | Recorder | library, inside `bir-hooks` (Tier 1) or `bir-proxy` (Tier 2) | Streams steps to the BaseIn service |
144
+
145
+ ### 3.2 Why a control server, rather than each proxy recording directly
146
+
147
+ Three proxies recording independently would produce three interleaved step streams
148
+ with no shared ordering, and would double-record every call the hook also sees. A
149
+ single control server gives one `stepIndex` sequence, one run lifecycle, one HTTP
150
+ chain to the recorder, and one place to dedupe. It is the role RRepeat's
151
+ `HookServer` already plays, widened to accept proxy reports.
152
+
153
+ ---
154
+
155
+ ## 4. Solution layout
156
+
157
+ ```
158
+ BaseInstRunnerMCP/
159
+ ├─ package.json # bin: bir, bir-proxy, bir-hooks
160
+ ├─ tsconfig.json
161
+ ├─ src/
162
+ │ ├─ bin/
163
+ │ │ ├─ bir-proxy.ts # stdio MCP proxy entry point
164
+ │ │ ├─ bir-hooks.ts # hook receiver + control server entry point
165
+ │ │ └─ bir.ts # install / status / doctor / wrap
166
+ │ ├─ jsonrpc/
167
+ │ │ ├─ framing.ts # line-delimited JSON-RPC read/write
168
+ │ │ └─ types.ts # Request | Response | Notification
169
+ │ ├─ proxy/
170
+ │ │ ├─ relay.ts # bidirectional relay, default-forward
171
+ │ │ ├─ intercept.ts # the small intercept table
172
+ │ │ └─ session.ts # per-proxy lifecycle, tier negotiation
173
+ │ ├─ upstream/
174
+ │ │ ├─ client.ts # UpstreamClient interface
175
+ │ │ ├─ stdio-client.ts # spawn + framing
176
+ │ │ └─ http-client.ts # streamable-http and sse
177
+ │ ├─ control/
178
+ │ │ ├─ server.ts # local HTTP control plane
179
+ │ │ ├─ discovery.ts # ~/.baseinstrunner/control/<key>.json
180
+ │ │ ├─ correlation.ts # __bir_call_id__ inject/extract/dedupe
181
+ │ │ └─ ordering.ts # monotonic stepIndex allocator
182
+ │ ├─ record/
183
+ │ │ ├─ recorder.ts # Recorder interface
184
+ │ │ ├─ remote-recorder.ts # BaseIn HTTP implementation
185
+ │ │ ├─ redact.ts # secret scrubbing
186
+ │ │ └─ truncate.ts # payload size caps
187
+ │ ├─ config/
188
+ │ │ ├─ resolve.ts # merged scope resolution per client
189
+ │ │ ├─ generate.ts # wrap entries → proxied config
190
+ │ │ └─ adapters/
191
+ │ │ ├─ claude-code.ts # 3-scope merge, ~/.claude.json + .mcp.json
192
+ │ │ └─ generic.ts # explicit --config <path>
193
+ │ └─ auth/client.ts # login, token refresh
194
+ └─ test/
195
+ ├─ fixtures/ # golden JSON-RPC transcripts
196
+ ├─ fake-upstream.ts # scriptable MCP server for tests
197
+ └─ *.test.ts
198
+ ```
199
+
200
+ ---
201
+
202
+ ## 5. Core contracts
203
+
204
+ ```ts
205
+ // upstream/client.ts — every upstream transport implements this.
206
+ export interface UpstreamClient {
207
+ /** Resolves when the handshake completed; rejects if the upstream is unusable. */
208
+ readonly ready: Promise<InitializeResult>;
209
+ /** Send any request and get the raw JSON-RPC result. No shape assumptions. */
210
+ request(method: string, params?: unknown, signal?: AbortSignal): Promise<unknown>;
211
+ /** Fire-and-forget notification toward the upstream. */
212
+ notify(method: string, params?: unknown): void;
213
+ /** Requests and notifications the upstream originates (sampling, elicitation, ...). */
214
+ onIncoming(handler: (msg: JsonRpcMessage) => void): void;
215
+ close(): void;
216
+ }
217
+
218
+ // record/recorder.ts — the surface both tiers use. Mirrors RRepeat's RemoteRecorder
219
+ // so the BaseIn server needs no new endpoints for v1.
220
+ export interface Recorder {
221
+ startRun(input: string, metadata?: Record<string, unknown>): string;
222
+ recordToolSelected(runId: string, stepIndex: number,
223
+ d: { toolName: string; toolInput: string; context?: string }): string;
224
+ recordToolResponse(runId: string, stepIndex: number,
225
+ d: { toolName: string; toolOutput?: string; toolError?: string }): string;
226
+ recordFinalAnswer(runId: string, stepIndex: number, d: { answer: string }): string;
227
+ finishRun(runId: string, finalOutput?: string, metrics?: RunMetrics): void;
228
+ flush(runId: string): Promise<void>;
229
+ }
230
+
231
+ // control/correlation.ts
232
+ export const BIR_CALL_ID = "__bir_call_id__";
233
+
234
+ // The step a proxy reports to the control server.
235
+ export interface ProxyStepReport {
236
+ callId?: string; // present in Tier 1 when the hook injected one
237
+ serverName: string; // config key, e.g. "chrome-devtools"
238
+ toolName: string; // upstream-local name, e.g. "navigate_page"
239
+ qualifiedName: string; // client-rendered, e.g. "mcp__chrome-devtools__navigate_page"
240
+ args: unknown; // post-redaction
241
+ result?: unknown; // whole CallToolResult, post-redaction and truncation
242
+ isError: boolean;
243
+ errorMessage?: string;
244
+ startedAt: number; // epoch ms
245
+ durationMs: number;
246
+ }
247
+ ```
248
+
249
+ `qualifiedName` is stored **alongside** `serverName` + `toolName`, never instead of
250
+ them. `mcp__<server>__<tool>` is Claude Code's rendering; other clients differ.
251
+ Storing the parts keeps scenarios portable across hosts (D8).
252
+
253
+ ---
254
+
255
+ ## 6. Implementation, phase by phase
256
+
257
+ Each phase ends in something runnable and testable. Do not start a phase before
258
+ its predecessor's acceptance test passes.
259
+
260
+ ### Phase 0 — Scaffold
261
+
262
+ 1. `npm init`; TypeScript strict, ESM (`"type": "module"`), Node >= 20.
263
+ 2. `package.json` bin map:
264
+ ```json
265
+ { "bir": "dist/bin/bir.js",
266
+ "bir-proxy": "dist/bin/bir-proxy.js",
267
+ "bir-hooks": "dist/bin/bir-hooks.js" }
268
+ ```
269
+ 3. Zero runtime dependencies beyond `zod` if you want schema validation. Node's
270
+ built-in `fetch`, `child_process` and `http` cover the rest. Resist adding an MCP
271
+ SDK — full passthrough means handling unknown methods, which a typed SDK fights.
272
+
273
+ **Acceptance:** `npx bir --version` prints.
274
+
275
+ ### Phase 1 — Framing and the bidirectional relay
276
+
277
+ This is the heart, and it is deliberately dumb.
278
+
279
+ 1. `jsonrpc/framing.ts` — read newline-delimited JSON from a stream, write the same.
280
+ Handle partial reads, oversized lines, and invalid JSON (respond `-32700`, never
281
+ crash).
282
+ 2. `proxy/relay.ts` — connect two message ports (host stdio ↔ upstream) and forward
283
+ every message in both directions, **by default**.
284
+
285
+ The design rule that makes full passthrough tractable:
286
+
287
+ > **Default-forward, intercept by exception.** Do not enumerate the protocol.
288
+ > Forward any message you have no specific reason to touch. The intercept table
289
+ > is three entries long.
290
+
291
+ 3. Id handling. Because the relay is 1:1, host ids can pass through unchanged. Keep
292
+ a `Map<hostId, { method, startedAt }>` anyway — you need it to time `tools/call`
293
+ and to match `notifications/cancelled`. Reserve a disjoint id space (a `bir:`
294
+ prefix) for any request the proxy ever originates, so collisions are impossible.
295
+
296
+ **Acceptance:** `bir-proxy -- npx -y chrome-devtools-mcp@latest` behaves
297
+ byte-identically to running the upstream directly, verified by a golden transcript
298
+ in `test/fixtures/`.
299
+
300
+ ### Phase 2 — Upstream transports
301
+
302
+ 1. `stdio-client.ts` — `spawn(command, args, { env, stdio: ['pipe','pipe','pipe'] })`.
303
+ Forward upstream stderr to the proxy's own stderr **prefixed**, never to stdout;
304
+ a stray stdout byte corrupts the host's JSON-RPC stream. This is the single most
305
+ common way stdio MCP servers break.
306
+ 2. `http-client.ts` — streamable-http and sse. Carry the entry's static headers for
307
+ auth, since the host's own OAuth handling does not survive a proxy in front of it
308
+ (§9). `bir install` writes them into the entry's env as `BIR_UPSTREAM_HEADERS`, a
309
+ JSON object of name to value, which the proxy reads once and removes from its
310
+ environment; `--header 'K: V'` is still accepted and wins on a clash. A remote
311
+ server Claude Code signs in itself (an `oauth` block, a `headersHelper`, `${VAR}`
312
+ headers, or no credential header) is left unwrapped unless `--server <name>`
313
+ names it.
314
+ 3. Lifecycle: an upstream that dies for good takes the proxy with it. When a stdio
315
+ upstream exits or cannot be spawned, or a legacy-SSE upstream's first connect
316
+ fails, answer the requests in flight, then exit with the upstream's exit status
317
+ (1 when it has none). The host then sees what it would see unwrapped: the server
318
+ failed, a reconnect offered, its tools gone from the model's list. `--restart`
319
+ first restarts a stdio upstream once and replays the host's handshake to it.
320
+ `BIR_PROXY_STAY_UP=1` keeps the earlier behaviour: stay up and answer every
321
+ request with an error.
322
+
323
+ **Acceptance:** the Phase 1 transcript test passes against a stdio upstream, an
324
+ http upstream, and an upstream killed mid-call.
325
+
326
+ ### Phase 3 — The intercept table
327
+
328
+ Only three methods are touched. Everything else relays untouched, both directions.
329
+
330
+ | Method | Direction | Action |
331
+ |---|---|---|
332
+ | `initialize` | host → upstream | **Relay, and rewrite as little as possible.** Forward the host's `capabilities` verbatim so the upstream learns whether sampling/elicitation exist. Return the upstream's `capabilities` and `serverInfo` verbatim, so prompts/resources/logging stay advertised. Do **not** hard-code `{ tools: {} }` — that is exactly what makes a proxy silently swallow prompts and resources. |
333
+ | `tools/list` | host → upstream | Relay. Where a control server may inject a call id (Phase 6), declare the `__bir_call_id__` key on each tool's input schema. Otherwise leave schemas untouched. |
334
+ | `tools/call` | host → upstream | Extract and strip `__bir_call_id__`; time the call; relay; report the step; return the upstream result **verbatim**. |
335
+
336
+ Everything else — `prompts/*`, `resources/*`, `completion/complete`,
337
+ `logging/setLevel`, `ping`, `notifications/cancelled`, `notifications/progress`,
338
+ and the reverse-direction `sampling/createMessage`, `elicitation/create`,
339
+ `roots/list`, `notifications/*/list_changed`, `notifications/message` — passes
340
+ through with no special case. That is the point of D7: unknown methods work
341
+ *because* they are never enumerated.
342
+
343
+ Two rules that are easy to get wrong:
344
+
345
+ - **Never rewrite a `tools/call` result.** Return the upstream object as-is,
346
+ including `_meta` and `structuredContent`. Record a *copy*.
347
+ - **Never let recording block the response.** Report the step on a fire-and-forget
348
+ queue after the result is already on its way to the host.
349
+
350
+ **Acceptance:** an upstream exposing prompts and resources is fully usable through
351
+ the proxy — `prompts/list` and `resources/read` reach it and return.
352
+
353
+ ### Phase 4 — Recorder and auth
354
+
355
+ 1. Port RRepeat's `auth/client.ts` (login, `saveCredentials`, refresh-on-401).
356
+ 2. Port `RemoteRecorder` unchanged in shape: client-generated `run_`/`step_` uuids,
357
+ a per-run ordered promise chain, best-effort sends, one token refresh on 401.
358
+ Endpoints, already live: `POST /recordings/runs`,
359
+ `POST /recordings/runs/:id/steps`, `PATCH /recordings/runs/:id/steps/:stepId`,
360
+ `POST /recordings/runs/:id/finish`.
361
+ 3. `redact.ts` — scrub before anything leaves the process. Minimum: values under
362
+ keys that name a secret, anything matching a bearer/JWT/`sk-` shape, and `env`
363
+ blocks. Applied to arguments *and* results. Keys are matched on whole name
364
+ segments (`token`, `accessToken`, `x-api-key`, `basic_auth`, `api_keys`), not
365
+ as the substring `/(token|secret|password|api[_-]?key|authorization|cookie)/i`
366
+ first planned here, which also caught `max_tokens` and `nextPageToken` and lost
367
+ real arguments (0.2.12's wider rule, with a bare `auth`, caught `author` too).
368
+ `BIR_REDACT_LEGACY_KEYS=1` applies
369
+ 0.2.12's substring rule as well.
370
+ 4. `truncate.ts` — cap each payload (suggest 64 KiB) with an explicit
371
+ `{ truncated: true, originalBytes }` marker. Base64 image blocks from browser
372
+ MCPs will otherwise dominate every run.
373
+
374
+ **Acceptance:** a Tier 2 run appears in the BaseIn service with correct steps and
375
+ no secret material, verified by a redaction fixture.
376
+
377
+ ### Phase 5 — Control plane and the hook binary (Tier 1)
378
+
379
+ **Discovery.** The control server writes
380
+ `~/.baseinstrunner/control/<sha256(cwd).slice(0,16)>.json`:
381
+
382
+ ```json
383
+ { "url": "http://127.0.0.1:53411",
384
+ "token": "<random 32 bytes, hex>",
385
+ "sessionId": "...",
386
+ "pid": 12345,
387
+ "startedAt": 1756600000000 }
388
+ ```
389
+
390
+ A proxy resolves it in this order: `BIR_CONTROL_URL` together with
391
+ `BIR_CONTROL_TOKEN` in its env (the only reliable channel for SDK sessions) → the
392
+ discovery file for its cwd, URL and token both → give up. The `BIR_CONTROL_URL`
393
+ that install writes into a generated entry is not registered against on its own:
394
+ for a user-scope server it names another project's recorder. Give-up is bounded:
395
+ **buffer steps in memory for up to 10 s while retrying**, then fall to Tier 2 and
396
+ flush the buffer into a self-owned run (or to passthrough, §0.1, which drops it).
397
+ The window exists because the host may spawn MCP servers before `SessionStart`
398
+ fires; the ordering between the two is not guaranteed. It is not a one-way door:
399
+ while unbound, the proxy looks again every 15 s and binds when a recorder answers.
400
+
401
+ **Control server HTTP surface** (loopback only, ephemeral port, bearer token from
402
+ the discovery file required on every route):
403
+
404
+ | Route | Caller | Purpose |
405
+ |---|---|---|
406
+ | `POST /session/start` | hook | Open the run; returns `runId` |
407
+ | `POST /session/prompt` | hook | `UserPromptSubmit` — the run's `input` |
408
+ | `POST /tool/pre` | hook | Built-in step opened; returns `{ stepIndex, callId? }` |
409
+ | `POST /tool/post` | hook | Built-in step closed with its result |
410
+ | `POST /session/stop` | hook | Final answer |
411
+ | `POST /session/end` | hook | `finishRun` + flush |
412
+ | `POST /proxy/register` | proxy | `{ serverName, pid, cwd }` → `{ runId, sessionId }` |
413
+ | `POST /proxy/step` | proxy | A `ProxyStepReport`; allocates `stepIndex` |
414
+ | `GET /health` | CLI | `bir doctor` |
415
+
416
+ **Ordering.** `ordering.ts` hands out `stepIndex` from one counter shared by hook
417
+ and proxy reports. Allocation happens at *report* time, not call time, so
418
+ concurrent MCP calls are ordered by completion — deterministic, and honest about
419
+ what actually happened.
420
+
421
+ **Hook wiring.** `bir-hooks` registers `UserPromptSubmit`, `PreToolUse`,
422
+ `PostToolUse`, `PostToolUseFailure`, `Stop`, `StopFailure`, `SessionStart`,
423
+ `SessionEnd`. Use `PostToolUseFailure` rather than inferring failure from
424
+ `PostToolUse` shape, and `SubagentStart` / `SubagentStop` (which carry `agent_id`)
425
+ to attribute subagent steps correctly.
426
+
427
+ **Acceptance:** a Claude Code session running one built-in and one MCP tool
428
+ produces a single run whose steps are in true execution order.
429
+
430
+ ### Phase 6 — Correlation and dedupe
431
+
432
+ In Tier 1 a wrapped MCP call is seen **twice**: once by the hook at `PreToolUse`,
433
+ once by the proxy at `tools/call`. Without correlation you double-record.
434
+
435
+ **Mechanism.** At `PreToolUse` for a wrapped `mcp__*` tool, the hook may return
436
+ `updatedInput = { ...original, [BIR_CALL_ID]: id }`. The proxy strips that key
437
+ before relaying and echoes it on its step report. Where no id rides along, the
438
+ control server joins the proxy's report to the hook's call by fingerprint
439
+ (below). Either way it merges the two views into one step:
440
+
441
+ - the hook contributes identity, `tool_use_id`, ordering position, and the model's
442
+ original arguments;
443
+ - the proxy contributes the true upstream result, `isError`, `structuredContent`,
444
+ and real execution duration.
445
+
446
+ This is RRepeat's `RREPEAT_SENTINEL` trick generalised, so it is proven — but
447
+ inherit its costs knowingly:
448
+
449
+ - **An id rides only with a permission decision.** Claude Code takes
450
+ `updatedInput` together with `permissionDecision: "allow"`, and `allow` skips
451
+ the prompt the user would have seen for that call. So the hook injects an id
452
+ only where `allow` changes nothing the user is asked (a session in
453
+ `bypassPermissions` mode) or is answered anyway (a step a replay pinned, whose
454
+ `updatedInput` must be applied; replay is its own consent). Never in `plan`
455
+ mode, and never into a server with no live proxy to strip it. Every other call
456
+ gets no decision from the hook and joins by fingerprint.
457
+ `BIR_CORRELATION_DECISION=allow` in the recorder's environment injects with
458
+ `allow` for every wrapped call, auto-approving them all (0.2.12's behaviour);
459
+ `=ask` injects with `ask`, which keeps the prompt.
460
+ - **The key has to be declared in the tool's schema** —
461
+ `__bir_call_id__: { type: "string", description: "Reserved. Leave unset." }` —
462
+ and that declaration is visible to the model on every call, not only recorded
463
+ ones. `additionalProperties` stays whatever the upstream declared.
464
+
465
+ Therefore:
466
+
467
+ - Declare the key **only** where a control server may inject one: while bound,
468
+ or while still negotiating in a directory set up for it (a discovery file, an
469
+ SDK channel or an install record), so a warm and a cold start show the model
470
+ the same schemas. Tier 2, passthrough and `bir off` never declare it.
471
+ - Declare it **only** on wrapped servers.
472
+ - Provide `--no-correlation` on the proxy (`bir install --no-correlation` writes
473
+ it), which declares nothing, and `BIR_NO_CORRELATION=1` on the recorder, which
474
+ injects nothing. Steps then join by fingerprint —
475
+ `(serverName, toolName, hash(args))`, the arguments hashed as they ran, before
476
+ redaction, within 10 minutes of the `PreToolUse` (the user may be reading a
477
+ permission prompt) — as every ordinary call does by default. Document it as
478
+ lossy under identical concurrent calls.
479
+
480
+ **Acceptance:** one MCP call in a Claude Code session produces exactly one step,
481
+ carrying both the model's arguments and the upstream's real result.
482
+
483
+ ### Phase 7 — Config generation and the CLI
484
+
485
+ 1. `config/adapters/claude-code.ts` — merge the three scopes in Claude Code's own
486
+ precedence order: **Local** (`~/.claude.json` → `projects[<cwd>].mcpServers`)
487
+ beats **Project** (`<cwd>/.mcp.json`) beats **User** (`~/.claude.json` top-level).
488
+ Match the local-scope key on a *normalised* path: Claude Code stores forward
489
+ slashes on Windows while `process.cwd()` returns backslashes. Getting this wrong
490
+ is the number one cause of "the proxy is installed but never runs".
491
+ 2. `config/generate.ts` — generate a wrapped entry for each resolved one, keeping the key:
492
+ ```json
493
+ "chrome-devtools": {
494
+ "command": "npx",
495
+ "args": ["-y", "-p", "baseinstrunner", "bir-proxy",
496
+ "--server-name", "chrome-devtools", "--",
497
+ "<original command>", "<original args...>"],
498
+ "env": { "BIR_CONTROL_URL": "http://127.0.0.1:53411" }
499
+ }
500
+ ```
501
+ Remote entries become
502
+ `bir-proxy --server-name X --url <url> --transport http`, with their headers in
503
+ `env.BIR_UPSTREAM_HEADERS`.
504
+ 3. **Write to the winning scope.** If a local-scope entry shadows the project one,
505
+ rewriting `.mcp.json` changes nothing. So the wrapped copy goes into this
506
+ project's local scope (`~/.claude.json` → `projects[<key>].mcpServers`, keyed
507
+ the way Claude Code keys it), which beats both other scopes; `.mcp.json` and the
508
+ user scope are never wrapped in place (`--mcp-json` or `BIR_WRAP_MCP_JSON=1` keeps the
509
+ old in-place shape). Claude Code never asks about a local-scope server, so a
510
+ `.mcp.json` server is copied only once Claude Code has approved it (a rejected
511
+ one never; `--server <name>` wraps one it has not asked about yet), and under a
512
+ managed policy that allows only its own MCP servers, or matches them by command
513
+ or URL, nothing is wrapped.
514
+ 4. Reversibility: stash each original entry in a sidecar
515
+ (`~/.baseinstrunner/installed.json`) so `bir uninstall` restores it: project
516
+ files byte for byte unless edited since, `~/.claude.json` entry by entry.
517
+ Credential values are not stashed; uninstall reads them back from the wrapped
518
+ entry. `bir uninstall --all` does this for every project on the machine.
519
+ 5. `bir doctor` — resolve config, list which servers are wrapped, query the control
520
+ server's `/health`, and confirm every wrapped name has a *registered* proxy.
521
+ Registration at the control server is the guarantee check; do not infer it from
522
+ the config file, and do not infer it from `serverInfo` (which must stay the
523
+ upstream's, per Phase 3).
524
+ 6. `bir wrap -- <cmd>` — manual single-server mode for clients with no adapter.
525
+ This is what makes D8 real: the generic path never needs to know where a client
526
+ stores its settings.
527
+
528
+ **Acceptance:** `bir install` on a project with three approved MCP servers wraps all
529
+ three, `bir doctor` reports three registered proxies, and `bir uninstall` puts back
530
+ every entry and file it changed (the team's `.mcp.json` was never touched).
531
+
532
+ ### Phase 8 — Hardening
533
+
534
+ - **Backpressure.** Bound the step queue (250 reports in the proxy,
535
+ `STEP_BACKLOG`). On overflow, drop oldest and set `lossy: true` on the run.
536
+ Never block a tool call on the recorder.
537
+ - **Degradation matrix** — §10, implemented as explicit branches, each with a test.
538
+ - **Startup cost.** N upstreams means N proxy processes *plus* N upstream processes.
539
+ Measure session-start latency; if it matters, add `--lazy` to defer the upstream
540
+ spawn until the first `tools/list`.
541
+ - **Windows.** `spawn` with `shell: false` and explicit `.cmd` resolution for `npx`;
542
+ path normalisation in the local-scope key; no assumptions about `$HOME`.
543
+
544
+ ---
545
+
546
+ ## 7. What a recorded step looks like
547
+
548
+ Mapped onto the existing BaseIn endpoints, one MCP call becomes two rows, exactly
549
+ as RRepeat records them today:
550
+
551
+ ```
552
+ POST /recordings/runs/:runId/steps
553
+ { id, stepIndex: 7, type: "tool_selected",
554
+ toolName: "mcp__chrome-devtools__navigate_page",
555
+ toolInput: "{\"url\":\"https://example.com\"}",
556
+ context: "<model reasoning, Tier 1 only>" }
557
+
558
+ POST /recordings/runs/:runId/steps
559
+ { id, stepIndex: 8, type: "tool_response",
560
+ toolName: "mcp__chrome-devtools__navigate_page",
561
+ toolOutput: "<serialised CallToolResult, redacted, truncated>" }
562
+ ```
563
+
564
+ Run `metadata` carries what is new, and what BaseIn-side consumers will need:
565
+
566
+ ```json
567
+ { "recorder": "baseinstrunner",
568
+ "tier": "bound",
569
+ "host": { "app": "claude-code", "version": "2.1.251" },
570
+ "wrappedServers": ["chrome-devtools", "github"],
571
+ "lossy": false }
572
+ ```
573
+
574
+ `tier` is the field that stops a Tier 2 partial trace being mistaken for a
575
+ complete one.
576
+
577
+ ---
578
+
579
+ ## 8. Full-passthrough method map
580
+
581
+ For implementers. The relay does not branch on these — the table documents what
582
+ must *survive* the relay, and is the basis of the conformance suite.
583
+
584
+ **Host → upstream:** `initialize`, `notifications/initialized`, `ping`,
585
+ `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`,
586
+ `resources/templates/list`, `resources/read`, `resources/subscribe`,
587
+ `resources/unsubscribe`, `completion/complete`, `logging/setLevel`,
588
+ `notifications/cancelled`, `notifications/progress`,
589
+ `notifications/roots/list_changed`.
590
+
591
+ **Upstream → host:** `sampling/createMessage`, `elicitation/create`, `roots/list`,
592
+ `notifications/message`, `notifications/progress`, `notifications/cancelled`,
593
+ `notifications/tools/list_changed`, `notifications/resources/list_changed`,
594
+ `notifications/resources/updated`, `notifications/prompts/list_changed`.
595
+
596
+ The reverse direction is precisely why a `tools/*`-only bridge is not enough: an
597
+ upstream that issues `elicitation/create` and receives no answer **hangs**. Claude
598
+ Code surfaces these as the `Elicitation` hook event, so a dropped one is at least
599
+ observable.
600
+
601
+ ---
602
+
603
+ ## 9. Security
604
+
605
+ - The proxy sits on the credential path for every wrapped server. For remote
606
+ upstreams it holds the headers, because the host's own OAuth flow authenticates
607
+ the server it is *configured with* — which is now the proxy. Store nothing; read
608
+ from env or the OS keychain at spawn. So a remote server Claude Code signs in
609
+ itself is left unwrapped by default (Phase 2). Static headers reach the proxy in
610
+ `BIR_UPSTREAM_HEADERS`, which it removes from its environment once read, and go
611
+ only to the configured origin: a redirect to another origin drops them all, and
612
+ a redirect from https to plain http (off loopback) is refused.
613
+ - Every argument and result crosses a process boundary and then the network.
614
+ Redaction (Phase 4.3) runs before both.
615
+ - The control server binds `127.0.0.1` only, on an ephemeral port, and requires the
616
+ token from the discovery file on every route. A local port that accepts
617
+ unauthenticated step reports is a local exfiltration channel.
618
+ - Discovery files are mode `0600`.
619
+ - `bir doctor` prints exactly which servers are wrapped, so a proxy can never sit
620
+ in the path invisibly.
621
+
622
+ ---
623
+
624
+ ## 10. Degradation matrix
625
+
626
+ The governing rule: **the host session must never fail because of BaseInstRunner.**
627
+
628
+ | Failure | Behaviour |
629
+ |---|---|
630
+ | Control server unreachable at startup | Buffer <= 10 s while retrying, then Tier 2 (passthrough for an entry that may not record here, §0.1). Keep looking every 15 s; bind when a recorder answers |
631
+ | Control server dies mid-session | Log `tier.downgrade`; what follows goes to a Tier 2 run of the proxy's own (at most one per proxy; none where the entry may not record), flagged `lossy`, until a recorder answers again. A bound proxy in an installed project whose recorder died (pid gone, port refusing) starts it again in the background (`bir-hooks revive`); a recorder stopped with `bir down` stays stopped |
632
+ | Recorder HTTP fails | Drop the send, log once per run, continue. Never retry inline: a background send is retried once, and only after a 429/503 or a connection that never reached the service. After a failure, hooks stop waiting on the service for a cool-down (5 s, doubling to 60 s) |
633
+ | Upstream fails to start, or exits | Answer the requests in flight, then exit with the upstream's status (1 if it had none). The host shows that one server failed, with a reconnect offered, as it would unwrapped. `BIR_PROXY_STAY_UP=1` keeps the proxy up instead, answering every request `MCP server "x" is not available` |
634
+ | A stdio upstream dies mid-call | Return `isError: true` with `MCP server "x" is not available` as content — a tool failure the model can react to, not a protocol error — then exit as above |
635
+ | Remote upstream unreachable or refusing | The proxy stays up and answers each request with a short JSON-RPC error that never echoes the server's body (`upstream unreachable: …`, `upstream requires sign-in (HTTP 401)`). Only connecting is bounded (10 s, `BIR_UPSTREAM_CONNECT_TIMEOUT_MS`); a slow tool is the host's to time out |
636
+ | Upstream answer too large (128 MiB by default) | End the host's call with an error instead of buffering it: `isError` `The result was too large to deliver (…)` from a stdio server (a -32000 error for other methods), `upstream answer too large` from a remote one (`BIR_UPSTREAM_MAX_MESSAGE_BYTES`). An oversized notification from a stdio server is dropped; an oversized server-to-client request is refused with -32600 |
637
+ | Unknown JSON-RPC method | Forward it. If the upstream rejects, relay the rejection |
638
+ | Step queue overflow | Drop oldest, flag the run `lossy` |
639
+ | Call-id key unwanted, or an injected id never echoed | `--no-correlation` on the proxy declares nothing; `BIR_NO_CORRELATION=1` on the recorder injects nothing. An injected id that no report echoes stops further ids to that server for the session. Either way steps join by fingerprint (Phase 6) |
640
+ | Runner switched off (`bir off`, `bir off --here`, `BIR_OFF=1`) | A stdio proxy started while off runs the real server itself, with inherited stdio and its exit code; a remote one relays without recording. Switched off mid-session, a proxy stops recording and declaring the key within about 1 s and keeps relaying. Hook routes answer `{}` |
641
+
642
+ ---
643
+
644
+ ## 11. Testing
645
+
646
+ 1. **Transcript identity (the core test).** Record a golden JSON-RPC transcript
647
+ against a real upstream directly, then through the proxy. Every host-visible
648
+ byte except timing must match. Run it for stdio, http and sse.
649
+ 2. **`fake-upstream.ts`** — a scriptable MCP server that can advertise arbitrary
650
+ capabilities, emit `notifications/tools/list_changed`, issue
651
+ `elicitation/create`, delay, and die on command. Everything below builds on it.
652
+ 3. **Passthrough conformance** — one case per row of §8, both directions.
653
+ 4. **Correlation** — a fake hook injecting `__bir_call_id__`; assert exactly one
654
+ merged step. Then the `--no-correlation` fingerprint path, including the known
655
+ ambiguity under identical concurrent calls.
656
+ 5. **Config adapter** — fixture `~/.claude.json` + `.mcp.json` pairs covering scope
657
+ precedence, the Windows path-normalisation case, remote entries, already-wrapped
658
+ entries (idempotent install), and reserved names.
659
+ 6. **Redaction** — assert no fixture secret reaches the recorder.
660
+ 7. **Integration** — a real Claude Code session against `fake-upstream`, asserting
661
+ run shape end to end. The only test that catches host-behaviour drift.
662
+
663
+ ---
664
+
665
+ ## 12. Milestones
666
+
667
+ | Milestone | Phases | Deliverable |
668
+ |---|---|---|
669
+ | **M1 — Invisible proxy** | 0–2 | `bir-proxy` wraps any upstream with zero observable difference |
670
+ | **M2 — Standalone recording** | 3–4 | Tier 2 runs land in BaseIn. Works with any MCP client, today |
671
+ | **M3 — Bound recording** | 5–6 | Tier 1: built-ins + MCP, one ordered stream, deduped |
672
+ | **M4 — Product** | 7–8 | `bir install / doctor / uninstall`, hardened, documented |
673
+ | **v2 — Replay** | R0–R7 | Run a calculated scenario on a similar-meaning match. Designed in [calculatedReplay.md](calculatedReplay.md); the runbook is [calculatedReplayGuide.md](calculatedReplayGuide.md) |
674
+
675
+ M2 is the first genuinely useful release, and it does not depend on hooks at all —
676
+ ship it before touching the control plane.
677
+
678
+ ---
679
+
680
+ ## 13. Open questions
681
+
682
+ 1. **Does the BaseIn service need `tier` / `lossy` as real columns**, or is run
683
+ `metadata` enough for the savings pipeline to exclude partial traces from
684
+ baselines? A Tier 2 run has no prompt, so similarity matching on `input` behaves
685
+ differently.
686
+ 2. **Coexistence with RRepeat.** If both are installed, both record the same session
687
+ into the same service. Should `bir-hooks` detect `rrepeat-hooks` and refuse, or
688
+ should runs carry a `recorder` discriminator and be deduped server-side? §7's
689
+ metadata assumes the latter.
690
+ 3. **`--lazy` upstream spawn** — worth it, or are N idle upstream processes
691
+ acceptable? Needs a measurement on a realistic five-server config before deciding.
692
+ 4. **Fingerprint fallback under concurrency.** Two identical parallel `tools/call`s
693
+ are genuinely indistinguishable without a call id. Accept the ambiguity, or make
694
+ correlation mandatory for Tier 1?