make-runner-mcp 2.1.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/server.js ADDED
@@ -0,0 +1,1027 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * make-runner MCP server (dynamic)
4
+ *
5
+ * Parses the project's own Makefile (following any `include`/`-include`
6
+ * directives, e.g. a root Makefile pulling in `docker/Makefile`) and
7
+ * exposes one MCP tool per discovered target. Also follows a root catch-all
8
+ * pattern rule (`%:`) whose recipe recursively invokes `$(MAKE) -C <dir>
9
+ * ...`, so targets only reachable through that forwarding are discovered
10
+ * and exposed too. For a second Makefile that isn't reachable through
11
+ * either of those real make mechanisms (e.g. its include path uses a
12
+ * `$(VAR)` this server can't resolve, or it's a wholly separate,
13
+ * independently-invoked Makefile), a `# make-runner: also-read <path>`
14
+ * comment anywhere in a parsed file tells this server (not `make`) to also
15
+ * read that file's targets — those are then run directly against that
16
+ * file (see MAKEFILE-GUIDE.md). Nothing is exposed or
17
+ * executed that isn't literally a target defined in those files — the
18
+ * model can't construct or type an arbitrary command, only invoke targets
19
+ * that already exist in files your team presumably reviews like any other
20
+ * source file.
21
+ *
22
+ * `make` is always invoked with an explicit `-f <Makefile>` pointing at
23
+ * the exact file that was parsed, and extra `args` are restricted to a
24
+ * flag allowlist plus `VAR=value` pairs (excluding make/shell control
25
+ * variables, including PATH) — no bare positional argument is ever
26
+ * permitted, since make treats one as an *additional build goal*, not
27
+ * just data, which would let a call smuggle a second, unvetted target
28
+ * onto an otherwise-allowed invocation. So a call can never swap in a
29
+ * different makefile, change directory, hijack what a recipe's bare
30
+ * commands resolve to, invoke an extra target, or otherwise run
31
+ * something other than the single vetted target it names.
32
+ *
33
+ * Env vars:
34
+ * PROJECT_DIR - project root containing the Makefile (required)
35
+ * MCP_MAKE_CONFIG - optional path to a JSON config, default:
36
+ * <PROJECT_DIR>/.mcp-make-config.json
37
+ * MCP_TRANSPORT - "http" (default) or "stdio". http is the
38
+ * default because it's the only mode that works
39
+ * correctly when the *calling* agent runs inside
40
+ * its own sandbox: the server runs here, outside
41
+ * any sandbox, with normal access to whatever it
42
+ * needs (e.g. the real Docker daemon), and the
43
+ * agent reaches it only over the network — never
44
+ * via a locally-spawned subprocess, which is what
45
+ * stdio requires and which a sandboxed agent
46
+ * re-execs *inside* its own container (see
47
+ * README's "Running behind a sandbox"). Set
48
+ * MCP_TRANSPORT=stdio to opt back into the
49
+ * simpler subprocess-per-client-config model for
50
+ * direct, unsandboxed use.
51
+ * MCP_HTTP_HOST - http mode only; default 0.0.0.0
52
+ * MCP_HTTP_PORT - http mode only; default 8791
53
+ * MCP_HTTP_TOKEN - http mode only; REQUIRED, no default. The
54
+ * server refuses to start without one — see
55
+ * README for how callers authenticate with it.
56
+ *
57
+ * Config file shape (all fields optional):
58
+ * {
59
+ * "deny": ["deploy", "destroy", "prod"], // substrings to block
60
+ * "allow": ["up", "test", "composer"], // if present, ONLY these run
61
+ * "envAllowlist": ["PATH", "HOME"] // if present, ONLY these env vars reach make
62
+ * }
63
+ * A config that fails to parse, or an `allow`/`envAllowlist` entry that
64
+ * isn't a valid string array, fails closed (denies everything / passes no
65
+ * env vars) rather than silently falling back to unrestricted access.
66
+ */
67
+
68
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
69
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
70
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
71
+ import {
72
+ CallToolRequestSchema,
73
+ ListToolsRequestSchema,
74
+ ListPromptsRequestSchema,
75
+ GetPromptRequestSchema,
76
+ isInitializeRequest,
77
+ } from "@modelcontextprotocol/sdk/types.js";
78
+ import { spawn } from "node:child_process";
79
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
80
+ import { createServer as createHttpServer } from "node:http";
81
+ import { randomUUID, timingSafeEqual } from "node:crypto";
82
+ import path from "node:path";
83
+ import { fileURLToPath } from "node:url";
84
+
85
+ const PROJECT_DIR = path.resolve(process.env.PROJECT_DIR || process.cwd());
86
+ const MAKEFILE = path.join(PROJECT_DIR, "Makefile");
87
+ const CONFIG_PATH =
88
+ process.env.MCP_MAKE_CONFIG || path.join(PROJECT_DIR, ".mcp-make-config.json");
89
+
90
+ // This server's own location, not the target project's — used to serve the
91
+ // "fix-makefile-links" MCP prompt (below) straight from the same checkout
92
+ // this server is running from, and to point that prompt's own verification
93
+ // step back at this exact server.js rather than asking whoever receives
94
+ // the prompt to go find one.
95
+ const SCRIPT_PATH = fileURLToPath(import.meta.url);
96
+ const FIX_MAKEFILE_LINKS_SKILL_PATH = path.join(
97
+ path.dirname(SCRIPT_PATH),
98
+ "skills",
99
+ "fix-makefile-links",
100
+ "SKILL.md"
101
+ );
102
+
103
+ // Read once at startup, not hardcoded — the MCP `initialize` response's
104
+ // serverInfo.version previously drifted from package.json's own version
105
+ // for two releases (stuck at "2.0.0" through v2.0.1 and v2.0.2) because
106
+ // nothing tied them together. Falls back to "0.0.0-unknown" rather than
107
+ // crashing the server over a cosmetic field if package.json is ever
108
+ // missing/unreadable next to this script.
109
+ function readOwnVersion() {
110
+ try {
111
+ const pkgPath = path.join(path.dirname(SCRIPT_PATH), "package.json");
112
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
113
+ } catch (err) {
114
+ console.error(`Failed to read own version from package.json: ${err.message}`);
115
+ return "0.0.0-unknown";
116
+ }
117
+ }
118
+ const SERVER_VERSION = readOwnVersion();
119
+
120
+ // HTTP is the default transport (stdio is the opt-in) specifically so this
121
+ // server runs *outside* a sandboxed agent's own container/filesystem, with
122
+ // the agent reaching it only over the network, never via a locally-spawned
123
+ // subprocess or a mounted socket — see the README's "Running behind a
124
+ // sandbox" section for why that separation matters and what broke without
125
+ // it. Breaking change from earlier versions: a client config that spawns
126
+ // this as a subprocess expecting stdio (the old default) must now set
127
+ // MCP_TRANSPORT=stdio explicitly, or it'll get an HTTP server trying to
128
+ // bind a port instead of a stdio handshake.
129
+ const MCP_TRANSPORT = process.env.MCP_TRANSPORT || "http";
130
+ const MCP_HTTP_HOST = process.env.MCP_HTTP_HOST || "0.0.0.0";
131
+ const MCP_HTTP_PORT = Number(process.env.MCP_HTTP_PORT || 8791);
132
+ const MCP_HTTP_TOKEN = process.env.MCP_HTTP_TOKEN || "";
133
+ const TIMEOUT_MS = 5 * 60 * 1000;
134
+ const KILL_GRACE_MS = 5000;
135
+ const MAX_OUTPUT_BYTES = 200_000;
136
+ const MAX_INCLUDE_DEPTH = 8;
137
+ // A session that never sends a proper close (crashed/killed client, buggy
138
+ // client) would otherwise stay in `sessions` forever; swept on a timer
139
+ // rather than trusting every client to close cleanly.
140
+ const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
141
+ const SESSION_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
142
+ // Makefile `## comment` text is surfaced verbatim as MCP tool descriptions
143
+ // — untrusted-adjacent content read straight into the calling agent's
144
+ // context on every listTools call, whether or not the target is ever
145
+ // invoked. This doesn't make that content trustworthy, but it bounds how
146
+ // much of it (and therefore how large an injected payload) gets through.
147
+ const MAX_DESCRIPTION_LENGTH = 200;
148
+
149
+ // Built-in denylist floor, always applied regardless of config. Words are
150
+ // matched with separators stripped so a target can't dodge the floor by
151
+ // inserting a `-`/`_`/`.` (e.g. "de-ploy", "re_lease"). "rm-" is matched as
152
+ // a literal prefix instead — stripping separators for it would make it
153
+ // match almost anything containing "rm" (confirm, affirm, firmware, ...).
154
+ const HARD_DENY_WORDS = ["deploy", "destroy", "prod", "publish", "release"];
155
+ const HARD_DENY_PREFIXES = ["rm-"];
156
+
157
+ // `make` flags are allowlisted, not blocklisted: anything not on this list
158
+ // is rejected, including -f/--file, -C/--directory, -I/--include-dir and
159
+ // --eval, all of which could point make at a different makefile or inject
160
+ // unrelated rules — precisely the escape hatch this server exists to close.
161
+ const SAFE_FLAGS = new Set([
162
+ "-n", "--dry-run", "--just-print", "--recon",
163
+ "-B", "--always-make",
164
+ "-k", "--keep-going",
165
+ "-s", "--silent", "--quiet",
166
+ "-i", "--ignore-errors",
167
+ "-q", "--question",
168
+ ]);
169
+
170
+ // VAR=value command-line assignments are allowed, but never for variables
171
+ // that control which interpreter, makefile(s), or executable search path
172
+ // are used — overriding these changes *how* (or via what) a target's
173
+ // commands execute, independent of the target itself, and command-line
174
+ // assignments take precedence over both the Makefile and the environment.
175
+ // PATH is included here because make auto-exports command-line variable
176
+ // assignments into every recipe's shell environment — an attacker-chosen
177
+ // PATH would let a recipe's bare command names (npm, git, sh, ...) resolve
178
+ // to an attacker-controlled binary instead of the real one.
179
+ //
180
+ // VAR_ASSIGNMENT only matches names starting with a letter/underscore, so
181
+ // every dot-prefixed GNU Make special variable (.SHELLFLAGS,
182
+ // .RECIPEPREFIX, .EXTRA_PREREQS, ...) simply fails to match VAR_ASSIGNMENT
183
+ // and falls through to validateArg()'s catch-all rejection (every bare/
184
+ // unrecognized argument is rejected outright) rather than needing to be
185
+ // named here individually. That's deliberate: allowlisting VAR_ASSIGNMENT
186
+ // to admit a leading dot would
187
+ // also newly admit variables like .EXTRA_PREREQS (which can force an
188
+ // unrelated, possibly-denied target to run as a prerequisite) unless every
189
+ // dangerous one were enumerated — the categorical block is safer than a
190
+ // denylist we might forget to keep complete.
191
+ const DANGEROUS_VARS = new Set([
192
+ "SHELL", "MAKE", "MAKEFLAGS", "MAKEFILES", "MAKELEVEL",
193
+ "MAKECMDGOALS", "VPATH", "GPATH", "PATH",
194
+ ]);
195
+ const VAR_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
196
+
197
+ // Deliberately more permissive than SAFE_ARG/SAFE_FLAGS: this is the value
198
+ // half of a VAR=value pair, which is how a target's own recipe (e.g.
199
+ // `composer: ## $(ARGS) passed to composer` running `composer $(ARGS)`)
200
+ // receives passthrough arguments like "install symfony/console --no-dev".
201
+ // Spaces are allowed on purpose so a whole argument list can travel as one
202
+ // argv element to `make` — argv, not a shell, so spaces here don't create
203
+ // any injection risk against *our* spawn() call. The risk that matters is
204
+ // downstream: make substitutes this value into a recipe line, which make
205
+ // then runs via `$(SHELL) -c "..."`, a real shell. So the charset still
206
+ // excludes anything that shell could interpret as a control/metacharacter
207
+ // (; & | $ ` ' " < > ( ) { } newline # \ * ? [ ]) — what's left is letters,
208
+ // digits, spaces, and the punctuation ordinary package names, paths,
209
+ // versions and flags use.
210
+ const SAFE_VAR_VALUE = /^[A-Za-z0-9 _.,+\/@:^~=-]*$/;
211
+
212
+ if (!existsSync(MAKEFILE)) {
213
+ console.error(`No Makefile found at ${MAKEFILE}. Set PROJECT_DIR.`);
214
+ process.exit(1);
215
+ }
216
+
217
+ // Resolved once so include-boundary checks compare real paths, not lexical
218
+ // ones — a symlinked subdirectory (e.g. `docker_files` pointing outside the
219
+ // project) would otherwise pass a plain path.resolve()-based prefix check
220
+ // while actually reading from outside PROJECT_DIR.
221
+ const PROJECT_DIR_REAL = realpathSync(PROJECT_DIR);
222
+
223
+ // `--diagnose` runs the same Makefile-parsing this server uses for real,
224
+ // then prints a human-readable report of what it found (and, more
225
+ // usefully, what it *couldn't* resolve) and exits — no MCP transport, no
226
+ // MCP_HTTP_TOKEN required. This is meant to be run directly by a person
227
+ // against their own project (`PROJECT_DIR=/path/to/project node
228
+ // /path/to/make-runner-mcp/server.js --diagnose`) to check whether their
229
+ // Makefile(s) will be read the way they expect *before* wiring the server
230
+ // into an agent, rather than discovering a gap only once an agent reports
231
+ // a target as missing. See MAKEFILE-GUIDE.md for how to act on each
232
+ // section of the report.
233
+ if (process.argv.includes("--diagnose")) {
234
+ runDiagnostics();
235
+ process.exit(0);
236
+ }
237
+
238
+ if (MCP_TRANSPORT === "http" && !MCP_HTTP_TOKEN) {
239
+ // Fail closed, loudly, at startup — not on the first request. A network
240
+ // listener that executes commands with no auth at all is the one mistake
241
+ // here that isn't recoverable by a later config fix; refuse to bind the
242
+ // socket rather than start unauthenticated and hope every caller behaves.
243
+ console.error("MCP_TRANSPORT=http requires MCP_HTTP_TOKEN to be set (a long random string). Refusing to start unauthenticated.");
244
+ process.exit(1);
245
+ } else if (MCP_TRANSPORT !== "http" && MCP_TRANSPORT !== "stdio") {
246
+ console.error(`Unknown MCP_TRANSPORT '${MCP_TRANSPORT}'. Use 'stdio' (default) or 'http'.`);
247
+ process.exit(1);
248
+ }
249
+
250
+ // Rejects anything that isn't a safe flag or a safe VAR=value assignment.
251
+ // Returns an error string, or null if OK.
252
+ //
253
+ // Bare positional arguments (anything that isn't a flag or VAR=value) are
254
+ // rejected outright, not merely character-filtered: make treats a trailing
255
+ // bare word as an *additional build goal*, not just data. A character-class
256
+ // filter alone would still let a call to an allowed tool smuggle a second,
257
+ // denylisted target name onto the same invocation (e.g. `args: ["clean-
258
+ // volumes"]` on an allowed target produces `make -f Makefile <allowed>
259
+ // clean-volumes`, running both) — bypassing isAllowed() entirely for the
260
+ // smuggled goal. Only the single target already vetted by isAllowed() may
261
+ // ever be passed as a goal.
262
+ function validateArg(a) {
263
+ if (typeof a !== "string" || a.length === 0) return "argument must be a non-empty string";
264
+
265
+ if (a.startsWith("-")) {
266
+ return SAFE_FLAGS.has(a) ? null : `flag '${a}' is not permitted`;
267
+ }
268
+
269
+ const varMatch = a.match(VAR_ASSIGNMENT);
270
+ if (varMatch) {
271
+ const [, varName, value] = varMatch;
272
+ if (DANGEROUS_VARS.has(varName)) return `variable '${varName}' cannot be overridden`;
273
+ if (!SAFE_VAR_VALUE.test(value)) return `invalid value for '${varName}'`;
274
+ return null;
275
+ }
276
+
277
+ return `bare argument '${a}' is not permitted (would be treated as an additional make goal — only flags and VAR=value assignments are allowed)`;
278
+ }
279
+
280
+ function sanitizeStringArray(value) {
281
+ if (!Array.isArray(value)) return [];
282
+ return value.filter((v) => typeof v === "string");
283
+ }
284
+
285
+ // A broken config must never grant *more* access than no config at all —
286
+ // e.g. a malformed `allow` list fails closed to "nothing allowed" rather
287
+ // than silently disabling the allowlist. This also guarantees `deny` and
288
+ // `allow` are always real string arrays, so isAllowed() below can never be
289
+ // handed a non-string entry and throw (that previously took the whole
290
+ // server down on every subsequent call, not just the one bad request).
291
+ function loadConfig() {
292
+ const empty = { deny: [], allow: null, envAllowlist: null };
293
+ if (!existsSync(CONFIG_PATH)) return empty;
294
+ try {
295
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
296
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
297
+ throw new Error("config must be a JSON object");
298
+ }
299
+ return {
300
+ deny: sanitizeStringArray(raw.deny),
301
+ allow: raw.allow == null ? null : sanitizeStringArray(raw.allow),
302
+ envAllowlist: raw.envAllowlist == null ? null : sanitizeStringArray(raw.envAllowlist),
303
+ };
304
+ } catch (err) {
305
+ console.error(`Failed to parse ${CONFIG_PATH}: ${err.message}`);
306
+ // A file exists but couldn't be understood at all — fail closed on
307
+ // every axis (deny all targets, pass no env vars) rather than
308
+ // guessing which restrictions the operator meant to apply.
309
+ return { deny: [], allow: [], envAllowlist: [] };
310
+ }
311
+ }
312
+
313
+ // Parses real targets out of a Makefile: lines like
314
+ // target: deps ## Optional self-documenting comment
315
+ // Skips non-catch-all pattern rules (%.o: %.c, docker-%:, etc.), special
316
+ // targets (.PHONY etc.), and variable assignments (which also contain a
317
+ // colon, e.g. FOO := bar). Follows include/-include/sinclude directives
318
+ // (e.g. a root Makefile pulling in docker/Makefile) so targets defined
319
+ // there are discovered too — the real `make` invocation would load them
320
+ // anyway via the same directive, so treating them as un-vetted would just
321
+ // leave real targets unexposed while offering no actual protection.
322
+ //
323
+ // Also follows the catch-all forwarding idiom — a bare `%:` rule whose
324
+ // recipe recursively invokes `$(MAKE) -C <dir> ...` to forward any goal
325
+ // not defined at this level to another directory's Makefile. Real `make`
326
+ // already resolves and runs such a forwarded goal correctly through this
327
+ // server's existing `make -f <Makefile> <target>` invocation (an explicit
328
+ // target always still wins over the pattern rule, same as real `make`);
329
+ // the only gap this closes is discovery, so those targets can be listed
330
+ // and invoked as MCP tools like any other.
331
+ //
332
+ // Both of the above are *real* make mechanisms: whatever file this server
333
+ // ultimately invokes with `-f <execCtx.file>` will itself load the same
334
+ // included/forwarded file the same way, so the target really does exist
335
+ // from that invocation's point of view. `execCtx` tracks which file/cwd a
336
+ // discovered target should actually be run through — it stays the parent
337
+ // call's file/dir for both of these, since that's what real `make` would
338
+ // use too.
339
+ //
340
+ // A third, non-make mechanism exists for projects where a second Makefile
341
+ // genuinely isn't reachable via include/forwarding (e.g. the include path
342
+ // can't be resolved because it uses a `$(VAR)`, or the second file is a
343
+ // wholly separate, independently-invoked Makefile with no real linkage at
344
+ // all): a `# make-runner: also-read <path>` (or `##`) comment anywhere in
345
+ // the file. This is a hint *to this server*, not to `make` — nothing about
346
+ // it changes what `make` itself would do with this file — so a target
347
+ // found this way is executed directly against that other file (`-f
348
+ // <linked file>`, cwd = its directory), not through execCtx.file. That
349
+ // means it won't see variables the root Makefile might otherwise have set
350
+ // for it; see MAKEFILE-GUIDE.md for when to reach for this vs. a real
351
+ // include/forwarding link.
352
+ //
353
+ // Every file is resolved to its real (symlink-dereferenced) path before
354
+ // being read or boundary-checked, so a symlinked directory pointing
355
+ // outside PROJECT_DIR can't be used to smuggle targets from outside the
356
+ // project past the "stay inside the project" check below.
357
+ // `diag`, when passed (only by runDiagnostics()), is a sink for problems
358
+ // that are otherwise silently skipped during normal parsing — an
359
+ // unresolvable path is exactly as "not a target" to a real MCP tool call
360
+ // either way, but a human running `--diagnose` benefits from being told
361
+ // *why* a file it expected to see wasn't read, rather than just seeing it
362
+ // missing from the output.
363
+ function parseTargetsInto(filePath, targets, visited, depth, execCtx, diag = null) {
364
+ if (depth > MAX_INCLUDE_DEPTH) return;
365
+
366
+ let real;
367
+ try {
368
+ real = realpathSync(filePath);
369
+ } catch {
370
+ if (diag) diag.missing.push(filePath);
371
+ return; // doesn't exist, or a broken symlink
372
+ }
373
+ if (real !== PROJECT_DIR_REAL && !real.startsWith(PROJECT_DIR_REAL + path.sep)) {
374
+ if (diag) diag.outsideProject.push(real);
375
+ return;
376
+ }
377
+ if (visited.has(real)) return;
378
+ visited.add(real);
379
+
380
+ const fileDir = path.dirname(real);
381
+ const text = readFileSync(real, "utf8");
382
+ const lines = text.split("\n");
383
+
384
+ // Directories to forward into, collected here but not resolved until
385
+ // after this file's own loop finishes (see the loop below this one) —
386
+ // so an explicit target declared anywhere in this file always wins over
387
+ // a same-named forwarded target, matching real `make`'s own precedence
388
+ // regardless of whether the `%:` rule appears before or after it.
389
+ const forwardDirs = [];
390
+
391
+ // Files named via the `also-read` comment marker, resolved after this
392
+ // file's own loop finishes for the same reason as forwardDirs above: an
393
+ // explicit target defined anywhere in this file wins over one pulled in
394
+ // from a linked file.
395
+ const linkedFiles = [];
396
+
397
+ for (let i = 0; i < lines.length; i++) {
398
+ const line = lines[i];
399
+ const inc = line.match(/^\s*(?:-|s)?include\s+(.+)$/);
400
+ if (inc) {
401
+ for (const part of inc[1].trim().split(/\s+/)) {
402
+ if (!part) continue;
403
+ if (part.includes("$")) {
404
+ if (diag) diag.unresolvedIncludes.push({ inFile: real, raw: part });
405
+ continue; // can't resolve variable expansions
406
+ }
407
+ const incPath = path.resolve(fileDir, part);
408
+ parseTargetsInto(incPath, targets, visited, depth + 1, execCtx, diag);
409
+ }
410
+ continue;
411
+ }
412
+
413
+ // The `also-read` marker: a plain comment, recognized by this server
414
+ // only — it has no effect on what real `make` does with this file.
415
+ // One or more whitespace-separated paths, each resolved relative to
416
+ // this file's own directory (matching how `include` resolves paths).
417
+ const alsoRead = line.match(/^\s*#{1,2}\s*make-runner:\s*also-read\s+(.+)$/);
418
+ if (alsoRead) {
419
+ for (const part of alsoRead[1].trim().split(/\s+/)) {
420
+ if (!part) continue;
421
+ if (part.includes("$")) {
422
+ if (diag) diag.unresolvedAlsoReads.push({ inFile: real, raw: part });
423
+ continue; // can't resolve variable expansions
424
+ }
425
+ let linkedPath = path.resolve(fileDir, part);
426
+ try {
427
+ if (statSync(linkedPath).isDirectory()) linkedPath = path.join(linkedPath, "Makefile");
428
+ } catch {
429
+ // doesn't exist at this exact path — fall through and let the
430
+ // recursive parseTargetsInto's own realpathSync try/catch report
431
+ // it as unreadable, same as any other missing include target.
432
+ }
433
+ linkedFiles.push(linkedPath);
434
+ }
435
+ continue;
436
+ }
437
+
438
+ // Matched against the rule part only (everything before make's own
439
+ // comment character, `#` — not specifically `##`; make itself treats
440
+ // a single unescaped `#` as starting a comment on a rule line, and
441
+ // plenty of real Makefiles use single-`#` descriptions rather than
442
+ // this project's own `##` convention). The trailing `[^=]*` below
443
+ // exists to rule out plain variable assignments like `FOO = bar`, but
444
+ // matching it against the *whole* line — comment included — would
445
+ // also reject any genuine target whose trailing comment happens to
446
+ // contain "=" (e.g. `# usage: make composer ARGS="require ..."`),
447
+ // which is exactly the kind of comment the ARGS passthrough
448
+ // convention (see README) encourages people to write. `\#` is treated
449
+ // as an escaped, literal `#` (matching make's own escaping), not a
450
+ // comment start.
451
+ const hashMatch = line.match(/(?<!\\)#/);
452
+ const rulePart = hashMatch ? line.slice(0, hashMatch.index) : line;
453
+
454
+ // The catch-all forwarding idiom: a rule whose target is *only* `%`
455
+ // (matches every goal), as opposed to a suffix/prefix pattern rule
456
+ // like `%.o: %.c` or `docker-%:`, which this regex does not match and
457
+ // which falls through to the general target regex below (and is
458
+ // skipped there, as always, since `%` isn't in its character class).
459
+ const catchAll = rulePart.match(/^%\s*:(?!=)(?:[^=]*)$/);
460
+ if (catchAll) {
461
+ // Scan only this rule's own recipe lines — consecutive lines
462
+ // immediately following it that start with a tab, make's own
463
+ // recipe-line convention — stopping at the first line that isn't
464
+ // one. The lazy `[^\n]*?` lets other flags (e.g.
465
+ // `--no-print-directory`) appear before `-C` on the same line.
466
+ let j = i + 1;
467
+ let forwardDir = null;
468
+ while (j < lines.length && /^\t/.test(lines[j])) {
469
+ const mk = lines[j].match(/\$\(MAKE\)[^\n]*?-C\s*"?([^\s"]+)"?/);
470
+ if (mk && !forwardDir) forwardDir = mk[1];
471
+ j++;
472
+ }
473
+ i = j - 1; // resume the outer loop after this rule's recipe lines
474
+ if (forwardDir && !forwardDir.includes("$")) forwardDirs.push(forwardDir); // else: not a forward, or unresolvable variable expansion
475
+ continue;
476
+ }
477
+
478
+ const m = rulePart.match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\s*:(?!=)(?:[^=]*)$/);
479
+ if (!m) continue;
480
+ const name = m[1];
481
+ if (name.startsWith(".")) continue; // .PHONY, .DEFAULT, etc.
482
+ if (targets.has(name)) continue; // first definition wins, like make itself
483
+
484
+ const commentMatch = line.match(/##\s*(.+)$/);
485
+ let description = commentMatch ? commentMatch[1].trim() : `Run 'make ${name}'`;
486
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
487
+ description = description.slice(0, MAX_DESCRIPTION_LENGTH) + "…";
488
+ }
489
+ targets.set(name, { description, execFile: execCtx.file, execDir: execCtx.dir });
490
+ }
491
+
492
+ for (const dir of forwardDirs) {
493
+ parseTargetsInto(path.resolve(fileDir, dir, "Makefile"), targets, visited, depth + 1, execCtx, diag);
494
+ }
495
+
496
+ // Unlike forwardDirs above, each linked file gets its *own* execCtx: it's
497
+ // executed directly (`-f <linked file>`, cwd = its directory), not via
498
+ // execCtx.file, since — being a server-only hint rather than a real make
499
+ // mechanism — real `make -f execCtx.file` would never actually load it.
500
+ for (const linkedPath of linkedFiles) {
501
+ parseTargetsInto(
502
+ linkedPath,
503
+ targets,
504
+ visited,
505
+ depth + 1,
506
+ { file: linkedPath, dir: path.dirname(linkedPath) },
507
+ diag
508
+ );
509
+ }
510
+ }
511
+
512
+ function parseTargets(diag = null) {
513
+ const targets = new Map(); // name -> { description, execFile, execDir }
514
+ parseTargetsInto(MAKEFILE, targets, new Set(), 0, { file: MAKEFILE, dir: PROJECT_DIR }, diag);
515
+ return targets;
516
+ }
517
+
518
+ function isAllowed(name, config) {
519
+ const lower = name.toLowerCase();
520
+ const normalized = lower.replace(/[-_.]/g, "");
521
+ if (HARD_DENY_WORDS.some((d) => normalized.includes(d))) return false;
522
+ if (HARD_DENY_PREFIXES.some((d) => lower.startsWith(d))) return false;
523
+ if (config.deny.some((d) => lower.includes(d.toLowerCase()))) return false;
524
+ if (config.allow && !config.allow.some((a) => a.toLowerCase() === lower)) return false;
525
+ return true;
526
+ }
527
+
528
+ function toolNameFor(target) {
529
+ // MCP tool names: keep it simple/safe, prefix to avoid collisions.
530
+ return `make__${target.replace(/[^A-Za-z0-9_-]/g, "_")}`;
531
+ }
532
+
533
+ // Single source of truth for target -> tool name, shared by ListTools and
534
+ // CallTool so they can never disagree about which target a call name
535
+ // resolves to. Distinct target names that normalize to the same tool name
536
+ // (e.g. "foo.bar" and "foo_bar" both -> "make__foo_bar") would otherwise
537
+ // silently collide, leaving the second one listed-but-uncallable (or not
538
+ // listed at all here) with no indication why — we skip and log instead.
539
+ function buildToolEntries(targets, config) {
540
+ const entries = [];
541
+ const usedToolNames = new Set();
542
+ for (const [name, meta] of targets) {
543
+ if (!isAllowed(name, config)) continue;
544
+ const toolName = toolNameFor(name);
545
+ if (usedToolNames.has(toolName)) {
546
+ console.error(
547
+ `Skipping target '${name}': tool name '${toolName}' collides with an already-exposed target.`
548
+ );
549
+ continue;
550
+ }
551
+ usedToolNames.add(toolName);
552
+ entries.push({ targetName: name, toolName, description: meta.description, execFile: meta.execFile, execDir: meta.execDir });
553
+ }
554
+ return entries;
555
+ }
556
+
557
+ // Human-facing report for `--diagnose` (see the flag's own comment above
558
+ // for why/when to run it). Automates the parts of MAKEFILE-GUIDE.md that
559
+ // this server can actually check for itself — what got discovered, what
560
+ // got silently skipped and why, which targets the denylist blocks — so a
561
+ // person can see the gap directly instead of having to hand this file to
562
+ // an agent and ask it to work through the guide by hand.
563
+ function runDiagnostics() {
564
+ const config = loadConfig();
565
+ const diag = { missing: [], outsideProject: [], unresolvedIncludes: [], unresolvedAlsoReads: [] };
566
+ const targets = parseTargets(diag);
567
+ const entries = buildToolEntries(targets, config);
568
+ const exposedNames = new Set(entries.map((e) => e.targetName));
569
+
570
+ const lines = [];
571
+ const section = (title) => lines.push("", `-- ${title} --`);
572
+
573
+ section(`EXPOSED TARGETS (${entries.length})`);
574
+ if (entries.length === 0) lines.push("none");
575
+ for (const e of entries) {
576
+ const via = e.execFile !== MAKEFILE ? ` [via ${path.relative(PROJECT_DIR, e.execFile)}]` : "";
577
+ const desc = e.description === `Run 'make ${e.targetName}'` ? "MISSING DESCRIPTION — add a `## ...` comment" : e.description;
578
+ lines.push(`${e.targetName}${via}: ${desc}`);
579
+ }
580
+
581
+ section("BLOCKED TARGETS (denied by hard denylist or config)");
582
+ const blocked = [...targets.keys()].filter((n) => !exposedNames.has(n));
583
+ if (blocked.length === 0) lines.push("none");
584
+ for (const name of blocked) {
585
+ const lower = name.toLowerCase();
586
+ const normalized = lower.replace(/[-_.]/g, "");
587
+ const hardHit = HARD_DENY_WORDS.find((d) => normalized.includes(d));
588
+ const prefixHit = HARD_DENY_PREFIXES.find((d) => lower.startsWith(d));
589
+ const configDenyHit = config.deny.find((d) => lower.includes(d.toLowerCase()));
590
+ let reason;
591
+ if (hardHit) reason = `hard denylist word '${hardHit}' — CHECK: false positive? see MAKEFILE-GUIDE.md §5`;
592
+ else if (prefixHit) reason = `hard denylist prefix '${prefixHit}' — CHECK: false positive? see MAKEFILE-GUIDE.md §5`;
593
+ else if (configDenyHit) reason = `.mcp-make-config.json deny entry '${configDenyHit}'`;
594
+ else if (config.allow) reason = "not in .mcp-make-config.json's allow list";
595
+ else reason = "blocked (reason unclear — re-check config)";
596
+ lines.push(`${name}: ${reason}`);
597
+ }
598
+
599
+ section("UNRESOLVED $(VAR) INCLUDE PATHS (can't be resolved by this server or real `make -f` parsing alone)");
600
+ if (diag.unresolvedIncludes.length === 0) lines.push("none");
601
+ for (const u of diag.unresolvedIncludes) {
602
+ lines.push(`${path.relative(PROJECT_DIR, u.inFile)}: include ${u.raw} — flag as NEEDS HUMAN DECISION per MAKEFILE-GUIDE.md §1`);
603
+ }
604
+
605
+ section("UNRESOLVED $(VAR) also-read PATHS");
606
+ if (diag.unresolvedAlsoReads.length === 0) lines.push("none");
607
+ for (const u of diag.unresolvedAlsoReads) {
608
+ lines.push(`${path.relative(PROJECT_DIR, u.inFile)}: also-read ${u.raw} — the also-read marker can't resolve variables either; use a literal relative path`);
609
+ }
610
+
611
+ section("FILES REFERENCED BUT NOT FOUND (missing include/also-read target, or broken symlink)");
612
+ if (diag.missing.length === 0) lines.push("none");
613
+ for (const m of diag.missing) lines.push(m);
614
+
615
+ section("FILES REJECTED FOR RESOLVING OUTSIDE THE PROJECT (symlink or `../` escape)");
616
+ if (diag.outsideProject.length === 0) lines.push("none");
617
+ for (const o of diag.outsideProject) lines.push(o);
618
+
619
+ console.log(`make-runner-mcp diagnostics for PROJECT_DIR=${PROJECT_DIR}`);
620
+ console.log(`Root Makefile: ${MAKEFILE}`);
621
+ console.log(lines.join("\n"));
622
+ }
623
+
624
+ // Serves skills/fix-makefile-links/SKILL.md (the same file distributed for
625
+ // manual/Claude-Code-skill installs — see README) as an MCP prompt, so
626
+ // simply connecting to this server is enough to get it: no separate file
627
+ // to copy into ~/.claude/skills first. One file stays the source of truth
628
+ // for the procedure; this just strips the Claude-Code-specific frontmatter
629
+ // and prepends the facts this running server already knows (its own
630
+ // PROJECT_DIR/Makefile/script path) so whoever receives the prompt doesn't
631
+ // need to search for a make-runner-mcp checkout the way a cold read of the
632
+ // skill file on its own would require.
633
+ function buildFixMakefileLinksPrompt() {
634
+ let raw;
635
+ try {
636
+ raw = readFileSync(FIX_MAKEFILE_LINKS_SKILL_PATH, "utf8");
637
+ } catch (err) {
638
+ console.error(`Failed to read ${FIX_MAKEFILE_LINKS_SKILL_PATH}: ${err.message}`);
639
+ return (
640
+ `Could not load the fix-makefile-links procedure from this server's own ` +
641
+ `checkout (expected at ${FIX_MAKEFILE_LINKS_SKILL_PATH}). Run ` +
642
+ `\`PROJECT_DIR=${PROJECT_DIR} node ${SCRIPT_PATH} --diagnose\` and fix any ` +
643
+ `gaps it reports using MAKEFILE-GUIDE.md's guidance on include/forwarding/` +
644
+ `also-read links.`
645
+ );
646
+ }
647
+ // Strip the leading YAML frontmatter block — it's Claude Code skill
648
+ // metadata (description/argument-hint/allowed-tools), meaningless to a
649
+ // raw MCP prompt message.
650
+ const body = raw.replace(/^---\n[\s\S]*?\n---\n/, "");
651
+ const context =
652
+ `You are receiving this as an MCP prompt served directly by the ` +
653
+ `make-runner-mcp server already configured for this project — you ` +
654
+ `already have everything Step 5 asks you to go find:\n\n` +
655
+ `Project root (PROJECT_DIR): ${PROJECT_DIR}\n` +
656
+ `Root Makefile: ${MAKEFILE}\n` +
657
+ `This server's own script, for Step 5's live verification: ${SCRIPT_PATH}\n` +
658
+ ` e.g. PROJECT_DIR="${PROJECT_DIR}" node "${SCRIPT_PATH}" --diagnose\n\n` +
659
+ `---\n\n`;
660
+ return context + body;
661
+ }
662
+
663
+ // By default the child inherits the full parent environment, which in an
664
+ // agent sandbox often carries API keys/tokens — those become readable (and
665
+ // echoable back into the tool result) by every recipe. Projects that want
666
+ // to narrow this can set `envAllowlist` in .mcp-make-config.json; absent
667
+ // that, behavior is unchanged from before (full passthrough) so this is
668
+ // opt-in hardening, not a breaking change.
669
+ //
670
+ // PATH always passes through, even when omitted from envAllowlist: Node
671
+ // resolves the `make` executable itself using PATH from the *child's* env
672
+ // (not the parent's), and recipes almost universally invoke bare command
673
+ // names (npm, git, gcc, ...) that likewise need PATH to resolve. Without
674
+ // this, narrowing envAllowlist to anything that forgets PATH would break
675
+ // every tool call outright rather than actually narrowing exposure.
676
+ function buildChildEnv(config) {
677
+ if (!config.envAllowlist) return { ...process.env };
678
+ const env = {};
679
+ for (const key of config.envAllowlist) {
680
+ if (process.env[key] !== undefined) env[key] = process.env[key];
681
+ }
682
+ if (env.PATH === undefined && process.env.PATH !== undefined) {
683
+ env.PATH = process.env.PATH;
684
+ }
685
+ return env;
686
+ }
687
+
688
+ function runMake(targetArgs, label, config, cwd = PROJECT_DIR) {
689
+ return new Promise((resolve) => {
690
+ // detached:true makes `child` a process-group leader (POSIX) so a
691
+ // timeout can terminate the whole tree, not just the immediate `make`
692
+ // process — otherwise a recipe that backgrounds work (e.g. `foo &`)
693
+ // can outlive both the timeout and the tool call that started it.
694
+ const child = spawn("make", targetArgs, {
695
+ cwd,
696
+ shell: false,
697
+ detached: process.platform !== "win32",
698
+ env: buildChildEnv(config),
699
+ });
700
+
701
+ let stdout = "";
702
+ let stderr = "";
703
+ let truncated = false;
704
+ let timedOut = false;
705
+
706
+ const killTree = (signal) => {
707
+ try {
708
+ if (process.platform !== "win32") process.kill(-child.pid, signal);
709
+ else child.kill(signal);
710
+ } catch {
711
+ // already exited
712
+ }
713
+ };
714
+
715
+ // Tracked separately from `timer` so a child that exits cleanly within
716
+ // the SIGTERM grace period cancels the pending SIGKILL too — otherwise
717
+ // it fires later against whatever process the OS has since reused
718
+ // `child.pid` for.
719
+ let killTimer = null;
720
+
721
+ const timer = setTimeout(() => {
722
+ timedOut = true;
723
+ killTree("SIGTERM");
724
+ killTimer = setTimeout(() => killTree("SIGKILL"), KILL_GRACE_MS);
725
+ }, TIMEOUT_MS);
726
+
727
+ const appendCapped = (buf, chunk) => {
728
+ if (buf.length >= MAX_OUTPUT_BYTES) {
729
+ truncated = true;
730
+ return buf;
731
+ }
732
+ const s = chunk.toString();
733
+ if (buf.length + s.length > MAX_OUTPUT_BYTES) {
734
+ truncated = true;
735
+ return buf + s.slice(0, MAX_OUTPUT_BYTES - buf.length);
736
+ }
737
+ return buf + s;
738
+ };
739
+
740
+ child.stdout.on("data", (c) => {
741
+ stdout = appendCapped(stdout, c);
742
+ });
743
+ child.stderr.on("data", (c) => {
744
+ stderr = appendCapped(stderr, c);
745
+ });
746
+
747
+ child.on("close", (code) => {
748
+ clearTimeout(timer);
749
+ if (killTimer) clearTimeout(killTimer);
750
+ resolve({
751
+ content: [
752
+ {
753
+ type: "text",
754
+ text:
755
+ `$ make ${targetArgs.join(" ")} (${label})\n` +
756
+ `exit code: ${code}\n\n--- stdout ---\n${stdout}\n` +
757
+ (stderr ? `--- stderr ---\n${stderr}\n` : "") +
758
+ (truncated ? "\n[output truncated]\n" : "") +
759
+ (timedOut ? `\n[timed out after ${TIMEOUT_MS / 1000}s, process group terminated]\n` : ""),
760
+ },
761
+ ],
762
+ isError: code !== 0 || timedOut,
763
+ });
764
+ });
765
+
766
+ child.on("error", (err) => {
767
+ clearTimeout(timer);
768
+ resolve({
769
+ content: [{ type: "text", text: `Failed to start make: ${err.message}` }],
770
+ isError: true,
771
+ });
772
+ });
773
+ });
774
+ }
775
+
776
+ // A fresh Server instance per connection, not a shared singleton — the SDK's
777
+ // Server/Protocol object represents one connected session (this is implicit
778
+ // in stdio, where there's inherently only ever one client, but explicit
779
+ // once there can be several concurrent HTTP sessions: reusing one Server
780
+ // across multiple transport.connect() calls produces "Server already
781
+ // initialized" on the second session's handshake). Cheap to construct —
782
+ // all the real state (Makefile/config) is re-read fresh on every request
783
+ // regardless, per the re-parse-on-every-call design above — so there's no
784
+ // cost to this beyond the one-time setup below.
785
+ function createServer() {
786
+ const server = new Server(
787
+ { name: "make-runner", version: SERVER_VERSION },
788
+ { capabilities: { tools: {}, prompts: {} } }
789
+ );
790
+
791
+ // Both handlers below are wrapped in try/catch as a last line of defense:
792
+ // no single bad input (a malformed config, an unreadable include, or a bug
793
+ // we haven't thought of) should be able to take down every future request.
794
+ // Fail closed — log the real error server-side, tell the caller nothing
795
+ // more than "rejected"/"no tools" — rather than let an exception propagate
796
+ // as a raw JSON-RPC error that both breaks subsequent calls and can leak
797
+ // internal detail back to the caller.
798
+
799
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
800
+ try {
801
+ const config = loadConfig();
802
+ const targets = parseTargets();
803
+ const tools = buildToolEntries(targets, config).map(({ targetName, toolName, description, execFile }) => {
804
+ // Flag targets reached only via the `also-read` marker (not a real
805
+ // make include/forwarding) so a calling agent understands this
806
+ // target runs against a separate Makefile, with its own directory
807
+ // as cwd — it won't see variables the root Makefile might set.
808
+ const viaNote = execFile !== MAKEFILE ? `, via ${path.relative(PROJECT_DIR, execFile)}` : "";
809
+ return {
810
+ name: toolName,
811
+ description: `${description} (make target: ${targetName}${viaNote})`,
812
+ inputSchema: {
813
+ type: "object",
814
+ properties: {
815
+ args: {
816
+ type: "array",
817
+ items: { type: "string" },
818
+ description: "Optional extra make flags and/or VAR=value pairs (e.g. ARGS=\"install symfony/console --no-dev\" for a target whose recipe uses $(ARGS)). No bare positional arguments — those would be treated as additional build goals.",
819
+ },
820
+ },
821
+ additionalProperties: false,
822
+ },
823
+ };
824
+ });
825
+ return { tools };
826
+ } catch (err) {
827
+ console.error(`ListTools failed: ${err.stack || err.message}`);
828
+ return { tools: [] };
829
+ }
830
+ });
831
+
832
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
833
+ try {
834
+ const { name: toolName, arguments: callArgs = {} } = request.params;
835
+
836
+ // Re-parse fresh on every call — never trust a cached mapping, so a
837
+ // Makefile edited between listing and calling can't be exploited to
838
+ // sneak a now-denied target through under an old tool name.
839
+ const config = loadConfig();
840
+ const targets = parseTargets();
841
+
842
+ const match = buildToolEntries(targets, config).find((t) => t.toolName === toolName);
843
+ if (!match) {
844
+ return {
845
+ content: [{ type: "text", text: `Rejected: '${toolName}' is not a currently allowed target.` }],
846
+ isError: true,
847
+ };
848
+ }
849
+
850
+ const extra = callArgs.args || [];
851
+ for (const a of extra) {
852
+ const err = validateArg(a);
853
+ if (err) {
854
+ return {
855
+ content: [{ type: "text", text: `Rejected: ${err}.` }],
856
+ isError: true,
857
+ };
858
+ }
859
+ }
860
+
861
+ // -f pins make to exactly the file we parsed and vetted `match` against
862
+ // — no argument path (allowlisted or not) can override this. For most
863
+ // targets that's the root MAKEFILE (real make will load whatever it
864
+ // includes/forwards to); for a target reached only via the
865
+ // `also-read` marker, match.execFile is that other file directly, run
866
+ // from its own directory (match.execDir), matching how it'd actually
867
+ // be invoked by hand.
868
+ return await runMake(
869
+ ["-f", match.execFile, match.targetName, ...extra],
870
+ `target: ${match.targetName}`,
871
+ config,
872
+ match.execDir
873
+ );
874
+ } catch (err) {
875
+ console.error(`CallTool failed: ${err.stack || err.message}`);
876
+ return {
877
+ content: [{ type: "text", text: "Rejected: internal error handling this request." }],
878
+ isError: true,
879
+ };
880
+ }
881
+ });
882
+
883
+ // A single prompt, "fix-makefile-links": see buildFixMakefileLinksPrompt()
884
+ // above for what it actually serves and why. This is what makes it
885
+ // reachable to any MCP client without a separate skill-file install —
886
+ // connecting to this server is enough.
887
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
888
+ prompts: [
889
+ {
890
+ name: "fix-makefile-links",
891
+ description:
892
+ "Find every Makefile in this project, check which ones make-runner-mcp can " +
893
+ "actually discover, and add the missing `also-read` link automatically for " +
894
+ "any that are orphaned — instead of just reporting the gap.",
895
+ },
896
+ ],
897
+ }));
898
+
899
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
900
+ const { name } = request.params;
901
+ if (name !== "fix-makefile-links") {
902
+ throw new Error(`Unknown prompt: ${name}`);
903
+ }
904
+ return {
905
+ description: "Diagnose and fix why make-runner-mcp isn't discovering a secondary Makefile's targets.",
906
+ messages: [
907
+ {
908
+ role: "user",
909
+ content: { type: "text", text: buildFixMakefileLinksPrompt() },
910
+ },
911
+ ],
912
+ };
913
+ });
914
+
915
+ return server;
916
+ }
917
+
918
+ if (MCP_TRANSPORT === "http") {
919
+ // Session id -> transport. A new client (no mcp-session-id header, an
920
+ // `initialize` request) gets its own fresh Server+Transport pair; every
921
+ // later request for that session is routed to the same transport, per
922
+ // the SDK's own documented multi-session pattern (see
923
+ // examples/server/simpleStreamableHttp.js) — reusing a single
924
+ // Server/transport across sessions produces "Server already initialized"
925
+ // on the second session's handshake, since a Server represents one
926
+ // connected session, not a process-wide singleton.
927
+ const sessions = new Map(); // sessionId -> { transport, lastSeen }
928
+
929
+ const sweepIdleSessions = () => {
930
+ const cutoff = Date.now() - SESSION_IDLE_TIMEOUT_MS;
931
+ for (const [sid, entry] of sessions) {
932
+ if (entry.lastSeen < cutoff) {
933
+ sessions.delete(sid);
934
+ try {
935
+ entry.transport.close();
936
+ } catch (err) {
937
+ console.error(`Error closing idle session ${sid}: ${err.stack || err.message}`);
938
+ }
939
+ }
940
+ }
941
+ };
942
+ const sweepTimer = setInterval(sweepIdleSessions, SESSION_SWEEP_INTERVAL_MS);
943
+ sweepTimer.unref();
944
+
945
+ const expectedAuth = `Bearer ${MCP_HTTP_TOKEN}`;
946
+
947
+ function isAuthorized(req) {
948
+ const authHeader = req.headers["authorization"] || "";
949
+ // Constant-time comparison, and a length check first since
950
+ // timingSafeEqual throws (rather than returning false) on a length
951
+ // mismatch — a naive `authHeader === expected` would otherwise leak
952
+ // the token's length via response-time differences.
953
+ const authBuf = Buffer.from(authHeader);
954
+ const expectedBuf = Buffer.from(expectedAuth);
955
+ return authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf);
956
+ }
957
+
958
+ function readJsonBody(req) {
959
+ return new Promise((resolve, reject) => {
960
+ let raw = "";
961
+ req.on("data", (c) => (raw += c));
962
+ req.on("end", () => {
963
+ if (!raw) return resolve(undefined);
964
+ try {
965
+ resolve(JSON.parse(raw));
966
+ } catch (err) {
967
+ reject(err);
968
+ }
969
+ });
970
+ req.on("error", reject);
971
+ });
972
+ }
973
+
974
+ const httpServer = createHttpServer(async (req, res) => {
975
+ if (!isAuthorized(req)) {
976
+ res.writeHead(401, { "content-type": "application/json" }).end(
977
+ JSON.stringify({ error: "unauthorized" })
978
+ );
979
+ return;
980
+ }
981
+
982
+ const sessionId = req.headers["mcp-session-id"];
983
+
984
+ try {
985
+ if (sessionId && sessions.has(sessionId)) {
986
+ const entry = sessions.get(sessionId);
987
+ entry.lastSeen = Date.now();
988
+ await entry.transport.handleRequest(req, res, req.method === "POST" ? await readJsonBody(req) : undefined);
989
+ return;
990
+ }
991
+
992
+ if (req.method === "POST" && !sessionId) {
993
+ const body = await readJsonBody(req);
994
+ if (!isInitializeRequest(body)) {
995
+ res.writeHead(400, { "content-type": "application/json" }).end(
996
+ JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: No valid session ID provided" }, id: null })
997
+ );
998
+ return;
999
+ }
1000
+ const transport = new StreamableHTTPServerTransport({
1001
+ sessionIdGenerator: () => randomUUID(),
1002
+ onsessioninitialized: (sid) => sessions.set(sid, { transport, lastSeen: Date.now() }),
1003
+ });
1004
+ transport.onclose = () => {
1005
+ if (transport.sessionId) sessions.delete(transport.sessionId);
1006
+ };
1007
+ await createServer().connect(transport);
1008
+ await transport.handleRequest(req, res, body);
1009
+ return;
1010
+ }
1011
+
1012
+ res.writeHead(400, { "content-type": "application/json" }).end(
1013
+ JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: No valid session ID provided" }, id: null })
1014
+ );
1015
+ } catch (err) {
1016
+ console.error(`HTTP transport error: ${err.stack || err.message}`);
1017
+ if (!res.headersSent) res.writeHead(500).end();
1018
+ }
1019
+ });
1020
+
1021
+ httpServer.listen(MCP_HTTP_PORT, MCP_HTTP_HOST, () => {
1022
+ console.error(`make-runner-mcp listening on http://${MCP_HTTP_HOST}:${MCP_HTTP_PORT} (MCP_HTTP_TOKEN required)`);
1023
+ });
1024
+ } else {
1025
+ const transport = new StdioServerTransport();
1026
+ await createServer().connect(transport);
1027
+ }