diffler-mcp 0.17.0 → 0.19.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 +14 -32
  2. package/index.js +31 -40
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,13 +1,11 @@
1
1
  # diffler-mcp
2
2
 
3
- A tiny stdio↔HTTP bridge that lets Claude Code (or any stdio MCP client) talk to
4
- the MCP server embedded in a running [diffler](https://github.com/matheusfillipe/diffler)
5
- review session.
3
+ A stdio↔HTTP bridge from Claude Code (or any stdio MCP client) to the MCP
4
+ server of a running [diffler](https://github.com/matheusfillipe/diffler).
6
5
 
7
- diffler's MCP server runs **inside the TUI** as a streamable-HTTP endpoint
8
- (`http://127.0.0.1:8417/mcp` by default) because it serves the live review state
9
- on the app's main loop. This proxy is spawned by Claude over stdio and forwards
10
- every tool call to that endpoint: it owns no state itself.
6
+ diffler serves MCP from inside the TUI as a streamable-HTTP endpoint
7
+ (`http://127.0.0.1:8417/mcp` by default). The proxy forwards every tool call
8
+ to that endpoint and keeps no state of its own.
11
9
 
12
10
  ## Use it with Claude Code
13
11
 
@@ -31,8 +29,8 @@ Or in a checked-in `.mcp.json`:
31
29
  }
32
30
  ```
33
31
 
34
- Start Claude anywhere inside the repo and the proxy auto-discovers the port
35
- from `.diffler/mcp.json`. No diffler running ⇒ every tool call reports which
32
+ Start Claude anywhere inside the repo and the proxy finds the port in
33
+ `.diffler/mcp.json`. With no diffler running, every tool call reports which
36
34
  directory it searched.
37
35
 
38
36
  ## Configuration
@@ -50,29 +48,15 @@ Resolution order (first match wins):
50
48
  `use_instance` switches; several are listed by `list_instances`
51
49
 
52
50
  `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.
51
+ When diffler isn't running yet, the proxy keeps retrying and announces its
52
+ tools once one appears, so you can start Claude before diffler. A call diffler
53
+ doesn't answer within 110 s fails with that instance named.
57
54
 
58
- Discovery covers the normal case, so nothing needs configuring:
59
-
60
- ```json
61
- {
62
- "mcpServers": {
63
- "diffler": {
64
- "command": "npx",
65
- "args": ["-y", "diffler-mcp"]
66
- }
67
- }
68
- }
69
- ```
70
-
71
- Reach for `--port`, `--host` or `--repo` when diffler runs somewhere the walk-up
55
+ Use `--port`, `--host` or `--repo` when diffler runs somewhere the walk-up
72
56
  cannot see, such as another machine over a tunnel.
73
57
 
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:
58
+ A human's diffler and an agent's shell are often in different repos. The proxy
59
+ adds two tools for that, alongside diffler's own:
76
60
 
77
61
  - **list_instances**: every running diffler this proxy can reach, across all
78
62
  repos, from the registry.
@@ -81,10 +65,8 @@ proxy-owned tools handle that, always available alongside diffler's own:
81
65
 
82
66
  ## Prefer HTTP directly?
83
67
 
84
- Claude Code speaks HTTP natively, so you can skip this proxy entirely:
68
+ Claude Code speaks HTTP, so you can skip the proxy and use a fixed port:
85
69
 
86
70
  ```bash
87
71
  claude mcp add --transport http diffler http://127.0.0.1:8417/mcp
88
72
  ```
89
-
90
- The proxy exists for the `npx`, zero-config, auto-port-discovery ergonomics.
package/index.js CHANGED
@@ -85,10 +85,8 @@ function pidAlive(pid) {
85
85
  }
86
86
  }
87
87
 
