@polypad/mcp 0.1.0 → 0.1.2
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 +10 -0
- package/dist/index.js +478 -88
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,16 @@ claude mcp add polypad -- npx -y @polypad/mcp
|
|
|
38
38
|
|
|
39
39
|
Safari cannot connect to a local server, so use Chrome, Edge or Firefox for the Polypad tab. The claude.ai website cannot be used either: it needs a hosted server.
|
|
40
40
|
|
|
41
|
+
## Several AI clients at once
|
|
42
|
+
|
|
43
|
+
You can add Polypad to Claude Code and Claude Desktop (or Cursor) and use them at the same time. The first server to start owns the connection and the others share it, so every client drives the same Polypad tab and there is one pairing code. If the first one is closed, another takes over with a new code: enter it in the Connect AI dialog. Use version 0.1.1 or newer everywhere.
|
|
44
|
+
|
|
45
|
+
## Troubleshooting
|
|
46
|
+
|
|
47
|
+
- **"Server not running"** in the dialog: your AI client has not started the server yet. Ask it something about Polypad, and check `npx -y @polypad/mcp --help` works (Node 22 or newer).
|
|
48
|
+
- **"Wrong code"**: enter the code from the latest start. It changes whenever the connection's owner restarts.
|
|
49
|
+
- **"Port 41720 is already in use"** (the tools say so): a program that is not a Polypad MCP server (or an old 0.1.0 one) holds the port. Close it, or use `--port 41721` here and in the dialog's Advanced section. The server checks again every 2 seconds.
|
|
50
|
+
|
|
41
51
|
## Options
|
|
42
52
|
|
|
43
53
|
```
|
package/dist/index.js
CHANGED
|
@@ -9446,7 +9446,7 @@ var require_websocket = __commonJS({
|
|
|
9446
9446
|
var http = __require("http");
|
|
9447
9447
|
var net = __require("net");
|
|
9448
9448
|
var tls = __require("tls");
|
|
9449
|
-
var { randomBytes, createHash } = __require("crypto");
|
|
9449
|
+
var { randomBytes: randomBytes2, createHash } = __require("crypto");
|
|
9450
9450
|
var { Duplex, Readable } = __require("stream");
|
|
9451
9451
|
var { URL: URL2 } = __require("url");
|
|
9452
9452
|
var PerMessageDeflate2 = require_permessage_deflate();
|
|
@@ -9997,7 +9997,7 @@ var require_websocket = __commonJS({
|
|
|
9997
9997
|
}
|
|
9998
9998
|
}
|
|
9999
9999
|
const defaultPort = isSecure ? 443 : 80;
|
|
10000
|
-
const key =
|
|
10000
|
+
const key = randomBytes2(16).toString("base64");
|
|
10001
10001
|
const request = isSecure ? https.request : http.request;
|
|
10002
10002
|
const protocolSet = /* @__PURE__ */ new Set();
|
|
10003
10003
|
let perMessageDeflate;
|
|
@@ -32205,6 +32205,8 @@ var CLOSE_BAD_MESSAGE = 4004;
|
|
|
32205
32205
|
var CLOSE_BAD_VERSION = 4005;
|
|
32206
32206
|
var CLOSE_HELLO_TIMEOUT = 4006;
|
|
32207
32207
|
var CLOSE_SHUTDOWN = 4007;
|
|
32208
|
+
var CLOSE_BAD_TOKEN = 4008;
|
|
32209
|
+
var BRIDGE_RETRY_MS = 2e3;
|
|
32208
32210
|
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
32209
32211
|
function parse3(text) {
|
|
32210
32212
|
try {
|
|
@@ -32234,6 +32236,37 @@ function parseTabMessage(text) {
|
|
|
32234
32236
|
return null;
|
|
32235
32237
|
}
|
|
32236
32238
|
}
|
|
32239
|
+
function parseClientMessage(text) {
|
|
32240
|
+
const m = parse3(text);
|
|
32241
|
+
if (!m) return null;
|
|
32242
|
+
switch (m.type) {
|
|
32243
|
+
case "relay-hello":
|
|
32244
|
+
if (typeof m.token !== "string" || typeof m.v !== "number") return null;
|
|
32245
|
+
return { type: "relay-hello", v: m.v, token: m.token };
|
|
32246
|
+
case "relay-call":
|
|
32247
|
+
if (typeof m.id !== "string" || typeof m.name !== "string") return null;
|
|
32248
|
+
return { type: "relay-call", id: m.id, name: m.name, input: m.input };
|
|
32249
|
+
default:
|
|
32250
|
+
return parseTabMessage(text);
|
|
32251
|
+
}
|
|
32252
|
+
}
|
|
32253
|
+
function parsePrimaryMessage(text) {
|
|
32254
|
+
const m = parse3(text);
|
|
32255
|
+
if (!m) return null;
|
|
32256
|
+
switch (m.type) {
|
|
32257
|
+
case "relay-welcome":
|
|
32258
|
+
if (typeof m.v !== "number" || typeof m.code !== "string") return null;
|
|
32259
|
+
return { type: "relay-welcome", v: m.v, code: m.code, server: typeof m.server === "string" ? m.server : void 0 };
|
|
32260
|
+
case "relay-result":
|
|
32261
|
+
if (typeof m.id !== "string" || !isResult(m.result)) return null;
|
|
32262
|
+
return { type: "relay-result", id: m.id, result: m.result };
|
|
32263
|
+
case "ping":
|
|
32264
|
+
case "pong":
|
|
32265
|
+
return { type: m.type };
|
|
32266
|
+
default:
|
|
32267
|
+
return null;
|
|
32268
|
+
}
|
|
32269
|
+
}
|
|
32237
32270
|
var encode3 = (message) => JSON.stringify(message);
|
|
32238
32271
|
var PAIRING_ALPHABET = "ABCDEFGHJKMNPQRTUVWXYZ346789";
|
|
32239
32272
|
var CODE_SHAPE = /^[A-Z0-9]{3}-?[A-Z0-9]{3}$/;
|
|
@@ -32243,6 +32276,7 @@ function normalizePairingCode(input2) {
|
|
|
32243
32276
|
const code = `${flat.slice(0, 3)}-${flat.slice(3)}`;
|
|
32244
32277
|
return CODE_SHAPE.test(code) ? code : null;
|
|
32245
32278
|
}
|
|
32279
|
+
var bridgeUrl = (port = BRIDGE_DEFAULT_PORT) => `ws://127.0.0.1:${port}`;
|
|
32246
32280
|
|
|
32247
32281
|
// src/cli.ts
|
|
32248
32282
|
var USAGE = `polypad-mcp: MCP server that lets an AI assistant build in your open Polypad tab.
|
|
@@ -32259,6 +32293,10 @@ Options:
|
|
|
32259
32293
|
The MCP client starts this program and talks to it over stdio. It prints a pairing code to stderr
|
|
32260
32294
|
(new on every start). In Polypad, click Connect AI and enter the code.
|
|
32261
32295
|
|
|
32296
|
+
Several clients (Claude Code and Claude Desktop, say) can each start one: the first owns the port and
|
|
32297
|
+
the others share its Polypad tab and its code. Environment: POLYPAD_MCP_HOME moves the folder that
|
|
32298
|
+
holds .polypad/mcp-<port>.json (default: your home folder).
|
|
32299
|
+
|
|
32262
32300
|
Add it to Claude Code: claude mcp add polypad -- npx -y @polypad/mcp
|
|
32263
32301
|
`;
|
|
32264
32302
|
function parseCli(argv) {
|
|
@@ -34771,25 +34809,25 @@ var inputs = {
|
|
|
34771
34809
|
};
|
|
34772
34810
|
var descriptions = {
|
|
34773
34811
|
new_project: "Start a new, empty Polypad project (optionally from a template), replacing the open one. All lengths in Polypad are millimetres and angles are degrees; Z is up.",
|
|
34774
|
-
set_parameter: "Create or change a named parameter (a number or an expression). Any length field of any feature can use the name, so one change resizes everything bound to it. Reports features that no longer build.",
|
|
34812
|
+
set_parameter: "Create or change a named parameter (a number or an expression). Any length field of any feature can use the name, so one change resizes everything bound to it. Reports features that no longer build. Set the sizes up front, then use the names in fields.",
|
|
34775
34813
|
add_primitive: "Add a solid box, cylinder, sphere or cone as a new body. Returns the new feature's id, which is also the body id for later commands. Sizes in mm.",
|
|
34776
34814
|
add_sketch: "Add a 2D sketch on a base plane or on a flat face and draw shapes in it (rectangle, circle, line, polyline, arc, slot, polygon). The sketch is solved and its remaining degrees of freedom reported. Use extrude or revolve to turn closed shapes into a solid.",
|
|
34777
34815
|
extrude: "Extrude the closed shapes of a sketch into a solid: as a new body, or cut from / joined to an existing body. Returns the feature id (the new body's id when mode is new).",
|
|
34778
|
-
revolve: "Revolve the closed shapes of a sketch about an axis in the sketch plane to make a solid of revolution: new body, or cut / join with an existing body.",
|
|
34779
|
-
combine: "Boolean-combine bodies: union, subtract (remove the tools from the target) or intersect. The target keeps its id; the tools are consumed.",
|
|
34780
|
-
hole: "Drill a screw hole (M2 to M10, with clearance, tap or heat-set insert fit and optional counterbore / countersink) into a flat face. Call list_faces first and pass the face index and its rebuildId.",
|
|
34781
|
-
fillet: "Round the given edges of a body with a radius. Call list_edges first and pass the edge indices and their rebuildId.",
|
|
34782
|
-
chamfer: "Bevel the given edges of a body. Call list_edges first and pass the edge indices and their rebuildId.",
|
|
34783
|
-
shell: "Hollow out a body, leaving the given faces open, with walls of a given thickness. Call list_faces first and pass the face indices and their rebuildId.",
|
|
34784
|
-
pattern: "Repeat a body in a row or grid, or around an axis, either fusing the copies into the body or making each copy a body of its own.",
|
|
34785
|
-
mirror: "Mirror a whole body across a plane (yz, xz or xy, optionally moved along its normal), joining the copy into the body or making it a separate body.",
|
|
34816
|
+
revolve: "Revolve the closed shapes of a sketch about an axis in the sketch plane to make a solid of revolution: new body, or cut / join with an existing body. Use it for round parts: draw half the outline beside the axis.",
|
|
34817
|
+
combine: "Boolean-combine bodies: union, subtract (remove the tools from the target) or intersect. The target keeps its id; the tools are consumed. To hollow a body use shell, not a subtracted box; to check a collision, intersect two bodies, measure the result, then undo.",
|
|
34818
|
+
hole: "Drill a screw hole (M2 to M10, with clearance, tap or heat-set insert fit and optional counterbore / countersink) into a flat face. Call list_faces first and pass the face index and its rebuildId. Use this for every screw, standoff or insert hole instead of cutting circles.",
|
|
34819
|
+
fillet: "Round the given edges of a body with a radius. Call list_edges first (narrow it with its filter) and pass the edge indices and their rebuildId. If it fails, try a smaller radius or fewer edges.",
|
|
34820
|
+
chamfer: "Bevel the given edges of a body. Call list_edges first (narrow it with its filter) and pass the edge indices and their rebuildId. If it fails, try a smaller distance or fewer edges.",
|
|
34821
|
+
shell: "Hollow out a body, leaving the given faces open, with walls of a given thickness. Call list_faces first and pass the face indices and their rebuildId. Use this for enclosures and cups instead of subtracting a smaller box; list_faces with filter normal finds the face to open.",
|
|
34822
|
+
pattern: "Repeat a body in a row or grid, or around an axis, either fusing the copies into the body or making each copy a body of its own. Model one copy (or one cutter) and pattern it instead of adding copies by hand.",
|
|
34823
|
+
mirror: "Mirror a whole body across a plane (yz, xz or xy, optionally moved along its normal), joining the copy into the body or making it a separate body. Model one side of a symmetric part, then mirror it.",
|
|
34786
34824
|
move: "Move, rotate and/or scale a whole body (translate in mm, rotate in degrees about X, Y, Z around a pivot).",
|
|
34787
|
-
update_feature: "Change fields of an existing feature (for example an extrude's distance or a box's width); everything after it rebuilds. The change is refused, and the project left as it was, if the result is not a valid model.",
|
|
34825
|
+
update_feature: "Change fields of an existing feature (for example an extrude's distance or a box's width); everything after it rebuilds. The change is refused, and the project left as it was, if the result is not a valid model. Prefer this over deleting and rebuilding a feature.",
|
|
34788
34826
|
delete_feature: "Delete a feature. Refused while other features still use it; delete those first.",
|
|
34789
34827
|
undo: "Undo the last change made to the project. Can be repeated.",
|
|
34790
34828
|
get_project: "Describe the whole project: parameters, every feature in order (id, name, type, main values, whether it built or the error), and every body (volume, area, bounding box). Start here, and call it again after changes to check the result.",
|
|
34791
|
-
list_faces: "List the faces of a body: index, flat or curved, normal, centre, area, and for flat faces the sketch frame (origin, x and y axes) with the face's bounds in it. The indices go into hole, shell and add_sketch (face). Returns a rebuildId those commands require.",
|
|
34792
|
-
list_edges: "List the edges of a body: index, convex / concave / smooth, length, straight or curved, direction, midpoint, and the faces they border. The indices go into fillet and chamfer. Returns a rebuildId those commands require.",
|
|
34829
|
+
list_faces: "List the faces of a body: index, flat or curved, normal, centre, area, and for flat faces the sketch frame (origin, x and y axes) with the face's bounds in it. The indices go into hole, shell and add_sketch (face). Returns a rebuildId those commands require. A body can have hundreds of faces: use the filter (normal, kind).",
|
|
34830
|
+
list_edges: "List the edges of a body: index, convex / concave / smooth, length, straight or curved, direction, midpoint, and the faces they border. The indices go into fillet and chamfer. Returns a rebuildId those commands require. A body can have hundreds of edges: use the filter (face, direction, kind).",
|
|
34793
34831
|
measure: "Measure bodies: volume (mm3), surface area (mm2), bounding box (mm) and its centre.",
|
|
34794
34832
|
render_view: "Render the model to a PNG image from a standard view, to look at the result. Needs the Polypad browser tab."
|
|
34795
34833
|
};
|
|
@@ -34802,7 +34840,7 @@ function commandSchemas() {
|
|
|
34802
34840
|
}
|
|
34803
34841
|
|
|
34804
34842
|
// src/bridge.ts
|
|
34805
|
-
import { randomInt, randomUUID
|
|
34843
|
+
import { randomBytes, randomInt, randomUUID as randomUUID2 } from "crypto";
|
|
34806
34844
|
import { createServer } from "http";
|
|
34807
34845
|
|
|
34808
34846
|
// ../../node_modules/.pnpm/ws@8.22.0/node_modules/ws/wrapper.mjs
|
|
@@ -34814,6 +34852,176 @@ var import_sender = __toESM(require_sender(), 1);
|
|
|
34814
34852
|
var import_subprotocol = __toESM(require_subprotocol(), 1);
|
|
34815
34853
|
var import_websocket = __toESM(require_websocket(), 1);
|
|
34816
34854
|
var import_websocket_server = __toESM(require_websocket_server(), 1);
|
|
34855
|
+
var wrapper_default = import_websocket.default;
|
|
34856
|
+
|
|
34857
|
+
// src/primary-file.ts
|
|
34858
|
+
import { timingSafeEqual } from "crypto";
|
|
34859
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
34860
|
+
import { homedir } from "os";
|
|
34861
|
+
import { join } from "path";
|
|
34862
|
+
function resolveHome(override) {
|
|
34863
|
+
return override ?? (process.env.POLYPAD_MCP_HOME || homedir());
|
|
34864
|
+
}
|
|
34865
|
+
var primaryFilePath = (home, port) => join(home, ".polypad", `mcp-${port}.json`);
|
|
34866
|
+
function writePrimaryFile(home, port, info) {
|
|
34867
|
+
const file2 = primaryFilePath(home, port);
|
|
34868
|
+
mkdirSync(join(home, ".polypad"), { recursive: true, mode: 448 });
|
|
34869
|
+
writeFileSync(file2, JSON.stringify(info), { mode: 384 });
|
|
34870
|
+
try {
|
|
34871
|
+
chmodSync(file2, 384);
|
|
34872
|
+
} catch {
|
|
34873
|
+
}
|
|
34874
|
+
}
|
|
34875
|
+
function readPrimaryFile(home, port) {
|
|
34876
|
+
try {
|
|
34877
|
+
const value = JSON.parse(readFileSync(primaryFilePath(home, port), "utf8"));
|
|
34878
|
+
if (typeof value !== "object" || value === null) return null;
|
|
34879
|
+
const { pid, token, code } = value;
|
|
34880
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || typeof token !== "string" || token.length < 16 || typeof code !== "string") return null;
|
|
34881
|
+
return { pid, token, code };
|
|
34882
|
+
} catch {
|
|
34883
|
+
return null;
|
|
34884
|
+
}
|
|
34885
|
+
}
|
|
34886
|
+
function removePrimaryFile(home, port, token) {
|
|
34887
|
+
try {
|
|
34888
|
+
const current = readPrimaryFile(home, port);
|
|
34889
|
+
if (current && !tokensMatch(current.token, token)) return;
|
|
34890
|
+
rmSync(primaryFilePath(home, port), { force: true });
|
|
34891
|
+
} catch {
|
|
34892
|
+
}
|
|
34893
|
+
}
|
|
34894
|
+
function pidAlive(pid) {
|
|
34895
|
+
try {
|
|
34896
|
+
process.kill(pid, 0);
|
|
34897
|
+
return true;
|
|
34898
|
+
} catch (error62) {
|
|
34899
|
+
return error62.code === "EPERM";
|
|
34900
|
+
}
|
|
34901
|
+
}
|
|
34902
|
+
function tokensMatch(a, b) {
|
|
34903
|
+
const x = Buffer.from(a);
|
|
34904
|
+
const y = Buffer.from(b);
|
|
34905
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
34906
|
+
}
|
|
34907
|
+
|
|
34908
|
+
// src/relay-link.ts
|
|
34909
|
+
import { randomUUID } from "crypto";
|
|
34910
|
+
var fail = (text) => ({ ok: false, text });
|
|
34911
|
+
var RelayLink = class {
|
|
34912
|
+
constructor(options) {
|
|
34913
|
+
this.options = options;
|
|
34914
|
+
}
|
|
34915
|
+
options;
|
|
34916
|
+
// The primary's pairing code, from relay-welcome.
|
|
34917
|
+
code = "";
|
|
34918
|
+
ws = null;
|
|
34919
|
+
open = false;
|
|
34920
|
+
pending = /* @__PURE__ */ new Map();
|
|
34921
|
+
closeHandler = null;
|
|
34922
|
+
get connected() {
|
|
34923
|
+
return this.open;
|
|
34924
|
+
}
|
|
34925
|
+
// Resolves once the primary has welcomed this relay. Rejects (and leaves nothing open) for anything else.
|
|
34926
|
+
connect() {
|
|
34927
|
+
return new Promise((resolve, reject) => {
|
|
34928
|
+
const ws = new wrapper_default(bridgeUrl(this.options.port), { maxPayload: BRIDGE_MAX_MESSAGE_BYTES, perMessageDeflate: false, handshakeTimeout: this.options.handshakeTimeoutMs ?? 3e3 });
|
|
34929
|
+
this.ws = ws;
|
|
34930
|
+
let settled = false;
|
|
34931
|
+
const giveUp = (reason) => {
|
|
34932
|
+
if (settled) return;
|
|
34933
|
+
settled = true;
|
|
34934
|
+
clearTimeout(helloTimer);
|
|
34935
|
+
ws.terminate();
|
|
34936
|
+
this.ws = null;
|
|
34937
|
+
reject(new Error(reason));
|
|
34938
|
+
};
|
|
34939
|
+
const helloTimer = setTimeout(() => giveUp("No answer to relay-hello"), this.options.handshakeTimeoutMs ?? 3e3);
|
|
34940
|
+
ws.on("error", (error62) => giveUp(error62.message));
|
|
34941
|
+
ws.on("open", () => ws.send(encode3({ type: "relay-hello", v: BRIDGE_PROTOCOL_VERSION, token: this.options.token })));
|
|
34942
|
+
ws.on("message", (data, isBinary) => {
|
|
34943
|
+
if (isBinary) return void ws.close(1003, "Text frames only");
|
|
34944
|
+
const message = parsePrimaryMessage(rawToString(data));
|
|
34945
|
+
if (!message) return void (settled ? ws.close(1002, "Not a Polypad bridge message") : giveUp("Not a Polypad MCP server"));
|
|
34946
|
+
switch (message.type) {
|
|
34947
|
+
case "relay-welcome":
|
|
34948
|
+
if (settled) return;
|
|
34949
|
+
settled = true;
|
|
34950
|
+
clearTimeout(helloTimer);
|
|
34951
|
+
this.code = message.code;
|
|
34952
|
+
this.open = true;
|
|
34953
|
+
resolve();
|
|
34954
|
+
return;
|
|
34955
|
+
case "relay-result": {
|
|
34956
|
+
const entry = this.pending.get(message.id);
|
|
34957
|
+
if (!entry) return;
|
|
34958
|
+
clearTimeout(entry.timer);
|
|
34959
|
+
this.pending.delete(message.id);
|
|
34960
|
+
entry.resolve(message.result);
|
|
34961
|
+
return;
|
|
34962
|
+
}
|
|
34963
|
+
case "ping":
|
|
34964
|
+
if (ws.readyState === ws.OPEN) ws.send(encode3({ type: "pong" }));
|
|
34965
|
+
return;
|
|
34966
|
+
case "pong":
|
|
34967
|
+
return;
|
|
34968
|
+
}
|
|
34969
|
+
});
|
|
34970
|
+
ws.on("close", (_code, reason) => {
|
|
34971
|
+
if (!settled) return giveUp(reason.toString() || "Closed before relay-welcome");
|
|
34972
|
+
const wasOpen = this.open;
|
|
34973
|
+
this.open = false;
|
|
34974
|
+
this.ws = null;
|
|
34975
|
+
this.failAll("The Polypad MCP server that this one shares stopped before Polypad answered. The change may or may not have been applied: call get_project to check (this server reconnects by itself).");
|
|
34976
|
+
if (wasOpen) this.closeHandler?.();
|
|
34977
|
+
});
|
|
34978
|
+
});
|
|
34979
|
+
}
|
|
34980
|
+
// Called once when an established link ends (the primary exited), not when close() is used.
|
|
34981
|
+
onClose(handler) {
|
|
34982
|
+
this.closeHandler = handler;
|
|
34983
|
+
}
|
|
34984
|
+
// Never rejects: every failure is an ok:false result with text for the model.
|
|
34985
|
+
call(name, input2) {
|
|
34986
|
+
const ws = this.ws;
|
|
34987
|
+
if (!ws || !this.open) return Promise.resolve(fail("The link to the shared Polypad MCP server is down."));
|
|
34988
|
+
const id = randomUUID();
|
|
34989
|
+
return new Promise((resolve) => {
|
|
34990
|
+
const timer = setTimeout(() => {
|
|
34991
|
+
this.pending.delete(id);
|
|
34992
|
+
resolve(fail(`Polypad did not answer ${name} within ${Math.round(this.options.callTimeoutMs / 1e3)} s. The change may still have been applied: call get_project to check before retrying.`));
|
|
34993
|
+
}, this.options.callTimeoutMs);
|
|
34994
|
+
this.pending.set(id, { resolve, timer, name });
|
|
34995
|
+
try {
|
|
34996
|
+
ws.send(encode3({ type: "relay-call", id, name, input: input2 }));
|
|
34997
|
+
} catch (error62) {
|
|
34998
|
+
clearTimeout(timer);
|
|
34999
|
+
this.pending.delete(id);
|
|
35000
|
+
resolve(fail(`Could not send ${name} to the shared Polypad MCP server: ${error62 instanceof Error ? error62.message : String(error62)}`));
|
|
35001
|
+
}
|
|
35002
|
+
});
|
|
35003
|
+
}
|
|
35004
|
+
close() {
|
|
35005
|
+
this.closeHandler = null;
|
|
35006
|
+
this.open = false;
|
|
35007
|
+
this.failAll("The Polypad MCP server stopped before Polypad answered.");
|
|
35008
|
+
this.ws?.terminate();
|
|
35009
|
+
this.ws = null;
|
|
35010
|
+
}
|
|
35011
|
+
failAll(text) {
|
|
35012
|
+
for (const [id, entry] of [...this.pending]) {
|
|
35013
|
+
clearTimeout(entry.timer);
|
|
35014
|
+
this.pending.delete(id);
|
|
35015
|
+
entry.resolve(fail(text));
|
|
35016
|
+
}
|
|
35017
|
+
}
|
|
35018
|
+
};
|
|
35019
|
+
function rawToString(data) {
|
|
35020
|
+
if (typeof data === "string") return data;
|
|
35021
|
+
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
|
35022
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
|
35023
|
+
return data.toString("utf8");
|
|
35024
|
+
}
|
|
34817
35025
|
|
|
34818
35026
|
// src/bridge.ts
|
|
34819
35027
|
function newPairingCode() {
|
|
@@ -34824,30 +35032,39 @@ function newPairingCode() {
|
|
|
34824
35032
|
function normalizeOrigin(origin) {
|
|
34825
35033
|
return origin.trim().replace(/\/+$/, "").toLowerCase();
|
|
34826
35034
|
}
|
|
34827
|
-
|
|
34828
|
-
const x = Buffer.from(a);
|
|
34829
|
-
const y = Buffer.from(b);
|
|
34830
|
-
return x.length === y.length && timingSafeEqual(x, y);
|
|
34831
|
-
}
|
|
34832
|
-
var fail = (text) => ({ ok: false, text });
|
|
35035
|
+
var fail2 = (text) => ({ ok: false, text });
|
|
34833
35036
|
var Bridge = class {
|
|
34834
|
-
code;
|
|
34835
35037
|
allowedOrigins;
|
|
34836
35038
|
options;
|
|
35039
|
+
home;
|
|
34837
35040
|
requestedPort;
|
|
35041
|
+
ownCode;
|
|
35042
|
+
firstAttach = true;
|
|
35043
|
+
closed = false;
|
|
35044
|
+
attaching = false;
|
|
35045
|
+
retryTimer = null;
|
|
34838
35046
|
http = null;
|
|
34839
35047
|
wss = null;
|
|
34840
35048
|
tab = null;
|
|
34841
35049
|
tabName;
|
|
34842
35050
|
pending = /* @__PURE__ */ new Map();
|
|
34843
35051
|
badCodeTimes = [];
|
|
35052
|
+
badTokenTimes = [];
|
|
34844
35053
|
pingTimer = null;
|
|
34845
35054
|
alive = /* @__PURE__ */ new WeakSet();
|
|
35055
|
+
// Primary: the secret the relays have to present, and the relays that did.
|
|
35056
|
+
relayToken = null;
|
|
35057
|
+
relays = /* @__PURE__ */ new Set();
|
|
35058
|
+
// Relay: the link to the primary and its pairing code.
|
|
35059
|
+
link = null;
|
|
35060
|
+
linkCode = "";
|
|
34846
35061
|
// Set when the socket could not be opened (port in use): every call then answers with this.
|
|
34847
35062
|
unavailable = null;
|
|
34848
35063
|
port = 0;
|
|
35064
|
+
role = "starting";
|
|
34849
35065
|
constructor(options = {}) {
|
|
34850
|
-
this.
|
|
35066
|
+
this.ownCode = options.code ?? newPairingCode();
|
|
35067
|
+
this.home = resolveHome(options.homeDir);
|
|
34851
35068
|
this.requestedPort = options.port ?? BRIDGE_DEFAULT_PORT;
|
|
34852
35069
|
this.allowedOrigins = new Set([...BRIDGE_DEFAULT_ORIGINS, ...options.origins ?? []].map(normalizeOrigin));
|
|
34853
35070
|
this.options = {
|
|
@@ -34856,65 +35073,45 @@ var Bridge = class {
|
|
|
34856
35073
|
badCodeWindowMs: options.badCodeWindowMs ?? 6e4,
|
|
34857
35074
|
helloTimeoutMs: options.helloTimeoutMs ?? 1e4,
|
|
34858
35075
|
pingIntervalMs: options.pingIntervalMs ?? 2e4,
|
|
35076
|
+
retryMs: options.retryMs ?? BRIDGE_RETRY_MS,
|
|
34859
35077
|
log: options.log ?? (() => {
|
|
34860
35078
|
})
|
|
34861
35079
|
};
|
|
34862
35080
|
}
|
|
35081
|
+
// The pairing code the user enters in the tab: this server's own as primary, the primary's as a relay.
|
|
35082
|
+
get code() {
|
|
35083
|
+
return this.role === "relay" ? this.linkCode : this.ownCode;
|
|
35084
|
+
}
|
|
35085
|
+
// A primary: whether the tab is paired. A relay: whether the link to the primary is up (the primary answers
|
|
35086
|
+
// "not connected" itself when it has no tab).
|
|
34863
35087
|
get connected() {
|
|
34864
|
-
return this.
|
|
35088
|
+
if (this.role === "relay") return this.link?.connected ?? false;
|
|
35089
|
+
return this.role === "primary" && this.tab !== null;
|
|
34865
35090
|
}
|
|
34866
35091
|
get clientName() {
|
|
34867
35092
|
return this.tabName;
|
|
34868
35093
|
}
|
|
34869
|
-
// Starts
|
|
34870
|
-
|
|
34871
|
-
|
|
34872
|
-
|
|
34873
|
-
|
|
34874
|
-
|
|
34875
|
-
});
|
|
34876
|
-
const wss = new import_websocket_server.default({
|
|
34877
|
-
noServer: true,
|
|
34878
|
-
maxPayload: BRIDGE_MAX_MESSAGE_BYTES,
|
|
34879
|
-
perMessageDeflate: false
|
|
34880
|
-
});
|
|
34881
|
-
http.on("upgrade", (req, socket, head) => {
|
|
34882
|
-
const origin = req.headers.origin;
|
|
34883
|
-
if (typeof origin !== "string" || !this.allowedOrigins.has(normalizeOrigin(origin))) {
|
|
34884
|
-
this.options.log(`Rejected a connection from origin ${typeof origin === "string" ? origin : "(none)"}. Add it with --origin if it is yours.`);
|
|
34885
|
-
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
34886
|
-
return;
|
|
34887
|
-
}
|
|
34888
|
-
if (wss.clients.size >= 16) {
|
|
34889
|
-
socket.end("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
34890
|
-
return;
|
|
34891
|
-
}
|
|
34892
|
-
wss.handleUpgrade(req, socket, head, (ws) => this.accept(ws));
|
|
34893
|
-
});
|
|
34894
|
-
http.once("error", (error62) => {
|
|
34895
|
-
wss.close();
|
|
34896
|
-
reject(error62);
|
|
34897
|
-
});
|
|
34898
|
-
http.listen(this.requestedPort, "127.0.0.1", () => {
|
|
34899
|
-
this.http = http;
|
|
34900
|
-
this.wss = wss;
|
|
34901
|
-
this.port = http.address().port;
|
|
34902
|
-
http.on("error", (error62) => this.options.log(`Bridge error: ${error62.message}`));
|
|
34903
|
-
this.startPings();
|
|
34904
|
-
resolve(this.port);
|
|
34905
|
-
});
|
|
34906
|
-
});
|
|
35094
|
+
// Starts serving on 127.0.0.1 as the primary, or, when the port is taken by a Polypad MCP server, relays through it.
|
|
35095
|
+
// Resolves with the port. When the port holder is something else this still resolves (role "busy": the tools explain
|
|
35096
|
+
// it and the server keeps trying); other errors, for example EACCES, reject.
|
|
35097
|
+
async listen() {
|
|
35098
|
+
await this.attach(true);
|
|
35099
|
+
return this.port;
|
|
34907
35100
|
}
|
|
34908
35101
|
// Runs a command on the paired tab. Never rejects: every failure is an ok:false result with text for the model.
|
|
34909
35102
|
call(name, input2) {
|
|
35103
|
+
if (this.role === "relay") {
|
|
35104
|
+
const link = this.link;
|
|
35105
|
+
return link ? link.call(name, input2) : Promise.resolve(fail2(this.notConnectedText()));
|
|
35106
|
+
}
|
|
34910
35107
|
const tab = this.tab;
|
|
34911
|
-
if (!tab) return Promise.resolve(
|
|
34912
|
-
const id =
|
|
35108
|
+
if (!tab) return Promise.resolve(fail2(this.notConnectedText()));
|
|
35109
|
+
const id = randomUUID2();
|
|
34913
35110
|
return new Promise((resolve) => {
|
|
34914
35111
|
const timer = setTimeout(() => {
|
|
34915
35112
|
this.pending.delete(id);
|
|
34916
35113
|
resolve(
|
|
34917
|
-
|
|
35114
|
+
fail2(
|
|
34918
35115
|
`Polypad did not answer ${name} within ${Math.round(this.options.callTimeoutMs / 1e3)} s. The change may still have been applied: call get_project to check before retrying.`
|
|
34919
35116
|
)
|
|
34920
35117
|
);
|
|
@@ -34923,7 +35120,7 @@ var Bridge = class {
|
|
|
34923
35120
|
try {
|
|
34924
35121
|
tab.send(encode3({ type: "call", id, name, input: input2 }));
|
|
34925
35122
|
} catch (error62) {
|
|
34926
|
-
this.settle(id,
|
|
35123
|
+
this.settle(id, fail2(`Could not send ${name} to Polypad: ${error62 instanceof Error ? error62.message : String(error62)}`));
|
|
34927
35124
|
}
|
|
34928
35125
|
});
|
|
34929
35126
|
}
|
|
@@ -34933,11 +35130,18 @@ var Bridge = class {
|
|
|
34933
35130
|
return `Polypad is not connected. Open Polypad in Chrome, Edge or Firefox, click Connect AI and enter code ${this.code}.${port}`;
|
|
34934
35131
|
}
|
|
34935
35132
|
async close() {
|
|
35133
|
+
this.closed = true;
|
|
35134
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
35135
|
+
this.retryTimer = null;
|
|
35136
|
+
this.releaseSync();
|
|
35137
|
+
this.link?.close();
|
|
35138
|
+
this.link = null;
|
|
34936
35139
|
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
34937
35140
|
this.pingTimer = null;
|
|
34938
35141
|
for (const client of this.wss?.clients ?? []) client.close(CLOSE_SHUTDOWN, "Server stopped");
|
|
34939
35142
|
this.failPending(null, "The Polypad MCP server stopped before Polypad answered.");
|
|
34940
35143
|
this.tab = null;
|
|
35144
|
+
this.relays.clear();
|
|
34941
35145
|
const http = this.http;
|
|
34942
35146
|
this.http = null;
|
|
34943
35147
|
this.wss?.close();
|
|
@@ -34949,15 +35153,147 @@ var Bridge = class {
|
|
|
34949
35153
|
});
|
|
34950
35154
|
}
|
|
34951
35155
|
}
|
|
34952
|
-
//
|
|
35156
|
+
// Deletes the file that tells other servers to relay through this one; safe to call from a process "exit" handler.
|
|
35157
|
+
releaseSync() {
|
|
35158
|
+
if (this.relayToken) removePrimaryFile(this.home, this.port, this.relayToken);
|
|
35159
|
+
this.relayToken = null;
|
|
35160
|
+
}
|
|
35161
|
+
// --- roles ------------------------------------------------------------------------------------------------------
|
|
35162
|
+
// Finds this server's role: primary if it can bind the port, else relay if a live Polypad MCP server holds it, else busy.
|
|
35163
|
+
async attach(initial) {
|
|
35164
|
+
if (this.closed || this.attaching) return;
|
|
35165
|
+
this.attaching = true;
|
|
35166
|
+
try {
|
|
35167
|
+
try {
|
|
35168
|
+
await this.bind();
|
|
35169
|
+
this.becomePrimary();
|
|
35170
|
+
return;
|
|
35171
|
+
} catch (error62) {
|
|
35172
|
+
if (error62.code !== "EADDRINUSE") {
|
|
35173
|
+
if (initial) throw error62;
|
|
35174
|
+
this.options.log(`Could not open the bridge: ${error62.message}`);
|
|
35175
|
+
this.becomeBusy(`Polypad's local bridge could not start: ${error62.message}`);
|
|
35176
|
+
return;
|
|
35177
|
+
}
|
|
35178
|
+
}
|
|
35179
|
+
if (this.closed) return;
|
|
35180
|
+
if (await this.tryRelay()) return;
|
|
35181
|
+
this.becomeBusy(
|
|
35182
|
+
`Polypad's local bridge could not start: port ${this.requestedPort} is already in use by a program that is not a Polypad MCP server this one can share. Tell the user: close that program, or start the Polypad server with --port <other> (and enter the same port in the Connect AI dialog). This server keeps checking and shares the connection by itself once the port is free.`
|
|
35183
|
+
);
|
|
35184
|
+
} finally {
|
|
35185
|
+
this.attaching = false;
|
|
35186
|
+
}
|
|
35187
|
+
}
|
|
35188
|
+
becomePrimary() {
|
|
35189
|
+
this.role = "primary";
|
|
35190
|
+
this.unavailable = null;
|
|
35191
|
+
if (!this.firstAttach) this.ownCode = newPairingCode();
|
|
35192
|
+
this.firstAttach = false;
|
|
35193
|
+
this.relayToken = randomBytes(32).toString("base64url");
|
|
35194
|
+
let shared = true;
|
|
35195
|
+
try {
|
|
35196
|
+
writePrimaryFile(this.home, this.port, { pid: process.pid, token: this.relayToken, code: this.ownCode });
|
|
35197
|
+
} catch (error62) {
|
|
35198
|
+
shared = false;
|
|
35199
|
+
this.relayToken = null;
|
|
35200
|
+
this.options.log(`Could not write the file other Polypad MCP servers use to share this one: ${error62.message}`);
|
|
35201
|
+
}
|
|
35202
|
+
this.options.log(`Polypad MCP server ready on ws://127.0.0.1:${this.port}${shared ? "" : " (not shareable with other MCP clients)"}`);
|
|
35203
|
+
this.options.log(`Pairing code: ${this.ownCode} (open Polypad, click Connect AI, enter this code)`);
|
|
35204
|
+
}
|
|
35205
|
+
async tryRelay() {
|
|
35206
|
+
const info = readPrimaryFile(this.home, this.requestedPort);
|
|
35207
|
+
if (!info || !pidAlive(info.pid)) return false;
|
|
35208
|
+
const link = new RelayLink({ port: this.requestedPort, token: info.token, callTimeoutMs: this.options.callTimeoutMs });
|
|
35209
|
+
try {
|
|
35210
|
+
await link.connect();
|
|
35211
|
+
} catch (error62) {
|
|
35212
|
+
this.options.log(`Port ${this.requestedPort} is taken and the Polypad MCP server there did not accept this one as a relay: ${error62.message}`);
|
|
35213
|
+
return false;
|
|
35214
|
+
}
|
|
35215
|
+
if (this.closed) {
|
|
35216
|
+
link.close();
|
|
35217
|
+
return true;
|
|
35218
|
+
}
|
|
35219
|
+
this.link = link;
|
|
35220
|
+
this.linkCode = link.code;
|
|
35221
|
+
this.port = this.requestedPort;
|
|
35222
|
+
this.role = "relay";
|
|
35223
|
+
this.firstAttach = false;
|
|
35224
|
+
this.unavailable = null;
|
|
35225
|
+
link.onClose(() => {
|
|
35226
|
+
if (this.link !== link) return;
|
|
35227
|
+
this.link = null;
|
|
35228
|
+
this.role = "busy";
|
|
35229
|
+
this.options.log("The Polypad MCP server this one was sharing stopped. Looking for a new one to share, or taking over.");
|
|
35230
|
+
void this.attach(false).catch((error62) => this.options.log(`Bridge error: ${error62.message}`));
|
|
35231
|
+
});
|
|
35232
|
+
this.options.log(`Port ${this.requestedPort} is used by another Polypad MCP server (pid ${info.pid}): relaying through it, same Polypad tab and same code.`);
|
|
35233
|
+
this.options.log(`Pairing code: ${this.linkCode} (open Polypad, click Connect AI, enter this code)`);
|
|
35234
|
+
return true;
|
|
35235
|
+
}
|
|
35236
|
+
becomeBusy(text) {
|
|
35237
|
+
const first = this.role !== "busy" || this.unavailable !== text;
|
|
35238
|
+
this.role = "busy";
|
|
35239
|
+
this.port = this.requestedPort;
|
|
35240
|
+
this.unavailable = text;
|
|
35241
|
+
if (first) this.options.log(`Could not open the bridge: port ${this.requestedPort} is already in use. Trying again every ${Math.round(this.options.retryMs / 1e3)} s. Start with --port <other> to use another port.`);
|
|
35242
|
+
if (this.closed) return;
|
|
35243
|
+
this.retryTimer = setTimeout(() => {
|
|
35244
|
+
this.retryTimer = null;
|
|
35245
|
+
void this.attach(false).catch((error62) => this.options.log(`Bridge error: ${error62.message}`));
|
|
35246
|
+
}, this.options.retryMs);
|
|
35247
|
+
this.retryTimer.unref();
|
|
35248
|
+
}
|
|
35249
|
+
// --- the primary's socket server --------------------------------------------------------------------------------
|
|
35250
|
+
// Starts listening on 127.0.0.1. Rejects (for example EADDRINUSE) without leaving anything open.
|
|
35251
|
+
bind() {
|
|
35252
|
+
return new Promise((resolve, reject) => {
|
|
35253
|
+
const http = createServer((_req, res) => {
|
|
35254
|
+
res.writeHead(426, { "content-type": "text/plain" });
|
|
35255
|
+
res.end("Polypad MCP bridge: WebSocket only.\n");
|
|
35256
|
+
});
|
|
35257
|
+
const wss = new import_websocket_server.default({
|
|
35258
|
+
noServer: true,
|
|
35259
|
+
maxPayload: BRIDGE_MAX_MESSAGE_BYTES,
|
|
35260
|
+
perMessageDeflate: false
|
|
35261
|
+
});
|
|
35262
|
+
http.on("upgrade", (req, socket, head) => {
|
|
35263
|
+
const origin = req.headers.origin;
|
|
35264
|
+
if (origin !== void 0 && (typeof origin !== "string" || !this.allowedOrigins.has(normalizeOrigin(origin)))) {
|
|
35265
|
+
this.options.log(`Rejected a connection from origin ${typeof origin === "string" ? origin : "(unreadable)"}. Add it with --origin if it is yours.`);
|
|
35266
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
35267
|
+
return;
|
|
35268
|
+
}
|
|
35269
|
+
if (wss.clients.size >= 16) {
|
|
35270
|
+
socket.end("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
35271
|
+
return;
|
|
35272
|
+
}
|
|
35273
|
+
wss.handleUpgrade(req, socket, head, (ws) => this.accept(ws, origin !== void 0));
|
|
35274
|
+
});
|
|
35275
|
+
http.once("error", (error62) => {
|
|
35276
|
+
wss.close();
|
|
35277
|
+
reject(error62);
|
|
35278
|
+
});
|
|
35279
|
+
http.listen(this.requestedPort, "127.0.0.1", () => {
|
|
35280
|
+
this.http = http;
|
|
35281
|
+
this.wss = wss;
|
|
35282
|
+
this.port = http.address().port;
|
|
35283
|
+
http.on("error", (error62) => this.options.log(`Bridge error: ${error62.message}`));
|
|
35284
|
+
this.startPings();
|
|
35285
|
+
resolve();
|
|
35286
|
+
});
|
|
35287
|
+
});
|
|
35288
|
+
}
|
|
34953
35289
|
send(ws, message) {
|
|
34954
35290
|
if (ws.readyState === ws.OPEN) ws.send(encode3(message));
|
|
34955
35291
|
}
|
|
34956
|
-
accept(ws) {
|
|
35292
|
+
accept(ws, hasOrigin) {
|
|
34957
35293
|
this.alive.add(ws);
|
|
34958
|
-
let
|
|
35294
|
+
let kind = "unknown";
|
|
34959
35295
|
const helloTimer = setTimeout(() => {
|
|
34960
|
-
if (
|
|
35296
|
+
if (kind === "unknown") ws.close(CLOSE_HELLO_TIMEOUT, "No hello received");
|
|
34961
35297
|
}, this.options.helloTimeoutMs);
|
|
34962
35298
|
ws.on("pong", () => this.alive.add(ws));
|
|
34963
35299
|
ws.on("error", () => {
|
|
@@ -34965,7 +35301,7 @@ var Bridge = class {
|
|
|
34965
35301
|
ws.on("message", (data, isBinary) => {
|
|
34966
35302
|
this.alive.add(ws);
|
|
34967
35303
|
if (isBinary) return void ws.close(CLOSE_BAD_MESSAGE, "Text frames only");
|
|
34968
|
-
const message =
|
|
35304
|
+
const message = parseClientMessage(rawToString2(data));
|
|
34969
35305
|
if (!message) return void ws.close(CLOSE_BAD_MESSAGE, "Not a Polypad bridge message");
|
|
34970
35306
|
switch (message.type) {
|
|
34971
35307
|
case "ping":
|
|
@@ -34973,10 +35309,26 @@ var Bridge = class {
|
|
|
34973
35309
|
case "pong":
|
|
34974
35310
|
return;
|
|
34975
35311
|
case "hello":
|
|
34976
|
-
if (
|
|
34977
|
-
|
|
34978
|
-
if (
|
|
35312
|
+
if (kind !== "unknown") return;
|
|
35313
|
+
if (!hasOrigin) return void ws.close(CLOSE_BAD_MESSAGE, "A Polypad tab connects from a web page (no Origin header)");
|
|
35314
|
+
if (this.pair(ws, message.code, message.v, message.client)) {
|
|
35315
|
+
kind = "tab";
|
|
35316
|
+
clearTimeout(helloTimer);
|
|
35317
|
+
}
|
|
35318
|
+
return;
|
|
35319
|
+
case "relay-hello":
|
|
35320
|
+
if (kind !== "unknown") return;
|
|
35321
|
+
if (this.admitRelay(ws, message.token, message.v)) {
|
|
35322
|
+
kind = "relay";
|
|
35323
|
+
clearTimeout(helloTimer);
|
|
35324
|
+
}
|
|
34979
35325
|
return;
|
|
35326
|
+
case "relay-call": {
|
|
35327
|
+
if (kind !== "relay") return void ws.close(CLOSE_BAD_MESSAGE, "Say relay-hello first");
|
|
35328
|
+
const { id } = message;
|
|
35329
|
+
void this.call(message.name, message.input).then((result) => this.send(ws, { type: "relay-result", id, result }));
|
|
35330
|
+
return;
|
|
35331
|
+
}
|
|
34980
35332
|
case "result":
|
|
34981
35333
|
if (this.tab !== ws) return void ws.close(CLOSE_BAD_MESSAGE, "Pair first");
|
|
34982
35334
|
if (this.pending.get(message.id)?.socket === ws) this.settle(message.id, message.result);
|
|
@@ -34985,6 +35337,10 @@ var Bridge = class {
|
|
|
34985
35337
|
});
|
|
34986
35338
|
ws.on("close", () => {
|
|
34987
35339
|
clearTimeout(helloTimer);
|
|
35340
|
+
if (kind === "relay") {
|
|
35341
|
+
this.relays.delete(ws);
|
|
35342
|
+
this.options.log("A Polypad MCP server that shared this one has left.");
|
|
35343
|
+
}
|
|
34988
35344
|
if (this.tab === ws) {
|
|
34989
35345
|
this.tab = null;
|
|
34990
35346
|
this.tabName = void 0;
|
|
@@ -34993,17 +35349,21 @@ var Bridge = class {
|
|
|
34993
35349
|
this.failPending(ws, "Polypad disconnected before it answered (the tab was closed, reloaded or disconnected). The change may or may not have been applied. Ask the user to reconnect with Connect AI, then call get_project.");
|
|
34994
35350
|
});
|
|
34995
35351
|
}
|
|
35352
|
+
// Wrong attempts within the window; true when no more are tolerated.
|
|
35353
|
+
tooMany(times) {
|
|
35354
|
+
const now = Date.now();
|
|
35355
|
+
while (times.length && now - times[0] > this.options.badCodeWindowMs) times.shift();
|
|
35356
|
+
return times.length >= this.options.maxBadCodes;
|
|
35357
|
+
}
|
|
34996
35358
|
// Returns true when the tab is now the paired one; otherwise closes it with the reason.
|
|
34997
35359
|
pair(ws, code, version2, client) {
|
|
34998
|
-
|
|
34999
|
-
while (this.badCodeTimes.length && now - this.badCodeTimes[0] > this.options.badCodeWindowMs) this.badCodeTimes.shift();
|
|
35000
|
-
if (this.badCodeTimes.length >= this.options.maxBadCodes) {
|
|
35360
|
+
if (this.tooMany(this.badCodeTimes)) {
|
|
35001
35361
|
ws.close(CLOSE_RATE_LIMITED, "Too many wrong codes. Wait a minute or restart the MCP server.");
|
|
35002
35362
|
return false;
|
|
35003
35363
|
}
|
|
35004
35364
|
const given = normalizePairingCode(code);
|
|
35005
|
-
if (!given || !
|
|
35006
|
-
this.badCodeTimes.push(now);
|
|
35365
|
+
if (!given || !tokensMatch(given, this.ownCode)) {
|
|
35366
|
+
this.badCodeTimes.push(Date.now());
|
|
35007
35367
|
this.options.log("Rejected a pairing attempt with a wrong code.");
|
|
35008
35368
|
ws.close(CLOSE_BAD_CODE, "Wrong pairing code");
|
|
35009
35369
|
return false;
|
|
@@ -35024,6 +35384,27 @@ var Bridge = class {
|
|
|
35024
35384
|
this.options.log(`Polypad connected${client ? ` (${client})` : ""}.`);
|
|
35025
35385
|
return true;
|
|
35026
35386
|
}
|
|
35387
|
+
// Returns true when the socket is now a relay; otherwise closes it with the reason.
|
|
35388
|
+
admitRelay(ws, token, version2) {
|
|
35389
|
+
if (this.tooMany(this.badTokenTimes)) {
|
|
35390
|
+
ws.close(CLOSE_RATE_LIMITED, "Too many wrong tokens");
|
|
35391
|
+
return false;
|
|
35392
|
+
}
|
|
35393
|
+
if (!this.relayToken || !tokensMatch(token, this.relayToken)) {
|
|
35394
|
+
this.badTokenTimes.push(Date.now());
|
|
35395
|
+
this.options.log("Rejected a relay with a wrong token.");
|
|
35396
|
+
ws.close(CLOSE_BAD_TOKEN, "Wrong relay token");
|
|
35397
|
+
return false;
|
|
35398
|
+
}
|
|
35399
|
+
if (version2 !== BRIDGE_PROTOCOL_VERSION) {
|
|
35400
|
+
ws.close(CLOSE_BAD_VERSION, `Protocol version ${version2} is not supported (server speaks ${BRIDGE_PROTOCOL_VERSION})`);
|
|
35401
|
+
return false;
|
|
35402
|
+
}
|
|
35403
|
+
this.relays.add(ws);
|
|
35404
|
+
this.send(ws, { type: "relay-welcome", v: BRIDGE_PROTOCOL_VERSION, code: this.ownCode, server: "polypad-mcp" });
|
|
35405
|
+
this.options.log("Another Polypad MCP server (Claude Code or Desktop, say) now shares this one.");
|
|
35406
|
+
return true;
|
|
35407
|
+
}
|
|
35027
35408
|
settle(id, result) {
|
|
35028
35409
|
const entry = this.pending.get(id);
|
|
35029
35410
|
if (!entry) return;
|
|
@@ -35034,7 +35415,7 @@ var Bridge = class {
|
|
|
35034
35415
|
// Fails the calls waiting on `socket`, or all of them when it is null.
|
|
35035
35416
|
failPending(socket, text) {
|
|
35036
35417
|
for (const [id, entry] of [...this.pending]) {
|
|
35037
|
-
if (socket === null || entry.socket === socket) this.settle(id,
|
|
35418
|
+
if (socket === null || entry.socket === socket) this.settle(id, fail2(text));
|
|
35038
35419
|
}
|
|
35039
35420
|
}
|
|
35040
35421
|
startPings() {
|
|
@@ -35057,7 +35438,7 @@ var Bridge = class {
|
|
|
35057
35438
|
this.pingTimer.unref();
|
|
35058
35439
|
}
|
|
35059
35440
|
};
|
|
35060
|
-
function
|
|
35441
|
+
function rawToString2(data) {
|
|
35061
35442
|
if (typeof data === "string") return data;
|
|
35062
35443
|
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
|
35063
35444
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
|
@@ -35070,12 +35451,23 @@ var SERVER_INSTRUCTIONS = `Polypad is a parametric CAD editor. These tools build
|
|
|
35070
35451
|
Units and axes: lengths are millimetres, angles degrees, Z is up. Any length field accepts a number or an expression over the project's parameters ("width / 2"); prefer set_parameter for sizes that belong together.
|
|
35071
35452
|
|
|
35072
35453
|
Workflow:
|
|
35073
|
-
1. Start with get_project to see what is already there (the user may have work open). new_project replaces the open project,
|
|
35454
|
+
1. Start with get_project to see what is already there (the user may have work open). new_project replaces the open project: if it has features, ask the user first and never call it over their work; use it freely only when the project is empty.
|
|
35074
35455
|
2. Build with add_primitive, or add_sketch then extrude / revolve. Ids returned by a command are the feature and body ids for later calls.
|
|
35075
35456
|
3. For hole, shell, fillet, chamfer and for sketching on a face, call list_faces or list_edges first. Pass the indices they return together with the same rebuildId. The rebuildId changes whenever the model changes, so after any change list again; a stale one is refused ("Call list_edges again").
|
|
35076
35457
|
4. A feature that does not build stays in the project with its error: fix it with update_feature, or remove it with delete_feature or undo.
|
|
35077
35458
|
5. Check your work: measure gives volume and bounding box, and render_view returns a PNG (iso, top, front, right) so you can look at the result. Compare the numbers with what was asked before you finish.
|
|
35078
35459
|
|
|
35460
|
+
Pick the tool by task:
|
|
35461
|
+
- Hollow part (enclosure, box, cup): shell the solid with the open face(s) removed (list_faces with filter normal "+z" finds the top); don't subtract a smaller box.
|
|
35462
|
+
- Screw holes, standoffs, inserts: hole (size, fit close/normal/loose/tap/insert, counterbore or countersink, blind depth), not cut circles.
|
|
35463
|
+
- Repeated parts (rows, grids, around a circle, lattices): model one body, or one cutter body, then pattern it (then combine subtract for a cutter). Symmetric parts: model one side, then mirror.
|
|
35464
|
+
- Round parts (knobs, turrets, finials, domes): add_sketch on XZ (sketch x = world X, sketch y = world Z), draw half the outline at x >= 0, then revolve with axis "y" (the world Z axis).
|
|
35465
|
+
- Sizes that belong together or may change: set_parameter first, then use the names in fields.
|
|
35466
|
+
- Changing or fixing a feature: update_feature (or undo the last step); don't delete and rebuild.
|
|
35467
|
+
- Picking edges: use the list_edges filter (face, direction, kind) and the list_faces filter (normal, kind) instead of reading every edge. For the rim of the top: list_faces normal "+z", then list_edges face <index>.
|
|
35468
|
+
- Fillet fails: try a smaller radius or fewer edges at once, or use chamfer.
|
|
35469
|
+
- Clearances and collisions: measure gives bounding boxes; combine intersect on two bodies, then measure, shows an overlap; then undo.
|
|
35470
|
+
|
|
35079
35471
|
Results: a result with isError set was refused or something in the model did not build; the text says what and how to fix it. Nothing is changed by a refused call.`;
|
|
35080
35472
|
function toToolResult(result) {
|
|
35081
35473
|
const content = [{ type: "text", text: result.text }];
|
|
@@ -35104,7 +35496,7 @@ function createPolypadMcp(options = {}) {
|
|
|
35104
35496
|
}
|
|
35105
35497
|
|
|
35106
35498
|
// src/index.ts
|
|
35107
|
-
var VERSION = true ? "0.1.
|
|
35499
|
+
var VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
35108
35500
|
var err = (line2) => process.stderr.write(`${line2}
|
|
35109
35501
|
`);
|
|
35110
35502
|
async function main() {
|
|
@@ -35129,12 +35521,9 @@ ${USAGE}`);
|
|
|
35129
35521
|
const mcp = createPolypadMcp({ port: cli.port, origins: cli.origins, version: VERSION, log: err });
|
|
35130
35522
|
try {
|
|
35131
35523
|
await mcp.bridge.listen();
|
|
35132
|
-
err(`Polypad MCP server ready on ws://127.0.0.1:${mcp.bridge.port}`);
|
|
35133
|
-
err(`Pairing code: ${mcp.bridge.code} (open Polypad, click Connect AI, enter this code)`);
|
|
35134
35524
|
} catch (error62) {
|
|
35135
|
-
|
|
35136
|
-
|
|
35137
|
-
err(`Could not open the bridge: ${reason}`);
|
|
35525
|
+
mcp.bridge.unavailable = `Polypad's local bridge could not start: ${error62.message} Tell the user; Polypad cannot be reached until it runs.`;
|
|
35526
|
+
err(`Could not open the bridge: ${error62.message}`);
|
|
35138
35527
|
}
|
|
35139
35528
|
const shutdown = () => {
|
|
35140
35529
|
void mcp.close().finally(() => process.exit(0));
|
|
@@ -35143,6 +35532,7 @@ ${USAGE}`);
|
|
|
35143
35532
|
process.on("SIGTERM", shutdown);
|
|
35144
35533
|
process.stdin.on("end", shutdown);
|
|
35145
35534
|
process.stdin.on("close", shutdown);
|
|
35535
|
+
process.on("exit", () => mcp.bridge.releaseSync());
|
|
35146
35536
|
await mcp.server.connect(new StdioServerTransport());
|
|
35147
35537
|
}
|
|
35148
35538
|
main().catch((error62) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polypad/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server that lets Claude, Cursor or another AI assistant build and inspect 3D models in your open Polypad tab (polypad.ink). Runs locally, nothing is uploaded.",
|
|
6
6
|
"license": "MIT",
|