parallelsandbox-mcp 0.1.0 → 0.2.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 (2) hide show
  1. package/index.mjs +81 -15
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -3,9 +3,10 @@
3
3
  // Every tool call is forwarded to https://mcp.parallelsandbox.com/mcp with the API key, except sandbox_sync,
4
4
  // which tars the local directory here and uploads it through POST /v1/boxes/{id}/sync.
5
5
 
6
- import { spawnSync } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
7
7
  import { existsSync, statSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
+ import { Readable, Transform } from "node:stream";
9
10
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
12
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -27,8 +28,11 @@ if (!API_KEY) {
27
28
  }
28
29
 
29
30
  const remote = new Client({ name: "parallelsandbox-mcp", version: "0.1.0" });
31
+ // 同一個 headers 物件每次請求都會被讀到,所以握手拿到對方是誰之後直接塞進去。
32
+ // 沒有這個,control 只知道「某個 API key 開了箱子」,人在 app 裡看不出是 Claude 還是 Codex 在用。
33
+ const remoteHeaders = { Authorization: `Bearer ${API_KEY}` };
30
34
  const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
31
- requestInit: { headers: { Authorization: `Bearer ${API_KEY}` } },
35
+ requestInit: { headers: remoteHeaders },
32
36
  });
33
37
 
