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/LICENSE +21 -0
- package/MAKEFILE-GUIDE.md +359 -0
- package/README.md +482 -0
- package/package.json +30 -0
- package/server.js +1027 -0
- package/skills/fix-makefile-links/SKILL.md +169 -0
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
|
+
}
|