premanmcp 0.10.6 → 0.10.7
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 +11 -5
- package/bin/account.js +19 -4
- package/bin/cli.js +8 -2
- package/bin/connect/agents.js +198 -0
- package/bin/connect/checkin.js +503 -0
- package/bin/connect/configs.js +276 -0
- package/bin/connect/dispatch.js +131 -0
- package/bin/connect/errors.js +16 -0
- package/bin/connect/guide.js +523 -0
- package/bin/connect/pairing.js +94 -0
- package/bin/connect.js +101 -1554
- package/bin/desktop.js +1 -1
- package/bin/detect.js +42 -3
- package/bin/hook.js +10 -1
- package/bin/hosted.js +4 -4
- package/bin/progress.js +2 -5
- package/bin/runner.js +3 -30
- package/bin/shared.js +26 -1
- package/bin/status.js +1 -6
- package/bin/tests.js +341 -0
- package/bin/verify.js +82 -54
- package/dist/server.js +53 -0
- package/package.json +2 -2
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting the first PreMan call to happen, so nobody has to be told to restart.
|
|
3
|
+
*
|
|
4
|
+
* Three routes to the same check-in, cheapest first: run the MCP server
|
|
5
|
+
* ourselves over stdio, open the user's agent, or wait. The terminal-opening
|
|
6
|
+
* machinery is here because "we ran your agent for you" is only true if the
|
|
7
|
+
* window it lands in is the one they go on to use.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { callBackendJson, onPath } from "../shared.js";
|
|
13
|
+
|
|
14
|
+
import { findAgent } from "./agents.js";
|
|
15
|
+
import { shellQuote } from "./configs.js";
|
|
16
|
+
|
|
17
|
+
const MCP_CONNECT_SOURCES = new Set([
|
|
18
|
+
"mcp",
|
|
19
|
+
"preman_status",
|
|
20
|
+
"premanmcp",
|
|
21
|
+
"agent",
|
|
22
|
+
"mcp_call_tool",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export async function waitForConnection(
|
|
26
|
+
args,
|
|
27
|
+
apiKey,
|
|
28
|
+
{
|
|
29
|
+
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
30
|
+
timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
|
|
31
|
+
expectedAgent = "",
|
|
32
|
+
pairingId = "",
|
|
33
|
+
stopWhen = null,
|
|
34
|
+
// Called once per unsuccessful poll, so a wait measured in minutes can show
|
|
35
|
+
// that it is still a wait rather than a hang.
|
|
36
|
+
onPoll = null,
|
|
37
|
+
} = {}
|
|
38
|
+
) {
|
|
39
|
+
const deadline = Date.now() + timeoutMs;
|
|
40
|
+
let interrupted = false;
|
|
41
|
+
const onInterrupt = () => {
|
|
42
|
+
interrupted = true;
|
|
43
|
+
};
|
|
44
|
+
process.on("SIGINT", onInterrupt);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
while (Date.now() < deadline && !interrupted) {
|
|
48
|
+
const status = await callBackendJson(
|
|
49
|
+
args,
|
|
50
|
+
"GET",
|
|
51
|
+
pairingId
|
|
52
|
+
? `/workbench/coding-agent/pairings/${encodeURIComponent(pairingId)}`
|
|
53
|
+
: "/workbench/coding-agent",
|
|
54
|
+
{ token: apiKey }
|
|
55
|
+
);
|
|
56
|
+
const pairingStatus = pairingId ? String(status.status || "") : "";
|
|
57
|
+
const connected = pairingId
|
|
58
|
+
? pairingStatus === "connected"
|
|
59
|
+
: Boolean(status.connected);
|
|
60
|
+
const connectedAgent = pairingId ? status.connected_agent : status.agent;
|
|
61
|
+
const backendAgent = findAgent(connectedAgent)?.id || "";
|
|
62
|
+
const verifiedBy = String(status.verified_by || "").trim().toLowerCase();
|
|
63
|
+
if (
|
|
64
|
+
status.ok &&
|
|
65
|
+
connected &&
|
|
66
|
+
(!expectedAgent ||
|
|
67
|
+
(backendAgent === expectedAgent && MCP_CONNECT_SOURCES.has(verifiedBy)))
|
|
68
|
+
) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (pairingId && (pairingStatus === "expired" || status.status_code === 404)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (stopWhen && stopWhen()) return false;
|
|
75
|
+
if (onPoll) onPoll();
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
process.off("SIGINT", onInterrupt);
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A dot per poll, and the newline that closes the run of them. */
|
|
85
|
+
export function pollTicker() {
|
|
86
|
+
let dots = 0;
|
|
87
|
+
return {
|
|
88
|
+
tick() {
|
|
89
|
+
dots += 1;
|
|
90
|
+
process.stdout.write(".");
|
|
91
|
+
},
|
|
92
|
+
end() {
|
|
93
|
+
if (!dots) return;
|
|
94
|
+
dots = 0;
|
|
95
|
+
process.stdout.write("\n");
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Auto check-in ───────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How to ask each agent to make one PreMan call without opening its UI.
|
|
104
|
+
*
|
|
105
|
+
* The link is established by the agent's first call, so anything that reaches
|
|
106
|
+
* `preman_status` finishes the connect. Claude Code needs the server on its
|
|
107
|
+
* allow-list because print mode refuses un-allowed MCP tools rather than
|
|
108
|
+
* prompting.
|
|
109
|
+
*/
|
|
110
|
+
export function headlessCheckIn(agent, serverName) {
|
|
111
|
+
const prompt = checkInPrompt(serverName);
|
|
112
|
+
if (agent.id === "cursor") {
|
|
113
|
+
// Without --approve-mcps, cursor-agent starts with only built-in tools even
|
|
114
|
+
// when mcp.json lists PreMan — the first-time connect check-in then reports
|
|
115
|
+
// that the server is not connected.
|
|
116
|
+
return { bin: "cursor-agent", args: ["--approve-mcps", "-p", prompt] };
|
|
117
|
+
}
|
|
118
|
+
if (agent.id === "claude_code") {
|
|
119
|
+
return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
|
|
120
|
+
}
|
|
121
|
+
if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function checkInPrompt(serverName) {
|
|
126
|
+
return `Call the ${serverName} MCP tool preman_status and report the result.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How to start each agent as the session the user actually works in.
|
|
131
|
+
*
|
|
132
|
+
* Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
|
|
133
|
+
* the agent anybody goes on to use, and its failures are invisible because
|
|
134
|
+
* nobody is looking at it. The interactive form is the same prompt without that
|
|
135
|
+
* flag — it needs a terminal of its own, which is what openInNewTerminal is for.
|
|
136
|
+
*/
|
|
137
|
+
export function interactiveCheckIn(agent, serverName) {
|
|
138
|
+
const prompt = checkInPrompt(serverName);
|
|
139
|
+
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["--approve-mcps", prompt] };
|
|
140
|
+
if (agent.id === "claude_code") {
|
|
141
|
+
return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
|
|
142
|
+
}
|
|
143
|
+
if (agent.id === "codex") return { bin: "codex", args: [prompt] };
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A command line for a spec, quoted for the shell the terminal will start. */
|
|
148
|
+
export function commandLine(spec) {
|
|
149
|
+
return [spec.bin, ...spec.args].map(shellQuote).join(" ");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Escape a shell line into an AppleScript string literal. */
|
|
153
|
+
function appleScriptString(value) {
|
|
154
|
+
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The terminal emulator to open a window in, or null when we know of none.
|
|
159
|
+
*
|
|
160
|
+
* `sync` marks the launchers that hand the work to an app and return — those
|
|
161
|
+
* report their own failure through an exit code, which is worth waiting for.
|
|
162
|
+
* The rest *are* the window, so they are spawned detached and outlive us.
|
|
163
|
+
*/
|
|
164
|
+
function terminalLauncher(line) {
|
|
165
|
+
if (process.platform === "darwin") {
|
|
166
|
+
const iterm = existsSync("/Applications/iTerm.app");
|
|
167
|
+
const script = iterm
|
|
168
|
+
? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
|
|
169
|
+
: `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
|
|
170
|
+
return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (process.platform === "win32") {
|
|
174
|
+
return {
|
|
175
|
+
bin: "cmd.exe",
|
|
176
|
+
args: ["/c", "start", "cmd.exe", "/k", line],
|
|
177
|
+
label: "Command Prompt",
|
|
178
|
+
sync: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Keep the shell alive afterwards: an agent that exits immediately would
|
|
183
|
+
// otherwise take its own error message off the screen with it.
|
|
184
|
+
const body = `${line}; exec ${process.env.SHELL || "sh"}`;
|
|
185
|
+
const candidates = [
|
|
186
|
+
{ bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
|
|
187
|
+
{ bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
|
|
188
|
+
{ bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
|
|
189
|
+
{ bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
|
|
190
|
+
];
|
|
191
|
+
return candidates.find((candidate) => onPath(candidate.bin)) || null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Open `command` in a new terminal window, best effort.
|
|
196
|
+
*
|
|
197
|
+
* Same contract as openUrl: never throws, and declines rather than guessing when
|
|
198
|
+
* nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
|
|
199
|
+
* stdout for CI, piped output and test runs. Callers fall back to a headless run.
|
|
200
|
+
*/
|
|
201
|
+
export function openInNewTerminal(command, options = {}) {
|
|
202
|
+
const cwd = options?.cwd || process.cwd();
|
|
203
|
+
const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
|
|
204
|
+
if (optOut && !["0", "false", "no"].includes(optOut)) {
|
|
205
|
+
return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
|
|
206
|
+
}
|
|
207
|
+
if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
|
|
208
|
+
|
|
209
|
+
const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
|
|
210
|
+
if (!launcher) return { opened: false, reason: "no terminal emulator found" };
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
if (launcher.sync) {
|
|
214
|
+
const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
|
|
215
|
+
if (done.error) return { opened: false, reason: done.error.message };
|
|
216
|
+
if (done.status !== 0) {
|
|
217
|
+
return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
|
|
221
|
+
child.unref();
|
|
222
|
+
}
|
|
223
|
+
return { opened: true, terminal: launcher.label };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return { opened: false, reason: error.message };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Finish the link ourselves instead of asking the user to go restart their agent.
|
|
231
|
+
*
|
|
232
|
+
* The config on disk is already correct at this point; all that is missing is
|
|
233
|
+
* one call from the agent, and telling someone to make it from another terminal
|
|
234
|
+
* is a dead end in the one terminal they are sitting in.
|
|
235
|
+
*
|
|
236
|
+
* So start the agent. Interactively, in a window of its own, because that is the
|
|
237
|
+
* session the user keeps: a print-mode run answers once, exits, and leaves them
|
|
238
|
+
* exactly where they started. Only when no window can be opened — CI, SSH, a
|
|
239
|
+
* machine with no terminal emulator — does this fall back to the headless run,
|
|
240
|
+
* which still finishes the link even though nobody sees it happen.
|
|
241
|
+
*
|
|
242
|
+
* `cwd` is where the agent starts, and it is load-bearing rather than tidiness:
|
|
243
|
+
* the MCP server reads its repo config out of the working directory, so starting
|
|
244
|
+
* the agent elsewhere is how a caller escapes a directory that would otherwise
|
|
245
|
+
* redirect it to a backend this key was never issued for.
|
|
246
|
+
*
|
|
247
|
+
* Returns `ran: false` when the agent's binary is absent or will not start, and
|
|
248
|
+
* the caller falls back to the printed instructions.
|
|
249
|
+
*/
|
|
250
|
+
export async function autoCheckIn(
|
|
251
|
+
args,
|
|
252
|
+
agent,
|
|
253
|
+
apiKey,
|
|
254
|
+
{
|
|
255
|
+
// Never outlast the connect's own wait budget: this phase is part of it, not
|
|
256
|
+
// an extra one bolted on the front.
|
|
257
|
+
timeoutMs = Math.min(
|
|
258
|
+
Number(process.env.PREMAN_AUTO_CHECKIN_MS) || 120000,
|
|
259
|
+
Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000
|
|
260
|
+
),
|
|
261
|
+
serverName = "preman",
|
|
262
|
+
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
263
|
+
cwd = process.cwd(),
|
|
264
|
+
pairingId = "",
|
|
265
|
+
onLaunch = () => {},
|
|
266
|
+
onPoll = null,
|
|
267
|
+
} = {}
|
|
268
|
+
) {
|
|
269
|
+
const session = interactiveCheckIn(agent, serverName);
|
|
270
|
+
if (session && onPath(session.bin)) {
|
|
271
|
+
const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd });
|
|
272
|
+
if (opened) {
|
|
273
|
+
onLaunch({ mode: "interactive", bin: session.bin, terminal });
|
|
274
|
+
// Detached, so there is no exit to watch for and no output to quote: the
|
|
275
|
+
// agent is in front of the user now, and the deadline is all that bounds
|
|
276
|
+
// this. Whoever is watching the window can Ctrl+C out of the wait.
|
|
277
|
+
const connected = await waitForConnection(args, apiKey, {
|
|
278
|
+
intervalMs,
|
|
279
|
+
timeoutMs,
|
|
280
|
+
expectedAgent: agent.id,
|
|
281
|
+
pairingId,
|
|
282
|
+
onPoll,
|
|
283
|
+
});
|
|
284
|
+
return { ran: true, connected, command: session.bin, interactive: true, terminal };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const spec = headlessCheckIn(agent, serverName);
|
|
289
|
+
if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
|
|
290
|
+
if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
|
|
291
|
+
|
|
292
|
+
let child;
|
|
293
|
+
try {
|
|
294
|
+
// Piped rather than ignored: an agent that runs and does not check in used to
|
|
295
|
+
// report exactly that and nothing else, which is the least useful sentence
|
|
296
|
+
// available. Its own last words usually name the cause.
|
|
297
|
+
child = spawn(spec.bin, spec.args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
298
|
+
} catch (error) {
|
|
299
|
+
return { ran: false, connected: false, reason: error.message };
|
|
300
|
+
}
|
|
301
|
+
onLaunch({ mode: "headless", bin: spec.bin });
|
|
302
|
+
|
|
303
|
+
let spawnError = null;
|
|
304
|
+
let exitedAt = 0;
|
|
305
|
+
let output = "";
|
|
306
|
+
const absorb = (chunk) => {
|
|
307
|
+
output = `${output}${chunk}`.slice(-4000);
|
|
308
|
+
};
|
|
309
|
+
child.stdout?.setEncoding("utf8");
|
|
310
|
+
child.stderr?.setEncoding("utf8");
|
|
311
|
+
child.stdout?.on("data", absorb);
|
|
312
|
+
child.stderr?.on("data", absorb);
|
|
313
|
+
child.on("error", (error) => {
|
|
314
|
+
spawnError = error;
|
|
315
|
+
exitedAt = exitedAt || Date.now();
|
|
316
|
+
});
|
|
317
|
+
child.on("exit", () => {
|
|
318
|
+
exitedAt = exitedAt || Date.now();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// The check-in can land moments after the agent's own process ends, so keep
|
|
322
|
+
// polling briefly past its exit rather than declaring failure at the edge.
|
|
323
|
+
const grace = intervalMs * 2;
|
|
324
|
+
try {
|
|
325
|
+
const connected = await waitForConnection(args, apiKey, {
|
|
326
|
+
intervalMs,
|
|
327
|
+
timeoutMs,
|
|
328
|
+
expectedAgent: agent.id,
|
|
329
|
+
pairingId,
|
|
330
|
+
onPoll,
|
|
331
|
+
stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
|
|
332
|
+
});
|
|
333
|
+
if (spawnError && !connected) {
|
|
334
|
+
return { ran: false, connected: false, reason: spawnError.message, output };
|
|
335
|
+
}
|
|
336
|
+
return { ran: true, connected, command: spec.bin, output };
|
|
337
|
+
} finally {
|
|
338
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The last non-empty line of an agent's output, for a one-line diagnosis. */
|
|
343
|
+
export function lastLine(output, cap = 200) {
|
|
344
|
+
const lines = String(output || "")
|
|
345
|
+
.split("\n")
|
|
346
|
+
.map((line) => line.trim())
|
|
347
|
+
.filter(Boolean);
|
|
348
|
+
return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── MCP self-test ───────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* How long to give the self-test, and whether to run it at all.
|
|
355
|
+
*
|
|
356
|
+
* `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
|
|
357
|
+
* run wants: the launcher is `npm exec premanmcp@latest`, so every case that
|
|
358
|
+
* reaches this would otherwise go to the registry.
|
|
359
|
+
*/
|
|
360
|
+
export function selfTestBudgetMs() {
|
|
361
|
+
const raw = process.env.PREMAN_SELFTEST_MS;
|
|
362
|
+
if (raw === undefined || raw === "") return 60000;
|
|
363
|
+
const parsed = Number(raw);
|
|
364
|
+
return Number.isFinite(parsed) ? parsed : 60000;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Run the config we just wrote, exactly as the agent will, and call one tool.
|
|
369
|
+
*
|
|
370
|
+
* This is both the fastest way to finish the link and the only check that proves
|
|
371
|
+
* the whole chain — launcher, package, key, backend — rather than proving the
|
|
372
|
+
* file is on disk. It matters because the failure it catches is invisible
|
|
373
|
+
* otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
|
|
374
|
+
* redirect the server to a backend the key was never issued for, and every
|
|
375
|
+
* symptom of that points at the key.
|
|
376
|
+
*
|
|
377
|
+
* Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
|
|
378
|
+
*/
|
|
379
|
+
export async function mcpSelfTest(
|
|
380
|
+
serverConfig,
|
|
381
|
+
{ timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
|
|
382
|
+
) {
|
|
383
|
+
if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
|
|
384
|
+
let child;
|
|
385
|
+
try {
|
|
386
|
+
child = spawn(serverConfig.command, serverConfig.args, {
|
|
387
|
+
cwd,
|
|
388
|
+
env: { ...process.env, ...serverConfig.env },
|
|
389
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return { ok: false, reason: error.message };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return new Promise((resolve) => {
|
|
396
|
+
let stdout = "";
|
|
397
|
+
let stderr = "";
|
|
398
|
+
let settled = false;
|
|
399
|
+
let stdinFailureTimer = null;
|
|
400
|
+
|
|
401
|
+
const finish = (result) => {
|
|
402
|
+
if (settled) return;
|
|
403
|
+
settled = true;
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
if (stdinFailureTimer) clearTimeout(stdinFailureTimer);
|
|
406
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
407
|
+
resolve(result);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const timer = setTimeout(
|
|
411
|
+
() => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
|
|
412
|
+
timeoutMs
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const failStdin = (error) => {
|
|
416
|
+
if (settled || stdinFailureTimer) return;
|
|
417
|
+
const reason = error?.message || "the MCP server closed stdin";
|
|
418
|
+
// A launcher that exits immediately usually closes its pipe before Node
|
|
419
|
+
// emits the child exit. Give that more useful diagnosis one event-loop
|
|
420
|
+
// turn to win; a live process with closed stdin still fails promptly.
|
|
421
|
+
stdinFailureTimer = setTimeout(() => finish({ ok: false, reason }), 25);
|
|
422
|
+
};
|
|
423
|
+
// Stream write failures are emitted asynchronously; try/catch around
|
|
424
|
+
// `write()` cannot prevent an EPIPE from crashing the CLI.
|
|
425
|
+
child.stdin.on("error", failStdin);
|
|
426
|
+
|
|
427
|
+
const send = (message) => {
|
|
428
|
+
if (settled) return;
|
|
429
|
+
if (child.stdin.destroyed || child.stdin.writableEnded) {
|
|
430
|
+
failStdin(new Error("the MCP server closed stdin"));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
|
|
435
|
+
if (error) failStdin(error);
|
|
436
|
+
});
|
|
437
|
+
} catch (error) {
|
|
438
|
+
failStdin(error);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
child.on("error", (error) => finish({ ok: false, reason: error.message }));
|
|
443
|
+
child.on("exit", (code) =>
|
|
444
|
+
finish({
|
|
445
|
+
ok: false,
|
|
446
|
+
reason: `the MCP server exited with code ${code}`,
|
|
447
|
+
stderr: lastLine(stderr),
|
|
448
|
+
})
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
child.stdout.setEncoding("utf8");
|
|
452
|
+
child.stderr.setEncoding("utf8");
|
|
453
|
+
child.stderr.on("data", (chunk) => {
|
|
454
|
+
stderr = `${stderr}${chunk}`.slice(-4000);
|
|
455
|
+
});
|
|
456
|
+
child.stdout.on("data", (chunk) => {
|
|
457
|
+
stdout += chunk;
|
|
458
|
+
let newline = stdout.indexOf("\n");
|
|
459
|
+
while (newline !== -1) {
|
|
460
|
+
const line = stdout.slice(0, newline).trim();
|
|
461
|
+
stdout = stdout.slice(newline + 1);
|
|
462
|
+
newline = stdout.indexOf("\n");
|
|
463
|
+
if (!line.startsWith("{")) continue;
|
|
464
|
+
let message;
|
|
465
|
+
try {
|
|
466
|
+
message = JSON.parse(line);
|
|
467
|
+
} catch {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (message.id === 1) {
|
|
471
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
472
|
+
send({
|
|
473
|
+
jsonrpc: "2.0",
|
|
474
|
+
id: 2,
|
|
475
|
+
method: "tools/call",
|
|
476
|
+
params: { name: "preman_status", arguments: {} },
|
|
477
|
+
});
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (message.id !== 2) continue;
|
|
481
|
+
const text = message.result?.content?.[0]?.text;
|
|
482
|
+
let status = {};
|
|
483
|
+
try {
|
|
484
|
+
status = text ? JSON.parse(text) : {};
|
|
485
|
+
} catch {
|
|
486
|
+
status = { raw: text };
|
|
487
|
+
}
|
|
488
|
+
finish({ ok: !message.error, status, reason: message.error?.message || "" });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
send({
|
|
493
|
+
jsonrpc: "2.0",
|
|
494
|
+
id: 1,
|
|
495
|
+
method: "initialize",
|
|
496
|
+
params: {
|
|
497
|
+
protocolVersion: "2024-11-05",
|
|
498
|
+
capabilities: {},
|
|
499
|
+
clientInfo: { name: "preman-connect-selftest", version: "1" },
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
}
|