34
38
  async function connectRemote() {
@@ -40,7 +44,19 @@ function textResult(text, isError = false) {
40
44
  return { content: [{ type: "text", text }], isError };
41
45
  }
42
46
 
43
- // sandbox_sync runs here: tar the local directory and upload it to the box through control.
47
+ // gitFileList repo 自己:追蹤中的檔案加上沒被 .gitignore 忽略的新檔,NUL 分隔。
48
+ // 這比固定黑名單準得多——開發者早就在 .gitignore 宣告過什麼是產物。實測 CubeLV 的 renderer:
49
+ // 黑名單口徑 4,434 MB(ios/DerivedData、ios/App/build、android/.gradle、dist-web-public 全都跟著上傳),
50
+ // git 口徑 18 MB。不是 git 工作區就回 null,退回黑名單。
51
+ function gitFileList(src) {
52
+ const inRepo = spawnSync("git", ["-C", src, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" });
53
+ if (inRepo.status !== 0 || inRepo.stdout.trim() !== "true") return null;
54
+ const ls = spawnSync("git", ["-C", src, "ls-files", "-co", "--exclude-standard", "-z"], { maxBuffer: 512 * 1024 * 1024 });
55
+ if (ls.status !== 0) return null;
56
+ return ls.stdout;
57
+ }
58
+
59
+ // sandbox_sync runs here: tar the local directory and stream it to the box through control.
44
60
  async function sync(args) {
45
61
  const { id, localPath, dest } = args || {};
46
62
  if (!id || !localPath || !dest) {
@@ -50,25 +66,66 @@ async function sync(args) {
50
66
  if (!existsSync(src) || !statSync(src).isDirectory()) {
51
67
  return textResult(`local directory not found: ${src}`, true);
52
68
  }
53
- const tar = spawnSync("tar", ["-czf", "-", "-C", src, ...SYNC_EXCLUDES.map((e) => `--exclude=${e}`), "."], { maxBuffer: 2 * 1024 * 1024 * 1024 });
54
- if (tar.status !== 0) {
55
- return textResult(`tar failed: ${tar.stderr?.toString() || tar.status}`, true);
69
+
70
+ const fileList = gitFileList(src);
71
+ const mode = fileList ? "git" : "excludes";
72
+ // 串流上傳,不把整包 tar 讀進記憶體:真實專案很容易超過幾 GB,buffer 起來會直接 OOM。
73
+ // COPYFILE_DISABLE=1:macOS 的 bsdtar 預設把每個檔的擴充屬性另存成 AppleDouble 成員(._foo),
74
+ // 在 Mac 上列檔會自己合回去所以看不出來,到 Linux 箱子裡就是一堆真的垃圾檔。
75
+ // 實測同步 CubeLV:4,160 個成員裡 2,063 個是這種,*.json 之類的 glob 會掃到二進位檔。
76
+ const tarEnv = { ...process.env, COPYFILE_DISABLE: "1" };
77
+ const tar = fileList
78
+ ? spawn("tar", ["-czf", "-", "-C", src, "--null", "-T", "-"], { env: tarEnv })
79
+ : spawn("tar", ["-czf", "-", "-C", src, ...SYNC_EXCLUDES.map((e) => `--exclude=${e}`), "."], { env: tarEnv });
80
+ if (fileList) {
81
+ tar.stdin.on("error", () => {});
82
+ tar.stdin.end(fileList);
56
83
  }
57
- const res = await fetch(`${API_URL}/v1/boxes/${encodeURIComponent(id)}/sync?dest=${encodeURIComponent(dest)}`, {
58
- method: "POST",
59
- headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/gzip" },
60
- body: tar.stdout,
84
+ let uploaded = 0;
85
+ const counter = new Transform({
86
+ transform(chunk, _enc, cb) {
87
+ uploaded += chunk.length;
88
+ cb(null, chunk);
89
+ },
61
90
  });
62
- const body = await res.text();
63
- if (!res.ok) {
91
+ let tarErr = "";
92
+ tar.stderr.on("data", (d) => {
93
+ if (tarErr.length < 4096) tarErr += d.toString();
94
+ });
95
+ const exited = new Promise((res) => tar.on("close", res));
96
+
97
+ let response;
98
+ try {
99
+ response = await fetch(`${API_URL}/v1/boxes/${encodeURIComponent(id)}/sync?dest=${encodeURIComponent(dest)}`, {
100
+ method: "POST",
101
+ headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/gzip" },
102
+ body: Readable.toWeb(tar.stdout.pipe(counter)),
103
+ duplex: "half",
104
+ });
105
+ } catch (err) {
106
+ tar.kill("SIGKILL");
107
+ return textResult(`sync failed while uploading: ${err?.message || err}${tarErr ? ` (tar: ${tarErr.trim()})` : ""}`, true);
108
+ }
109
+ const code = await exited;
110
+ const body = await response.text();
111
+ if (code !== 0) {
112
+ return textResult(`tar failed (exit ${code}): ${tarErr.trim() || "no output"}`, true);
113
+ }
114
+ if (!response.ok) {
64
115
  let message = body;
65
116
  try {
66
117
  message = JSON.parse(body).error || body;
67
118
  } catch {}
68
- return textResult(`sync failed (HTTP ${res.status}): ${message}`, true);
119
+ return textResult(`sync failed (HTTP ${response.status}): ${message}`, true);
69
120
  }
70
- const mb = (tar.stdout.length / 1024 / 1024).toFixed(1);
71
- return textResult(JSON.stringify({ ok: true, dest, uploadedMB: Number(mb), excluded: SYNC_EXCLUDES }, null, 2));
121
+ const mb = Number((uploaded / 1024 / 1024).toFixed(1));
122
+ const how = mode === "git" ? "git tracked + untracked files, honouring .gitignore" : `denylist: ${SYNC_EXCLUDES.join(", ")}`;
123
+ // uploadedMB 對小專案永遠是 0,看起來像什麼都沒傳;bytes 與檔數才看得出成功。
124
+ const out = { ok: true, dest, uploadedBytes: uploaded, uploadedMB: mb, selected: how };
125
+ // fileList 是 git ls-files -z 的原始 bytes(不是字串:檔名不一定是合法 UTF-8,轉字串會壞掉),
126
+ // 所以檔數用數 NUL 分隔符算,不要用 split。
127
+ if (fileList) out.files = fileList.reduce((n, b) => (b === 0 ? n + 1 : n), 0);
128
+ return textResult(JSON.stringify(out, null, 2));
72
129
  }
73
130
 
74
131
  const server = new Server({ name: "parallelsandbox", version: "0.1.0" }, { capabilities: { tools: {} } });
@@ -78,7 +135,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
78
135
  return { tools };
79
136
  });
80
137
 
138
+ // tellRemoteWhoIsCalling:把呼叫端 initialize 時報的名字轉給 control(X-Psbx-Client)。
139
+ // 報不出來就不帶,control 那邊就不顯示,不猜。
140
+ function tellRemoteWhoIsCalling() {
141
+ const who = server.getClientVersion?.();
142
+ if (!who?.name) return;
143
+ remoteHeaders["X-Psbx-Client"] = who.version ? `${who.name}/${who.version}` : who.name;
144
+ }
145
+
81
146
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
147
+ tellRemoteWhoIsCalling();
82
148
  const { name, arguments: args } = req.params;
83
149
  if (name === "sandbox_sync") {
84
150
  return sync(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallelsandbox-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "stdio adapter for the ParallelSandbox MCP server: runs locally so sandbox_sync can upload your working directory",
5
5
  "type": "module",
6
6
  "bin": {