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
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 & ships software." width="100%">
|
|
365
|
+
</a>
|
|
366
|
+
</p>
|
|
@@ -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[];
|