diffler-mcp 0.13.1 → 0.14.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 (3) hide show
  1. package/README.md +23 -4
  2. package/index.js +347 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,10 +39,21 @@ directory it searched.
39
39
 
40
40
  Resolution order (first match wins):
41
41
 
42
- 1. `--url <url>` / `DIFFLER_MCP_URL`: full endpoint, e.g. `http://127.0.0.1:8417/mcp`
43
- 2. `--port <n>` / `DIFFLER_MCP_PORT` and `--host <h>` / `DIFFLER_MCP_HOST`
44
- 3. the live port in the nearest `.diffler/mcp.json`, searching the start
45
- directory (`--repo <path>`, default: cwd) and then each parent
42
+ 1. `use_instance` called earlier in this session
43
+ 2. `--url <url>` / `DIFFLER_MCP_URL`: full endpoint, e.g. `http://127.0.0.1:8417/mcp`
44
+ 3. `--port <n>` / `DIFFLER_MCP_PORT` and `--host <h>` / `DIFFLER_MCP_HOST`
45
+ 4. the live port in the nearest `.diffler/mcp.json`, searching the start
46
+ directory (`--repo <path>`, default: cwd) and then each parent, if that
47
+ diffler is still running
48
+ 5. the per-user instance registry (`$XDG_STATE_HOME/diffler/instances`,
49
+ `~/.local/state` by default): the most recently started live diffler;
50
+ `use_instance` switches; several are listed by `list_instances`
51
+
52
+ `use_instance` connects before it answers, so a bad target never gets bound.
53
+ When diffler isn't running yet, the proxy keeps retrying quietly and announces
54
+ its tools once one appears, so a human can start Claude first and diffler
55
+ second. A call diffler doesn't answer within 110 s fails with that instance
56
+ named.
46
57
 
47
58
  Discovery covers the normal case, so nothing needs configuring:
48
59
 
@@ -60,6 +71,14 @@ Discovery covers the normal case, so nothing needs configuring:
60
71
  Reach for `--port`, `--host` or `--repo` when diffler runs somewhere the walk-up
61
72
  cannot see, such as another machine over a tunnel.
62
73
 
74
+ A human's diffler and an agent's shell are often in different repos. Two
75
+ proxy-owned tools handle that, always available alongside diffler's own:
76
+
77
+ - **list_instances**: every running diffler this proxy can reach, across all
78
+ repos, from the registry.
79
+ - **use_instance**: point this proxy at one of them, by repo path (or a
80
+ unique directory-name suffix) or port, for the rest of this session.
81
+
63
82
  ## Prefer HTTP directly?
64
83
 
65
84
  Claude Code speaks HTTP natively, so you can skip this proxy entirely:
package/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // stdio↔HTTP MCP proxy bridging Claude Code to a running diffler TUI, lazily
3
3
  // (re)connecting so it survives diffler quitting and restarting on a new port.
4
4
 
5
- import { readFileSync } from "node:fs";
5
+ import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
6
+ import { homedir } from "node:os";
6
7
  import { dirname, join, resolve } from "node:path";
7
8
 
8
9
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -16,6 +17,34 @@ import {
16
17
 
17
18
  const DEFAULT_HOST = "127.0.0.1";
18
19
  const ENDPOINT_FILE = join(".diffler", "mcp.json");
20
+ // A missing diffler is common (a human starts Claude before the TUI), so the
21
+ // background retry below stops at a ceiling.
22
+ const RETRY_MS = Number(process.env.DIFFLER_MCP_RETRY_MS) || 3000;
23
+ const RETRY_MAX_MS = Number(process.env.DIFFLER_MCP_RETRY_MAX_MS) || 10 * 60 * 1000;
24
+ // Longer than a client's own request ceiling, so a hung diffler fails the
25
+ // call itself, with the diagnosis this proxy can give.
26
+ const CALL_DEADLINE_MS = Number(process.env.DIFFLER_MCP_CALL_DEADLINE_MS) || 110_000;
27
+
28
+ const PROXY_TOOLS = [
29
+ {
30
+ name: "list_instances",
31
+ description: "List every running diffler instance this proxy can reach, across all repos.",
32
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
33
+ },
34
+ {
35
+ name: "use_instance",
36
+ description:
37
+ "Point this proxy at one diffler instance, by repo path (or a unique directory-name suffix) or port.",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ repo: { type: "string" },
42
+ port: { type: "number" },
43
+ },
44
+ additionalProperties: false,
45
+ },
46
+ },
47
+ ];
19
48
 
