@polypad/mcp 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/dist/index.js +454 -75
  3. 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 = randomBytes(16).toString("base64");
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) {
@@ -34802,7 +34840,7 @@ function commandSchemas() {
34802
34840
  }
34803
34841
 
34804
34842
  // src/bridge.ts
34805
- import { randomInt, randomUUID, timingSafeEqual } from "crypto";
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
- function codesMatch(a, b) {
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.code = options.code ?? newPairingCode();
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.tab !== null;
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 listening on 127.0.0.1. Rejects (for example EADDRINUSE) without leaving anything open.
34870
- listen() {
34871
- return new Promise((resolve, reject) => {
34872
- const http = createServer((_req, res) => {
34873
- res.writeHead(426, { "content-type": "text/plain" });
34874
- res.end("Polypad MCP bridge: WebSocket only.\n");
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(fail(this.notConnectedText()));
34912
- const id = randomUUID();
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
- fail(
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, fail(`Could not send ${name} to Polypad: ${error62 instanceof Error ? error62.message : String(error62)}`));
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
- // --- internals --------------------------------------------------------------------------------------------------
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 paired = false;
35294
+ let kind = "unknown";
34959
35295
  const helloTimer = setTimeout(() => {
34960
- if (!paired) ws.close(CLOSE_HELLO_TIMEOUT, "No hello received");
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 = parseTabMessage(rawToString(data));
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 (paired) return;
34977
- paired = this.pair(ws, message.code, message.v, message.client);
34978
- if (paired) clearTimeout(helloTimer);
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
+ }
34979
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
+ }
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
- const now = Date.now();
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 || !codesMatch(given, this.code)) {
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, fail(text));
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 rawToString(data) {
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");
@@ -35104,7 +35485,7 @@ function createPolypadMcp(options = {}) {
35104
35485
  }
35105
35486
 
35106
35487
  // src/index.ts
35107
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
35488
+ var VERSION = true ? "0.1.1" : "0.0.0-dev";
35108
35489
  var err = (line2) => process.stderr.write(`${line2}
35109
35490
  `);
35110
35491
  async function main() {
@@ -35129,12 +35510,9 @@ ${USAGE}`);
35129
35510
  const mcp = createPolypadMcp({ port: cli.port, origins: cli.origins, version: VERSION, log: err });
35130
35511
  try {
35131
35512
  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
35513
  } catch (error62) {
35135
- const reason = error62.code === "EADDRINUSE" ? `port ${cli.port} is already in use (another polypad-mcp running?). Start with --port <other>.` : error62.message;
35136
- mcp.bridge.unavailable = `Polypad's local bridge could not start: ${reason} Tell the user; Polypad cannot be reached until it runs.`;
35137
- err(`Could not open the bridge: ${reason}`);
35514
+ mcp.bridge.unavailable = `Polypad's local bridge could not start: ${error62.message} Tell the user; Polypad cannot be reached until it runs.`;
35515
+ err(`Could not open the bridge: ${error62.message}`);
35138
35516
  }
35139
35517
  const shutdown = () => {
35140
35518
  void mcp.close().finally(() => process.exit(0));
@@ -35143,6 +35521,7 @@ ${USAGE}`);
35143
35521
  process.on("SIGTERM", shutdown);
35144
35522
  process.stdin.on("end", shutdown);
35145
35523
  process.stdin.on("close", shutdown);
35524
+ process.on("exit", () => mcp.bridge.releaseSync());
35146
35525
  await mcp.server.connect(new StdioServerTransport());
35147
35526
  }
35148
35527
  main().catch((error62) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polypad/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",