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
package/README.md ADDED
@@ -0,0 +1,366 @@
1
+ # specguard-mcp
2
+
3
+ > The MCP bridge to [SpecGuard](https://github.com/yatfa-ai/specguard) — gives an AI agent the suite
4
+ > intelligence behind a very large test suite as tools.
5
+
6
+ `specguard-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server that connects
7
+ an MCP-capable agent (Claude Code, Claude Desktop, …) to SpecGuard, so the agent can ask what a
8
+ suite covers, what it costs to run and where the gaps are — without a line of HTTP or auth code in
9
+ its prompt.
10
+
11
+ SpecGuard is built [primarily for AI coding agents](https://github.com/yatfa-ai/specguard); this
12
+ bridge is how an agent reaches it without scraping a web UI.
13
+
14
+ > **Status: bootstrap.** Two tools ship today, each wrapping a capability that already exists. The
15
+ > toolset **grows gradually** — see [Adding a tool](#adding-a-tool). It is not published to npm yet;
16
+ > install from a checkout.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ git clone https://github.com/yatfa-ai/specguard-mcp.git
22
+ cd specguard-mcp && npm install && npm run build
23
+ ```
24
+
25
+ Requires Node.js 20 or newer.
26
+
27
+ ## Configure
28
+
29
+ Nothing is required to *start* the server. Each tool asks for what it needs when it is called, so a
30
+ missing variable comes back as one readable sentence in a tool result — never as a server that
31
+ refuses to boot and takes the tools that needed no configuration down with it.
32
+
33
+ | Variable | Needed by | Default | What it is |
34
+ | --- | --- | --- | --- |
35
+ | `SPECGUARD_ENDPOINT` | `get_repository_overview` | — | your SpecGuard instance's root URL, **including the scheme** — e.g. `https://specguard.example.com`, or `http://localhost:3000`. A value with no scheme is refused by name (`SPECGUARD_ENDPOINT is not a usable URL: "sg.example.com"`) rather than surfacing later as an opaque failure. `SPECGUARD_URL` is accepted as an alias, and is the name every message uses when it is the one you set. A blank value counts as unset, so leaving `SPECGUARD_ENDPOINT` empty in a templated config falls through to `SPECGUARD_URL` instead of suppressing it |
36
+ | `SPECGUARD_API_KEY` | `get_repository_overview` | — | an agent/CI API key (`sgk_…`) issued by that deployment |
37
+ | `SPECGUARD_LINT_COMMAND` | `lint_intent_annotations` | `specguard-lint` | the command that runs the linter. Most Ruby projects need `bundle exec specguard-lint` |
38
+ | `SPECGUARD_TIMEOUT_MS` | HTTP tools | `30000` | how long a call to SpecGuard may take |
39
+
40
+ `SPECGUARD_ENDPOINT` and `SPECGUARD_API_KEY` are the same variables
41
+ [`specguard-rspec`](https://github.com/yatfa-ai/specguard-rspec) uses to ship a run, so a repository
42
+ that already posts telemetry to SpecGuard already has them.
43
+
44
+ Register it with your MCP client — for Claude Code:
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "specguard": {
50
+ "command": "node",
51
+ "args": ["/path/to/specguard-mcp/dist/bin/specguard-mcp.js"],
52
+ "env": {
53
+ "SPECGUARD_ENDPOINT": "https://specguard.example.com",
54
+ "SPECGUARD_API_KEY": "sgk_…",
55
+ "SPECGUARD_LINT_COMMAND": "bundle exec specguard-lint"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## The tools
63
+
64
+ ### `lint_intent_annotations`
65
+
66
+ Validates the `@intent:` annotations in a Ruby project's spec files against the
67
+ [OpenTestIntent](https://github.com/yatfa-ai/open-test-intent) schema, by running that project's own
68
+ `specguard-lint --json`. Findings come back as data — file, line, failure kind, every violated rule
69
+ — rather than as a prose report to regex.
70
+
71
+ | argument | |
72
+ | --- | --- |
73
+ | `project_dir` | the project to lint; defaults to the server's working directory. A path that does not exist, or is not a directory, is refused by name — never reported as a missing linter |
74
+ | `paths` | specific spec files, relative to `project_dir`; omit to check all of them. An empty list is an error rather than a synonym for "everything", because a run that selected nothing must not come back clean |
75
+ | `changed` | check only what differs from the merge base with the default branch — CI's mode |
76
+ | `base` | diff `changed` against this ref instead |
77
+
78
+ Needs no SpecGuard deployment and no API key. A **missing** annotation is never a failure: adoption
79
+ is gradual by design, so a suite with no annotations lints clean.
80
+
81
+ **The exit code is a verdict, and the mapping matters.** `specguard-lint` exits `0` clean, `1` on a
82
+ malformed annotation, and `2` when it could not do its job. Exit `1` comes back as a **successful**
83
+ tool call carrying findings — an agent told "the tool failed" retries the tool, where an agent handed
84
+ a finding fixes the annotation. Only exit `2` is a tool error, and it carries the linter's stderr,
85
+ because the gem deliberately emits no document on that path.
86
+
87
+ ### `get_repository_overview`
88
+
89
+ Asks SpecGuard what a repository's suite looks like **without running it** — the cold-start question
90
+ from Project Goals. One call returns the latest CI run (spec counts, annotated ratio, wall-clock and
91
+ per-shard cost), where that run spent its time (heaviest files, heaviest directories, slowest
92
+ individual examples with file and line), which descriptions are repeated across the suite (the
93
+ overcoverage ranking — one description carried by many examples, and which files it is spread over),
94
+ which areas grew or shrank and which got slower or faster since the previous run on the same branch
95
+ (the per-area comparisons, at both the example-count grain and the runtime grain), the recent run
96
+ history, and the branches that have runs. Pass `branch` for two more: which tests fail intermittently
97
+ rather than consistently (the cross-run flakiness ranking) and how the areas moved across the whole
98
+ branch window rather than between the last two runs.
99
+
100
+ | argument | |
101
+ | --- | --- |
102
+ | `branch` | narrow the run **history** to one branch, for a real growth series — and unlock `unstable_tests` and `directory_growth`, which read the same window |
103
+ | `spec_directory` | open ONE of the heaviest directories and list the spec files inside it |
104
+ | `spec_file` | open ONE of the heaviest spec files and list the individual examples inside it |
105
+ | `repeated_description` | open ONE repeated description and list the examples that all share it |
106
+ | `unstable_test` | open ONE flaky test and list its outcome run by run across the window, newest run first (needs `branch`) |
107
+ | `commit_sha` | anchor the answer on ONE named run instead of the repository's newest one — every run-grain block moves with it, `history` does not |
108
+ | `unannotated_examples` | `true` to list the individual tests SpecGuard cannot see — the examples behind the annotated ratio — and, in the same answer, which areas carry the most of them |
109
+
110
+ `branch` narrows `history` only — `latest_run` always names the repository's newest run, which on a
111
+ busy repo may be on another branch. That is a property of the endpoint, not of this bridge — and
112
+ `commit_sha` is the remedy: it names WHICH RUN to describe, where `branch` asks about a series. Every
113
+ run-grain block re-anchors together (`latest_run` and its rollups, the four run-grain drill-ins —
114
+ `spec_directory_files`, `spec_file_examples`, `repeated_description_examples`, `unannotated_examples`
115
+ — `shards`, both growth windows, `previous_test_run`); `history` does not, so the
116
+ `history[0] == latest_run` identity holds
117
+ on a default call and is **not** expected to hold under an explicit ask. Nor does `unstable_test_runs`,
118
+ which is read over the branch window rather than off the anchored run. (`unannotated_directories`,
119
+ below, re-anchors too and is *not* a fifth drill-in: this roster carries one representative key per
120
+ drill-in **parameter**, not one entry per response key — `spec_directory` opens three blocks and only
121
+ `spec_directory_files` is listed, with `directory_run_file_growth` and `directory_runtime_file_growth`
122
+ absent for the same reason. `unannotated_directories` is a second block of an existing parameter's ask
123
+ and adds no parameter, so it is covered above by *`latest_run` and its rollups* and the roster stays at
124
+ four.) A sha with no run — a stale
125
+ bookmark, a pruned run, a commit whose CI never reported — does not error: the endpoint
126
+ falls back to the newest run and says so, so read `run_anchor.resolved` rather than trusting that a
127
+ successful response is about the commit you named.
128
+
129
+ `branch` is also the gate on the two blocks read over that same window, and they are `null` without
130
+ it: `unstable_tests` (which tests failed intermittently across the window rather than consistently)
131
+ and `directory_growth` (how each area moved between the two **endpoints** of the window). The
132
+ per-area comparisons against the **previous run** — `directory_run_growth` at the example-count
133
+ grain and `directory_runtime_growth` at the runtime grain — are a different question and take no
134
+ branch at all: they scope to the latest run's own branch by construction, so a plain unparameterised
135
+ call already carries them. The two grains are independent, which is why both ship: making an
136
+ existing spec slow adds zero examples and shows up only in the runtime pair, and splitting one slow
137
+ spec into four fast ones is `+3` examples and *less* time.
138
+
139
+ `spec_directories` ranks the heaviest areas but stops at the area grain, so it says *where* the time
140
+ went and not *which files* spent it. `spec_directory` is the next question: pass a path exactly as
141
+ served in `latest_run.spec_directories.rows[].path` and `latest_run.spec_directory_files` opens with
142
+ the files in that one directory (`total_seconds`, `recorded_count`, `timed_count` each), plus the
143
+ **area's** own `file_count`/`recorded_count`/`timed_count` and the `limit` the row list was cut at —
144
+ those totals describe the whole area, not the returned page, so don't re-derive them from `rows`.
145
+ Omit the argument and the key is `null`, meaning *you did not ask*; an area the run recorded nothing
146
+ for answers `rows: []` rather than an error, so a renamed or deleted directory is an empty result and
147
+ not a failure.
148
+
149
+ That one ask opens **three** blocks, each in its own grain: `latest_run.spec_directory_files` for
150
+ which files carry the area's wall clock, `directory_run_file_growth` for which of them changed size
151
+ since the previous run, and `directory_runtime_file_growth` for which of them changed time. The last
152
+ two are the answer to the question the area-grain comparisons dead-end on — `spec/models 412 → 459
153
+ (+47)`, but *which files did that* — so they need no second parameter.
154
+
155
+ `spec_files` ranks the heaviest files but stops at the file grain, so it says *which files* cost the
156
+ most and not *which examples* inside them spent it. `spec_file` is the next question: pass a path
157
+ exactly as served in `latest_run.spec_files.rows[].path` and `latest_run.spec_file_examples` opens
158
+ with up to 50 of that file's individual examples, cut by **duration** (`name`, `file_path`,
159
+ `line_number`, `spec_file_path`, `duration_seconds`, `outcome` each), plus the **file's** own
160
+ `recorded_count`/`timed_count` and the `limit` the row list was cut at — those totals describe the
161
+ whole file, not the returned page, so don't re-derive them from `rows`. Omit the argument and the
162
+ key is `null`, meaning *you did not ask*; a path that matched nothing answers `rows: []` rather than
163
+ an error, so a renamed or deleted spec file and a stale bookmark are empty results and not failures.
164
+
165
+ `repeated_descriptions` ranks the descriptions carried by the most examples — the overcoverage
166
+ ranking — but names the description and the files it was seen in, not *which* examples say the same
167
+ thing. `repeated_description` is the next question: pass a description exactly as served in
168
+ `latest_run.repeated_descriptions.rows[].name` and `latest_run.repeated_description_examples` opens
169
+ with up to 25 of that group's members (the same six fields), plus the **group's** own
170
+ `recorded_count`/`timed_count` and the `limit` the row list was cut at — again totals for the whole
171
+ group and not for the returned page. This is the **only** way to reach a group's members:
172
+ `slowest_examples` is the run-wide top ten and rarely contains them, and walking `spec_file` over
173
+ each path in the row's `files_seen` is N unrelated lists each cut by duration, with no guarantee the
174
+ group's members survive the cut in any of them. Omit the argument and the key is `null`; a
175
+ description that matched nothing answers `rows: []`, so a test renamed since and an edited
176
+ description are empty results and not failures.
177
+
178
+ `unstable_tests` ranks the tests that failed intermittently across the branch window, but a row
179
+ carries `run_count`, `failed_run_count` and `outcome_words` — and those three figures are
180
+ *identical* for four failures in runs 27–30 and four failures in runs 3, 11, 19 and 26. The first is
181
+ a **regression** and the work is to find the commit; the second is genuine **flakiness** and the work
182
+ is quarantine or shared state. `unstable_test` is the next question: pass a description exactly as
183
+ served in `unstable_tests.rows[].name` and `unstable_tests.unstable_test_runs` opens with that
184
+ description's rows run by run in window order, **newest run first**, up to 200 (`test_run_id`,
185
+ `commit_sha`, `branch`, `ingested_at`, `outcome`, `duration_seconds`, `spec_file_path`,
186
+ `line_number` each), plus the **description's** own
187
+ `recorded_count`/`reported_outcome_count`/`unreported_outcome_count`, the window's `run_count` and
188
+ the `limit` the row list was cut at.
189
+
190
+ Note the two ways it differs from the drill-ins above. The answer lands **inside** the flakiness
191
+ block — `unstable_tests.unstable_test_runs`, not under `latest_run.*` — and `branch` is a hard
192
+ prerequisite rather than a suggestion: `unstable_tests` is `null` without it, so `unstable_test` sent
193
+ alone leaves no block to drill into at all, and not an empty `rows: []` either. Omit the argument and
194
+ the key is `null`; a description the window recorded nothing for answers `rows: []`, so a renamed
195
+ test — which starts a new history under the project's semantic identity rule — is an empty result and
196
+ not a failure.
197
+
198
+ **Mind the direction.** The rows are newest run first: element 0 is the most recent run in the
199
+ window, so the run a failure *started* at is the **last** row of the leading failed block, not the
200
+ first. Read front-to-back as run 1 onwards and the regression above reads as four failures at the
201
+ start of the window that have passed since — a fixed flake, the exact inversion of the truth, and
202
+ nothing errors to signal it. The 200-row cap drops the **oldest** rows for the same reason, so a
203
+ truncated sequence is still the recent runs. Read the run off each row's `commit_sha`/`test_run_id`
204
+ and never off its index: a run that recorded nothing under the description contributes no row, and a
205
+ description carried by two examples in one run contributes two, so `rows` is not one entry per run
206
+ and its length is not the window's `run_count`.
207
+
208
+ `annotated_ratio` is the product's adoption metric and it was the one population on this endpoint
209
+ you could not walk down: the dashboard prints *"SpecGuard cannot see the other N tests"* and could
210
+ not name one of them either, so an agent told to raise annotation coverage learned how far it had to
211
+ go and not a single test to annotate. `unannotated_examples` is that rung. It is the one argument
212
+ here that is a **flag rather than a name** — pass `true`, not a value — because it opens a
213
+ *population* rather than a pick: `total_specs` minus `annotated_specs` is a subtraction, and a
214
+ subtraction has no line to name. Which population is still yours to choose: sent alone the flag
215
+ opens the whole run, and sent **together with** `spec_file` or `spec_directory` it narrows to that
216
+ file, that area, or the AND of the two — those two keep opening their own blocks as well, so
217
+ narrowing this one is additional rather than instead. `latest_run.unannotated_examples` opens with
218
+ up to 100 of the unannotated examples **of whatever you asked for** (`name`, `file_path`,
219
+ `line_number`, `spec_file_path` each — four fields, not the per-example drill-ins' six), plus that
220
+ same population's own `recorded_count`, the `limit` the row list was cut at, and
221
+ `spec_file`/`spec_directory` **echoed back** as the server read them — `null` for each one you did
222
+ not send. Read the echo before the count: the **worklist's** `recorded_count` — and only that one,
223
+ because the map below deliberately does not narrow — is the figure you would reconcile against
224
+ `total_specs - annotated_specs`, and it counts the *narrowed* population whenever either echo is
225
+ non-null, so that reconciliation is expected to hold only when both are `null`. Do not re-derive
226
+ that count from `rows` either way: un-narrowed, this population is routinely the entire run — a
227
+ repository that has just installed the gem has every test in it — so the cap fires as the normal
228
+ case here rather than the exotic one, and a narrowed ask is cut at the same 100.
229
+
230
+ That one ask opens **two** blocks, each in its own grain: `latest_run.unannotated_examples` for
231
+ *which tests* to go and annotate, and `latest_run.unannotated_directories` for *where the debt is* —
232
+ the run's annotation debt rolled up by code area, which is what you pick the next `spec_directory`
233
+ narrowing **from**. Both come from the one flag; there is no second argument to send and no new
234
+ value. The map's rows carry `path`, `unannotated_count` and the `recorded_count` that area was
235
+ counted against (the operands, never a fraction), plus `directory_count` — **every** area the run
236
+ touched, not every area with debt, and not `rows.size` — and its **own** `limit`, which is **10 and
237
+ not the worklist's 100**. Two caps under one ask, and the difference is the kind of list: 100 caps a
238
+ *worklist* to work through, 10 caps a *ranking* to pick from. The orders differ for the same reason —
239
+ the worklist is file-navigable, the map is ranked `unannotated_count` descending with `path` as a
240
+ tiebreak only. A fully-annotated area is a real **row** with `unannotated_count: 0`, never an
241
+ omission; those rows sort last *collectively*, so on a run with more areas than the cap they are cut
242
+ and never seen, but on a run inside the cap they *are* listed and listed is correct. So `rows.size` is
243
+ not a count of areas *with* debt — read each row's `unannotated_count`. Both blocks are at run grain,
244
+ so both move with `commit_sha`.
245
+
246
+ **The two blocks disagree in two places, on purpose — do not reconcile them by arithmetic.**
247
+ *Scope:* `spec_file`/`spec_directory` narrow the **worklist** and its `recorded_count`, and the
248
+ **map stays whole-run** under both. So under a narrowing `unannotated_examples.recorded_count` is
249
+ *not* the sum of `unannotated_directories.rows[].unannotated_count`, and neither figure is wrong:
250
+ one counts the area or file you named, the other ranks the whole run. The map is whole-run by design
251
+ because it is the thing you choose a narrowing *from* — narrowed to the area you had already picked
252
+ it would answer nothing. The sum is short of the run's total whenever `directory_count > rows.size`
253
+ besides, narrowing or not. *Null versus empty:* on a run that recorded no per-example rows at all,
254
+ with the flag sent, `unannotated_examples` is a **present** block with `rows: []` and
255
+ `recorded_count: 0` while `unannotated_directories` is **`null`**. That is a signal rather than an
256
+ inconsistency — `recorded_count: 0` on the worklist means *both* "fully annotated" and "recorded
257
+ nothing", and the map is how you tell them apart: a present map beside that zero means the zero is
258
+ the success state, a `null` map means the run recorded nothing and the zero is an absence of data.
259
+
260
+ `false` means the same as omitting it and sends nothing at all. That matters more here than
261
+ elsewhere: the server reads only whether the parameter was **named**, so `?unannotated_examples=false`
262
+ on the wire would open the block for a caller who asked for it not to be — declining is not sending,
263
+ which is how every other argument here is declined too. Omit the flag and **both** keys are `null`,
264
+ meaning you did not ask. A **fully-annotated run is not an error and not a `null`**: the worklist
265
+ answers 200 with `rows: []` and `recorded_count: 0`, because that is the state the metric exists to
266
+ reach — walk a repository to completion and the block goes empty rather than vanishing. A narrowing
267
+ that matched nothing reads the same way and is never a 404: an unknown or renamed path, an already
268
+ fully-annotated file, and a contradictory file-and-area pair all answer `rows: []` with both
269
+ narrowings echoed, which is an empty intersection rather than a dropped parameter.
270
+
271
+ **Two blocks come back on every response, and they take no argument at all.** They answer what every
272
+ figure above silently depends on — is SpecGuard still being fed? `delivery_health` is why the data
273
+ may be **stale**: `refusing` compares stamps rather than reading a live wire — it is true when the
274
+ newest refusal is newer than the newest *accepted* run, and true when nothing has ever been
275
+ accepted, so a repository refused once and quiet since still answers `true`. Read it with
276
+ `last_rejection_at` and judge recency yourself. Each retained rejection carries the endpoint's own
277
+ reasons and, where the client reported one, the client
278
+ version that sent it. A `latest_run` from days ago beside a live rejection stream is a suite
279
+ SpecGuard *stopped accepting*, not a suite nobody ran. `credential_health` covers the break that one
280
+ structurally **cannot** see: a rejected key resolves no repository and writes no rejection row, so an
281
+ authentication-broken pipeline is invisible to every rejection figure — it names any key that was
282
+ **rotated** and has not authenticated since, a secret some pipeline has not picked up.
283
+ A quiet answer is a **finding, not a gap**: `refusing: false` is "nothing was refused" and
284
+ `rotated_and_unused: false` is "no key is stranded", and neither means "SpecGuard does not track
285
+ that". Do **not** read `api_key.last_used_at` as evidence anything was *accepted* — it is stamped on
286
+ the way in, before the payload is looked at, so a repository having every run thrown away still
287
+ reports it seconds ago; the endpoint says so itself in `acceptance_reported_by` and
288
+ `rotation_reported_by`, which name these two blocks. Where a bound sits beside a list, the list is
289
+ a **page and not the set**: `limit` next to `rows`, or on that list's `*_window` block, which is
290
+ also where the *order* the cut was made in is named when the list has one. What announces the cut
291
+ varies too — `truncated`, `bounded`, `returned` short of `limit`, or a `recorded_count` larger than
292
+ the rows served — so read the bound beside the list in front of you and never take a full-looking
293
+ ranking for the whole set. Where **no** bound sits beside a list, it is everything there was:
294
+ `credential_health.keys` and both `latest_run.shards` lists are complete by construction, which is
295
+ a finding and not a disclosure someone forgot.
296
+
297
+ Figures are `null` where CI did not report them. A `null` means *not measured*; it is never a zero,
298
+ because a zero would read as a measurement that was taken.
299
+
300
+ ## How it works
301
+
302
+ ```
303
+ agent ⇄ specguard-mcp ⇄ SpecGuard (HTTP, the same API the dashboard uses)
304
+ (stdio/MCP) ⇄ specguard-lint (subprocess, in your project)
305
+ ```
306
+
307
+ The bridge is a **thin client**: it shells out and it calls the API, and it re-implements neither.
308
+ It carries no knowledge of the OpenTestIntent schema, holds no copy of the linter's rules, and
309
+ reshapes no response — both tools return the shape of the capability they wrap, so a field added
310
+ upstream reaches the agent without a release here.
311
+
312
+ Authorization and project scoping are enforced by SpecGuard, never by this bridge, using the same
313
+ `sgk_…` keys CI uses to ingest runs. The bridge adds no credentials of its own and stores nothing.
314
+
315
+ No argument ever reaches a shell: subprocesses are spawned with an argument list, so a path from a
316
+ model is a path that does not exist rather than a command.
317
+
318
+ ## Adding a tool
319
+
320
+ The toolset fills in as more of SpecGuard lands. Adding one is two mechanical edits:
321
+
322
+ 1. a new file under `src/tools/` that default-exports a `ToolDefinition`;
323
+ 2. one entry appended to the array in `src/tools/index.ts`.
324
+
325
+ There is no third — no third *wiring* edit, at least: every tool also earns a `### ` section with an
326
+ argument table above, and every argument earns a row in that table, since this README ships as the
327
+ package's published documentation, and `test/readme.test.ts` derives that obligation from the
328
+ registry so a missing section or an undocumented parameter fails the suite. `src/server.ts` iterates
329
+ that array and contains no per-tool code — no `switch`, no hard-coded name — and everything a tool
330
+ touches the world with (config, subprocesses, `fetch`) is injected, so a new tool is testable
331
+ without a live deployment for free. The property tests in `test/tools/registry.test.ts` run over
332
+ whatever the registry holds, so a tool added later is checked by tests written today.
333
+
334
+ **Only wrap capabilities that exist.** A tool in `tools/list` is a promise an agent acts on; one that
335
+ discovers cleanly and fails on use is worse than one that is absent, because the agent has already
336
+ committed to a plan by the time it finds out. `/check-intent` and duplicate clustering are therefore
337
+ not here, and should arrive when their backing data and engine do.
338
+
339
+ Transport is chosen in `bin/specguard-mcp.ts` and nowhere else — stdio today, so an HTTP/SSE
340
+ entrypoint is a sibling of that file rather than a change to the server.
341
+
342
+ ## Development
343
+
344
+ ```bash
345
+ npm run build # compile to dist/
346
+ npm test # typecheck, then run the suite
347
+ npm run typecheck # types only
348
+ ```
349
+
350
+ ## Related repositories
351
+
352
+ - [`specguard`](https://github.com/yatfa-ai/specguard) — the platform: ingest API + Hotwire dashboard
353
+ - [`specguard-rspec`](https://github.com/yatfa-ai/specguard-rspec) — Ruby client (RSpec formatter + `@intent` linter)
354
+ - [`open-test-intent`](https://github.com/yatfa-ai/open-test-intent) — the annotation protocol SpecGuard consumes
355
+
356
+ ## License
357
+
358
+ ISC
359
+
360
+ ---
361
+
362
+ <p align="center">
363
+ <a href="https://yatfa.com">
364
+ <img src="assets/built-with-yatfa.png" alt="Built with yatfa — a team of AI agents that plans, builds &amp; ships software." width="100%">
365
+ </a>
366
+ </p>
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer } from "../src/server.js";
4
+ /**
5
+ * The stdio entrypoint — the only place a transport is named.
6
+ *
7
+ * SPGD-310 scopes stdio and puts HTTP/SSE in a later follow-up. Keeping the
8
+ * choice here rather than inside `createServer` is what makes that follow-up a
9
+ * sibling of this file instead of a change to the server.
10
+ *
11
+ * NOTHING MAY BE WRITTEN TO STDOUT. On stdio, stdout IS the protocol channel:
12
+ * a stray `console.log` — a debug line, a deprecation notice — is framed as a
13
+ * JSON-RPC message and corrupts the stream, and the failure surfaces to the
14
+ * user as an unexplained disconnect. Every diagnostic below goes to stderr,
15
+ * which MCP clients collect as the server's log.
16
+ *
17
+ * The server itself is built with no I/O and no validation of anything, so
18
+ * `createServer` cannot fail on a missing SPECGUARD_API_KEY — that is checked
19
+ * by the tools that need it, at call time. A server that refused to start
20
+ * without a key would take the lint tool, which needs no key at all, down with
21
+ * it.
22
+ */
23
+ async function main() {
24
+ const server = createServer();
25
+ await server.connect(new StdioServerTransport());
26
+ process.stderr.write("specguard-mcp: ready on stdio\n");
27
+ }
28
+ // A rejection anywhere in the tool path is already caught and returned as a
29
+ // tool error; this is the backstop for the connection itself. It exits non-zero
30
+ // so a supervising client reports a dead server rather than a silent one.
31
+ main().catch((error) => {
32
+ process.stderr.write(`specguard-mcp: fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
33
+ process.exit(1);
34
+ });
35
+ //# sourceMappingURL=specguard-mcp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specguard-mcp.js","sourceRoot":"","sources":["../../bin/specguard-mcp.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEhD;;;;;;;;;;;;;;;;;;GAkBG;AACH,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,YAAY,EAAE,CAAC;IAC9B,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,oBAAoB,EAAE,CAAC,CAAC;IACjD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,iCAAiC,CAAC,CAAC;AAC1D,CAAC;AAED,4EAA4E;AAC5E,gFAAgF;AAChF,0EAA0E;AAC1E,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,yBAAyB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CACnG,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Where configuration comes from, and — more importantly — WHEN it is read.
3
+ *
4
+ * == Nothing is required to start the server
5
+ *
6
+ * The bootstrap ships two tools with genuinely different needs: the lint tool
7
+ * shells out to a local binary and needs no SpecGuard deployment at all, while
8
+ * the repository tool needs both an endpoint and a key. A server that validated
9
+ * everything at boot would refuse to start for someone who installed it purely
10
+ * to lint — and MCP's failure mode for that is not a helpful message, it is a
11
+ * client reporting "server exited" with the reason buried in a log nobody
12
+ * reads.
13
+ *
14
+ * So `loadConfig` never throws. Each tool asks for what it needs, at call time,
15
+ * through the `require*` helpers below, and a missing variable comes back as one
16
+ * legible sentence in a tool result the agent can act on. This is also the
17
+ * property that keeps the toolset growable: a tool added later that needs a
18
+ * third variable adds a `require*` helper and changes nothing about startup.
19
+ *
20
+ * == SPECGUARD_ENDPOINT, with SPECGUARD_URL as an accepted alias
21
+ *
22
+ * `SPECGUARD_ENDPOINT` is the name the shipped `specguard-rspec` gem already
23
+ * reads, so a repository whose CI posts runs to SpecGuard has it set — that is
24
+ * the whole reason this server borrows the name rather than coining one. The
25
+ * SPGD-310 brief writes it as `SPECGUARD_URL`, so that spelling is accepted
26
+ * too rather than left as a silent no-op for anyone who follows the ticket.
27
+ * `SPECGUARD_ENDPOINT` wins when both are set and disagree, because it is the
28
+ * one the rest of the toolchain is already reading.
29
+ */
30
+ export interface Config {
31
+ /** SpecGuard deployment root, trailing slash stripped. `undefined` when unset. */
32
+ readonly endpoint: string | undefined;
33
+ /**
34
+ * WHICH variable the endpoint was read from — so a message about a bad value
35
+ * can name the variable the operator actually set.
36
+ *
37
+ * Without this, someone who followed the brief and set `SPECGUARD_URL` to a
38
+ * malformed value would be told to go and fix `SPECGUARD_ENDPOINT`, which they
39
+ * never set. Accepting two spellings is only a kindness if the diagnostics
40
+ * speak the one that was used.
41
+ */
42
+ readonly endpointVariable: EndpointVariable | undefined;
43
+ /** An `sgk_…` API key. `undefined` when unset. */
44
+ readonly apiKey: string | undefined;
45
+ /**
46
+ * The command that runs the `@intent` linter, already tokenised.
47
+ *
48
+ * Defaults to `["specguard-lint"]`. Most Ruby projects need the gem resolved
49
+ * through their bundle, which is a deployment fact this server cannot guess —
50
+ * set `SPECGUARD_LINT_COMMAND="bundle exec specguard-lint"` for those. It is
51
+ * tokenised here rather than handed to a shell, so nothing an agent passes as
52
+ * a tool argument can reach one.
53
+ */
54
+ readonly lintCommand: readonly string[];
55
+ /** How long an HTTP call to SpecGuard may take, in milliseconds. */
56
+ readonly requestTimeoutMs: number;
57
+ }
58
+ export declare const DEFAULT_LINT_COMMAND: readonly string[];
59
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
60
+ /** Reads config from an environment. Never throws — see the note above. */
61
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
62
+ /** What a tool that talks to the SpecGuard API needs, once both halves are known. */
63
+ export interface ApiConfig {
64
+ readonly endpoint: string;
65
+ /**
66
+ * WHICH variable `endpoint` came from, carried one layer further out.
67
+ *
68
+ * `requireHttpUrl` already names the variable the operator set when it refuses
69
+ * a malformed value, but the HTTP client one level down has its own diagnostics
70
+ * — unreachable, 404, a body that is not JSON — and each of them tells the
71
+ * operator to go and check the endpoint. Without the name here, all three said
72
+ * `SPECGUARD_ENDPOINT` unconditionally, so someone who followed the brief and
73
+ * set `SPECGUARD_URL` was sent to fix a variable they never set. Threading it
74
+ * onto `ApiConfig` rather than re-deriving it means every HTTP-backed tool
75
+ * added later inherits correct naming the same way it inherits the URL check.
76
+ */
77
+ readonly endpointVariable: EndpointVariable;
78
+ readonly apiKey: string;
79
+ readonly requestTimeoutMs: number;
80
+ }
81
+ export type EndpointVariable = "SPECGUARD_ENDPOINT" | "SPECGUARD_URL";
82
+ /**
83
+ * Both halves or a legible failure — never one half and a surprise later.
84
+ *
85
+ * Reported together rather than one at a time: an operator who set neither
86
+ * should learn that in one round trip instead of fixing a variable, re-calling,
87
+ * and being told about the next one.
88
+ *
89
+ * The endpoint is also PARSED here, not merely counted as present. It is spent
90
+ * later inside `new URL(...)` in the HTTP client, where a malformed value throws
91
+ * a bare `TypeError` — which is not a `SpecGuardMcpError`, so the server's error
92
+ * boundary reads it as a defect and tells the agent "this is a bug in the
93
+ * bridge, not in your project or configuration". For the commonest config typo
94
+ * there is (omitting `https://`) that sentence is the exact opposite of the
95
+ * truth, and it sends an agent looking in the one place the problem is not.
96
+ * Validating here rather than at the call site is deliberate: every HTTP-backed
97
+ * tool added later comes through this function and inherits the check.
98
+ */
99
+ export declare function requireApiConfig(config: Config): ApiConfig;
100
+ /**
101
+ * Splits a configured command into argv WITHOUT a shell, honouring single and
102
+ * double quotes so a path with a space survives.
103
+ *
104
+ * A shell is not used anywhere in this server, and this function is why it does
105
+ * not need to be: `spawn` receives a program and a list, so no argument — least
106
+ * of all a file path an agent passed to a tool — is ever parsed as syntax.
107
+ */
108
+ export declare function tokenise(raw: string | undefined): string[];