Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ jobs:

- run: pnpm install --frozen-lockfile

# The SEA sidecar clones a node binary; the arm64 runner cross-builds
# x86_64, so that leg needs a target-arch node of the exact same version
# (the build script rejects a skew).
- name: fetch x86_64 node for the SEA sidecar
if: matrix.args == '--target x86_64-apple-darwin'
run: |
V=$(node --version)
curl -fsSL "https://nodejs.org/dist/${V}/node-${V}-darwin-x64.tar.gz" | tar xz -C "$RUNNER_TEMP"
echo "ZPLAB_SEA_NODE=$RUNNER_TEMP/node-${V}-darwin-x64/bin/node" >> "$GITHUB_ENV"

- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ pnpm --filter @zplab/mcp-server exec tsx src/index.ts --http --port 4923 --token
`Authorization: Bearer <token>`; loopback-only binding plus Origin/Host checks
guard against local drive-by and DNS-rebinding.

## Claude Desktop config
## MCP client config

Most MCP clients (Claude Desktop and others) accept this `mcpServers` shape:

```json
{
Expand Down
52 changes: 37 additions & 15 deletions packages/mcp-server/scripts/build-sea.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,34 @@ import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
const outDir = join(pkgDir, "dist-sea");
const { values } = parseArgs({
options: {
// Node executable to clone for the target platform/arch.
node: { type: "string", default: process.execPath },
out: { type: "string" },
// Node to clone for the target arch; env form for the CI cross legs.
node: { type: "string", default: process.env.ZPLAB_SEA_NODE ?? process.execPath },
},
});

const isWindows = process.platform === "win32";
const isMac = process.platform === "darwin";
const exeName = values.out ?? (isWindows ? "zplab-mcp.exe" : "zplab-mcp");
// Under `tauri build` (env from the CLI) the exe goes where externalBin
// expects it; standalone runs keep dist-sea. Intermediates stay in dist-sea.
const triple = process.env.TAURI_ENV_TARGET_TRIPLE;
const workDir = join(pkgDir, "dist-sea");
const outDir = triple ? join(dirname(dirname(pkgDir)), "src-tauri", "binaries") : workDir;
const exeName = triple
? `zplab-mcp-${triple}${isWindows ? ".exe" : ""}`
: `zplab-mcp${isWindows ? ".exe" : ""}`;

const run = (cmd, args) => execFileSync(cmd, args, { stdio: "inherit", cwd: pkgDir });
// Both tool bins are JS shims, so run them through node directly: no shell,
// no pnpm wrapper, works identically on every platform.
const require = createRequire(import.meta.url);
const runBin = (spec, args) => run(process.execPath, [require.resolve(spec), ...args]);

mkdirSync(workDir, { recursive: true });
mkdirSync(outDir, { recursive: true });
const bundle = join(outDir, "zplab-mcp.cjs");
const blob = join(outDir, "zplab-mcp.blob");
const bundle = join(workDir, "zplab-mcp.cjs");
const blob = join(workDir, "zplab-mcp.blob");
const exe = join(outDir, exeName);

runBin("esbuild/bin/esbuild", [
Expand All @@ -43,14 +49,13 @@ runBin("esbuild/bin/esbuild", [
]);

writeFileSync(
join(outDir, "sea-config.json"),
join(workDir, "sea-config.json"),
JSON.stringify({ main: bundle, output: blob, disableExperimentalSEAWarning: true }),
);
run(process.execPath, ["--experimental-sea-config", join(outDir, "sea-config.json")]);
run(process.execPath, ["--experimental-sea-config", join(workDir, "sea-config.json")]);

// The blob is generated by the RUNNER's node but runs inside the target
// binary; the SEA format is version-coupled, so a skew fails at runtime.
// A foreign-arch target may not be executable here (then verify manually).
// The SEA format is version-coupled: a blob from the runner's node fails at
// runtime inside a different-version target binary.
if (values.node !== process.execPath) {
let targetVersion = null;
try {
Expand All @@ -62,10 +67,27 @@ if (values.node !== process.execPath) {
throw new Error(`target node is ${targetVersion}, runner is ${process.version}`);
}
}
// A runner-arch node under a target-triple name would ship a binary the
// target machines cannot start.
if (triple) {
const tripleArch = triple.startsWith("aarch64") ? "arm64" : "x64";
let nodeArch = process.arch;
if (values.node !== process.execPath) {
try {
nodeArch = execFileSync(values.node, ["-p", "process.arch"]).toString().trim();
} catch {
nodeArch = "unknown";
}
}
if (nodeArch !== "unknown" && nodeArch !== tripleArch) {
throw new Error(
`node is ${nodeArch} but the target triple needs ${tripleArch}; pass --node/ZPLAB_SEA_NODE`,
);
}
}
copyFileSync(values.node, exe);
// postject invalidates the executable's signature; macOS arm64 refuses to run
// unsigned binaries, so strip before injecting and ad-hoc re-sign after (the
// release carries no OS code signing, only the Tauri updater signature).
// postject invalidates the signature and macOS arm64 refuses unsigned
// binaries: strip before injecting, ad-hoc re-sign after.
if (isMac) run("codesign", ["--remove-signature", exe]);
runBin("postject/dist/cli.js", [
exe,
Expand Down
9 changes: 5 additions & 4 deletions packages/mcp-server/scripts/smoke-sea.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,16 @@ const check = (name, cond, detail = "") => {
};

const t0 = Date.now();
// stderr inherited so a startup crash (port in use, bad binary) shows its
// real cause instead of only the listening timeout below.
const child = spawn(exe, ["--http", "--port", String(PORT), "--token", TOK], {
stdio: ["ignore", "pipe", "inherit"],
// stderr inherited so a startup crash shows its real cause; token over stdin
// exactly like the Tauri host hands it over.
const child = spawn(exe, ["--http", "--port", String(PORT), "--token-stdin"], {
stdio: ["pipe", "pipe", "inherit"],
});
child.on("error", (e) => {
console.error("spawn failed:", e.message);
process.exit(1);
});
child.stdin.end(`${TOK}\n`);
const events = [];
let onListening;
let onExited;
Expand Down
10 changes: 10 additions & 0 deletions packages/mcp-server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,16 @@ describe("mcp-server http transport", () => {
expect(res.status).toBe(400);
});

it("rejects an oversized MCP-route body with 413", async () => {
const res = await post(
{ jsonrpc: "2.0", id: 9, method: "tools/call", params: { pad: "x".repeat(17 * 1024 * 1024) } },
{ token: TOKEN },
).catch(() => null);
// Node may reset the socket mid-upload after the 413; both prove the cap.
if (res) expect(res.status).toBe(413);
else expect(res).toBeNull();
});

it("rejects an oversized design-response body with 413", async () => {
const res = await fetch(`http://127.0.0.1:${server.port}/design-response`, {
method: "POST",
Expand Down
95 changes: 62 additions & 33 deletions packages/mcp-server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,31 @@ import { buildServer } from "./server.js";

