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.
- package/README.md +14 -32
- package/index.js +31 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# diffler-mcp
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
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
|
|
8
|
-
(`http://127.0.0.1:8417/mcp` by default)
|
|
9
|
-
|
|
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
|
|
35
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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.
|
|
75
|
-
|
|
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
|
|
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
|
-
//
|
|
89
|
-
//
|
|
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
|
|
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
|
|
189
|
-
//
|
|
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`
|
|
231
|
-
//
|
|
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
|
-
//
|
|
288
|
-
//
|
|
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
|
-
//
|
|
321
|
-
//
|
|
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
|
-
//
|
|
352
|
-
//
|
|
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
|
|
467
|
-
//
|
|
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
|
});
|