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.
- package/index.mjs +81 -15
- 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:
|
|
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
|
-
//
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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 ${
|
|
119
|
+
return textResult(`sync failed (HTTP ${response.status}): ${message}`, true);
|
|
69
120
|
}
|
|
70
|
-
const mb = (
|
|
71
|
-
|
|
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