const HOST = "127.0.0.1";

/** Cap for the app's design-response body. A design file with embedded
* graphics runs to a few MB; anything beyond this is not a real label. */
const MAX_DESIGN_RESPONSE_BYTES = 16 * 1024 * 1024;
/** Body cap for every route: a real design file with graphics is a few MB,
* beyond that is only an authed local DoS vector. */
const MAX_BODY_BYTES = 16 * 1024 * 1024;

/** Read the whole body, capped. Resolves null after answering 413 and
* destroying the socket (the caller must bail out then). */
function readBodyCapped(req: IncomingMessage, res: ServerResponse): Promise<Buffer | null> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
let size = 0;
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
if (!res.headersSent) res.writeHead(413);
res.end();
req.destroy();
resolve(null);
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", () => resolve(null));
});
}

export interface HttpServerOptions {
port: number;
Expand All @@ -35,32 +57,21 @@ function hasValidToken(req: IncomingMessage, expected: string): boolean {
/** The app's reply to a designRequest event. Bypasses the SDK transport (it is
* not MCP JSON-RPC), so it re-checks the Host header for loopback itself; the
* bearer token was already verified by the shared gate. */
function handleDesignResponse(req: IncomingMessage, res: ServerResponse): void {
async function handleDesignResponse(req: IncomingMessage, res: ServerResponse): Promise<void> {
const host = (req.headers.host ?? "").replace(/:\d+$/, "");
if (req.method !== "POST" || (host !== "127.0.0.1" && host !== "localhost")) {
res.writeHead(403).end();
return;
}
const chunks: Buffer[] = [];
let size = 0;
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_DESIGN_RESPONSE_BYTES) {
res.writeHead(413).end();
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
let delivered = false;
try {
delivered = resolveDesignResponse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch {
// Malformed JSON falls through to the 400 below.
}
if (!res.writableEnded) res.writeHead(delivered ? 204 : 400).end();
});
const body = await readBodyCapped(req, res);
if (body === null) return;
let delivered = false;
try {
delivered = resolveDesignResponse(JSON.parse(body.toString("utf8")));
} catch {
// Malformed JSON falls through to the 400 below.
}
if (!res.writableEnded) res.writeHead(delivered ? 204 : 400).end();
}

/** Loopback-only Streamable HTTP server with mandatory bearer auth. A fresh
Expand All @@ -80,7 +91,11 @@ export async function startHttpServer(options: HttpServerOptions): Promise<Runni
}

if (req.url === "/design-response") {
handleDesignResponse(req, res);
// An unhandled rejection would kill the process (same guard as below).
handleDesignResponse(req, res).catch(() => {
if (!res.headersSent && res.writable) res.writeHead(500);
res.end();
});
return;
}

Expand All @@ -98,14 +113,28 @@ export async function startHttpServer(options: HttpServerOptions): Promise<Runni
void transport.close();
void server.close();
});
server
.connect(transport)
.then(() => transport.handleRequest(req, res))
.catch(() => {
// Guard against a handler that already responded or closed the socket.
if (!res.headersSent && res.writable) res.writeHead(500);
res.end();
});
// Body read here (capped), then passed as parsedBody: a passive cap
// listener would start flowing before the transport reads, losing chunks.
void (async () => {
const body = await readBodyCapped(req, res);
if (body === null) return;
let parsedBody: unknown;
if (body.length > 0) {
try {
parsedBody = JSON.parse(body.toString("utf8"));
} catch {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "invalid JSON body" }));
return;
}
}
await server.connect(transport);
await transport.handleRequest(req, res, parsedBody);
})().catch(() => {
// Guard against a handler that already responded or closed the socket.
if (!res.headersSent && res.writable) res.writeHead(500);
res.end();
});
});

const boundPort = await new Promise<number>((resolve, reject) => {
Expand Down
29 changes: 26 additions & 3 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,34 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { buildServer } from "./server.js";
import { startHttpServer } from "./http.js";

/** First stdin line as the bearer token, keeping it off argv (readable by
* any same-user process). The host closes stdin right after the line. */
function readTokenFromStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let buf = "";
const onData = (d: Buffer) => {
buf += d.toString("utf8");
const i = buf.indexOf("\n");
if (i >= 0) {
process.stdin.off("data", onData);
process.stdin.pause();
resolve(buf.slice(0, i).trim());
}
};
process.stdin.on("data", onData);
process.stdin.once("end", () => reject(new Error("stdin closed before a token line")));
process.stdin.once("error", reject);
});
}

