@reticlehq/server 2.3.0 → 2.4.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/dist/bridge.js +43 -3
- package/dist/bridge.test-harness.d.ts +5 -0
- package/dist/bridge.test-harness.js +12 -0
- package/dist/cli-parse.d.ts +14 -2
- package/dist/cli-parse.js +40 -12
- package/dist/cli-port.d.ts +3 -0
- package/dist/cli-port.js +21 -0
- package/dist/cli.js +9 -2
- package/dist/flows/decision.d.ts +0 -6
- package/dist/flows/decision.js +47 -6
- package/dist/flows/flow-tools.js +14 -6
- package/dist/init/capabilities.d.ts +17 -0
- package/dist/init/capabilities.js +51 -0
- package/dist/init/detect.d.ts +25 -0
- package/dist/init/detect.js +65 -3
- package/dist/init/next-patch.d.ts +41 -0
- package/dist/init/next-patch.js +106 -0
- package/dist/init/node-io.js +25 -0
- package/dist/init/patch-kind.d.ts +20 -0
- package/dist/init/patch-kind.js +10 -0
- package/dist/init/plan-framework.d.ts +35 -0
- package/dist/init/plan-framework.js +217 -0
- package/dist/init/plan.d.ts +51 -3
- package/dist/init/plan.js +125 -125
- package/dist/init/run.d.ts +17 -0
- package/dist/init/run.js +207 -16
- package/dist/init/slash-command.d.ts +18 -0
- package/dist/init/slash-command.js +52 -0
- package/dist/init/snippets.d.ts +44 -3
- package/dist/init/snippets.js +152 -6
- package/dist/init/vite-config.d.ts +4 -10
- package/dist/init/vite-config.js +15 -7
- package/dist/input/driven-redaction.d.ts +15 -0
- package/dist/input/driven-redaction.js +76 -0
- package/dist/input/network-detail.d.ts +9 -3
- package/dist/input/network-detail.js +35 -15
- package/dist/mcp-proxy.d.ts +35 -3
- package/dist/mcp-proxy.js +174 -27
- package/dist/session/session-manager.js +6 -0
- package/dist/session/session.d.ts +6 -0
- package/dist/session/session.js +7 -0
- package/dist/tools/read-tools.js +4 -0
- package/package.json +2 -2
package/dist/mcp-proxy.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import * as net from 'node:net';
|
|
3
|
-
import {
|
|
3
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { LOOPBACK_HOST, MCP_SSE_PATH, ReticleDir } from '@reticlehq/core';
|
|
4
7
|
import { log } from './log.js';
|
|
5
8
|
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
6
9
|
/**
|
|
@@ -16,6 +19,99 @@ const DAEMON_POLL_INTERVAL_MS = 100;
|
|
|
16
19
|
function delay(ms) {
|
|
17
20
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
21
|
}
|
|
22
|
+
/** Reconnect backoff: linear, capped, so a briefly-restarting daemon is picked up fast. */
|
|
23
|
+
const RECONNECT_BASE_MS = 250;
|
|
24
|
+
const RECONNECT_CAP_MS = 5_000;
|
|
25
|
+
/**
|
|
26
|
+
* How many consecutive failed reconnects before the proxy gives up and exits. At the capped backoff
|
|
27
|
+
* this is a few minutes — long enough to ride out a daemon restart, short enough that a genuinely
|
|
28
|
+
* dead daemon lets the agent host respawn the proxy (which spawns a fresh daemon) instead of hanging.
|
|
29
|
+
*/
|
|
30
|
+
export const MAX_RECONNECT_ATTEMPTS = 60;
|
|
31
|
+
export function reconnectDelayMs(attempt) {
|
|
32
|
+
return Math.min(RECONNECT_BASE_MS * attempt, RECONNECT_CAP_MS);
|
|
33
|
+
}
|
|
34
|
+
/** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
|
|
35
|
+
export const RECONNECT_INITIALIZE_ID = '__reticle_proxy_reinit';
|
|
36
|
+
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
37
|
+
export function proxyLogPath() {
|
|
38
|
+
return join(homedir(), ReticleDir.ROOT, 'mcp-proxy.log');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Log to stderr (which the agent host usually swallows) AND to ~/.reticle/mcp-proxy.log, which it
|
|
42
|
+
* does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
|
|
43
|
+
* so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
|
|
44
|
+
*/
|
|
45
|
+
function proxyLog(event, fields = {}) {
|
|
46
|
+
log(event, fields);
|
|
47
|
+
try {
|
|
48
|
+
const dir = join(homedir(), ReticleDir.ROOT);
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
appendFileSync(proxyLogPath(), `${JSON.stringify({ event, ...fields })}\n`, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Logging must never be the thing that kills the proxy.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function parseJsonRpc(line) {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(line);
|
|
59
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : null;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const INITIALIZE_METHOD = 'initialize';
|
|
66
|
+
const INITIALIZED_METHOD = 'notifications/initialized';
|
|
67
|
+
/**
|
|
68
|
+
* Makes a reconnect invisible to the client.
|
|
69
|
+
*
|
|
70
|
+
* The daemon builds a FRESH `McpServer` per SSE connection, so a reconnected session has never seen
|
|
71
|
+
* the client's `initialize` — every subsequent `tools/call` would hit an uninitialized server. The
|
|
72
|
+
* proxy therefore remembers the handshake the client sent once and replays it into each new session.
|
|
73
|
+
* The replay is re-issued under a reserved id so the daemon's response to it can be dropped rather
|
|
74
|
+
* than reaching the client's stdout, where a duplicate JSON-RPC id would be a protocol violation.
|
|
75
|
+
*/
|
|
76
|
+
export class HandshakeReplay {
|
|
77
|
+
#initialize = null;
|
|
78
|
+
#initialized = null;
|
|
79
|
+
#pendingReplayId = null;
|
|
80
|
+
/** Record one outbound client line. Cheap enough to call for every stdin message. */
|
|
81
|
+
observeOutbound(line) {
|
|
82
|
+
const msg = parseJsonRpc(line);
|
|
83
|
+
if (msg === null)
|
|
84
|
+
return;
|
|
85
|
+
// The first initialize is the session's identity; a client re-issuing one does not change it.
|
|
86
|
+
if (msg.method === INITIALIZE_METHOD && this.#initialize === null)
|
|
87
|
+
this.#initialize = line;
|
|
88
|
+
else if (msg.method === INITIALIZED_METHOD)
|
|
89
|
+
this.#initialized = line;
|
|
90
|
+
}
|
|
91
|
+
/** Lines to POST into a freshly-established session, before any queued client traffic. */
|
|
92
|
+
replayLines() {
|
|
93
|
+
if (this.#initialize === null)
|
|
94
|
+
return [];
|
|
95
|
+
const msg = parseJsonRpc(this.#initialize);
|
|
96
|
+
if (msg === null)
|
|
97
|
+
return [];
|
|
98
|
+
this.#pendingReplayId = RECONNECT_INITIALIZE_ID;
|
|
99
|
+
const reinit = { ...msg, id: RECONNECT_INITIALIZE_ID };
|
|
100
|
+
return this.#initialized === null
|
|
101
|
+
? [JSON.stringify(reinit)]
|
|
102
|
+
: [JSON.stringify(reinit), this.#initialized];
|
|
103
|
+
}
|
|
104
|
+
/** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
|
|
105
|
+
shouldSuppressInbound(line) {
|
|
106
|
+
if (this.#pendingReplayId === null)
|
|
107
|
+
return false;
|
|
108
|
+
const msg = parseJsonRpc(line);
|
|
109
|
+
if (msg === null || msg.id !== this.#pendingReplayId)
|
|
110
|
+
return false;
|
|
111
|
+
this.#pendingReplayId = null; // one response per replay — anything later is not ours to swallow
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
19
115
|
/**
|
|
20
116
|
* Incremental SSE frame parser for the MCP front door.
|
|
21
117
|
*
|
|
@@ -126,46 +222,92 @@ export function buildSessionUrl(rawData, port) {
|
|
|
126
222
|
}
|
|
127
223
|
/**
|
|
128
224
|
* Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
|
|
129
|
-
* Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until
|
|
130
|
-
*
|
|
131
|
-
*
|
|
225
|
+
* Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.
|
|
226
|
+
*
|
|
227
|
+
* A dropped SSE stream used to exit the process, which is what made the agent's `reticle_*` tools
|
|
228
|
+
* vanish mid-session with no message and no way for the agent to get them back — only a human running
|
|
229
|
+
* `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
|
|
230
|
+
* transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
|
|
132
231
|
*/
|
|
133
232
|
export function startMcpProxy(port) {
|
|
134
233
|
return new Promise((_resolve, reject) => {
|
|
135
234
|
let postUrl = null;
|
|
136
235
|
const stdinQueue = [];
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const sse = new SseFrameParser();
|
|
141
|
-
res.on('data', (chunk) => {
|
|
142
|
-
for (const frame of sse.push(chunk))
|
|
143
|
-
onSseEvent(frame.event, frame.data, port);
|
|
144
|
-
});
|
|
145
|
-
res.on('end', () => {
|
|
146
|
-
log('reticle_mcp_proxy_sse_ended', { port });
|
|
147
|
-
process.exit(0);
|
|
148
|
-
});
|
|
149
|
-
res.on('error', (err) => {
|
|
150
|
-
log('reticle_mcp_proxy_sse_error', { error: err.message });
|
|
151
|
-
process.exit(1);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
req.on('error', (err) => reject(err));
|
|
236
|
+
const replay = new HandshakeReplay();
|
|
237
|
+
let stopped = false;
|
|
238
|
+
let attempts = 0;
|
|
155
239
|
function onSseEvent(event, data, p) {
|
|
156
240
|
if (event === 'endpoint') {
|
|
157
241
|
const url = buildSessionUrl(data, p);
|
|
158
242
|
postUrl = url;
|
|
159
|
-
//
|
|
160
|
-
|
|
243
|
+
// The new session's McpServer has never seen the client's initialize — replay it first, then
|
|
244
|
+
// flush whatever the client sent while we were reconnecting.
|
|
245
|
+
for (const line of replay.replayLines())
|
|
246
|
+
void postToSession(url, line);
|
|
247
|
+
for (const queued of stdinQueue.splice(0))
|
|
161
248
|
void postToSession(url, queued);
|
|
162
|
-
}
|
|
163
249
|
return;
|
|
164
250
|
}
|
|
165
|
-
if (event === 'message') {
|
|
251
|
+
if (event === 'message' && !replay.shouldSuppressInbound(data)) {
|
|
166
252
|
process.stdout.write(`${data}\n`);
|
|
167
253
|
}
|
|
168
254
|
}
|
|
255
|
+
function scheduleReconnect(reason, detail) {
|
|
256
|
+
if (stopped)
|
|
257
|
+
return;
|
|
258
|
+
postUrl = null;
|
|
259
|
+
attempts++;
|
|
260
|
+
if (attempts > MAX_RECONNECT_ATTEMPTS) {
|
|
261
|
+
proxyLog('reticle_mcp_proxy_gave_up', {
|
|
262
|
+
port,
|
|
263
|
+
reason,
|
|
264
|
+
attempts,
|
|
265
|
+
...(detail !== undefined ? { detail } : {}),
|
|
266
|
+
});
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
const wait = reconnectDelayMs(attempts);
|
|
270
|
+
proxyLog('reticle_mcp_proxy_reconnecting', {
|
|
271
|
+
port,
|
|
272
|
+
reason,
|
|
273
|
+
attempt: attempts,
|
|
274
|
+
retryInMs: wait,
|
|
275
|
+
...(detail !== undefined ? { detail } : {}),
|
|
276
|
+
});
|
|
277
|
+
setTimeout(() => connect(false), wait).unref();
|
|
278
|
+
}
|
|
279
|
+
function connect(first) {
|
|
280
|
+
const req = http.get({ host: LOOPBACK_HOST, port, path: MCP_SSE_PATH }, (res) => {
|
|
281
|
+
attempts = 0; // a stream we actually established resets the budget
|
|
282
|
+
if (!first)
|
|
283
|
+
proxyLog('reticle_mcp_proxy_reconnected', { port });
|
|
284
|
+
res.setEncoding('utf8');
|
|
285
|
+
// `end` and `error` can both fire on one response; only the first should drive a reconnect.
|
|
286
|
+
let settled = false;
|
|
287
|
+
const drop = (reason, detail) => {
|
|
288
|
+
if (settled)
|
|
289
|
+
return;
|
|
290
|
+
settled = true;
|
|
291
|
+
scheduleReconnect(reason, detail);
|
|
292
|
+
};
|
|
293
|
+
const sse = new SseFrameParser();
|
|
294
|
+
res.on('data', (chunk) => {
|
|
295
|
+
for (const frame of sse.push(chunk))
|
|
296
|
+
onSseEvent(frame.event, frame.data, port);
|
|
297
|
+
});
|
|
298
|
+
res.on('end', () => drop('sse_ended'));
|
|
299
|
+
res.on('error', (err) => drop('sse_error', err.message));
|
|
300
|
+
// A socket dying under us (daemon killed, network stack reset) emits NEITHER `end` nor
|
|
301
|
+
// `error` — only `aborted`/`close`. Listening for just the first two is why the proxy sat
|
|
302
|
+
// there afterwards holding a dead stream. `settled` keeps the clean case single-fire.
|
|
303
|
+
res.on('aborted', () => drop('sse_aborted'));
|
|
304
|
+
res.on('close', () => drop('sse_closed'));
|
|
305
|
+
});
|
|
306
|
+
// The very first connect failing means there is no daemon to talk to — that is a startup
|
|
307
|
+
// failure the caller handles. Later ones are just the daemon bouncing: keep retrying.
|
|
308
|
+
req.on('error', (err) => (first ? reject(err) : scheduleReconnect('connect_error', err.message)));
|
|
309
|
+
}
|
|
310
|
+
connect(true);
|
|
169
311
|
// ── stdin reader ─────────────────────────────────────────────────────────
|
|
170
312
|
process.stdin.setEncoding('utf8');
|
|
171
313
|
let stdinBuffer = '';
|
|
@@ -177,6 +319,7 @@ export function startMcpProxy(port) {
|
|
|
177
319
|
const trimmed = line.trim();
|
|
178
320
|
if (trimmed === '')
|
|
179
321
|
continue;
|
|
322
|
+
replay.observeOutbound(trimmed);
|
|
180
323
|
if (postUrl === null) {
|
|
181
324
|
stdinQueue.push(trimmed);
|
|
182
325
|
}
|
|
@@ -185,6 +328,10 @@ export function startMcpProxy(port) {
|
|
|
185
328
|
}
|
|
186
329
|
}
|
|
187
330
|
});
|
|
188
|
-
|
|
331
|
+
// The client going away is the one clean shutdown — stop reconnecting and exit.
|
|
332
|
+
process.stdin.on('end', () => {
|
|
333
|
+
stopped = true;
|
|
334
|
+
process.exit(0);
|
|
335
|
+
});
|
|
189
336
|
});
|
|
190
337
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NO_SESSION_CONNECTED_ERROR } from '@reticlehq/core';
|
|
2
|
+
import { declareDrivenRedactionKeys, forgetDrivenRedactionKeys, } from '../input/driven-redaction.js';
|
|
2
3
|
import { Session } from './session.js';
|
|
3
4
|
/** The scheme://host:port of a URL, or undefined if it can't be parsed. Used to compare origins. */
|
|
4
5
|
export function originOf(url) {
|
|
@@ -59,12 +60,17 @@ export class SessionManager {
|
|
|
59
60
|
add(session) {
|
|
60
61
|
const previous = this.#sessions.get(session.id);
|
|
61
62
|
this.#sessions.set(session.id, session);
|
|
63
|
+
// Publish what this app declared sensitive to the driven-path rule. Here rather than in the
|
|
64
|
+
// bridge because EVERY path that registers a session goes through this method, and a declaration
|
|
65
|
+
// that silently fails to register is a leak nothing would report.
|
|
66
|
+
declareDrivenRedactionKeys(session.id, session.redactKeys);
|
|
62
67
|
return previous;
|
|
63
68
|
}
|
|
64
69
|
remove(session) {
|
|
65
70
|
if (this.#sessions.get(session.id) !== session)
|
|
66
71
|
return false;
|
|
67
72
|
session.rejectAll('session disconnected');
|
|
73
|
+
forgetDrivenRedactionKeys(session.id);
|
|
68
74
|
return this.#sessions.delete(session.id);
|
|
69
75
|
}
|
|
70
76
|
get(sessionId) {
|
|
@@ -27,6 +27,12 @@ export declare class Session {
|
|
|
27
27
|
title: string;
|
|
28
28
|
adapters: string[];
|
|
29
29
|
hasCapabilities: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Extra key names this app declared sensitive via `connect({ redact: { keys } })`. Held so the
|
|
32
|
+
* DRIVEN path can redact them too — a request body the daemon captures from the network stack
|
|
33
|
+
* never passes through the SDK, so nothing else would.
|
|
34
|
+
*/
|
|
35
|
+
readonly redactKeys: readonly string[];
|
|
30
36
|
constructor(hello: HelloMessage, socket: WebSocket, clock: Clock);
|
|
31
37
|
/** Milliseconds since this session connected — the authoritative buffer clock. */
|
|
32
38
|
elapsed(): number;
|
package/dist/session/session.js
CHANGED
|
@@ -32,6 +32,12 @@ export class Session {
|
|
|
32
32
|
title;
|
|
33
33
|
adapters;
|
|
34
34
|
hasCapabilities;
|
|
35
|
+
/**
|
|
36
|
+
* Extra key names this app declared sensitive via `connect({ redact: { keys } })`. Held so the
|
|
37
|
+
* DRIVEN path can redact them too — a request body the daemon captures from the network stack
|
|
38
|
+
* never passes through the SDK, so nothing else would.
|
|
39
|
+
*/
|
|
40
|
+
redactKeys;
|
|
35
41
|
#socket;
|
|
36
42
|
#clock;
|
|
37
43
|
#startedAt;
|
|
@@ -70,6 +76,7 @@ export class Session {
|
|
|
70
76
|
this.title = hello.title;
|
|
71
77
|
this.adapters = hello.adapters;
|
|
72
78
|
this.hasCapabilities = hello.hasCapabilities ?? false;
|
|
79
|
+
this.redactKeys = hello.redactKeys ?? [];
|
|
73
80
|
this.#socket = socket;
|
|
74
81
|
this.#clock = clock;
|
|
75
82
|
this.#startedAt = clock();
|
package/dist/tools/read-tools.js
CHANGED
|
@@ -270,6 +270,10 @@ export const READ_TOOLS = [
|
|
|
270
270
|
store: z.string().optional(),
|
|
271
271
|
path: z.string().optional(),
|
|
272
272
|
availableKeys: z.array(z.string()).optional(),
|
|
273
|
+
// Present only when `availableKeys` is a SAMPLE. Declared for the same reason as the truncation
|
|
274
|
+
// report below: a capped list of 50 keys with no count reads as a complete one, so an agent
|
|
275
|
+
// concludes the key it wants is absent when it is merely past the cap.
|
|
276
|
+
totalKeys: z.number().optional(),
|
|
273
277
|
component: z
|
|
274
278
|
.object({ ok: z.boolean(), reason: z.string().optional(), state: z.unknown().optional() })
|
|
275
279
|
.optional(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticlehq/server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Reticle bridge + MCP server. Hosts the browser WS endpoint and exposes MCP tools to the coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.13.0",
|
|
23
23
|
"ws": "^8.18.0",
|
|
24
24
|
"zod": "^3.24.1",
|
|
25
|
-
"@reticlehq/core": "2.
|
|
25
|
+
"@reticlehq/core": "2.4.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/pngjs": "^6.0.5",
|