20
49
  function parseArgs(argv) {
21
50
  const opts = {};
@@ -41,39 +70,198 @@ function parseArgs(argv) {
41
70
  return opts;
42
71
  }
43
72
 
73
+ // A process that no longer exists (ESRCH) is dead; anything else (alive, or
74
+ // EPERM because it's owned by someone else) counts as alive. A missing pid
75
+ // means an older TUI that predates this field, which we can't disprove.
76
+ function pidAlive(pid) {
77
+ if (pid == null) {
78
+ return true;
79
+ }
80
+ try {
81
+ process.kill(pid, 0);
82
+ return true;
83
+ } catch (err) {
84
+ return err.code !== "ESRCH";
85
+ }
86
+ }
87
+
44
88
  // Each diffler publishes its live port under its own repo root, so the walk-up
45
- // finds the instance owning the directory the editor launched from. Falling
46
- // back to a fixed port would instead attach to whichever repo's diffler happens
47
- // to hold it and serve that repo's review.
48
- function discoverPort(from) {
49
- const start = resolve(from);
50
- let dir = start;
89
+ // finds the instance owning the directory the editor launched from. Returns
90
+ // null both when nothing is found and when the closest file names a dead
91
+ // process, so the caller falls through to the cross-repo registry either way.
92
+ function findLocalEndpoint(from) {
93
+ let dir = resolve(from);
51
94
  for (;;) {
52
95
  try {
53
- const { port } = JSON.parse(readFileSync(join(dir, ENDPOINT_FILE), "utf8"));
96
+ const { port, pid } = JSON.parse(readFileSync(join(dir, ENDPOINT_FILE), "utf8"));
54
97
  if (typeof port === "number") {
55
- return port;
98
+ return pidAlive(pid) ? port : null;
56
99
  }
57
100
  } catch {
58
101
  // unreadable or malformed reads like absent: keep walking up
59
102
  }
60
103
  const parent = dirname(dir);
61
104
  if (parent === dir) {
62
- throw new Error(`no diffler is running in ${start} or any parent (no ${ENDPOINT_FILE})`);
105
+ return null;
63
106
  }
64
107
  dir = parent;
65
108
  }
66
109
  }
67
110
 
111
+ // Mirrors how the diffler binary resolves XDG_STATE_HOME, so both sides agree
112
+ // on where the per-user instance registry lives.
113
+ function registryDir() {
114
+ const base = process.env.DIFFLER_STATE_DIR || process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
115
+ return join(base, "diffler", "instances");
116
+ }
117
+
118
+ // Every running diffler that isn't the one found by the walk-up: read from
119
+ // the registry, dropping (and deleting) entries whose process has exited.
120
+ function liveRegistry() {
121
+ let files;
122
+ try {
123
+ files = readdirSync(registryDir());
124
+ } catch {
125
+ return [];
126
+ }
127
+ const live = [];
128
+ for (const file of files) {
129
+ if (!file.endsWith(".json")) {
130
+ continue;
131
+ }
132
+ const path = join(registryDir(), file);
133
+ let entry;
134
+ try {
135
+ entry = JSON.parse(readFileSync(path, "utf8"));
136
+ } catch {
137
+ continue;
138
+ }
139
+ if (!pidAlive(entry.pid)) {
140
+ try {
141
+ unlinkSync(path);
142
+ } catch {
143
+ // another proxy already pruned it
144
+ }
145
+ continue;
146
+ }
147
+ entry.mtimeMs = statSync(path).mtimeMs;
148
+ live.push(entry);
149
+ }
150
+ return live;
151
+ }
152
+
153
+ function describeInstances(instances) {
154
+ return instances.map((i) => `${i.repo} (port ${i.port})`).join(", ");
155
+ }
156
+
157
+ function matchInstances(instances, { repo, port }) {
158
+ if (port != null) {
159
+ return instances.filter((i) => i.port === Number(port));
160
+ }
161
+ if (!repo) {
162
+ return [];
163
+ }
164
+ const exact = instances.filter((i) => i.repo === repo);
165
+ if (exact.length) {
166
+ return exact;
167
+ }
168
+ return instances.filter((i) => i.repo.endsWith(`/${repo}`) || i.repo.endsWith(`\\${repo}`));
169
+ }
170
+
171
+ // Best-effort label for an error message: which repo (and port) a URL names,
172
+ // falling back to the port alone when the registry doesn't know it.
173
+ function describeUrl(url) {
174
+ const match = liveRegistry().find((i) => i.url === url);
175
+ if (match) {
176
+ return `${match.repo} (port ${match.port})`;
177
+ }
178
+ try {
179
+ return `port ${new URL(url).port}`;
180
+ } catch {
181
+ return "diffler";
182
+ }
183
+ }
184
+
185
+ class CallDeadline extends Error {}
186
+
187
+ // Dropping the `upstream` reference alone leaves the transport's
188
+ // AbortController and reconnection timer alive, so every place that
189
+ // abandons a client closes it first.
190
+ function closeQuietly(client) {
191
+ if (!client) {
192
+ return;
193
+ }
194
+ client.close().catch(() => {});
195
+ }
196
+
197
+ // A call to the upstream that never answers must not hang the client
198
+ // forever; racing it against a timer is what lets the proxy give up and
199
+ // report it. `fn` is what to race (a tool call, a tools/list), so both share
200
+ // the same deadline and reconnect handling in `withUpstream` below.
201
+ function withDeadline(client, fn, meta) {
202
+ let timer;
203
+ const timeout = new Promise((_, reject) => {
204
+ timer = setTimeout(() => reject(new CallDeadline(meta)), CALL_DEADLINE_MS);
205
+ });
206
+ return Promise.race([fn(client), timeout]).finally(() => clearTimeout(timer));
207
+ }
208
+
209
+ function toolError(text) {
210
+ return { isError: true, content: [{ type: "text", text }] };
211
+ }
212
+
213
+ function deadlineError(meta) {
214
+ const seconds = Math.round(CALL_DEADLINE_MS / 1000);
215
+ return toolError(`${meta || "diffler"} did not answer within ${seconds}s. Call again to reconnect.`);
216
+ }
217
+
218
+ function unreachableError(err) {
219
+ const instances = liveRegistry();
220
+ const extra = instances.length
221
+ ? ` Other diffler instances running: ${describeInstances(instances)}.`
222
+ : "";
223
+ return toolError(`diffler isn't reachable. Is it running in this repo? (${err.message ?? err})${extra}`);
224
+ }
225
+
226
+ // Set by the use_instance tool; overrides every other resolution source for
227
+ // the rest of this proxy process.
228
+ let overrideUrl = null;
229
+
230
+ // `ambiguous` marks the one resolution path with no explicit say-so from the
231
+ // human or the caller: more than one diffler is running and the newest was
232
+ // picked for them. `forwardCall` uses it to tell the agent once per bind.
68
233
  function resolveUrl(opts) {
234
+ if (overrideUrl) {
235
+ return { url: overrideUrl, ambiguous: false };
236
+ }
69
237
  const env = process.env;
70
238
  const explicit = opts.url || env.DIFFLER_MCP_URL;
71
239
  if (explicit) {
72
- return explicit;
240
+ return { url: explicit, ambiguous: false };
73
241
  }
74
242
  const host = opts.host || env.DIFFLER_MCP_HOST || DEFAULT_HOST;
75
- const port = opts.port || env.DIFFLER_MCP_PORT || discoverPort(opts.repo || process.cwd());
76
- return `http://${host}:${port}/mcp`;
243
+ const explicitPort = opts.port || env.DIFFLER_MCP_PORT;
244
+ if (explicitPort) {
245
+ return { url: `http://${host}:${explicitPort}/mcp`, ambiguous: false };
246
+ }
247
+ const local = findLocalEndpoint(opts.repo || process.cwd());
248
+ if (local !== null) {
249
+ return { url: `http://${host}:${local}/mcp`, ambiguous: false };
250
+ }
251
+ const instances = liveRegistry();
252
+ if (instances.length === 1) {
253
+ return { url: instances[0].url, ambiguous: false };
254
+ }
255
+ if (instances.length > 1) {
256
+ const newest = instances.reduce((a, b) => (b.mtimeMs > a.mtimeMs ? b : a));
257
+ process.stderr.write(
258
+ `diffler-mcp: several diffler instances are running (${describeInstances(instances)}); binding the most recently started, ${newest.repo} (port ${newest.port}). Call use_instance to switch.\n`,
259
+ );
260
+ return { url: newest.url, ambiguous: true };
261
+ }
262
+ throw new Error(
263
+ `no diffler is running in ${resolve(opts.repo || process.cwd())} or any parent (no ${ENDPOINT_FILE})`,
264
+ );
77
265
  }
78
266
 
79
267
  async function main() {
@@ -84,13 +272,28 @@ async function main() {
84
272
  );
85
273
 
86
274
  let upstream = null;
275
+ let upstreamMeta = null;
87
276
  let connecting = null;
277
+ let retryActive = false;
278
+ // Set on an ambiguous auto-bind (several instances, newest picked with no
279
+ // say-so); `forwardCall` reports it on the first result after and clears
280
+ // it. A resolution the caller or the human chose leaves this null.
281
+ let pendingBindNotice = null;
88
282
 
89
283
  const connect = async () => {
284
+ const { url, ambiguous } = resolveUrl(opts);
90
285
  const client = new Client({ name: "diffler-mcp-proxy", version: "0.1.0" });
91
- await client.connect(new StreamableHTTPClientTransport(new URL(resolveUrl(opts))));
286
+ await client.connect(new StreamableHTTPClientTransport(new URL(url)));
287
+ // One round trip before caching: a registered pid can be alive while the
288
+ // MCP server behind it is not (mid-restart, a crashed listener), and this
289
+ // is what tells the two apart.
290
+ await client.listTools();
92
291
  // cache synchronously after the await so a later close can't race ahead of it
93
292
  upstream = client;
293
+ upstreamMeta = describeUrl(url);
294
+ pendingBindNotice = ambiguous
295
+ ? `Bound to ${upstreamMeta} because several diffler instances are running; call use_instance to switch.`
296
+ : null;
94
297
  const drop = () => {
95
298
  if (upstream === client) {
96
299
  upstream = null;
@@ -114,37 +317,149 @@ async function main() {
114
317
  return connecting;
115
318
  };
116
319
 
320
+ // Runs `fn` against the live upstream client under the deadline above; a
321
+ // deadline drops the upstream and fails outright, no second wait on the
322
+ // same call, while any other failure gets one reconnect-and-retry. Shared
323
+ // by tools/list and tools/call, so a hung diffler fails either one alike.
117
324
  const withUpstream = async (fn) => {
325
+ let client;
326
+ const attempt = async () => {
327
+ client = await ensureUpstream();
328
+ const meta = upstreamMeta;
329
+ try {
330
+ return await withDeadline(client, fn, meta);
331
+ } catch (err) {
332
+ if (err instanceof CallDeadline) {
333
+ closeQuietly(client);
334
+ upstream = null;
335
+ }
336
+ throw err;
337
+ }
338
+ };
118
339
  try {
119
- return await fn(await ensureUpstream());
120
- } catch {
340
+ return await attempt();
341
+ } catch (err) {
342
+ if (err instanceof CallDeadline) {
343
+ throw err;
344
+ }
345
+ closeQuietly(client);
121
346
  upstream = null;
122
- return await fn(await ensureUpstream());
347
+ return await attempt();
348
+ }
349
+ };
350
+
351
+ // Lets a human start Claude first and diffler second: a `tools/list` that
352
+ // finds nothing arms this, and it keeps trying quietly until diffler
353
+ // appears (or ten minutes pass), announcing the new tools once it does.
354
+ const startRetryingUpstream = () => {
355
+ if (retryActive) {
356
+ return;
357
+ }
358
+ retryActive = true;
359
+ const deadline = Date.now() + RETRY_MAX_MS;
360
+ const attempt = async () => {
361
+ try {
362
+ await ensureUpstream();
363
+ retryActive = false;
364
+ server.sendToolListChanged?.().catch(() => {});
365
+ return;
366
+ } catch {
367
+ // still unreachable
368
+ }
369
+ if (Date.now() >= deadline) {
370
+ retryActive = false;
371
+ return;
372
+ }
373
+ setTimeout(attempt, RETRY_MS);
374
+ };
375
+ setTimeout(attempt, RETRY_MS);
376
+ };
377
+
378
+ // Forwards one tool call, appending the ambiguous-bind notice to the first
379
+ // result after such a bind (once only: it clears itself here).
380
+ const forwardCall = async (params) => {
381
+ try {
382
+ const result = await withUpstream((client) => client.callTool(params));
383
+ if (!pendingBindNotice) {
384
+ return result;
385
+ }
386
+ const notice = pendingBindNotice;
387
+ pendingBindNotice = null;
388
+ return { ...result, content: [...(result.content ?? []), { type: "text", text: notice }] };
389
+ } catch (err) {
390
+ return err instanceof CallDeadline ? deadlineError(err.message) : unreachableError(err);
391
+ }
392
+ };
393
+
394
+ const currentUrl = () => {
395
+ try {
396
+ return resolveUrl(opts).url;
397
+ } catch {
398
+ return null;
123
399
  }
124
400
  };
125
401
 
402
+ const handleListInstances = () => {
403
+ const here = currentUrl();
404
+ const instances = liveRegistry().map((i) => ({
405
+ repo: i.repo,
406
+ port: i.port,
407
+ pid: i.pid,
408
+ current: i.url === here,
409
+ }));
410
+ return { content: [{ type: "text", text: JSON.stringify({ instances }) }] };
411
+ };
412
+
413
+ const handleUseInstance = async (args) => {
414
+ const instances = liveRegistry();
415
+ const matches = matchInstances(instances, args);
416
+ if (matches.length === 0) {
417
+ return toolError(
418
+ `no running instance matches ${JSON.stringify(args)}. Choices: ${describeInstances(instances) || "none running"}.`,
419
+ );
420
+ }
421
+ if (matches.length > 1) {
422
+ return toolError(
423
+ `${JSON.stringify(args)} matches more than one instance: ${describeInstances(matches)}. Be more specific.`,
424
+ );
425
+ }
426
+ const target = matches[0];
427
+ const previousOverride = overrideUrl;
428
+ overrideUrl = target.url;
429
+ closeQuietly(upstream);
430
+ upstream = null;
431
+ try {
432
+ await ensureUpstream();
433
+ } catch (err) {
434
+ overrideUrl = previousOverride;
435
+ return toolError(`could not connect to ${target.repo} (port ${target.port}): ${err.message ?? err}`);
436
+ }
437
+ server.sendToolListChanged?.().catch(() => {});
438
+ return {
439
+ content: [
440
+ { type: "text", text: JSON.stringify({ ok: true, repo: target.repo, port: target.port }) },
441
+ ],
442
+ };
443
+ };
444
+
126
445
  server.setRequestHandler(ListToolsRequestSchema, async () => {
446
+ let upstreamTools = [];
127
447
  try {
128
- return await withUpstream((client) => client.listTools());
448
+ upstreamTools = (await withUpstream((client) => client.listTools())).tools ?? [];
129
449
  } catch {
130
- return { tools: [] };
450
+ upstreamTools = [];
451
+ startRetryingUpstream();
131
452
  }
453
+ return { tools: [...PROXY_TOOLS, ...upstreamTools] };
132
454
  });
133
455
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
134
- try {
135
- return await withUpstream((client) => client.callTool(request.params));
136
- } catch (err) {
137
- upstream = null;
138
- return {
139
- isError: true,
140
- content: [
141
- {
142
- type: "text",
143
- text: `diffler isn't reachable. Is it running in this repo? (${err.message ?? err})`,
144
- },
145
- ],
146
- };
456
+ if (request.params.name === "list_instances") {
457
+ return handleListInstances();
458
+ }
459
+ if (request.params.name === "use_instance") {
460
+ return handleUseInstance(request.params.arguments ?? {});
147
461
  }
462
+ return forwardCall(request.params);
148
463
  });
149
464
 
150
465
  await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffler-mcp",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "stdio↔HTTP MCP proxy bridging Claude Code (or any stdio MCP client) to a running diffler review session",
5
5
  "type": "module",
6
6
  "bin": {