@writepanda/mcp 1.180.0 → 1.191.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.
@@ -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
+ }