88
- // Each diffler publishes its live port under its own repo root, so the walk-up
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.
88
+ // We return null for a dead process too, so the caller falls through to the
89
+ // cross-repo registry.
92
90
  function findLocalEndpoint(from) {
93
91
  let dir = resolve(from);
94
92
  for (;;) {
@@ -98,7 +96,7 @@ function findLocalEndpoint(from) {
98
96
  return pidAlive(pid) ? port : null;
99
97
  }
100
98
  } catch {
101
- // unreadable or malformed reads like absent: keep walking up
99
+ // We treat an unreadable or malformed file as absent and keep walking up.
102
100
  }
103
101
  const parent = dirname(dir);
104
102
  if (parent === dir) {
@@ -115,8 +113,6 @@ function registryDir() {
115
113
  return join(base, "diffler", "instances");
116
114
  }
117
115
 
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
116
  function liveRegistry() {
121
117
  let files;
122
118
  try {
@@ -154,6 +150,19 @@ function describeInstances(instances) {
154
150
  return instances.map((i) => `${i.repo} (port ${i.port})`).join(", ");
155
151
  }
156
152
 
153
+ // One diffler with several project tabs registers each project under the
154
+ // same url: one entry per url is one instance.
155
+ function distinctInstances(instances) {
156
+ const byUrl = new Map();
157
+ for (const instance of instances) {
158
+ const seen = byUrl.get(instance.url);
159
+ if (!seen || instance.mtimeMs > seen.mtimeMs) {
160
+ byUrl.set(instance.url, instance);
161
+ }
162
+ }
163
+ return [...byUrl.values()];
164
+ }
165
+
157
166
  function matchInstances(instances, { repo, port }) {
158
167
  if (port != null) {
159
168
  return instances.filter((i) => i.port === Number(port));
@@ -168,8 +177,6 @@ function matchInstances(instances, { repo, port }) {
168
177
  return instances.filter((i) => i.repo.endsWith(`/${repo}`) || i.repo.endsWith(`\\${repo}`));
169
178
  }
170
179
 
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
180
  function describeUrl(url) {
174
181
  const match = liveRegistry().find((i) => i.url === url);
175
182
  if (match) {
@@ -185,8 +192,8 @@ function describeUrl(url) {
185
192
  class CallDeadline extends Error {}
186
193
 
187
194
  // 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.
195
+ // AbortController and reconnection timer alive, so we close every client we
196
+ // abandon.
190
197
  function closeQuietly(client) {
191
198
  if (!client) {
192
199
  return;
@@ -194,10 +201,6 @@ function closeQuietly(client) {
194
201
  client.close().catch(() => {});
195
202
  }
196
203
 
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
204
  function withDeadline(client, fn, meta) {
202
205
  let timer;
203
206
  const timeout = new Promise((_, reject) => {
@@ -223,13 +226,10 @@ function unreachableError(err) {
223
226
  return toolError(`diffler isn't reachable. Is it running in this repo? (${err.message ?? err})${extra}`);
224
227
  }
225
228
 
226
- // Set by the use_instance tool; overrides every other resolution source for
227
- // the rest of this proxy process.
228
229
  let overrideUrl = null;
229
230
 
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.
231
+ // `ambiguous` means we picked the newest of several instances with nobody
232
+ // choosing, so `forwardCall` tells the agent once per bind.
233
233
  function resolveUrl(opts) {
234
234
  if (overrideUrl) {
235
235
  return { url: overrideUrl, ambiguous: false };
@@ -248,7 +248,7 @@ function resolveUrl(opts) {
248
248
  if (local !== null) {
249
249
  return { url: `http://${host}:${local}/mcp`, ambiguous: false };
250
250
  }
251
- const instances = liveRegistry();
251
+ const instances = distinctInstances(liveRegistry());
252
252
  if (instances.length === 1) {
253
253
  return { url: instances[0].url, ambiguous: false };
254
254
  }
@@ -275,20 +275,16 @@ async function main() {
275
275
  let upstreamMeta = null;
276
276
  let connecting = null;
277
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
278
  let pendingBindNotice = null;
282
279
 
283
280
  const connect = async () => {
284
281
  const { url, ambiguous } = resolveUrl(opts);
285
282
  const client = new Client({ name: "diffler-mcp-proxy", version: "0.1.0" });
286
283
  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.
284
+ // A registered pid can be alive while its MCP server is not (mid-restart,
285
+ // a crashed listener), so we make one round trip before caching.
290
286
  await client.listTools();
291
- // cache synchronously after the await so a later close can't race ahead of it
287
+ // We cache synchronously after the await so a later close can't race ahead of it.
292
288
  upstream = client;
293
289
  upstreamMeta = describeUrl(url);
294
290
  pendingBindNotice = ambiguous
@@ -317,10 +313,8 @@ async function main() {
317
313
  return connecting;
318
314
  };
319
315
 
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.
316
+ // A missed deadline drops the upstream and fails at once, so a hung diffler
317
+ // costs one wait; any other failure gets one reconnect and retry.
324
318
  const withUpstream = async (fn) => {
325
319
  let client;
326
320
  const attempt = async () => {
@@ -348,9 +342,8 @@ async function main() {
348
342
  }
349
343
  };
350
344
 
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.
345
+ // A human may start Claude before diffler, so we keep retrying after an
346
+ // empty `tools/list` and announce the tools once diffler appears.
354
347
  const startRetryingUpstream = () => {
355
348
  if (retryActive) {
356
349
  return;
@@ -375,8 +368,6 @@ async function main() {
375
368
  setTimeout(attempt, RETRY_MS);
376
369
  };
377
370
 
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
371
  const forwardCall = async (params) => {
381
372
  try {
382
373
  const result = await withUpstream((client) => client.callTool(params));
@@ -418,7 +409,7 @@ async function main() {
418
409
  `no running instance matches ${JSON.stringify(args)}. Choices: ${describeInstances(instances) || "none running"}.`,
419
410
  );
420
411
  }
421
- if (matches.length > 1) {
412
+ if (distinctInstances(matches).length > 1) {
422
413
  return toolError(
423
414
  `${JSON.stringify(args)} matches more than one instance: ${describeInstances(matches)}. Be more specific.`,
424
415
  );
@@ -463,8 +454,8 @@ async function main() {
463
454
  });
464
455
 
465
456
  await server.connect(new StdioServerTransport());
466
- // stdout is the MCP channel, so the startup diagnosis goes to stderr, where
467
- // clients surface it; the proxy stays up and reconnects when diffler starts
457
+ // stdout is the MCP channel, so we write the startup diagnosis to stderr and
458
+ // stay up to reconnect when diffler starts.
468
459
  void ensureUpstream().catch((err) => {
469
460
  process.stderr.write(`diffler-mcp: ${err.message ?? err}\n`);
470
461
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffler-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.19.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": {