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.
Files changed (38) hide show
  1. package/README.md +366 -0
  2. package/dist/bin/specguard-mcp.d.ts +2 -0
  3. package/dist/bin/specguard-mcp.js +35 -0
  4. package/dist/bin/specguard-mcp.js.map +1 -0
  5. package/dist/src/config.d.ts +108 -0
  6. package/dist/src/config.js +172 -0
  7. package/dist/src/config.js.map +1 -0
  8. package/dist/src/errors.d.ts +60 -0
  9. package/dist/src/errors.js +64 -0
  10. package/dist/src/errors.js.map +1 -0
  11. package/dist/src/index.d.ts +5 -0
  12. package/dist/src/index.js +5 -0
  13. package/dist/src/index.js.map +1 -0
  14. package/dist/src/server.d.ts +28 -0
  15. package/dist/src/server.js +113 -0
  16. package/dist/src/server.js.map +1 -0
  17. package/dist/src/support/run-command.d.ts +86 -0
  18. package/dist/src/support/run-command.js +322 -0
  19. package/dist/src/support/run-command.js.map +1 -0
  20. package/dist/src/support/specguard-api.d.ts +11 -0
  21. package/dist/src/support/specguard-api.js +157 -0
  22. package/dist/src/support/specguard-api.js.map +1 -0
  23. package/dist/src/tools/args.d.ts +48 -0
  24. package/dist/src/tools/args.js +66 -0
  25. package/dist/src/tools/args.js.map +1 -0
  26. package/dist/src/tools/index.d.ts +33 -0
  27. package/dist/src/tools/index.js +34 -0
  28. package/dist/src/tools/index.js.map +1 -0
  29. package/dist/src/tools/lint-intent-annotations.d.ts +45 -0
  30. package/dist/src/tools/lint-intent-annotations.js +342 -0
  31. package/dist/src/tools/lint-intent-annotations.js.map +1 -0
  32. package/dist/src/tools/repository-overview.d.ts +424 -0
  33. package/dist/src/tools/repository-overview.js +797 -0
  34. package/dist/src/tools/repository-overview.js.map +1 -0
  35. package/dist/src/tools/types.d.ts +111 -0
  36. package/dist/src/tools/types.js +2 -0
  37. package/dist/src/tools/types.js.map +1 -0
  38. 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;