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,172 @@
|
|
|
1
|
+
import { ConfigError } from "./errors.js";
|
|
2
|
+
export const DEFAULT_LINT_COMMAND = ["specguard-lint"];
|
|
3
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
4
|
+
/** Reads config from an environment. Never throws — see the note above. */
|
|
5
|
+
export function loadConfig(env = process.env) {
|
|
6
|
+
const lintCommand = tokenise(env["SPECGUARD_LINT_COMMAND"]);
|
|
7
|
+
// WHICH variable is in play is decided ONCE, and the value is then read from
|
|
8
|
+
// the variable that answer names. An earlier version derived the two
|
|
9
|
+
// separately — the name via `presence()` (blank is unset) and the value via
|
|
10
|
+
// `??` (blank is a value) — and the two rules disagreed on exactly one input:
|
|
11
|
+
// a blank `SPECGUARD_ENDPOINT` alongside a good `SPECGUARD_URL` made `??`
|
|
12
|
+
// return the empty string, so the alias was never consulted and the operator
|
|
13
|
+
// was told the endpoint was unset while looking at the one they had set. That
|
|
14
|
+
// is the silent no-op the alias exists to prevent, and a templated MCP client
|
|
15
|
+
// `env` block with every key present and only some filled in is the ordinary
|
|
16
|
+
// way to produce it. Reading through the chosen name makes the two
|
|
17
|
+
// structurally incapable of disagreeing, rather than agreeing by coincidence.
|
|
18
|
+
const endpointVariable = presence(env["SPECGUARD_ENDPOINT"]) !== undefined
|
|
19
|
+
? "SPECGUARD_ENDPOINT"
|
|
20
|
+
: presence(env["SPECGUARD_URL"]) !== undefined
|
|
21
|
+
? "SPECGUARD_URL"
|
|
22
|
+
: undefined;
|
|
23
|
+
return {
|
|
24
|
+
endpoint: endpointVariable === undefined ? undefined : normaliseEndpoint(env[endpointVariable]),
|
|
25
|
+
endpointVariable,
|
|
26
|
+
apiKey: presence(env["SPECGUARD_API_KEY"]),
|
|
27
|
+
lintCommand: lintCommand.length > 0 ? lintCommand : DEFAULT_LINT_COMMAND,
|
|
28
|
+
requestTimeoutMs: positiveInteger(env["SPECGUARD_TIMEOUT_MS"]) ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The name to speak when no variable was set at all — the message is telling
|
|
33
|
+
* someone to set one, and this is the spelling the rest of the toolchain reads.
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_ENDPOINT_VARIABLE = "SPECGUARD_ENDPOINT";
|
|
36
|
+
/**
|
|
37
|
+
* Both halves or a legible failure — never one half and a surprise later.
|
|
38
|
+
*
|
|
39
|
+
* Reported together rather than one at a time: an operator who set neither
|
|
40
|
+
* should learn that in one round trip instead of fixing a variable, re-calling,
|
|
41
|
+
* and being told about the next one.
|
|
42
|
+
*
|
|
43
|
+
* The endpoint is also PARSED here, not merely counted as present. It is spent
|
|
44
|
+
* later inside `new URL(...)` in the HTTP client, where a malformed value throws
|
|
45
|
+
* a bare `TypeError` — which is not a `SpecGuardMcpError`, so the server's error
|
|
46
|
+
* boundary reads it as a defect and tells the agent "this is a bug in the
|
|
47
|
+
* bridge, not in your project or configuration". For the commonest config typo
|
|
48
|
+
* there is (omitting `https://`) that sentence is the exact opposite of the
|
|
49
|
+
* truth, and it sends an agent looking in the one place the problem is not.
|
|
50
|
+
* Validating here rather than at the call site is deliberate: every HTTP-backed
|
|
51
|
+
* tool added later comes through this function and inherits the check.
|
|
52
|
+
*/
|
|
53
|
+
export function requireApiConfig(config) {
|
|
54
|
+
const endpointVariable = config.endpointVariable ?? DEFAULT_ENDPOINT_VARIABLE;
|
|
55
|
+
const missing = [];
|
|
56
|
+
if (config.endpoint === undefined)
|
|
57
|
+
missing.push(endpointVariable);
|
|
58
|
+
if (config.apiKey === undefined)
|
|
59
|
+
missing.push("SPECGUARD_API_KEY");
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
throw new ConfigError(`This tool talks to a SpecGuard deployment, and ${missing.join(" and ")} ` +
|
|
62
|
+
`${missing.length === 1 ? "is" : "are"} not set in the MCP server's environment. ` +
|
|
63
|
+
"Set them in your MCP client's server config " +
|
|
64
|
+
`(${endpointVariable} is your deployment's root URL, SPECGUARD_API_KEY an sgk_… key ` +
|
|
65
|
+
"issued from its API keys page). Tools that do not reach the deployment are unaffected.");
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
endpoint: requireHttpUrl(config.endpoint, endpointVariable),
|
|
69
|
+
endpointVariable,
|
|
70
|
+
apiKey: config.apiKey,
|
|
71
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* An absolute `http(s)` URL, or a `ConfigError` that names the variable and the
|
|
76
|
+
* value it holds.
|
|
77
|
+
*
|
|
78
|
+
* Both failing shapes are worth naming because they fail in different places
|
|
79
|
+
* without this. `sg.example.com` has no scheme and `new URL` rejects it
|
|
80
|
+
* outright; `localhost:3000` is *accepted* by `new URL` — as protocol
|
|
81
|
+
* `localhost:` with path `3000` — and instead dies much later as an unhelpful
|
|
82
|
+
* transport error. An operator making either mistake made the same mistake, so
|
|
83
|
+
* they get the same answer.
|
|
84
|
+
*/
|
|
85
|
+
function requireHttpUrl(endpoint, name) {
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = new URL(endpoint);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
parsed = undefined;
|
|
92
|
+
}
|
|
93
|
+
if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
|
94
|
+
throw new ConfigError(`${name} is not a usable URL: ${JSON.stringify(endpoint)}. It must be your SpecGuard ` +
|
|
95
|
+
"deployment's root URL including the scheme — for example " +
|
|
96
|
+
"https://specguard.example.com, or http://localhost:3000 for a local deployment. " +
|
|
97
|
+
`This is the MCP server's own environment: fix ${name} in your MCP client's server ` +
|
|
98
|
+
"config. Nothing about your project or your arguments is wrong.");
|
|
99
|
+
}
|
|
100
|
+
return endpoint;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A trailing slash on the endpoint would produce `https://host//api/v1/…` once
|
|
104
|
+
* joined. Harmless on most servers and confusing in every error message that
|
|
105
|
+
* echoes the URL back, so it is stripped once here rather than defended against
|
|
106
|
+
* at each call site.
|
|
107
|
+
*/
|
|
108
|
+
function normaliseEndpoint(raw) {
|
|
109
|
+
const value = presence(raw);
|
|
110
|
+
return value === undefined ? undefined : value.replace(/\/+$/, "");
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Blank is unset.
|
|
114
|
+
*
|
|
115
|
+
* `SPECGUARD_API_KEY=` in a CI environment file is somebody turning the
|
|
116
|
+
* integration off, and treating it as a present-but-empty key would send
|
|
117
|
+
* `Authorization: Bearer ` and turn a configuration mistake into a 401 —
|
|
118
|
+
* a failure that names the wrong cause. The gem's `ValidatorBackend` collapses
|
|
119
|
+
* unset and blank for the same reason.
|
|
120
|
+
*/
|
|
121
|
+
function presence(raw) {
|
|
122
|
+
const trimmed = raw?.trim();
|
|
123
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
124
|
+
}
|
|
125
|
+
function positiveInteger(raw) {
|
|
126
|
+
const value = Number(presence(raw));
|
|
127
|
+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Splits a configured command into argv WITHOUT a shell, honouring single and
|
|
131
|
+
* double quotes so a path with a space survives.
|
|
132
|
+
*
|
|
133
|
+
* A shell is not used anywhere in this server, and this function is why it does
|
|
134
|
+
* not need to be: `spawn` receives a program and a list, so no argument — least
|
|
135
|
+
* of all a file path an agent passed to a tool — is ever parsed as syntax.
|
|
136
|
+
*/
|
|
137
|
+
export function tokenise(raw) {
|
|
138
|
+
const value = presence(raw);
|
|
139
|
+
if (value === undefined)
|
|
140
|
+
return [];
|
|
141
|
+
const tokens = [];
|
|
142
|
+
let current = "";
|
|
143
|
+
let quote;
|
|
144
|
+
let started = false;
|
|
145
|
+
for (const char of value) {
|
|
146
|
+
if (quote !== undefined) {
|
|
147
|
+
if (char === quote)
|
|
148
|
+
quote = undefined;
|
|
149
|
+
else
|
|
150
|
+
current += char;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (char === '"' || char === "'") {
|
|
154
|
+
quote = char;
|
|
155
|
+
started = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (/\s/.test(char)) {
|
|
159
|
+
if (started)
|
|
160
|
+
tokens.push(current);
|
|
161
|
+
current = "";
|
|
162
|
+
started = false;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
current += char;
|
|
166
|
+
started = true;
|
|
167
|
+
}
|
|
168
|
+
if (started)
|
|
169
|
+
tokens.push(current);
|
|
170
|
+
return tokens;
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failures the server knows how to turn into an MCP tool error.
|
|
3
|
+
*
|
|
4
|
+
* The distinction that matters here is between a bug (which should crash loudly
|
|
5
|
+
* and be fixed) and an *expected* failure the calling agent can act on — a
|
|
6
|
+
* missing API key, a SpecGuard deployment saying 401, a linter binary that is
|
|
7
|
+
* not on PATH. The second kind must reach the agent as a tool result it can
|
|
8
|
+
* read, never as a dead stdio pipe: an MCP server that exits on a bad API key
|
|
9
|
+
* takes every other tool down with it, including the ones that never needed the
|
|
10
|
+
* key.
|
|
11
|
+
*/
|
|
12
|
+
/** Base for every failure that is a legitimate answer rather than a defect. */
|
|
13
|
+
export declare class SpecGuardMcpError extends Error {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The environment does not carry what this tool needs.
|
|
18
|
+
*
|
|
19
|
+
* Raised at CALL time, never at boot — see `resolveApiConfig`. The message names
|
|
20
|
+
* the variable, because "unauthorized" is not something an agent can fix and
|
|
21
|
+
* "set SPECGUARD_API_KEY" is.
|
|
22
|
+
*/
|
|
23
|
+
export declare class ConfigError extends SpecGuardMcpError {
|
|
24
|
+
readonly name = "ConfigError";
|
|
25
|
+
}
|
|
26
|
+
/** The SpecGuard deployment was reached and refused, or answered unusably. */
|
|
27
|
+
export declare class ApiError extends SpecGuardMcpError {
|
|
28
|
+
readonly name = "ApiError";
|
|
29
|
+
/** The HTTP status, when there was a response at all. */
|
|
30
|
+
readonly status: number | undefined;
|
|
31
|
+
constructor(message: string, status?: number);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The arguments the agent supplied are not the shape the schema advertises.
|
|
35
|
+
*
|
|
36
|
+
* What separates this from its two neighbours is WHERE IN THE CALL it is raised:
|
|
37
|
+
* argument shape is checked first, so this throws BEFORE any config is resolved
|
|
38
|
+
* and BEFORE any command is spawned. Nothing was reached and refused, so it is
|
|
39
|
+
* not an `ApiError`; nothing was run and broke, so it is not a `CommandError`.
|
|
40
|
+
* Both of those say the fault lies somewhere the agent cannot see, and invite a
|
|
41
|
+
* retry that cannot help. This is the one failure class the agent can fix
|
|
42
|
+
* unaided, on the next call, from the message alone.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately NOT named `TypeError`: `describeError` treats the global
|
|
45
|
+
* `TypeError` as the canonical non-`SpecGuardMcpError` defect, and shadowing it
|
|
46
|
+
* would invert that branch.
|
|
47
|
+
*/
|
|
48
|
+
export declare class ArgumentError extends SpecGuardMcpError {
|
|
49
|
+
readonly name = "ArgumentError";
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A wrapped command could not be run, or broke while running.
|
|
53
|
+
*
|
|
54
|
+
* NOT "the command reported a problem with your code" — that is a finding and
|
|
55
|
+
* goes back as a successful result. This is the tool itself being unusable,
|
|
56
|
+
* which for `specguard-lint` is precisely its documented exit 2.
|
|
57
|
+
*/
|
|
58
|
+
export declare class CommandError extends SpecGuardMcpError {
|
|
59
|
+
readonly name = "CommandError";
|
|
60
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failures the server knows how to turn into an MCP tool error.
|
|
3
|
+
*
|
|
4
|
+
* The distinction that matters here is between a bug (which should crash loudly
|
|
5
|
+
* and be fixed) and an *expected* failure the calling agent can act on — a
|
|
6
|
+
* missing API key, a SpecGuard deployment saying 401, a linter binary that is
|
|
7
|
+
* not on PATH. The second kind must reach the agent as a tool result it can
|
|
8
|
+
* read, never as a dead stdio pipe: an MCP server that exits on a bad API key
|
|
9
|
+
* takes every other tool down with it, including the ones that never needed the
|
|
10
|
+
* key.
|
|
11
|
+
*/
|
|
12
|
+
/** Base for every failure that is a legitimate answer rather than a defect. */
|
|
13
|
+
export class SpecGuardMcpError extends Error {
|
|
14
|
+
name = "SpecGuardMcpError";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The environment does not carry what this tool needs.
|
|
18
|
+
*
|
|
19
|
+
* Raised at CALL time, never at boot — see `resolveApiConfig`. The message names
|
|
20
|
+
* the variable, because "unauthorized" is not something an agent can fix and
|
|
21
|
+
* "set SPECGUARD_API_KEY" is.
|
|
22
|
+
*/
|
|
23
|
+
export class ConfigError extends SpecGuardMcpError {
|
|
24
|
+
name = "ConfigError";
|
|
25
|
+
}
|
|
26
|
+
/** The SpecGuard deployment was reached and refused, or answered unusably. */
|
|
27
|
+
export class ApiError extends SpecGuardMcpError {
|
|
28
|
+
name = "ApiError";
|
|
29
|
+
/** The HTTP status, when there was a response at all. */
|
|
30
|
+
status;
|
|
31
|
+
constructor(message, status) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.status = status;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The arguments the agent supplied are not the shape the schema advertises.
|
|
38
|
+
*
|
|
39
|
+
* What separates this from its two neighbours is WHERE IN THE CALL it is raised:
|
|
40
|
+
* argument shape is checked first, so this throws BEFORE any config is resolved
|
|
41
|
+
* and BEFORE any command is spawned. Nothing was reached and refused, so it is
|
|
42
|
+
* not an `ApiError`; nothing was run and broke, so it is not a `CommandError`.
|
|
43
|
+
* Both of those say the fault lies somewhere the agent cannot see, and invite a
|
|
44
|
+
* retry that cannot help. This is the one failure class the agent can fix
|
|
45
|
+
* unaided, on the next call, from the message alone.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately NOT named `TypeError`: `describeError` treats the global
|
|
48
|
+
* `TypeError` as the canonical non-`SpecGuardMcpError` defect, and shadowing it
|
|
49
|
+
* would invert that branch.
|
|
50
|
+
*/
|
|
51
|
+
export class ArgumentError extends SpecGuardMcpError {
|
|
52
|
+
name = "ArgumentError";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A wrapped command could not be run, or broke while running.
|
|
56
|
+
*
|
|
57
|
+
* NOT "the command reported a problem with your code" — that is a finding and
|
|
58
|
+
* goes back as a successful result. This is the tool itself being unusable,
|
|
59
|
+
* which for `specguard-lint` is precisely its documented exit 2.
|
|
60
|
+
*/
|
|
61
|
+
export class CommandError extends SpecGuardMcpError {
|
|
62
|
+
name = "CommandError";
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,+EAA+E;AAC/E,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IACxB,IAAI,GAAW,mBAAmB,CAAC;CACtD;AAED;;;;;;GAMG;AACH,MAAM,OAAO,WAAY,SAAQ,iBAAiB;IAC9B,IAAI,GAAG,aAAa,CAAC;CACxC;AAED,8EAA8E;AAC9E,MAAM,OAAO,QAAS,SAAQ,iBAAiB;IAC3B,IAAI,GAAG,UAAU,CAAC;IAEpC,yDAAyD;IAChD,MAAM,CAAqB;IAEpC,YAAY,OAAe,EAAE,MAAe;QAC1C,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAO,aAAc,SAAQ,iBAAiB;IAChC,IAAI,GAAG,eAAe,CAAC;CAC1C;AAED;;;;;;GAMG;AACH,MAAM,OAAO,YAAa,SAAQ,iBAAiB;IAC/B,IAAI,GAAG,cAAc,CAAC;CACzC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createServer, SERVER_NAME, SERVER_VERSION, type CreateServerOptions } from "./server.js";
|
|
2
|
+
export { loadConfig, requireApiConfig, tokenise, type ApiConfig, type Config } from "./config.js";
|
|
3
|
+
export { ApiError, ArgumentError, CommandError, ConfigError, SpecGuardMcpError } from "./errors.js";
|
|
4
|
+
export { TOOLS } from "./tools/index.js";
|
|
5
|
+
export type { ToolContext, ToolDefinition, ToolResult } from "./tools/types.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
|
2
|
+
export { loadConfig, requireApiConfig, tokenise } from "./config.js";
|
|
3
|
+
export { ApiError, ArgumentError, CommandError, ConfigError, SpecGuardMcpError } from "./errors.js";
|
|
4
|
+
export { TOOLS } from "./tools/index.js";
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,cAAc,EAA4B,MAAM,aAAa,CAAC;AAClG,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,QAAQ,EAA+B,MAAM,aAAa,CAAC;AAClG,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACpG,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { type Config } from "./config.js";
|
|
3
|
+
import type { ToolContext, ToolDefinition } from "./tools/types.js";
|
|
4
|
+
export declare const SERVER_NAME = "specguard-mcp";
|
|
5
|
+
export declare const SERVER_VERSION = "0.1.0";
|
|
6
|
+
export interface CreateServerOptions {
|
|
7
|
+
/** Overrides the tools served. Defaults to the registry. Tests pass their own. */
|
|
8
|
+
readonly tools?: readonly ToolDefinition[];
|
|
9
|
+
readonly config?: Config;
|
|
10
|
+
readonly runCommand?: ToolContext["runCommand"];
|
|
11
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Builds the MCP server: two request handlers over the tool registry, and no
|
|
15
|
+
* knowledge of any tool in particular.
|
|
16
|
+
*
|
|
17
|
+
* Everything a tool touches the world with — config, subprocesses, `fetch` — is
|
|
18
|
+
* resolved once here and handed down as a `ToolContext`. That is what makes the
|
|
19
|
+
* whole surface testable without a SpecGuard deployment or an installed gem, and
|
|
20
|
+
* it is a property a tool added later inherits rather than has to arrange.
|
|
21
|
+
*
|
|
22
|
+
* The transport is NOT chosen here. `createServer` returns an unconnected
|
|
23
|
+
* `Server`, and `bin/specguard-mcp.ts` connects it to stdio. SPGD-310 scopes
|
|
24
|
+
* stdio only and puts HTTP/SSE in a later follow-up; keeping the choice out of
|
|
25
|
+
* this file is what makes that follow-up a new entrypoint rather than a change
|
|
26
|
+
* to the server.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createServer(options?: CreateServerOptions): Server;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { SpecGuardMcpError } from "./errors.js";
|
|
5
|
+
import { runCommand as defaultRunCommand } from "./support/run-command.js";
|
|
6
|
+
import { TOOLS } from "./tools/index.js";
|
|
7
|
+
export const SERVER_NAME = "specguard-mcp";
|
|
8
|
+
export const SERVER_VERSION = "0.1.0";
|
|
9
|
+
/**
|
|
10
|
+
* Builds the MCP server: two request handlers over the tool registry, and no
|
|
11
|
+
* knowledge of any tool in particular.
|
|
12
|
+
*
|
|
13
|
+
* Everything a tool touches the world with — config, subprocesses, `fetch` — is
|
|
14
|
+
* resolved once here and handed down as a `ToolContext`. That is what makes the
|
|
15
|
+
* whole surface testable without a SpecGuard deployment or an installed gem, and
|
|
16
|
+
* it is a property a tool added later inherits rather than has to arrange.
|
|
17
|
+
*
|
|
18
|
+
* The transport is NOT chosen here. `createServer` returns an unconnected
|
|
19
|
+
* `Server`, and `bin/specguard-mcp.ts` connects it to stdio. SPGD-310 scopes
|
|
20
|
+
* stdio only and puts HTTP/SSE in a later follow-up; keeping the choice out of
|
|
21
|
+
* this file is what makes that follow-up a new entrypoint rather than a change
|
|
22
|
+
* to the server.
|
|
23
|
+
*/
|
|
24
|
+
export function createServer(options = {}) {
|
|
25
|
+
const tools = options.tools ?? TOOLS;
|
|
26
|
+
const context = {
|
|
27
|
+
config: options.config ?? loadConfig(),
|
|
28
|
+
runCommand: options.runCommand ?? defaultRunCommand,
|
|
29
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
30
|
+
};
|
|
31
|
+
const byName = indexByName(tools);
|
|
32
|
+
const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
33
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
34
|
+
tools: tools.map(describe),
|
|
35
|
+
}));
|
|
36
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
37
|
+
const tool = byName.get(request.params.name);
|
|
38
|
+
// An unknown name is a protocol-level error rather than a tool result: the
|
|
39
|
+
// agent did not call a tool that failed, it called something that is not a
|
|
40
|
+
// tool, and reporting that as a failed call would invite a retry.
|
|
41
|
+
if (tool === undefined) {
|
|
42
|
+
throw new Error(`Unknown tool "${request.params.name}". This server serves: ` +
|
|
43
|
+
`${tools.map((entry) => entry.name).join(", ")}.`);
|
|
44
|
+
}
|
|
45
|
+
return runTool(tool, request.params.arguments ?? {}, context);
|
|
46
|
+
});
|
|
47
|
+
return server;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One tool call, with the error boundary that keeps a bad call from becoming a
|
|
51
|
+
* dead server.
|
|
52
|
+
*
|
|
53
|
+
* The split is between failures the agent can act on and defects it cannot.
|
|
54
|
+
* A `SpecGuardMcpError` — no API key, a 401, a linter that is not installed, an
|
|
55
|
+
* argument of the wrong type — comes back as `isError: true` with a sentence
|
|
56
|
+
* naming the fix, which is a result the model reads and responds to. Anything
|
|
57
|
+
* else is a bug in this server, and it is reported the same way rather than
|
|
58
|
+
* thrown, because on stdio an unhandled rejection takes the transport down and
|
|
59
|
+
* the agent sees "server exited" with no reason at all.
|
|
60
|
+
*/
|
|
61
|
+
async function runTool(tool, args, context) {
|
|
62
|
+
try {
|
|
63
|
+
const result = await tool.run(args, context);
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: result.text }],
|
|
66
|
+
...(result.structured === undefined ? {} : { structuredContent: result.structured }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
isError: true,
|
|
72
|
+
content: [{ type: "text", text: describeError(tool, error) }],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function describeError(tool, error) {
|
|
77
|
+
if (error instanceof SpecGuardMcpError)
|
|
78
|
+
return error.message;
|
|
79
|
+
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
80
|
+
// Named as a defect in this bridge rather than as a verdict about the user's
|
|
81
|
+
// code or configuration — an agent that is told the problem is its input will
|
|
82
|
+
// keep editing its input.
|
|
83
|
+
return `specguard-mcp hit an internal error running \`${tool.name}\` — this is a bug in the bridge, not in your project or configuration. ${detail}`;
|
|
84
|
+
}
|
|
85
|
+
/** The registry entry as MCP puts it on the wire. */
|
|
86
|
+
function describe(tool) {
|
|
87
|
+
return {
|
|
88
|
+
name: tool.name,
|
|
89
|
+
title: tool.title,
|
|
90
|
+
description: tool.description,
|
|
91
|
+
inputSchema: tool.inputSchema,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Duplicate names are caught at construction, not on first call.
|
|
96
|
+
*
|
|
97
|
+
* Two entries sharing a name silently shadow one another in the lookup while
|
|
98
|
+
* both appear in `tools/list`, so an agent would be offered a tool that can
|
|
99
|
+
* never be reached. This is the one invariant of the registry the server
|
|
100
|
+
* enforces itself, because it is the one an added entry can violate by
|
|
101
|
+
* accident.
|
|
102
|
+
*/
|
|
103
|
+
function indexByName(tools) {
|
|
104
|
+
const byName = new Map();
|
|
105
|
+
for (const tool of tools) {
|
|
106
|
+
if (byName.has(tool.name)) {
|
|
107
|
+
throw new Error(`Two tools are registered under the name "${tool.name}".`);
|
|
108
|
+
}
|
|
109
|
+
byName.set(tool.name, tool);
|
|
110
|
+
}
|
|
111
|
+
return byName;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface CommandResult {
|
|
2
|
+
/** The process's exit code, or `null` when a signal killed it. */
|
|
3
|
+
readonly code: number | null;
|
|
4
|
+
readonly signal: NodeJS.Signals | null;
|
|
5
|
+
readonly stdout: string;
|
|
6
|
+
readonly stderr: string;
|
|
7
|
+
/**
|
|
8
|
+
* Whether the stream hit `MAX_OUTPUT_BYTES` and lost its tail.
|
|
9
|
+
*
|
|
10
|
+
* Reported rather than left to be inferred from the marker in the text: a
|
|
11
|
+
* caller that parses the output needs to know the difference between "this
|
|
12
|
+
* program emits garbage" and "we cut its output in half", and only this file
|
|
13
|
+
* knows which happened.
|
|
14
|
+
*/
|
|
15
|
+
readonly stdoutTruncated: boolean;
|
|
16
|
+
readonly stderrTruncated: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the streams were read to their end before this result was produced.
|
|
19
|
+
*
|
|
20
|
+
* Normally true: the result is built when the pipes close, so everything the
|
|
21
|
+
* command wrote is here. It is false only on the backstop path below — the
|
|
22
|
+
* process has exited but something still holds its pipe open, so we answer
|
|
23
|
+
* with what was buffered rather than never answering at all.
|
|
24
|
+
*
|
|
25
|
+
* A SEPARATE field from `stdoutTruncated` rather than a second way to set it,
|
|
26
|
+
* because the two have different causes and different remedies, and the caller
|
|
27
|
+
* acts on the difference: `stdoutTruncated` means the output exceeded
|
|
28
|
+
* `MAX_OUTPUT_BYTES` and the fix is to ask for less (see
|
|
29
|
+
* `lint-intent-annotations.ts`'s "narrow the run with `paths`"), which would
|
|
30
|
+
* be advice about the wrong problem here. This tail was lost to a leaked file
|
|
31
|
+
* descriptor and asking for less output would not change it.
|
|
32
|
+
*
|
|
33
|
+
* Where that difference is acted on, so this stays checkable rather than
|
|
34
|
+
* merely asserted: `parseReport` in `lint-intent-annotations.ts` consults this
|
|
35
|
+
* on both of its failure branches — `undrainedPipe` there names the
|
|
36
|
+
* pipe-holder instead of blaming the linter or `SPECGUARD_LINT_COMMAND`, and
|
|
37
|
+
* is ordered after the truncation branch for the run that manages both. Note
|
|
38
|
+
* it is consulted only where something is already missing: false here does NOT
|
|
39
|
+
* mean the result is incomplete, only that the tail was not waited for, so a
|
|
40
|
+
* run whose last undrained byte was a newline is an ordinary success.
|
|
41
|
+
*/
|
|
42
|
+
readonly outputDrained: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface RunCommandOptions {
|
|
45
|
+
readonly cwd?: string | undefined;
|
|
46
|
+
readonly timeoutMs?: number | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Extra guidance appended when the program is not on PATH.
|
|
49
|
+
*
|
|
50
|
+
* Supplied by the caller because only the caller knows which env var
|
|
51
|
+
* configures ITS command. A generic helper that hard-codes one tool's variable
|
|
52
|
+
* name starts lying the moment a second tool spawns something else.
|
|
53
|
+
*/
|
|
54
|
+
readonly notFoundHint?: string | undefined;
|
|
55
|
+
}
|
|
56
|
+
export type RunCommand = (argv: readonly string[], options?: RunCommandOptions) => Promise<CommandResult>;
|
|
57
|
+
/** Beyond this, a wrapped command's output is truncated rather than buffered. */
|
|
58
|
+
export declare const MAX_OUTPUT_BYTES: number;
|
|
59
|
+
/** Default ceiling on how long a wrapped command may run. */
|
|
60
|
+
export declare const DEFAULT_COMMAND_TIMEOUT_MS = 120000;
|
|
61
|
+
/**
|
|
62
|
+
* How long `close` may lag `exit` before the result is produced without it.
|
|
63
|
+
*
|
|
64
|
+
* `exit` and `close` are two events, not one: `close` additionally waits for
|
|
65
|
+
* every writer of the child's stdout/stderr pipes to let go, and a process that
|
|
66
|
+
* is not the child can be holding them. Long enough that the ordinary
|
|
67
|
+
* milliseconds-apart drain is never cut short; short enough that a leaked
|
|
68
|
+
* descriptor costs a truncated tail instead of a call that never returns.
|
|
69
|
+
*/
|
|
70
|
+
export declare const EXIT_CLOSE_GRACE_MS = 1000;
|
|
71
|
+
/**
|
|
72
|
+
* Runs a program with an argument LIST, never through a shell.
|
|
73
|
+
*
|
|
74
|
+
* `spawn` without `shell: true` is the load-bearing detail: tool arguments
|
|
75
|
+
* arrive from a model, so a file path containing `; rm -rf …` has to be a
|
|
76
|
+
* path that does not exist rather than a command. There is no escaping to get
|
|
77
|
+
* right because nothing is ever parsed as syntax.
|
|
78
|
+
*
|
|
79
|
+
* A non-zero exit is NOT an error here. `specguard-lint` uses its exit code as
|
|
80
|
+
* a three-valued verdict — 0 clean, 1 malformed annotations, 2 the tool could
|
|
81
|
+
* not do its job — so deciding what a code means is the caller's job and this
|
|
82
|
+
* function reports it. What IS an error is the process never running (missing
|
|
83
|
+
* binary) or never finishing (timeout): both leave the caller with no verdict
|
|
84
|
+
* at all, which is the one outcome that must not be mistaken for a clean run.
|
|
85
|
+
*/
|
|
86
|
+
export declare const runCommand: RunCommand;
|