specguard-mcp 0.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/README.md +366 -0
- package/dist/bin/specguard-mcp.d.ts +2 -0
- package/dist/bin/specguard-mcp.js +35 -0
- package/dist/bin/specguard-mcp.js.map +1 -0
- package/dist/src/config.d.ts +108 -0
- package/dist/src/config.js +172 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/errors.d.ts +60 -0
- package/dist/src/errors.js +64 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +5 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/server.d.ts +28 -0
- package/dist/src/server.js +113 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/support/run-command.d.ts +86 -0
- package/dist/src/support/run-command.js +322 -0
- package/dist/src/support/run-command.js.map +1 -0
- package/dist/src/support/specguard-api.d.ts +11 -0
- package/dist/src/support/specguard-api.js +157 -0
- package/dist/src/support/specguard-api.js.map +1 -0
- package/dist/src/tools/args.d.ts +48 -0
- package/dist/src/tools/args.js +66 -0
- package/dist/src/tools/args.js.map +1 -0
- package/dist/src/tools/index.d.ts +33 -0
- package/dist/src/tools/index.js +34 -0
- package/dist/src/tools/index.js.map +1 -0
- package/dist/src/tools/lint-intent-annotations.d.ts +45 -0
- package/dist/src/tools/lint-intent-annotations.js +342 -0
- package/dist/src/tools/lint-intent-annotations.js.map +1 -0
- package/dist/src/tools/repository-overview.d.ts +424 -0
- package/dist/src/tools/repository-overview.js +797 -0
- package/dist/src/tools/repository-overview.js.map +1 -0
- package/dist/src/tools/types.d.ts +111 -0
- package/dist/src/tools/types.js +2 -0
- package/dist/src/tools/types.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { CommandError } from "../errors.js";
|
|
4
|
+
/** Beyond this, a wrapped command's output is truncated rather than buffered. */
|
|
5
|
+
export const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
6
|
+
/** Default ceiling on how long a wrapped command may run. */
|
|
7
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
8
|
+
/**
|
|
9
|
+
* How long `close` may lag `exit` before the result is produced without it.
|
|
10
|
+
*
|
|
11
|
+
* `exit` and `close` are two events, not one: `close` additionally waits for
|
|
12
|
+
* every writer of the child's stdout/stderr pipes to let go, and a process that
|
|
13
|
+
* is not the child can be holding them. Long enough that the ordinary
|
|
14
|
+
* milliseconds-apart drain is never cut short; short enough that a leaked
|
|
15
|
+
* descriptor costs a truncated tail instead of a call that never returns.
|
|
16
|
+
*/
|
|
17
|
+
export const EXIT_CLOSE_GRACE_MS = 1_000;
|
|
18
|
+
/**
|
|
19
|
+
* Runs a program with an argument LIST, never through a shell.
|
|
20
|
+
*
|
|
21
|
+
* `spawn` without `shell: true` is the load-bearing detail: tool arguments
|
|
22
|
+
* arrive from a model, so a file path containing `; rm -rf …` has to be a
|
|
23
|
+
* path that does not exist rather than a command. There is no escaping to get
|
|
24
|
+
* right because nothing is ever parsed as syntax.
|
|
25
|
+
*
|
|
26
|
+
* A non-zero exit is NOT an error here. `specguard-lint` uses its exit code as
|
|
27
|
+
* a three-valued verdict — 0 clean, 1 malformed annotations, 2 the tool could
|
|
28
|
+
* not do its job — so deciding what a code means is the caller's job and this
|
|
29
|
+
* function reports it. What IS an error is the process never running (missing
|
|
30
|
+
* binary) or never finishing (timeout): both leave the caller with no verdict
|
|
31
|
+
* at all, which is the one outcome that must not be mistaken for a clean run.
|
|
32
|
+
*/
|
|
33
|
+
export const runCommand = (argv, options = {}) => {
|
|
34
|
+
const [program, ...args] = argv;
|
|
35
|
+
if (program === undefined) {
|
|
36
|
+
throw new CommandError("No command was configured to run.");
|
|
37
|
+
}
|
|
38
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
// Checked before spawning, not diagnosed afterwards. `spawn` reports a
|
|
41
|
+
// working directory it cannot use by TWO different routes depending on the
|
|
42
|
+
// errno: an async `error` event (ENOENT, for a directory that is not there)
|
|
43
|
+
// and a synchronous throw (ENOTDIR, for one that is a file). The synchronous
|
|
44
|
+
// one is not a `CommandError`, so it would escape this whole file and be
|
|
45
|
+
// reported to the agent as a bug in the bridge. Asking first collapses both
|
|
46
|
+
// into one sentence that names the directory.
|
|
47
|
+
if (options.cwd !== undefined && !isDirectory(options.cwd)) {
|
|
48
|
+
reject(new CommandError(unusableCwd(program, options.cwd)));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let child;
|
|
52
|
+
try {
|
|
53
|
+
child = spawnChild(program, args, options);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Belt and braces for the synchronous route: whatever `spawn` decides to
|
|
57
|
+
// throw, the agent gets a sentence about a command, never a raw errno that
|
|
58
|
+
// the error boundary would classify as a defect in this server.
|
|
59
|
+
reject(new CommandError(describeSpawnFailure(program, error, options)));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const stdout = new OutputBuffer();
|
|
63
|
+
const stderr = new OutputBuffer();
|
|
64
|
+
let settled = false;
|
|
65
|
+
let timedOut = false;
|
|
66
|
+
let exited = false;
|
|
67
|
+
let graceTimer;
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
// Whether the RUN is still live — asked of the process, not of the kill.
|
|
70
|
+
// `exit` and `close` are separate events, so there is a window in which
|
|
71
|
+
// the process is already gone and a complete result is already on its way.
|
|
72
|
+
// A deadline landing in there has nothing to time out, and claiming one
|
|
73
|
+
// would throw the finished run away, exit code and document and all, to
|
|
74
|
+
// report that it never finished.
|
|
75
|
+
//
|
|
76
|
+
// `killRun`'s answer cannot be that test on its own, which is the trap
|
|
77
|
+
// this guard exists for. It reports whether anything in the process GROUP
|
|
78
|
+
// was signalled, and the group outlives the child by exactly the
|
|
79
|
+
// grandchild holding the pipes open — the same grandchild that made the
|
|
80
|
+
// window wide enough for a deadline to land in it at all. So on the
|
|
81
|
+
// topology this file is about, the kill SUCCEEDS after the child is gone,
|
|
82
|
+
// and a `timedOut` read off that answer calls a completed `code: 0` run a
|
|
83
|
+
// timeout. Only the child's own exit can contradict it.
|
|
84
|
+
//
|
|
85
|
+
// Returning rather than killing and discarding the answer: once the child
|
|
86
|
+
// has been reaped its pid is no longer ours to signal — see `killRun`.
|
|
87
|
+
if (exited)
|
|
88
|
+
return;
|
|
89
|
+
timedOut = killRun(child);
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
// `unref` so a hung command's timer cannot by itself hold the process open.
|
|
92
|
+
timer.unref?.();
|
|
93
|
+
const finish = (fn) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
if (graceTimer !== undefined)
|
|
99
|
+
clearTimeout(graceTimer);
|
|
100
|
+
fn();
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* The one place a result is produced, reached from `close` and from the
|
|
104
|
+
* grace backstop alike — so the two cannot drift into disagreeing about
|
|
105
|
+
* what a timeout is. `timedOut` still decides reject-vs-resolve on BOTH:
|
|
106
|
+
* our own deadline is this server's decision and has no verdict to hand
|
|
107
|
+
* back, while a signal from outside is a fact about the child the caller
|
|
108
|
+
* has to be able to act on.
|
|
109
|
+
*/
|
|
110
|
+
const settleWith = (code, signal, drained) => {
|
|
111
|
+
finish(() => {
|
|
112
|
+
if (timedOut) {
|
|
113
|
+
reject(new CommandError(`\`${program}\` did not finish within ${timeoutMs}ms and was killed, ` +
|
|
114
|
+
"so it produced no verdict."));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
resolve({
|
|
118
|
+
code,
|
|
119
|
+
signal,
|
|
120
|
+
stdout: stdout.text(),
|
|
121
|
+
stderr: stderr.text(),
|
|
122
|
+
stdoutTruncated: stdout.truncated,
|
|
123
|
+
stderrTruncated: stderr.truncated,
|
|
124
|
+
outputDrained: drained,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
129
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
130
|
+
child.on("error", (error) => {
|
|
131
|
+
finish(() => reject(new CommandError(describeSpawnFailure(program, error, options))));
|
|
132
|
+
});
|
|
133
|
+
/**
|
|
134
|
+
* The backstop, and the reason this promise no longer depends on `close`.
|
|
135
|
+
*
|
|
136
|
+
* `close` fires only once the process has ended AND every writer of its
|
|
137
|
+
* stdout/stderr pipes has let go. Those descriptors are inherited, so a
|
|
138
|
+
* grandchild holds them: `bundle exec specguard-lint` — the configuration
|
|
139
|
+
* this project's README recommends — makes `bundle` the child and the linter
|
|
140
|
+
* a grandchild, and killing the group can still miss a great-grandchild that
|
|
141
|
+
* escaped it by double-forking. When that happens `close` never arrives, and
|
|
142
|
+
* with it as the only settle the promise never settles: in an MCP server
|
|
143
|
+
* that is a tool call that never returns, and therefore a calling agent that
|
|
144
|
+
* never returns.
|
|
145
|
+
*
|
|
146
|
+
* So `exit` — which needs nothing but the process — starts a short clock.
|
|
147
|
+
* The normal drain finishes in milliseconds and `close` wins the race; only
|
|
148
|
+
* a genuinely leaked descriptor reaches the timer, and it pays a truncated
|
|
149
|
+
* tail rather than the whole session.
|
|
150
|
+
*/
|
|
151
|
+
child.on("exit", (code, signal) => {
|
|
152
|
+
// Recorded before anything else, and read by the deadline above: from here
|
|
153
|
+
// on we hold a real `code`/`signal`, so whatever the timer may still find
|
|
154
|
+
// alive in the process group, this is not a run that produced no verdict.
|
|
155
|
+
exited = true;
|
|
156
|
+
if (settled)
|
|
157
|
+
return;
|
|
158
|
+
graceTimer = setTimeout(() => {
|
|
159
|
+
// Released here rather than left to the garbage collector: the pipe is
|
|
160
|
+
// still open by definition on this path, so the two buffers behind it
|
|
161
|
+
// (up to MAX_OUTPUT_BYTES each) would be retained for the life of
|
|
162
|
+
// whatever is holding the descriptor.
|
|
163
|
+
child.stdout.destroy();
|
|
164
|
+
child.stderr.destroy();
|
|
165
|
+
settleWith(code, signal, false);
|
|
166
|
+
}, EXIT_CLOSE_GRACE_MS);
|
|
167
|
+
// Deliberately NOT `unref`'d, unlike the deadline above. That one is a
|
|
168
|
+
// ceiling on someone else's work and must never be the reason this process
|
|
169
|
+
// stays up; this one IS the settle — an unref'd timer only fires while
|
|
170
|
+
// something else holds the loop open, and the thing holding it here is the
|
|
171
|
+
// very pipe we are giving up on. It is bounded at EXIT_CLOSE_GRACE_MS and
|
|
172
|
+
// cleared by `finish`, so the most it can cost is one second of shutdown.
|
|
173
|
+
});
|
|
174
|
+
child.on("close", (code, signal) => settleWith(code, signal, true));
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Kills the whole run, and answers whether there was anything to kill.
|
|
179
|
+
*
|
|
180
|
+
* `child.kill()` signals the CHILD. With `bundle exec specguard-lint` the child
|
|
181
|
+
* is `bundle` and the thing doing the work is its grandchild, which survives —
|
|
182
|
+
* still running, and still holding the pipes that `close` waits on. `detached`
|
|
183
|
+
* in `spawnChild` makes the child a process-group leader precisely so this
|
|
184
|
+
* function can signal the negated pid and reach the whole tree.
|
|
185
|
+
*
|
|
186
|
+
* The return value is the answer to "was a live process signalled", which is
|
|
187
|
+
* half of what the caller needs to decide whether a timeout actually happened —
|
|
188
|
+
* the other half is whether the CHILD has exited, because this group can outlive
|
|
189
|
+
* it. ESRCH — the group is already gone — is the normal way to learn "no", so it
|
|
190
|
+
* falls back to `child.kill`, whose `false` says the same thing about the child
|
|
191
|
+
* alone and whose own liveness check is what makes this safe on a platform where
|
|
192
|
+
* the group kill is not available.
|
|
193
|
+
*
|
|
194
|
+
* ONLY CALLED WHILE THE CHILD IS STILL LIVE, and that is a precondition rather
|
|
195
|
+
* than a convenience. `child.kill()` is safe by construction — Node reaped the
|
|
196
|
+
* child, so it knows the handle is spent and no-ops — but `process.kill(-pid)`
|
|
197
|
+
* is a raw signal at a number with no such check. After the reap that number is
|
|
198
|
+
* free, and on the one path where the group is ALSO empty (so ESRCH would have
|
|
199
|
+
* been the honest answer) a recycled pid that now leads some unrelated group
|
|
200
|
+
* would take a SIGKILL meant for a linter. It needs pid wraparound to land on a
|
|
201
|
+
* group leader, so it is vanishingly unlikely — but it is unrecoverable and
|
|
202
|
+
* aimed at a stranger, so the caller checks `exited` first rather than paying
|
|
203
|
+
* it. The cost of that choice is that stragglers outliving an already-exited
|
|
204
|
+
* child are left to leave on their own; the grace backstop settles regardless,
|
|
205
|
+
* and this is what `main` did too, where a kill after the reap was a no-op.
|
|
206
|
+
*/
|
|
207
|
+
function killRun(child) {
|
|
208
|
+
const pid = child.pid;
|
|
209
|
+
// Unset when the spawn itself failed; there is no process and no group.
|
|
210
|
+
if (pid === undefined)
|
|
211
|
+
return false;
|
|
212
|
+
try {
|
|
213
|
+
process.kill(-pid, "SIGKILL");
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return child.kill("SIGKILL");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The one place `spawn` is called.
|
|
222
|
+
*
|
|
223
|
+
* Extracted so the call can be wrapped in a `try` without the surrounding
|
|
224
|
+
* promise losing the child's type.
|
|
225
|
+
*/
|
|
226
|
+
function spawnChild(program, args, options) {
|
|
227
|
+
return spawn(program, [...args], {
|
|
228
|
+
cwd: options.cwd,
|
|
229
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
230
|
+
// Explicitly off. Stated rather than defaulted, because this is the line
|
|
231
|
+
// that keeps a model-supplied argument from reaching a shell.
|
|
232
|
+
shell: false,
|
|
233
|
+
// A process group of its own, so the timeout can signal the whole run
|
|
234
|
+
// rather than only the process this server happens to have spawned — see
|
|
235
|
+
// `killRun`.
|
|
236
|
+
//
|
|
237
|
+
// Deliberately NOT paired with `child.unref()`, so the parent still waits on
|
|
238
|
+
// this handle rather than handing a linter permission to outlive the server.
|
|
239
|
+
// That is not the whole lifetime story though, and the honest version is
|
|
240
|
+
// that detaching buys the reach of the kill and PAYS for it here: a new
|
|
241
|
+
// group is also a new session, outside this server's controlling terminal,
|
|
242
|
+
// so a signal aimed at OUR group — an interactive Ctrl-C, a supervisor's
|
|
243
|
+
// `kill -- -PGID` — no longer reaches a lint run in flight. Nothing in
|
|
244
|
+
// `bin/specguard-mcp.ts` installs a SIGINT/SIGTERM handler to kill
|
|
245
|
+
// outstanding children at teardown, so such a run is now orphaned where it
|
|
246
|
+
// would previously have died alongside us. Worth it, because the failure
|
|
247
|
+
// being traded away is an agent that never returns rather than a stray
|
|
248
|
+
// process — but it is a trade, and a teardown handler is what would close
|
|
249
|
+
// it.
|
|
250
|
+
detached: true,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Which thing failed to run — asked rather than assumed.
|
|
255
|
+
*
|
|
256
|
+
* Node reports a non-existent `cwd` as `ENOENT` on the spawn: the same code and
|
|
257
|
+
* the same shape as a program that is not on PATH. Answering both with "it is not
|
|
258
|
+
* on this server's PATH" tells an operator whose only mistake was the directory
|
|
259
|
+
* to go and change the one thing that was right, and the caller of this function
|
|
260
|
+
* knows the difference is checkable. So on the error path — where an extra `stat`
|
|
261
|
+
* costs nothing — we ask which of the two is actually missing before naming a
|
|
262
|
+
* cause.
|
|
263
|
+
*
|
|
264
|
+
* This is the same reasoning as the non-zero-exit rule above: an outcome that
|
|
265
|
+
* leaves the caller with no verdict must not be described as some other outcome.
|
|
266
|
+
*/
|
|
267
|
+
function describeSpawnFailure(program, error, options) {
|
|
268
|
+
const cwd = options.cwd;
|
|
269
|
+
// Consulted before the errno, because more than one code means this same thing.
|
|
270
|
+
if (cwd !== undefined && !isDirectory(cwd))
|
|
271
|
+
return unusableCwd(program, cwd);
|
|
272
|
+
if (error.code === "ENOENT") {
|
|
273
|
+
const hint = options.notFoundHint === undefined ? "" : ` ${options.notFoundHint}`;
|
|
274
|
+
return `Could not run \`${program}\`: it is not on this server's PATH.${hint}`;
|
|
275
|
+
}
|
|
276
|
+
return `Could not run \`${program}\`: ${error.message}`;
|
|
277
|
+
}
|
|
278
|
+
function unusableCwd(program, cwd) {
|
|
279
|
+
return (`Could not run \`${program}\`: its working directory ${JSON.stringify(cwd)} does not exist, ` +
|
|
280
|
+
"or is not a directory. The command itself was not the problem.");
|
|
281
|
+
}
|
|
282
|
+
function isDirectory(path) {
|
|
283
|
+
try {
|
|
284
|
+
return statSync(path).isDirectory();
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Bounded accumulation of a child's output.
|
|
292
|
+
*
|
|
293
|
+
* An unbounded buffer here would let a linter run over a very large suite —
|
|
294
|
+
* exactly the suites SpecGuard exists for — decide this server's memory
|
|
295
|
+
* ceiling. Truncation is announced in the returned text rather than silent,
|
|
296
|
+
* because a JSON document cut in half must not look like a document that ended.
|
|
297
|
+
*/
|
|
298
|
+
class OutputBuffer {
|
|
299
|
+
#chunks = [];
|
|
300
|
+
#bytes = 0;
|
|
301
|
+
#truncated = false;
|
|
302
|
+
get truncated() {
|
|
303
|
+
return this.#truncated;
|
|
304
|
+
}
|
|
305
|
+
push(chunk) {
|
|
306
|
+
if (this.#truncated)
|
|
307
|
+
return;
|
|
308
|
+
if (this.#bytes + chunk.byteLength > MAX_OUTPUT_BYTES) {
|
|
309
|
+
this.#chunks.push(chunk.subarray(0, MAX_OUTPUT_BYTES - this.#bytes));
|
|
310
|
+
this.#truncated = true;
|
|
311
|
+
this.#bytes = MAX_OUTPUT_BYTES;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.#chunks.push(chunk);
|
|
315
|
+
this.#bytes += chunk.byteLength;
|
|
316
|
+
}
|
|
317
|
+
text() {
|
|
318
|
+
const body = Buffer.concat(this.#chunks).toString("utf8");
|
|
319
|
+
return this.#truncated ? `${body}\n[truncated at ${MAX_OUTPUT_BYTES} bytes]` : body;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
//# sourceMappingURL=run-command.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-command.js","sourceRoot":"","sources":["../../../src/support/run-command.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { requireApiConfig, type ApiConfig } from "../config.js";
|
|
2
|
+
/**
|
|
3
|
+
* The SpecGuard HTTP client — a Bearer key and a path, and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Authorization is enforced by the deployment (`Api::BaseController`), never
|
|
6
|
+
* here: this carries the operator's key and reports what came back. The bridge
|
|
7
|
+
* adds no credentials of its own and makes no access decisions, so there is no
|
|
8
|
+
* second place for the permission model to be got wrong.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getJson(api: ApiConfig, path: string, query: Record<string, string | undefined>, fetchImpl: typeof globalThis.fetch): Promise<unknown>;
|
|
11
|
+
export { requireApiConfig };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { requireApiConfig } from "../config.js";
|
|
2
|
+
import { ApiError } from "../errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* The SpecGuard HTTP client — a Bearer key and a path, and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* Authorization is enforced by the deployment (`Api::BaseController`), never
|
|
7
|
+
* here: this carries the operator's key and reports what came back. The bridge
|
|
8
|
+
* adds no credentials of its own and makes no access decisions, so there is no
|
|
9
|
+
* second place for the permission model to be got wrong.
|
|
10
|
+
*/
|
|
11
|
+
export async function getJson(api, path, query, fetchImpl) {
|
|
12
|
+
const url = new URL(`${api.endpoint}${path}`);
|
|
13
|
+
for (const [key, value] of Object.entries(query)) {
|
|
14
|
+
if (value !== undefined)
|
|
15
|
+
url.searchParams.set(key, value);
|
|
16
|
+
}
|
|
17
|
+
const { response, body } = await fetchWithTimeout(url, api, fetchImpl);
|
|
18
|
+
if (!response.ok)
|
|
19
|
+
throw describeFailure(response.status, body, api);
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(body);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new ApiError(`${api.endpoint} answered ${response.status} but the body was not JSON. ` +
|
|
25
|
+
`Check that ${api.endpointVariable} points at a SpecGuard deployment and not, say, a proxy ` +
|
|
26
|
+
"or login page.", response.status);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Tells "the deadline won the race" apart from any value a phase could produce.
|
|
31
|
+
*
|
|
32
|
+
* A resolved sentinel rather than a rejecting deadline promise: a promise that
|
|
33
|
+
* rejects has to be raced against every phase or its rejection is unhandled, and
|
|
34
|
+
* an unhandled rejection on stdio takes the whole transport down — the failure
|
|
35
|
+
* mode `errors.ts` exists to avoid.
|
|
36
|
+
*/
|
|
37
|
+
const TIMED_OUT = Symbol("specguard-api deadline");
|
|
38
|
+
/**
|
|
39
|
+
* Headers AND body under ONE deadline.
|
|
40
|
+
*
|
|
41
|
+
* `SPECGUARD_TIMEOUT_MS` is documented in `config.ts` as how long an HTTP call
|
|
42
|
+
* to SpecGuard may take, and a call is not over when its headers arrive. An
|
|
43
|
+
* `AbortController` armed only around `fetchImpl` is disarmed the moment the
|
|
44
|
+
* response object resolves, so a deployment that answers `200 OK` and then
|
|
45
|
+
* dribbles — or freezes — the body leaves the body read awaiting with no
|
|
46
|
+
* deadline and no live signal. In an MCP server that is not a slow answer: it is
|
|
47
|
+
* a tool call, and therefore the agent that called it, which never returns.
|
|
48
|
+
*
|
|
49
|
+
* ONE TOTAL BUDGET, not one per phase. `requestTimeoutMs` bounds the whole call:
|
|
50
|
+
* headers and body share it, so a response whose headers took 29s of a 30s
|
|
51
|
+
* budget has 1s left in which to deliver its body. The sibling transport in
|
|
52
|
+
* `specguard-rspec` (`lib/specguard/rspec/transport.rb`) gives each phase its own
|
|
53
|
+
* full `@timeout` because `Net::HTTP` exposes exactly that knob and no other;
|
|
54
|
+
* here the deadline is ours to place, and a single total is both stricter and
|
|
55
|
+
* the thing an operator who set one number actually meant.
|
|
56
|
+
*
|
|
57
|
+
* The race is explicit rather than left to the abort signal. Aborting is still
|
|
58
|
+
* done — it tears a real connection down instead of leaking it — but WHETHER an
|
|
59
|
+
* aborted signal also errors an already-delivered body stream is a property of
|
|
60
|
+
* the fetch implementation, and this function takes `fetchImpl` from its caller.
|
|
61
|
+
* Racing the deadline here is what makes the bound hold for any implementation
|
|
62
|
+
* rather than for one in particular.
|
|
63
|
+
*
|
|
64
|
+
* The body is read HERE, inside the deadline, rather than by the caller one
|
|
65
|
+
* frame later, so there is no window in which the read is awaiting somewhere the
|
|
66
|
+
* timer does not reach.
|
|
67
|
+
*/
|
|
68
|
+
async function fetchWithTimeout(url, api, fetchImpl) {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
let timer;
|
|
71
|
+
const deadline = new Promise((resolve) => {
|
|
72
|
+
timer = setTimeout(() => {
|
|
73
|
+
controller.abort();
|
|
74
|
+
resolve(TIMED_OUT);
|
|
75
|
+
}, api.requestTimeoutMs);
|
|
76
|
+
// `unref` so a stalled body's timer cannot by itself hold the process open —
|
|
77
|
+
// the same reason `run-command.ts` unrefs the timer that kills a hung child.
|
|
78
|
+
timer.unref?.();
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
const response = await Promise.race([
|
|
82
|
+
fetchImpl(url, {
|
|
83
|
+
method: "GET",
|
|
84
|
+
headers: {
|
|
85
|
+
Authorization: `Bearer ${api.apiKey}`,
|
|
86
|
+
Accept: "application/json",
|
|
87
|
+
"User-Agent": "specguard-mcp",
|
|
88
|
+
},
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
}),
|
|
91
|
+
deadline,
|
|
92
|
+
]);
|
|
93
|
+
if (response === TIMED_OUT)
|
|
94
|
+
throw timedOut(api);
|
|
95
|
+
const body = await Promise.race([response.text(), deadline]);
|
|
96
|
+
if (body === TIMED_OUT)
|
|
97
|
+
throw timedOut(api);
|
|
98
|
+
return { response, body };
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
// Already diagnosed above — a timeout, converted while the phase that hit it
|
|
102
|
+
// was still known. Rethrown before the transport branch so a deadline can
|
|
103
|
+
// never be re-described as "could not reach", which would name a cause that
|
|
104
|
+
// is the opposite of what happened: the deployment was reached, and stopped.
|
|
105
|
+
if (error instanceof ApiError)
|
|
106
|
+
throw error;
|
|
107
|
+
// A transport failure and a refusal are different problems with different
|
|
108
|
+
// fixes, and "fetch failed" names neither. The endpoint is echoed because
|
|
109
|
+
// the commonest cause by far is that it is wrong — and the variable is named
|
|
110
|
+
// from the config rather than spelled out here, so an operator who set
|
|
111
|
+
// SPECGUARD_URL is not sent to fix a variable they never set.
|
|
112
|
+
if (controller.signal.aborted)
|
|
113
|
+
throw timedOut(api);
|
|
114
|
+
throw new ApiError(`Could not reach ${api.endpoint}: ${error instanceof Error ? error.message : String(error)}. ` +
|
|
115
|
+
`Check ${api.endpointVariable} and that the deployment is reachable from this machine.`);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
// Cleared on every exit — success, HTTP failure, transport failure and
|
|
119
|
+
// timeout alike — because the timer now outlives the fetch call itself.
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One sentence for both phases, because the operator's move is the same either
|
|
125
|
+
* way: raise `SPECGUARD_TIMEOUT_MS` or find out why the deployment is slow.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately an `ApiError` and deliberately WITHOUT a status. Letting an
|
|
128
|
+
* `AbortError` escape instead would reach `describeError` in `server.ts` as a
|
|
129
|
+
* non-`SpecGuardMcpError` and be reported to the agent as "a bug in the bridge,
|
|
130
|
+
* not in your project or configuration" — exactly inverting the diagnosis for a
|
|
131
|
+
* peer that stalled. And there was no response, so there is no status to carry.
|
|
132
|
+
*/
|
|
133
|
+
function timedOut(api) {
|
|
134
|
+
return new ApiError(`${api.endpoint} did not respond within ${api.requestTimeoutMs}ms.`);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The status turned into something the agent can act on.
|
|
138
|
+
*
|
|
139
|
+
* 401 is called out by name because it is the one an operator will actually
|
|
140
|
+
* hit, and because SpecGuard answers it deliberately flat — "a valid Bearer API
|
|
141
|
+
* key is required", with no detail about why — so the useful half of the
|
|
142
|
+
* diagnosis has to be supplied from this side.
|
|
143
|
+
*/
|
|
144
|
+
function describeFailure(status, body, api) {
|
|
145
|
+
if (status === 401) {
|
|
146
|
+
return new ApiError("SpecGuard rejected the API key (401). SPECGUARD_API_KEY must be an sgk_… key issued by " +
|
|
147
|
+
`${api.endpoint} for the repository you are asking about — keys are per-repository, and a ` +
|
|
148
|
+
"revoked key reads the same as a wrong one.", status);
|
|
149
|
+
}
|
|
150
|
+
if (status === 404) {
|
|
151
|
+
return new ApiError(`${api.endpoint} has no such endpoint (404). Check that ${api.endpointVariable} is the ` +
|
|
152
|
+
"deployment's root URL, without a path.", status);
|
|
153
|
+
}
|
|
154
|
+
return new ApiError(`SpecGuard answered ${status}${body.trim() === "" ? "" : `: ${body.trim().slice(0, 500)}`}`, status);
|
|
155
|
+
}
|
|
156
|
+
export { requireApiConfig };
|
|
157
|
+
//# sourceMappingURL=specguard-api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specguard-api.js","sourceRoot":"","sources":["../../../src/support/specguard-api.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool-agnostic argument coercions — the shape checks every tool needs and
|
|
3
|
+
* no tool should re-derive.
|
|
4
|
+
*
|
|
5
|
+
* == Why this file exists at all
|
|
6
|
+
*
|
|
7
|
+
* `optionalString` was written twice, once per tool file, in the same commit
|
|
8
|
+
* that created both. The copies then diverged on the one thing a copy is most
|
|
9
|
+
* likely to get wrong: which of the four error classes to throw. One picked
|
|
10
|
+
* `ApiError`, the other `CommandError`, with byte-identical message strings —
|
|
11
|
+
* so the same malformed argument was reported as two different kinds of failure
|
|
12
|
+
* depending on which tool received it. That was repaired by re-pointing both
|
|
13
|
+
* throws at `ArgumentError`, but repairing two copies is not the same as having
|
|
14
|
+
* one, and the next tool author still has to choose the class again from
|
|
15
|
+
* scratch.
|
|
16
|
+
*
|
|
17
|
+
* `types.ts` promised, until SPGD-558 narrowed the claim to *wiring* edits,
|
|
18
|
+
* that adding a tool was "two mechanical edits with no third". Hand-copying
|
|
19
|
+
* these coercions and re-picking an error class was the unlisted third edit.
|
|
20
|
+
* This file removes it.
|
|
21
|
+
*
|
|
22
|
+
* == What belongs here, and what does not
|
|
23
|
+
*
|
|
24
|
+
* Only checks about the SHAPE of a value — is it a string, is it a boolean —
|
|
25
|
+
* whose failure is always `ArgumentError`, the class meaning "the call was
|
|
26
|
+
* malformed and the agent can fix it unaided on the next call".
|
|
27
|
+
*
|
|
28
|
+
* A check that reasons about what an argument MEANS to one particular wrapped
|
|
29
|
+
* tool stays with that tool. `optionalStringArray` in
|
|
30
|
+
* `lint-intent-annotations.ts` is the standing example: its refusals of an
|
|
31
|
+
* empty list and of a leading dash are about the linter's own argument grammar
|
|
32
|
+
* — what `paths: []` would make `specguard-lint` audit, what its OptionParser
|
|
33
|
+
* would read a `-` as — and they throw `CommandError` for that reason. Moving
|
|
34
|
+
* it here would drag one tool's semantics into a file the next tool imports.
|
|
35
|
+
*
|
|
36
|
+
* == Placement
|
|
37
|
+
*
|
|
38
|
+
* `src/tools/` rather than `src/support/`. `src/support/` holds injected
|
|
39
|
+
* outside-world capabilities — `run-command.ts`, `specguard-api.ts` — which
|
|
40
|
+
* reach tools through `ToolContext` and are substituted in tests. These are
|
|
41
|
+
* pure functions with no context and nothing to inject: they are part of the
|
|
42
|
+
* tool-authoring contract, which lives here beside `types.ts`.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* A non-blank string, or nothing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function optionalString(value: unknown, field: string): string | undefined;
|
|
48
|
+
export declare function optionalBoolean(value: unknown, field: string): boolean | undefined;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ArgumentError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The tool-agnostic argument coercions — the shape checks every tool needs and
|
|
4
|
+
* no tool should re-derive.
|
|
5
|
+
*
|
|
6
|
+
* == Why this file exists at all
|
|
7
|
+
*
|
|
8
|
+
* `optionalString` was written twice, once per tool file, in the same commit
|
|
9
|
+
* that created both. The copies then diverged on the one thing a copy is most
|
|
10
|
+
* likely to get wrong: which of the four error classes to throw. One picked
|
|
11
|
+
* `ApiError`, the other `CommandError`, with byte-identical message strings —
|
|
12
|
+
* so the same malformed argument was reported as two different kinds of failure
|
|
13
|
+
* depending on which tool received it. That was repaired by re-pointing both
|
|
14
|
+
* throws at `ArgumentError`, but repairing two copies is not the same as having
|
|
15
|
+
* one, and the next tool author still has to choose the class again from
|
|
16
|
+
* scratch.
|
|
17
|
+
*
|
|
18
|
+
* `types.ts` promised, until SPGD-558 narrowed the claim to *wiring* edits,
|
|
19
|
+
* that adding a tool was "two mechanical edits with no third". Hand-copying
|
|
20
|
+
* these coercions and re-picking an error class was the unlisted third edit.
|
|
21
|
+
* This file removes it.
|
|
22
|
+
*
|
|
23
|
+
* == What belongs here, and what does not
|
|
24
|
+
*
|
|
25
|
+
* Only checks about the SHAPE of a value — is it a string, is it a boolean —
|
|
26
|
+
* whose failure is always `ArgumentError`, the class meaning "the call was
|
|
27
|
+
* malformed and the agent can fix it unaided on the next call".
|
|
28
|
+
*
|
|
29
|
+
* A check that reasons about what an argument MEANS to one particular wrapped
|
|
30
|
+
* tool stays with that tool. `optionalStringArray` in
|
|
31
|
+
* `lint-intent-annotations.ts` is the standing example: its refusals of an
|
|
32
|
+
* empty list and of a leading dash are about the linter's own argument grammar
|
|
33
|
+
* — what `paths: []` would make `specguard-lint` audit, what its OptionParser
|
|
34
|
+
* would read a `-` as — and they throw `CommandError` for that reason. Moving
|
|
35
|
+
* it here would drag one tool's semantics into a file the next tool imports.
|
|
36
|
+
*
|
|
37
|
+
* == Placement
|
|
38
|
+
*
|
|
39
|
+
* `src/tools/` rather than `src/support/`. `src/support/` holds injected
|
|
40
|
+
* outside-world capabilities — `run-command.ts`, `specguard-api.ts` — which
|
|
41
|
+
* reach tools through `ToolContext` and are substituted in tests. These are
|
|
42
|
+
* pure functions with no context and nothing to inject: they are part of the
|
|
43
|
+
* tool-authoring contract, which lives here beside `types.ts`.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* A non-blank string, or nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function optionalString(value, field) {
|
|
49
|
+
if (value === undefined || value === null)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (typeof value !== "string")
|
|
52
|
+
throw new ArgumentError(`\`${field}\` must be a string.`);
|
|
53
|
+
// Trimmed, not merely tested for blankness. `project_dir` becomes a `cwd`, so
|
|
54
|
+
// " /srv/app" — a path an agent produces by concatenating one — would otherwise
|
|
55
|
+
// be a directory that cannot exist, reported as a directory that does not.
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
return trimmed === "" ? undefined : trimmed;
|
|
58
|
+
}
|
|
59
|
+
export function optionalBoolean(value, field) {
|
|
60
|
+
if (value === undefined || value === null)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (typeof value !== "boolean")
|
|
63
|
+
throw new ArgumentError(`\`${field}\` must be a boolean.`);
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=args.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"args.js","sourceRoot":"","sources":["../../../src/tools/args.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,KAAc,EAAE,KAAa;IAC1D,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5D,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,MAAM,IAAI,aAAa,CAAC,KAAK,KAAK,sBAAsB,CAAC,CAAC;IACzF,8EAA8E;IAC9E,gFAAgF;IAChF,2EAA2E;IAC3E,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,OAAO,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;AAC9C,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAc,EAAE,KAAa;IAC3D,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5D,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,MAAM,IAAI,aAAa,CAAC,KAAK,KAAK,uBAAuB,CAAC,CAAC;IAC3F,OAAO,KAAK,CAAC;AACf,CAAC"}
|