@writepanda/mcp 1.122.0 → 1.183.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/bin/appConnection.mjs +201 -0
- package/bin/server.mjs +2511 -1477
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// How the MCP server finds and reaches the running PandaStudio app.
|
|
2
|
+
//
|
|
3
|
+
// Two kinds of caller spawn this server:
|
|
4
|
+
//
|
|
5
|
+
// • External agents (Claude Desktop, Claude Code, Codex, Cursor…). The app
|
|
6
|
+
// may not be running, so a call reads the token/port files the app writes
|
|
7
|
+
// and, when nothing answers, launches the app and waits for it.
|
|
8
|
+
//
|
|
9
|
+
// • The app's own chat panel (PANDASTUDIO_CALLER=in-app). The app is running
|
|
10
|
+
// by definition: it spawned us, and it hands us its live port and token in
|
|
11
|
+
// the environment. Launching "the app" from here is always wrong, and
|
|
12
|
+
// waiting 60-90 s for it hid every real failure behind the client's
|
|
13
|
+
// generic "Request timed out". So in-app calls never launch anything and
|
|
14
|
+
// fail within seconds with the actual reason, prefixed with
|
|
15
|
+
// IN_APP_UNAVAILABLE so the app can show it (and a Retry) in the chat.
|
|
16
|
+
//
|
|
17
|
+
// Kept free of MCP-sdk imports so the pure parts are unit-testable.
|
|
18
|
+
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import http from "node:http";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
/** Prefix of every in-app "can't reach PandaStudio" tool error. The app's
|
|
25
|
+
* agent runner matches it (electron/agent/toolsUnavailable.ts). */
|
|
26
|
+
export const IN_APP_UNAVAILABLE = "PANDASTUDIO_TOOLS_UNAVAILABLE";
|
|
27
|
+
|
|
28
|
+
export function isInAppCaller(env = process.env) {
|
|
29
|
+
return env.PANDASTUDIO_CALLER === "in-app";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configDir(env = process.env, platform = process.platform) {
|
|
33
|
+
if (env.PANDASTUDIO_CONFIG_DIR) return env.PANDASTUDIO_CONFIG_DIR;
|
|
34
|
+
if (platform === "win32") {
|
|
35
|
+
const appData = env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
36
|
+
return path.join(appData, "pandastudio");
|
|
37
|
+
}
|
|
38
|
+
return path.join(os.homedir(), ".config", "pandastudio");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A text file's value without whitespace or a UTF-8 byte-order mark (an
|
|
42
|
+
* editor or a sync tool on Windows can add one, and Number() of
|
|
43
|
+
* "<BOM>7878" is NaN). */
|
|
44
|
+
export function cleanFileValue(raw) {
|
|
45
|
+
const s = String(raw);
|
|
46
|
+
return (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s).trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Credentials the app passed in the environment (in-app spawns only). */
|
|
50
|
+
export function envCredentials(env = process.env) {
|
|
51
|
+
const port = Number(env.PANDASTUDIO_AUTOMATION_PORT);
|
|
52
|
+
const token = env.PANDASTUDIO_AUTOMATION_TOKEN;
|
|
53
|
+
if (!Number.isInteger(port) || port <= 0 || typeof token !== "string" || !token) return null;
|
|
54
|
+
return { port, token, source: "env" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Credentials from the token/port files. Errors name the file and the cause. */
|
|
58
|
+
export async function fileCredentials(dir = configDir()) {
|
|
59
|
+
const portFile = path.join(dir, "port");
|
|
60
|
+
const tokenFile = path.join(dir, "token");
|
|
61
|
+
let portRaw;
|
|
62
|
+
let tokenRaw;
|
|
63
|
+
try {
|
|
64
|
+
portRaw = await readFile(portFile, "utf-8");
|
|
65
|
+
tokenRaw = await readFile(tokenFile, "utf-8");
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const file = portRaw === undefined ? portFile : tokenFile;
|
|
68
|
+
throw new Error(`can't read ${file} (${err?.code ?? err?.message ?? err})`);
|
|
69
|
+
}
|
|
70
|
+
const port = Number(cleanFileValue(portRaw));
|
|
71
|
+
const token = cleanFileValue(tokenRaw);
|
|
72
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
73
|
+
throw new Error(`invalid port in ${portFile}: ${JSON.stringify(portRaw.slice(0, 20))}`);
|
|
74
|
+
}
|
|
75
|
+
if (!token) throw new Error(`empty token in ${tokenFile}`);
|
|
76
|
+
return { port, token, source: "files" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One health probe of the automation server:
|
|
81
|
+
* up — it answered 200
|
|
82
|
+
* busy — connected (or still connecting) but no answer within `timeoutMs`:
|
|
83
|
+
* the app's main process is stalled
|
|
84
|
+
* down — refused / reset / any other socket error: nothing is listening
|
|
85
|
+
* `detail` says what happened, for error messages.
|
|
86
|
+
*/
|
|
87
|
+
export function probeHealth(port, timeoutMs = 1500) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
let settled = false;
|
|
90
|
+
const done = (state, detail) => {
|
|
91
|
+
if (settled) return;
|
|
92
|
+
settled = true;
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
req.destroy();
|
|
95
|
+
resolve({ state, detail });
|
|
96
|
+
};
|
|
97
|
+
const req = http.get({ host: "127.0.0.1", port, path: "/v1/health", agent: false }, (res) => {
|
|
98
|
+
res.resume();
|
|
99
|
+
if (res.statusCode === 200) done("up", "HTTP 200");
|
|
100
|
+
else done("down", `HTTP ${res.statusCode} from /v1/health`);
|
|
101
|
+
});
|
|
102
|
+
req.on("error", (err) => done("down", err?.code ?? err?.message ?? String(err)));
|
|
103
|
+
const timer = setTimeout(() => done("busy", `no answer within ${timeoutMs} ms`), timeoutMs);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Wait for the automation server, tolerating a stalled main process for up to
|
|
109
|
+
* `busyWaitMs`. Returns the last probe ({state:"up"} on success).
|
|
110
|
+
*/
|
|
111
|
+
export async function waitForHealth(
|
|
112
|
+
port,
|
|
113
|
+
{ probeMs = 1500, busyWaitMs = 30_000, onBusy, probe = probeHealth } = {},
|
|
114
|
+
) {
|
|
115
|
+
const deadline = Date.now() + busyWaitMs;
|
|
116
|
+
let warned = false;
|
|
117
|
+
for (;;) {
|
|
118
|
+
const r = await probe(port, probeMs);
|
|
119
|
+
if (r.state === "up" || r.state === "down" || Date.now() >= deadline) return r;
|
|
120
|
+
if (!warned && onBusy) onBusy();
|
|
121
|
+
warned = true;
|
|
122
|
+
probeMs = Math.min(probeMs * 2, 5000);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function unavailable(reason) {
|
|
127
|
+
const err = new Error(`${IN_APP_UNAVAILABLE}: ${reason}`);
|
|
128
|
+
err.inAppUnavailable = true;
|
|
129
|
+
return err;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve working credentials for an in-app call, or throw an
|
|
134
|
+
* IN_APP_UNAVAILABLE error with the reason. Never launches anything.
|
|
135
|
+
*
|
|
136
|
+
* Order: the credentials the app handed us (env), then the files (the app
|
|
137
|
+
* restarted its automation server on another port since it spawned us). The
|
|
138
|
+
* whole thing stays well inside the MCP client's 60 s request timeout.
|
|
139
|
+
*/
|
|
140
|
+
export async function connectInApp({
|
|
141
|
+
env = process.env,
|
|
142
|
+
dir = configDir(env),
|
|
143
|
+
probeMs = 1500,
|
|
144
|
+
busyWaitMs = 20_000,
|
|
145
|
+
probe = probeHealth,
|
|
146
|
+
readFiles = fileCredentials,
|
|
147
|
+
} = {}) {
|
|
148
|
+
const attempts = [];
|
|
149
|
+
const candidates = [];
|
|
150
|
+
const fromEnv = envCredentials(env);
|
|
151
|
+
if (fromEnv) candidates.push(fromEnv);
|
|
152
|
+
let fileError = null;
|
|
153
|
+
try {
|
|
154
|
+
const fromFiles = await readFiles(dir);
|
|
155
|
+
if (!fromEnv || fromFiles.port !== fromEnv.port || fromFiles.token !== fromEnv.token) {
|
|
156
|
+
candidates.push(fromFiles);
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
fileError = err?.message ?? String(err);
|
|
160
|
+
}
|
|
161
|
+
if (candidates.length === 0) {
|
|
162
|
+
throw unavailable(
|
|
163
|
+
`PandaStudio didn't pass its automation address to the tools and ${fileError ?? "the token/port files are missing"}.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
for (const c of candidates) {
|
|
167
|
+
const r = await waitForHealth(c.port, { probeMs, busyWaitMs, probe });
|
|
168
|
+
if (r.state === "up") return c;
|
|
169
|
+
attempts.push(
|
|
170
|
+
r.state === "busy"
|
|
171
|
+
? `127.0.0.1:${c.port} accepted the connection but didn't answer for ${Math.round(busyWaitMs / 1000)} s (the app is busy or stuck)`
|
|
172
|
+
: `127.0.0.1:${c.port} (from ${c.source === "env" ? "the app" : dir}) is unreachable: ${r.detail}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
throw unavailable(`PandaStudio's automation server didn't answer. ${attempts.join("; ")}.`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Environment for LAUNCHING the desktop app. This server runs as Electron in
|
|
180
|
+
* node mode (ELECTRON_RUN_AS_NODE=1), and a child inherits that: spawning
|
|
181
|
+
* PandaStudio.exe / PandaStudio.app with it set starts a bare Node REPL that
|
|
182
|
+
* exits at once instead of the app, so an agent's "launch it and wait" always
|
|
183
|
+
* timed out. Drop it, and everything that only means something to this process.
|
|
184
|
+
*/
|
|
185
|
+
export function appLaunchEnv(env = process.env) {
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const [k, v] of Object.entries(env)) {
|
|
188
|
+
const upper = k.toUpperCase();
|
|
189
|
+
if (
|
|
190
|
+
upper === "ELECTRON_RUN_AS_NODE" ||
|
|
191
|
+
upper === "ELECTRON_NO_ATTACH_CONSOLE" ||
|
|
192
|
+
upper === "PANDASTUDIO_CALLER" ||
|
|
193
|
+
upper === "PANDASTUDIO_AUTOMATION_PORT" ||
|
|
194
|
+
upper === "PANDASTUDIO_AUTOMATION_TOKEN"
|
|
195
|
+
) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
out[k] = v;
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|