parallelsandbox-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +63 -14
- 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";
|
|
@@ -40,7 +41,19 @@ function textResult(text, isError = false) {
|
|
|
40
41
|
return { content: [{ type: "text", text }], isError };
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
+
// gitFileList 問 repo 自己:追蹤中的檔案加上沒被 .gitignore 忽略的新檔,NUL 分隔。
|
|
45
|
+
// 這比固定黑名單準得多——開發者早就在 .gitignore 宣告過什麼是產物。實測 CubeLV 的 renderer:
|
|
46
|
+
// 黑名單口徑 4,434 MB(ios/DerivedData、ios/App/build、android/.gradle、dist-web-public 全都跟著上傳),
|
|
47
|
+
// git 口徑 18 MB。不是 git 工作區就回 null,退回黑名單。
|
|
48
|
+
function gitFileList(src) {
|
|
49
|
+
const inRepo = spawnSync("git", ["-C", src, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" });
|
|
50
|
+
if (inRepo.status !== 0 || inRepo.stdout.trim() !== "true") return null;
|
|
51
|
+
const ls = spawnSync("git", ["-C", src, "ls-files", "-co", "--exclude-standard", "-z"], { maxBuffer: 512 * 1024 * 1024 });
|
|
52
|
+
if (ls.status !== 0) return null;
|
|
53
|
+
return ls.stdout;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// sandbox_sync runs here: tar the local directory and stream it to the box through control.
|
|
44
57
|
async function sync(args) {
|
|
45
58
|
const { id, localPath, dest } = args || {};
|
|
46
59
|
if (!id || !localPath || !dest) {
|
|
@@ -50,25 +63,61 @@ async function sync(args) {
|
|
|
50
63
|
if (!existsSync(src) || !statSync(src).isDirectory()) {
|
|
51
64
|
return textResult(`local directory not found: ${src}`, true);
|
|
52
65
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
|
|
67
|
+
const fileList = gitFileList(src);
|
|
68
|
+
const mode = fileList ? "git" : "excludes";
|
|
69
|
+
// 串流上傳,不把整包 tar 讀進記憶體:真實專案很容易超過幾 GB,buffer 起來會直接 OOM。
|
|
70
|
+
// COPYFILE_DISABLE=1:macOS 的 bsdtar 預設把每個檔的擴充屬性另存成 AppleDouble 成員(._foo),
|
|
71
|
+
// 在 Mac 上列檔會自己合回去所以看不出來,到 Linux 箱子裡就是一堆真的垃圾檔。
|
|
72
|
+
// 實測同步 CubeLV:4,160 個成員裡 2,063 個是這種,*.json 之類的 glob 會掃到二進位檔。
|
|
73
|
+
const tarEnv = { ...process.env, COPYFILE_DISABLE: "1" };
|
|
74
|
+
const tar = fileList
|
|
75
|
+
? spawn("tar", ["-czf", "-", "-C", src, "--null", "-T", "-"], { env: tarEnv })
|
|
76
|
+
: spawn("tar", ["-czf", "-", "-C", src, ...SYNC_EXCLUDES.map((e) => `--exclude=${e}`), "."], { env: tarEnv });
|
|
77
|
+
if (fileList) {
|
|
78
|
+
tar.stdin.on("error", () => {});
|
|
79
|
+
tar.stdin.end(fileList);
|
|
56
80
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
let uploaded = 0;
|
|
82
|
+
const counter = new Transform({
|
|
83
|
+
transform(chunk, _enc, cb) {
|
|
84
|
+
uploaded += chunk.length;
|
|
85
|
+
cb(null, chunk);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
let tarErr = "";
|
|
89
|
+
tar.stderr.on("data", (d) => {
|
|
90
|
+
if (tarErr.length < 4096) tarErr += d.toString();
|
|
61
91
|
});
|
|
62
|
-
const
|
|
63
|
-
|
|
92
|
+
const exited = new Promise((res) => tar.on("close", res));
|
|
93
|
+
|
|
94
|
+
let response;
|
|
95
|
+
try {
|
|
96
|
+
response = await fetch(`${API_URL}/v1/boxes/${encodeURIComponent(id)}/sync?dest=${encodeURIComponent(dest)}`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/gzip" },
|
|
99
|
+
body: Readable.toWeb(tar.stdout.pipe(counter)),
|
|
100
|
+
duplex: "half",
|
|
101
|
+
});
|
|
102
|
+
} catch (err) {
|
|
103
|
+
tar.kill("SIGKILL");
|
|
104
|
+
return textResult(`sync failed while uploading: ${err?.message || err}${tarErr ? ` (tar: ${tarErr.trim()})` : ""}`, true);
|
|
105
|
+
}
|
|
106
|
+
const code = await exited;
|
|
107
|
+
const body = await response.text();
|
|
108
|
+
if (code !== 0) {
|
|
109
|
+
return textResult(`tar failed (exit ${code}): ${tarErr.trim() || "no output"}`, true);
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) {
|
|
64
112
|
let message = body;
|
|
65
113
|
try {
|
|
66
114
|
message = JSON.parse(body).error || body;
|
|
67
115
|
} catch {}
|
|
68
|
-
return textResult(`sync failed (HTTP ${
|
|
116
|
+
return textResult(`sync failed (HTTP ${response.status}): ${message}`, true);
|
|
69
117
|
}
|
|
70
|
-
const mb = (
|
|
71
|
-
|
|
118
|
+
const mb = Number((uploaded / 1024 / 1024).toFixed(1));
|
|
119
|
+
const how = mode === "git" ? "git tracked + untracked files, honouring .gitignore" : `denylist: ${SYNC_EXCLUDES.join(", ")}`;
|
|
120
|
+
return textResult(JSON.stringify({ ok: true, dest, uploadedMB: mb, selected: how }, null, 2));
|
|
72
121
|
}
|
|
73
122
|
|
|
74
123
|
const server = new Server({ name: "parallelsandbox", version: "0.1.0" }, { capabilities: { tools: {} } });
|
package/package.json
CHANGED