@basein/runner 0.1.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.
- package/LICENSE +201 -0
- package/README.md +276 -0
- package/dist/auth/client.d.ts +85 -0
- package/dist/auth/client.js +284 -0
- package/dist/bin/bir-hooks.d.ts +48 -0
- package/dist/bin/bir-hooks.js +201 -0
- package/dist/bin/bir-proxy.d.ts +45 -0
- package/dist/bin/bir-proxy.js +207 -0
- package/dist/bin/bir-scenario.d.ts +24 -0
- package/dist/bin/bir-scenario.js +177 -0
- package/dist/bin/bir.d.ts +21 -0
- package/dist/bin/bir.js +876 -0
- package/dist/config/adapters/claude-code.d.ts +76 -0
- package/dist/config/adapters/claude-code.js +181 -0
- package/dist/config/adapters/generic.d.ts +17 -0
- package/dist/config/adapters/generic.js +36 -0
- package/dist/config/generate.d.ts +127 -0
- package/dist/config/generate.js +114 -0
- package/dist/config/resolve.d.ts +68 -0
- package/dist/config/resolve.js +132 -0
- package/dist/control/client.d.ts +56 -0
- package/dist/control/client.js +86 -0
- package/dist/control/correlation.d.ts +86 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/discovery.d.ts +50 -0
- package/dist/control/discovery.js +123 -0
- package/dist/control/ordering.d.ts +38 -0
- package/dist/control/ordering.js +44 -0
- package/dist/control/paths.d.ts +32 -0
- package/dist/control/paths.js +56 -0
- package/dist/control/server.d.ts +272 -0
- package/dist/control/server.js +1131 -0
- package/dist/control/transcript.d.ts +75 -0
- package/dist/control/transcript.js +241 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +32 -0
- package/dist/jsonrpc/framing.d.ts +49 -0
- package/dist/jsonrpc/framing.js +143 -0
- package/dist/jsonrpc/types.d.ts +52 -0
- package/dist/jsonrpc/types.js +46 -0
- package/dist/proxy/intercept.d.ts +55 -0
- package/dist/proxy/intercept.js +147 -0
- package/dist/proxy/relay.d.ts +97 -0
- package/dist/proxy/relay.js +166 -0
- package/dist/proxy/session.d.ts +116 -0
- package/dist/proxy/session.js +319 -0
- package/dist/record/housekeeping.d.ts +34 -0
- package/dist/record/housekeeping.js +39 -0
- package/dist/record/queue.d.ts +48 -0
- package/dist/record/queue.js +96 -0
- package/dist/record/recorder.d.ts +111 -0
- package/dist/record/recorder.js +39 -0
- package/dist/record/redact.d.ts +37 -0
- package/dist/record/redact.js +119 -0
- package/dist/record/remote-recorder.d.ts +110 -0
- package/dist/record/remote-recorder.js +301 -0
- package/dist/record/truncate.d.ts +36 -0
- package/dist/record/truncate.js +85 -0
- package/dist/replay/bundle.d.ts +36 -0
- package/dist/replay/bundle.js +89 -0
- package/dist/replay/controller.d.ts +300 -0
- package/dist/replay/controller.js +807 -0
- package/dist/replay/coverage.d.ts +41 -0
- package/dist/replay/coverage.js +56 -0
- package/dist/replay/derive.d.ts +58 -0
- package/dist/replay/derive.js +166 -0
- package/dist/replay/executor.d.ts +78 -0
- package/dist/replay/executor.js +233 -0
- package/dist/replay/logic.d.ts +31 -0
- package/dist/replay/logic.js +50 -0
- package/dist/replay/plan.d.ts +181 -0
- package/dist/replay/plan.js +397 -0
- package/dist/replay/pricing.d.ts +41 -0
- package/dist/replay/pricing.js +76 -0
- package/dist/replay/source-run.d.ts +50 -0
- package/dist/replay/source-run.js +98 -0
- package/dist/replay/tool-error.d.ts +22 -0
- package/dist/replay/tool-error.js +60 -0
- package/dist/replay/types.d.ts +116 -0
- package/dist/replay/types.js +35 -0
- package/dist/upstream/client.d.ts +78 -0
- package/dist/upstream/client.js +114 -0
- package/dist/upstream/http-client.d.ts +78 -0
- package/dist/upstream/http-client.js +261 -0
- package/dist/upstream/lazy-client.d.ts +31 -0
- package/dist/upstream/lazy-client.js +53 -0
- package/dist/upstream/stdio-client.d.ts +57 -0
- package/dist/upstream/stdio-client.js +203 -0
- package/dist/util/log.d.ts +27 -0
- package/dist/util/log.js +51 -0
- package/dist/util/version.d.ts +2 -0
- package/dist/util/version.js +40 -0
- package/docs/BaseInstRunner.md +621 -0
- package/docs/calculatedReplay.md +1185 -0
- package/docs/calculatedReplayGuide.md +448 -0
- package/docs/installRun.md +413 -0
- package/docs/mcpmark.md +752 -0
- package/docs/quickstart.md +201 -0
- package/docs/t-bench.md +394 -0
- package/package.json +56 -0
|
@@ -0,0 +1,621 @@
|
|
|
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?
|