// No top-level await: the SEA sidecar build bundles this entry as CJS.
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
http: { type: "boolean", default: false },
port: { type: "string" },
token: { type: "string" },
"token-stdin": { type: "boolean", default: false },
},
});

Expand All @@ -19,12 +40,14 @@ async function main(): Promise<void> {
process.stderr.write("--http requires a valid --port\n");
process.exit(1);
}
if (!values.token) {
process.stderr.write("--http requires --token\n");
const token = values["token-stdin"] ? await readTokenFromStdin() : values.token;
if (!token) {
process.stderr.write("--http requires --token or --token-stdin\n");
process.exit(1);
}
await startHttpServer({ port, token: values.token });
await startHttpServer({ port, token });
} else {
// stdin is the JSON-RPC transport here, so --token-stdin cannot apply.
const server = buildServer();
await server.connect(new StdioServerTransport());
}
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,9 @@ export function buildServer(options: BuildServerOptions = {}): McpServer {
title: "Get current ZPLab design",
description:
"Read the design currently open in the ZPLab desktop app: the design file " +
"plus render-exact bounds and overlaps (the app reports measured barcode/text " +
"sizes, so nothing is approx). Only available when ZPLab launched this server.",
"plus bounds and overlaps using the app's render-measured barcode/text sizes " +
"(approx only flags anything not rendered yet). Only available when ZPLab " +
"launched this server.",
inputSchema: {},
},
async () => {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
# updater signing key, lives only in GH secrets
.tauri-signing.key
.tauri-signing.key.pub
binaries
19 changes: 19 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
fn main() {
// tauri-build requires the externalBin sidecar in every profile, but dev
// spawns from source: a debug placeholder keeps fresh-clone compiles going,
// and release deletes a leftover placeholder so it can never get bundled.
let triple = std::env::var("TARGET").unwrap_or_default();
let ext = if triple.contains("windows") {
".exe"
} else {
""
};
let path = std::path::PathBuf::from(format!("binaries/zplab-mcp-{triple}{ext}"));
let is_placeholder = std::fs::metadata(&path).is_ok_and(|m| m.len() == 0);
if std::env::var("PROFILE").as_deref() == Ok("debug") {
if !path.exists() {
let _ = std::fs::create_dir_all("binaries");
let _ = std::fs::write(&path, b"");
}
} else if is_placeholder {
let _ = std::fs::remove_file(&path);
}
tauri_build::build()
}
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ pub fn run() {
credentials::credential_delete,
mcp::mcp_start,
mcp::mcp_stop,
mcp::mcp_status
mcp::mcp_status,
mcp::mcp_listeners_ready
]);
// On the builder, not in setup(): config windows exist before the setup
// closure runs, and window-state only restores/tracks via on_window_ready.
Expand Down
Loading