@oliverames/mcp-server-for-wave 1.0.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/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@oliverames/mcp-server-for-wave",
3
+ "version": "1.0.0",
4
+ "description": "Local MCP server for Wave Accounting: invoices, estimates, customers, products, taxes, and bookkeeping",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/oliverames/wave-mcp-server.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/oliverames/wave-mcp-server/issues"
12
+ },
13
+ "homepage": "https://github.com/oliverames/wave-mcp-server#readme",
14
+ "keywords": [
15
+ "mcp",
16
+ "model-context-protocol",
17
+ "wave",
18
+ "waveapps",
19
+ "accounting",
20
+ "invoicing",
21
+ "bookkeeping",
22
+ "small-business"
23
+ ],
24
+ "type": "module",
25
+ "main": "index.js",
26
+ "bin": {
27
+ "mcp-server-for-wave": "index.js",
28
+ "wave-mcp-server": "index.js"
29
+ },
30
+ "files": [
31
+ "index.js",
32
+ "scripts/",
33
+ "assets/icon.png",
34
+ "docs/hosted-oauth-connector.md",
35
+ "docs/privacy.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "scripts": {
41
+ "start": "node index.js",
42
+ "deps:ensure": "node --input-type=module -e \"await import('@modelcontextprotocol/sdk/server/mcp.js')\" >/dev/null 2>&1 || npm ci --silent --no-audit --no-fund",
43
+ "pretest": "npm run deps:ensure",
44
+ "test": "node --test test/unit.test.mjs",
45
+ "presmoke:list-tools": "npm run deps:ensure",
46
+ "smoke:list-tools": "node scripts/smoke-list-tools.mjs",
47
+ "presmoke:schema": "npm run deps:ensure",
48
+ "smoke:schema": "node scripts/smoke-validate-graphql.mjs",
49
+ "presmoke:packed": "npm run deps:ensure",
50
+ "smoke:packed": "node scripts/smoke-packed-install.mjs",
51
+ "sync:plugin": "node scripts/sync-plugin-metadata.mjs",
52
+ "version": "npm run sync:plugin && git add .agents/plugins/marketplace.json .claude-plugin/marketplace.json .claude-plugin/plugin.json codex/.codex-plugin/plugin.json codex/.codex-plugin/mcp.json .hermes-plugin/marketplace.json .hermes-plugin/plugin.json .hermes-plugin/mcp.json .antigravity-plugin/marketplace.json .antigravity-plugin/plugin.json .antigravity-plugin/mcp_config.json .mcp.json",
53
+ "release:check": "node scripts/check-release-consistency.mjs",
54
+ "release:check:registry": "node scripts/check-release-consistency.mjs --registry",
55
+ "build:mcpb": "node scripts/build-mcpb.mjs"
56
+ },
57
+ "dependencies": {
58
+ "@modelcontextprotocol/sdk": "^1.29.0",
59
+ "zod": "^4.4.3"
60
+ }
61
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Build an MCPB bundle: a single .mcpb file a desktop MCP host can install
5
+ * without npm or a terminal.
6
+ *
7
+ * The bundle is a zip carrying a manifest, the server, and its production
8
+ * dependencies. User configuration (the Wave token, the default business,
9
+ * the write opt-in) is declared in the manifest so the host can prompt for it
10
+ * and inject the values at launch, which keeps credentials out of the bundle.
11
+ */
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import process from "node:process";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
20
+ const distDir = path.join(projectRoot, "dist");
21
+ const stageDir = path.join(distDir, "mcpb-stage");
22
+
23
+ const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
24
+ const { version } = packageJson;
25
+ const outputPath = path.join(distDir, `wave-mcp-server-${version}.mcpb`);
26
+
27
+ const manifest = {
28
+ manifest_version: "0.2",
29
+ name: "wave-mcp-server",
30
+ display_name: "Wave",
31
+ version,
32
+ description: packageJson.description,
33
+ long_description:
34
+ "Covers Wave Accounting's public GraphQL API in full: invoices and payments, estimates and deposits, " +
35
+ "customers, vendors, products, sales taxes, the chart of accounts, and double-entry bookkeeping " +
36
+ "transactions. Tools that change or send data stay hidden until you enable them.",
37
+ author: { name: "Oliver Ames", email: "oliverames@gmail.com", url: "https://github.com/oliverames" },
38
+ homepage: "https://github.com/oliverames/wave-mcp-server#readme",
39
+ documentation: "https://github.com/oliverames/wave-mcp-server#readme",
40
+ support: "https://github.com/oliverames/wave-mcp-server/issues",
41
+ icon: "icon.png",
42
+ license: "MIT",
43
+ keywords: packageJson.keywords,
44
+ server: {
45
+ type: "node",
46
+ entry_point: "index.js",
47
+ mcp_config: {
48
+ command: "node",
49
+ args: ["${__dirname}/index.js"],
50
+ env: {
51
+ WAVE_ACCESS_TOKEN: "${user_config.access_token}",
52
+ WAVE_BUSINESS_ID: "${user_config.business_id}",
53
+ WAVE_ALLOW_WRITES: "${user_config.allow_writes}",
54
+ // A bundle has no agent config file to read; the host injects values.
55
+ WAVE_DISABLE_AGENT_CONFIG_FALLBACK: "1",
56
+ },
57
+ },
58
+ },
59
+ user_config: {
60
+ access_token: {
61
+ type: "string",
62
+ title: "Wave access token",
63
+ description: "OAuth2 access token from https://developer.waveapps.com/",
64
+ sensitive: true,
65
+ required: true,
66
+ },
67
+ business_id: {
68
+ type: "string",
69
+ title: "Default business ID",
70
+ description: "Optional. Lets tools omit business_id. Find it with wave_list_businesses.",
71
+ required: false,
72
+ },
73
+ allow_writes: {
74
+ type: "string",
75
+ title: "Enable write tools",
76
+ description:
77
+ 'Set to 1 to enable tools that create, change, delete, or email records. Left empty, the server is read-only.',
78
+ required: false,
79
+ default: "",
80
+ },
81
+ },
82
+ compatibility: {
83
+ claude_desktop: ">=0.10.0",
84
+ platforms: ["darwin", "win32", "linux"],
85
+ runtimes: { node: ">=18.0.0" },
86
+ },
87
+ };
88
+
89
+ function run(command, args, options = {}) {
90
+ execFileSync(command, args, { stdio: "inherit", ...options });
91
+ }
92
+
93
+ fs.rmSync(stageDir, { recursive: true, force: true });
94
+ fs.mkdirSync(stageDir, { recursive: true });
95
+
96
+ fs.writeFileSync(path.join(stageDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
97
+ fs.copyFileSync(path.join(projectRoot, "index.js"), path.join(stageDir, "index.js"));
98
+ fs.copyFileSync(path.join(projectRoot, "package.json"), path.join(stageDir, "package.json"));
99
+ fs.copyFileSync(path.join(projectRoot, "package-lock.json"), path.join(stageDir, "package-lock.json"));
100
+ fs.copyFileSync(path.join(projectRoot, "assets", "icon.png"), path.join(stageDir, "icon.png"));
101
+ fs.copyFileSync(path.join(projectRoot, "LICENSE"), path.join(stageDir, "LICENSE"));
102
+ fs.copyFileSync(path.join(projectRoot, "README.md"), path.join(stageDir, "README.md"));
103
+
104
+ // Production dependencies only: a bundle ships what it needs to run, and dev
105
+ // tooling would multiply its size for no benefit.
106
+ console.log("Installing production dependencies into the bundle...");
107
+ run("npm", ["ci", "--omit=dev", "--no-audit", "--no-fund"], { cwd: stageDir });
108
+
109
+ fs.rmSync(outputPath, { force: true });
110
+ console.log(`Packing ${path.relative(projectRoot, outputPath)}...`);
111
+ run("zip", ["-qr", outputPath, "."], { cwd: stageDir });
112
+
113
+ fs.rmSync(stageDir, { recursive: true, force: true });
114
+
115
+ const sizeMb = (fs.statSync(outputPath).size / 1024 / 1024).toFixed(2);
116
+ console.log(`Built ${path.relative(projectRoot, outputPath)} (${sizeMb} MB)`);
117
+
118
+ if (!fs.existsSync(outputPath)) {
119
+ console.error("Bundle was not produced.");
120
+ process.exit(1);
121
+ }
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Fail the build when anything about a release disagrees with package.json.
5
+ *
6
+ * Version strings live in eight files across five hosts plus the server
7
+ * handshake. Catching drift here is cheaper than shipping a plugin manifest
8
+ * that advertises a version npm never published.
9
+ *
10
+ * Pass --registry to additionally check whether this version is already on
11
+ * npm, which prevents a duplicate publish.
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import process from "node:process";
18
+
19
+ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
20
+ const packageJson = readJson("package.json");
21
+ const { version, name: packageName } = packageJson;
22
+ const pluginName = "wave-mcp-server";
23
+
24
+ const problems = [];
25
+
26
+ // 1. Plugin manifests carry the package version.
27
+ for (const manifestPath of [
28
+ ".claude-plugin/plugin.json",
29
+ "codex/.codex-plugin/plugin.json",
30
+ ".hermes-plugin/plugin.json",
31
+ ".antigravity-plugin/plugin.json",
32
+ ]) {
33
+ const manifest = readJson(manifestPath);
34
+ if (manifest.version !== version) {
35
+ problems.push(`${manifestPath}: version ${manifest.version} != package.json ${version}`);
36
+ }
37
+ }
38
+
39
+ // 2. Marketplace entries carry the package version.
40
+ for (const marketplacePath of [
41
+ ".claude-plugin/marketplace.json",
42
+ ".agents/plugins/marketplace.json",
43
+ ".hermes-plugin/marketplace.json",
44
+ ".antigravity-plugin/marketplace.json",
45
+ ]) {
46
+ const marketplace = readJson(marketplacePath);
47
+ const plugin = (marketplace.plugins ?? []).find((p) => p.name === pluginName);
48
+ if (!plugin) {
49
+ problems.push(`${marketplacePath}: no plugin entry named ${pluginName}`);
50
+ } else if (plugin.version !== version) {
51
+ problems.push(`${marketplacePath}: plugin version ${plugin.version} != package.json ${version}`);
52
+ }
53
+ }
54
+
55
+ // 3. MCP configs install the right package.
56
+ for (const mcpConfigPath of [
57
+ ".mcp.json",
58
+ "codex/.codex-plugin/mcp.json",
59
+ ".hermes-plugin/mcp.json",
60
+ ".antigravity-plugin/mcp_config.json",
61
+ ]) {
62
+ const config = readJson(mcpConfigPath);
63
+ const server = config.mcpServers?.[pluginName] ?? config[pluginName];
64
+ if (!server) {
65
+ problems.push(`${mcpConfigPath}: no server entry named ${pluginName}`);
66
+ continue;
67
+ }
68
+ const target = (server.args ?? []).find((arg) => typeof arg === "string" && arg.startsWith("@oliverames/"));
69
+ if (target !== `${packageName}@latest`) {
70
+ problems.push(`${mcpConfigPath}: installs ${target}, expected ${packageName}@latest`);
71
+ }
72
+ }
73
+
74
+ // 4. The handshake version matches the package.
75
+ const source = fs.readFileSync(path.join(projectRoot, "index.js"), "utf8");
76
+ const declared = source.match(/const SERVER_VERSION = "([^"]*)";/)?.[1];
77
+ if (declared !== version) {
78
+ problems.push(`index.js: SERVER_VERSION ${declared} != package.json ${version}`);
79
+ }
80
+
81
+ // 5. Files listed for publish actually exist.
82
+ for (const entry of packageJson.files ?? []) {
83
+ const target = path.join(projectRoot, entry.replace(/\/$/, ""));
84
+ if (!fs.existsSync(target)) {
85
+ problems.push(`package.json files: "${entry}" does not exist`);
86
+ }
87
+ }
88
+
89
+ if (problems.length) {
90
+ console.error("Release consistency check failed:\n");
91
+ for (const problem of problems) console.error(` - ${problem}`);
92
+ console.error("\nRun `npm run sync:plugin` to propagate the version, then re-check.");
93
+ process.exit(1);
94
+ }
95
+
96
+ console.log(`Release consistency OK for ${packageName}@${version}.`);
97
+
98
+ if (process.argv.includes("--registry")) {
99
+ await checkRegistry();
100
+ }
101
+
102
+ async function checkRegistry() {
103
+ const url = `https://registry.npmjs.org/${packageName.replace("/", "%2F")}`;
104
+ let published;
105
+ try {
106
+ const response = await fetch(url);
107
+ if (response.status === 404) {
108
+ console.log(`${packageName} is not on npm yet; ${version} would be the first publish.`);
109
+ return;
110
+ }
111
+ published = await response.json();
112
+ } catch (error) {
113
+ console.error(`Could not reach the npm registry: ${error.message}`);
114
+ process.exit(1);
115
+ }
116
+
117
+ const versions = Object.keys(published.versions ?? {});
118
+ if (versions.includes(version)) {
119
+ console.error(`${packageName}@${version} is already published. Bump the version before releasing.`);
120
+ process.exit(1);
121
+ }
122
+ console.log(`${packageName}@${version} is not yet published (latest is ${published["dist-tags"]?.latest ?? "none"}).`);
123
+ }
124
+
125
+ function readJson(relativePath) {
126
+ return JSON.parse(fs.readFileSync(path.join(projectRoot, relativePath), "utf8"));
127
+ }
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Launch the server over stdio exactly as an MCP client would, and report what
5
+ * it advertises.
6
+ *
7
+ * This is the check that "the module imports" cannot make: it proves the
8
+ * process starts, speaks JSON-RPC on stdout, and enumerates its tools and
9
+ * resources. It needs no Wave credentials.
10
+ */
11
+
12
+ import { spawn } from "node:child_process";
13
+ import path from "node:path";
14
+ import process from "node:process";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
18
+ const entryPoint = path.join(projectRoot, "index.js");
19
+
20
+ // Codex CLI kills a server that has not completed initialize in this long.
21
+ const CODEX_STARTUP_BUDGET_MS = 10000;
22
+
23
+ async function probe({ allowWrites }) {
24
+ const child = spawn(process.execPath, [entryPoint], {
25
+ cwd: projectRoot,
26
+ stdio: ["pipe", "pipe", "ignore"],
27
+ env: {
28
+ ...process.env,
29
+ WAVE_ACCESS_TOKEN: process.env.WAVE_ACCESS_TOKEN || "smoke-test-token",
30
+ WAVE_ALLOW_WRITES: allowWrites ? "1" : "",
31
+ // Read only what this probe sets, not the developer's real agent config.
32
+ WAVE_DISABLE_AGENT_CONFIG_FALLBACK: "1",
33
+ },
34
+ });
35
+
36
+ let buffer = "";
37
+ const pending = new Map();
38
+
39
+ child.stdout.on("data", (chunk) => {
40
+ buffer += chunk;
41
+ let newline;
42
+ while ((newline = buffer.indexOf("\n")) >= 0) {
43
+ const line = buffer.slice(0, newline).trim();
44
+ buffer = buffer.slice(newline + 1);
45
+ if (!line) continue;
46
+ const message = JSON.parse(line);
47
+ const resolve = pending.get(message.id);
48
+ if (resolve) {
49
+ pending.delete(message.id);
50
+ resolve(message);
51
+ }
52
+ }
53
+ });
54
+
55
+ const send = (id, method, params = {}) =>
56
+ new Promise((resolve, reject) => {
57
+ pending.set(id, resolve);
58
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
59
+ setTimeout(() => reject(new Error(`${method} timed out`)), CODEX_STARTUP_BUDGET_MS);
60
+ });
61
+
62
+ const started = Date.now();
63
+ const init = await send(1, "initialize", {
64
+ protocolVersion: "2025-06-18",
65
+ capabilities: {},
66
+ clientInfo: { name: "smoke-list-tools", version: "1" },
67
+ });
68
+ const startupMs = Date.now() - started;
69
+
70
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
71
+ const tools = (await send(2, "tools/list")).result.tools;
72
+ const resources = (await send(3, "resources/list")).result.resources;
73
+
74
+ child.stdin.end();
75
+ child.kill();
76
+
77
+ return { init: init.result, tools, resources, startupMs };
78
+ }
79
+
80
+ const readOnly = await probe({ allowWrites: false });
81
+ const readWrite = await probe({ allowWrites: true });
82
+
83
+ console.log(`server: ${readOnly.init.serverInfo.name} v${readOnly.init.serverInfo.version}`);
84
+ console.log(`instructions: ${readOnly.init.instructions ? "present" : "MISSING"}`);
85
+ console.log(`startup: ${readOnly.startupMs}ms (Codex allows ${CODEX_STARTUP_BUDGET_MS}ms)`);
86
+ console.log(`resources: ${readOnly.resources.length}`);
87
+ console.log(`tools: ${readOnly.tools.length} read-only, ${readWrite.tools.length} with WAVE_ALLOW_WRITES=1`);
88
+
89
+ const problems = [];
90
+ if (!readOnly.init.instructions) problems.push("initialize returned no instructions field (Codex reads it)");
91
+ if (readOnly.startupMs > CODEX_STARTUP_BUDGET_MS) problems.push(`startup ${readOnly.startupMs}ms exceeds Codex's budget`);
92
+ if (readWrite.tools.length <= readOnly.tools.length) problems.push("WAVE_ALLOW_WRITES did not register any extra tools");
93
+
94
+ // Nothing that changes or sends data may appear without the opt-in.
95
+ const readOnlyNames = new Set(readOnly.tools.map((t) => t.name));
96
+ for (const gated of ["wave_delete_invoice", "wave_send_invoice", "wave_delete_customer", "wave_create_invoice"]) {
97
+ if (readOnlyNames.has(gated)) problems.push(`${gated} is registered without WAVE_ALLOW_WRITES`);
98
+ }
99
+
100
+ for (const tool of readWrite.tools) {
101
+ if (!tool.name.startsWith("wave_")) problems.push(`${tool.name} lacks the wave_ prefix`);
102
+ if (!tool.description) problems.push(`${tool.name} has no description`);
103
+ if (!tool.annotations?.title) problems.push(`${tool.name} has no annotation title`);
104
+ }
105
+
106
+ if (problems.length) {
107
+ console.error("\nProblems:");
108
+ for (const problem of problems) console.error(` - ${problem}`);
109
+ process.exit(1);
110
+ }
111
+
112
+ console.log("\nAll checks passed.");
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pack the package, install it, and start it through its bin symlink.
5
+ *
6
+ * This exists because of a bug that every other check missed. npm installs the
7
+ * bin as a *relative symlink* (`node_modules/.bin/mcp-server-for-wave`), so
8
+ * `process.argv[1]` is neither absolute nor the real file. An autostart guard
9
+ * comparing `import.meta.url` to a raw argv[1] therefore never matched, and
10
+ * the server silently did nothing under `npx` -- the way essentially every
11
+ * user launches it. Running `node index.js` directly, as the other smoke test
12
+ * does, works fine and hides it completely.
13
+ *
14
+ * So: exercise the artifact the way a user gets it, not the way a developer
15
+ * runs it.
16
+ */
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { spawn } from "node:child_process";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import process from "node:process";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
27
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "wave-mcp-packed-"));
28
+ const STARTUP_BUDGET_MS = 15000;
29
+
30
+ function run(command, args, options = {}) {
31
+ return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options });
32
+ }
33
+
34
+ let failed = false;
35
+
36
+ try {
37
+ console.log(`Packing into ${workDir} ...`);
38
+ const packOutput = run("npm", ["pack", "--silent", "--pack-destination", workDir], { cwd: projectRoot });
39
+ const tarball = path.join(workDir, packOutput.trim().split("\n").pop().trim());
40
+ if (!fs.existsSync(tarball)) throw new Error(`npm pack did not produce ${tarball}`);
41
+ console.log(`Packed ${path.basename(tarball)}`);
42
+
43
+ fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({ name: "packed-smoke", private: true }, null, 2));
44
+ console.log("Installing the tarball ...");
45
+ run("npm", ["install", "--silent", "--no-audit", "--no-fund", tarball], { cwd: workDir });
46
+
47
+ // Both bin names the package declares must work.
48
+ for (const binName of ["mcp-server-for-wave", "wave-mcp-server"]) {
49
+ const binPath = path.join(workDir, "node_modules", ".bin", binName);
50
+ if (!fs.existsSync(binPath)) throw new Error(`bin ${binName} was not installed`);
51
+ const stat = fs.lstatSync(binPath);
52
+ console.log(`\n${binName}: ${stat.isSymbolicLink() ? "symlink" : "file"}`);
53
+ await probeBin(workDir, binName);
54
+ }
55
+
56
+ console.log("\nPacked install starts and responds over stdio.");
57
+ } catch (error) {
58
+ console.error(`\nFAILED: ${error.message}`);
59
+ failed = true;
60
+ } finally {
61
+ fs.rmSync(workDir, { recursive: true, force: true });
62
+ }
63
+
64
+ process.exit(failed ? 1 : 0);
65
+
66
+ /** Launch the installed bin the way a client would, and complete a handshake. */
67
+ async function probeBin(cwd, binName) {
68
+ // Deliberately relative, matching how npm and npx invoke it.
69
+ const relativeBin = path.join("node_modules", ".bin", binName);
70
+
71
+ const child = spawn(process.execPath, [relativeBin], {
72
+ cwd,
73
+ stdio: ["pipe", "pipe", "pipe"],
74
+ env: {
75
+ ...process.env,
76
+ WAVE_ACCESS_TOKEN: "packed-smoke-token",
77
+ WAVE_DISABLE_AGENT_CONFIG_FALLBACK: "1",
78
+ WAVE_ALLOW_WRITES: "1",
79
+ // Must NOT be set: this test is precisely about autostart working.
80
+ WAVE_MCP_NO_AUTOSTART: "",
81
+ },
82
+ });
83
+
84
+ let stderr = "";
85
+ child.stderr.on("data", (chunk) => {
86
+ stderr += chunk;
87
+ });
88
+
89
+ let buffer = "";
90
+ const pending = new Map();
91
+ child.stdout.on("data", (chunk) => {
92
+ buffer += chunk;
93
+ let newline;
94
+ while ((newline = buffer.indexOf("\n")) >= 0) {
95
+ const line = buffer.slice(0, newline).trim();
96
+ buffer = buffer.slice(newline + 1);
97
+ if (!line) continue;
98
+ const message = JSON.parse(line);
99
+ const resolve = pending.get(message.id);
100
+ if (resolve) {
101
+ pending.delete(message.id);
102
+ resolve(message);
103
+ }
104
+ }
105
+ });
106
+
107
+ const send = (id, method, params = {}) =>
108
+ new Promise((resolve, reject) => {
109
+ pending.set(id, resolve);
110
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
111
+ setTimeout(
112
+ () =>
113
+ reject(
114
+ new Error(
115
+ `${binName}: ${method} produced no response within ${STARTUP_BUDGET_MS}ms. ` +
116
+ `The bin likely did not autostart. stderr: ${stderr.trim() || "(empty)"}`
117
+ )
118
+ ),
119
+ STARTUP_BUDGET_MS
120
+ );
121
+ });
122
+
123
+ try {
124
+ const init = await send(1, "initialize", {
125
+ protocolVersion: "2025-06-18",
126
+ capabilities: {},
127
+ clientInfo: { name: "packed-smoke", version: "1" },
128
+ });
129
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
130
+ const tools = (await send(2, "tools/list")).result.tools;
131
+
132
+ console.log(` serverInfo: ${init.result.serverInfo.name} v${init.result.serverInfo.version}`);
133
+ console.log(` tools: ${tools.length}`);
134
+ if (tools.length === 0) throw new Error(`${binName} advertised no tools`);
135
+ } finally {
136
+ child.stdin.end();
137
+ child.kill();
138
+ }
139
+ }