specguard-mcp
v0.1.45
Published
MCP server exposing SpecGuard suite intelligence to AI coding agents
Maintainers
Readme
specguard-mcp
The MCP bridge to SpecGuard — gives an AI agent the suite intelligence behind a very large test suite as tools.
specguard-mcp is a Model Context Protocol server that connects
an MCP-capable agent (Claude Code, Claude Desktop, …) to SpecGuard, so the agent can ask what a
suite covers, what it costs to run and where the gaps are — without a line of HTTP or auth code in
its prompt.
SpecGuard is built primarily for AI coding agents; this bridge is how an agent reaches it without scraping a web UI.
Status: bootstrap. A small set of tools ships today, each wrapping a capability that already exists — see The tools for what is in it. The toolset grows gradually — see Adding a tool. It is not published to npm yet; install from a checkout.
Install
git clone https://github.com/yatfa-ai/specguard-mcp.git
cd specguard-mcp && npm install && npm run buildRequires Node.js 20 or newer.
Configure
Nothing is required to start the server. Each tool asks for what it needs when it is called, so a missing variable comes back as one readable sentence in a tool result — never as a server that refuses to boot and takes the tools that needed no configuration down with it.
| Variable | Needed by | Default | What it is |
| --- | --- | --- | --- |
| SPECGUARD_ENDPOINT | get_repository_overview, get_intent_schema, get_server_version, list_repositories, add_repository, registrable_repositories | — | 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. get_server_version and get_intent_schema need only this variable — they send no API key, because the routes they read are unauthenticated by design |
| SPECGUARD_API_KEY | get_repository_overview, near_duplicate_clusters, find_tests_near_behavior (default calls) | — | an agent/CI API key (sgk_…) issued by that deployment — a per-repository key, which is the single repository those tools answer about by default |
| SPECGUARD_USER_API_KEY | list_repositories (fallback), add_repository, registrable_repositories, remove_repository (fallback), create_repository_api_key (fallback), revoke_repository_api_key (fallback), list_repository_api_keys (fallback), list_repository_agent_keys (fallback), revoke_repository_agent_key (fallback), list_repository_agent_keys_presented_revoked (fallback), list_repository_members (fallback), add_repository_member (fallback), update_repository_member_permissions (fallback), remove_repository_member (fallback), get_repository_overview / near_duplicate_clusters / find_tests_near_behavior with repository (fallback), rename_repository | — | a user API key (sgu_…), minted from that deployment's account page. A different credential from the one above, not a second place to put the same value: SpecGuard decides which of them a request may use from the token's prefix, before it reads anything, and answers 401 for the other one. Set whichever your tools need — both, if you use both |
| SPECGUARD_AGENT_API_KEY | list_repositories (preferred), remove_repository (preferred), create_repository_api_key (preferred), revoke_repository_api_key (preferred), list_repository_api_keys (preferred), list_repository_agent_keys (preferred), revoke_repository_agent_key (preferred), list_repository_agent_keys_presented_revoked (preferred), list_repository_members (preferred), add_repository_member (preferred), update_repository_member_permissions (preferred), remove_repository_member (preferred), get_repository_overview / near_duplicate_clusters / find_tests_near_behavior with repository (preferred) | — | an agent API key (sga_…), minted from that deployment's account page (Agent keys panel) with an explicit set of repositories and permissions. It speaks for nobody: its reach is exactly the set granted onto it, fixed at mint time, and every read is bounded by that set server-side. This is the credential to give an automated agent — one key, many repositories, none of a person's rights. When it and SPECGUARD_USER_API_KEY are both set, every tool that answers either key — list_repositories, remove_repository, the members tools, the key-lifecycle tools, and get_repository_overview / near_duplicate_clusters / find_tests_near_behavior with repository — uses this one, so discovery stays inside the set the other tools can reach |
| SPECGUARD_LINT_COMMAND | lint_intent_annotations | specguard-lint | the command that runs the linter — the one switch between the Ruby and the JS/TS client. Most Ruby projects need bundle exec specguard-lint; a JS/TS project sets npx -p @yatfa/specguard specguard lint (and needs the validator backend: a validate-intent binary whose path SPECGUARD_VALIDATE_INTENT names — today the only way to supply it) |
| SPECGUARD_TIMEOUT_MS | HTTP tools | 30000 | how long a call to SpecGuard may take |
SPECGUARD_ENDPOINT and SPECGUARD_API_KEY are the same variables
specguard-ruby uses to ship a run, so a repository
that already posts telemetry to SpecGuard already has them. SPECGUARD_USER_API_KEY is not one
of them — the gem has no notion of a user key — so that one is minted and set here for the first
time.
Register it with your MCP client — for Claude Code:
{
"mcpServers": {
"specguard": {
"command": "node",
"args": ["/path/to/specguard-mcp/dist/bin/specguard-mcp.js"],
"env": {
"SPECGUARD_ENDPOINT": "https://specguard.example.com",
"SPECGUARD_API_KEY": "sgk_…",
"SPECGUARD_USER_API_KEY": "sgu_…",
"SPECGUARD_AGENT_API_KEY": "sga_…",
"SPECGUARD_LINT_COMMAND": "bundle exec specguard-lint"
}
}
}
}The tools
lint_intent_annotations
Validates the @intent: annotations in a project's test files against the
OpenTestIntent schema, by running that project's own
linter with --json — specguard-lint (Ruby/RSpec, *_spec.rb) or specguard lint (JS/TS,
@yatfa/specguard; .ts/.tsx/.js/.jsx/.mjs/.cjs). SPECGUARD_LINT_COMMAND selects which;
the server does not auto-detect it. A JS/TS run needs the validator backend: a validate-intent binary
whose path SPECGUARD_VALIDATE_INTENT names (install it with open-test-intent's scripts/install.sh). That variable is
today the only way to supply it — the npm package's auto-resolution is a seam that resolves nothing yet — and without a
binary the linter exits 2 and says why (see the specguard-ts README, "The validate-intent binary"). Findings come back as data — file, line, failure kind, every violated rule
— rather than as a prose report to regex.
| argument | |
| --- | --- |
| 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 |
| paths | specific test files, relative to project_dir; omit to check every spec file outside dependency/build directories. An empty list is an error rather than a synonym for "everything", because a run that selected nothing must not come back clean |
| changed | check what the branch changed since the merge base with the default branch — CI's mode; needs a linter version that ships --changed: the Ruby gem and @yatfa/specguard main do, @yatfa/specguard 0.1.1 on npm does not (exit 2, invalid option: --changed); untracked spec files count too (no git add needed); --exclude-standard keeps .gitignored paths out of the untracked leg alone, and a tracked file is never subject to .gitignore, so the dependency/build directory fence holds ignored tracked paths out |
| base | diff changed against this ref instead |
The report is the linter's own document, echoed untouched, so its summary keys and finding kinds
differ per client — Ruby reports files/annotations/failed; JS/TS reports
files/annotations/malformed/unreadable. Both clients can emit an unreachable finding kind (an
annotation extraction can never claim: stacked, separated, shadowed, or trailing on a group line); JS/TS
additionally emits no-match, which Ruby folds into read — read the report rather than assuming one shape.
Needs no SpecGuard deployment and no API key. A missing annotation is never a failure: adoption is gradual by design, so a suite with no annotations lints clean.
The exit code is a verdict, and the mapping matters. Either linter exits 0 clean, 1 on a
malformed annotation, and 2 when it could not do its job. Exit 1 comes back as a successful
tool call carrying findings — an agent told "the tool failed" retries the tool, where an agent handed
a finding fixes the annotation. Only exit 2 is a tool error, and it carries the linter's stderr,
because the gem deliberately emits no document on that path.
get_intent_schema
Serves the OpenTestIntent schema document itself — the contract an @intent: annotation is
validated against — read live from the deployment's own unauthenticated root-level schema mirror,
which hands back the canonical document's bytes verbatim.
Call it before writing or editing an annotation. This is the reference; lint_intent_annotations
is the judge, and a judge can only tell you that something you already wrote is wrong.
Reading the contract is the only way to learn parts of it. A refusal names the legal values of a
field you got wrong, and says nothing at all about a field you omitted — so the fastest way to
learn the rules from refusals alone is to submit values you believe are invalid. And an optional
property can never appear in any refusal: required does not name it, and a closed-object check
only ever reports the key you did send, so nothing you submit at any value causes the linter to
mention it. That part of the contract is reachable here and nowhere else in this toolset.
The answer is an envelope that carries the whole document in both shapes, derived from one fetched body so they cannot disagree — plus the enforcement identity beside it:
| field | |
| --- | --- |
| structured.schema | the document parsed — the shape to read the field rules and any enumerated values off |
| structured.enforced | the identity the deployment itself serves from its GET /version: schema_sha256, the digest of the contract it actually enforces, and schema_origin, where those bytes come from on the server — or null when the deployment could not say |
| text | the served bytes unchanged — no re-encoding, no reformatting, so a digest taken over this text matches the canonical document's, which is the mirror's whole promise |
The end-to-end check is derivable from this one tool. Take a SHA-256 digest over text and
compare it with enforced.schema_sha256: a match means the reference you read is the contract
that will judge you; a mismatch means the bytes you received are not what the deployment
enforces — do not author annotations against them, and read enforced.schema_origin to see which
half of the disagreement is the server's. The tool serves both facts side by side and never
judges: a mismatch is never refused or warned here, because the judgment is yours.
enforced: null means "this deployment could not say" — its /version omits the identity
keys (a build predating the identity channel), answers non-2xx, or the identity fetch fails
entirely — and the document answer still stands in full. It is not the same null as a served
schema_sha256: null inside a present enforced: that is the deployment's own honest "I
enforce from this origin and cannot currently read it", passed through exactly as served.
Nothing in this bridge restates the schema's contents — not here, not in a tool description, not in a test. The answer is the contract; any prose copy of it would be one release behind the document this tool serves, and this repo deliberately vendors no copy of its own.
A 404 means the deployment predates the mirror route: the endpoint is right and the build is old. The error text names the endpoint as possibly misconfigured, because a non-contract 404 body names no cause — when the other tools work against the same endpoint, read it as "the deployment needs upgrading", not as a wrong URL. A 2xx whose body will not parse is reported as a malformed mirror instead, naming the route rather than your configuration: the response arrived, so the endpoint is not the thing to go and fix.
Needs no API key of any kind — both routes it reads (the schema mirror and GET /version) are
unauthenticated by design, because a contract is not a secret — so it works with
SPECGUARD_ENDPOINT alone and sends no Authorization header. Takes no arguments.
get_repository_overview
Asks SpecGuard what a repository's suite looks like without running it — the cold-start question
from Project Goals. One call returns the latest CI run (spec counts, annotated ratio, wall-clock and
per-shard cost), where that run spent its time (heaviest files, heaviest directories, slowest
individual examples with file and line), which descriptions are repeated across the suite (the
overcoverage ranking — one description carried by many examples, and which files it is spread over),
which areas grew or shrank and which got slower or faster since the previous run on the same branch
(the per-area comparisons, at both the example-count grain and the runtime grain), the recent run
history, and the branches that have runs. Pass branch for two more: which tests fail intermittently
rather than consistently (the cross-run flakiness ranking) and how the areas moved across the whole
branch window rather than between the last two runs.
Everything above is answered about one repository, and which one has two spellings. By default
it is the repository the configured sgk_… key resolves to — the key is the subject. Pass
repository (the numeric id list_repositories reports) and the call goes to the plural
endpoint, GET /api/v1/repositories/:id, which answers either member credential, whichever is
set: SPECGUARD_AGENT_API_KEY (sga_…) — preferred, and the set of repositories granted onto it
at mint time is the boundary ids are looked up inside (list_repositories under the same key lists
exactly the set that is reachable) — or, when no agent key is set, SPECGUARD_USER_API_KEY
(sgu_…), a person key whose accessible set is the boundary instead. With both set the agent
key wins, so the answer stays inside the set that key can act on. Same overview body, same ladder,
every parameter below honoured identically — with one
deliberate omission this path owns: api_key is absent from the plural body rather than nulled,
because that block describes the credential that made the request and no member credential is a
repository key (an absent key there is the surface's shape, never a dropped block). Other
than that, only the subject moves. That is what makes the other repositories visible: an sgk_
key is one repository
by design, so a credential holding only that could never ask about a second one. The server owns
every refusal on the plural path: a repository outside the presented credential's grant answers
404, indistinguishable from one that does not exist, person and agent alike. SpecGuard refuses each
credential kind in the other's place before it reads anything, so the pairing is not mixable: the
default call wants the sgk_ key, the repository call wants a member key — and a call made with
the wrong kind, or with neither member key set, is refused here, naming the variable(s) to set,
before any request is made.
| argument | |
| --- | --- |
| repository | ask about THIS repository (its numeric id from list_repositories) under the agent key (SPECGUARD_AGENT_API_KEY, sga_…), or — when no agent key is set — the user key (SPECGUARD_USER_API_KEY, sgu_…), instead of the one the sgk_… key resolves to — omit it for the default, sgk_-keyed call |
| branch | narrow the run history to one branch, for a real growth series — and unlock unstable_tests, slowest_tests (with slowest_tests_window), directory_growth and layer_growth (with layer_growth_window), which read the same window |
| spec_directory | open ONE of the heaviest directories and list the spec files inside it |
| spec_file | open ONE of the heaviest spec files and list the individual examples inside it |
| repeated_description | open ONE repeated description and list the examples that all share it |
| unstable_test | open ONE flaky test and list its outcome run by run across the window, newest run first (needs branch) |
| 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 |
| layer | narrow latest_run.slowest_examples — and, when branch is also asked, the cross-run slowest_tests — to one declared layer (unit, integration, request, system or undeclared) — see below |
| unannotated_examples | true to list the individual tests carrying no @intent — the examples behind the annotated ratio, each labelled with what SpecGuard reads of it — and, in the same answer, which areas carry the most of them |
branch narrows history only — latest_run always names the repository's newest run, which on a
busy repo may be on another branch. That is a property of the endpoint, not of this bridge — and
commit_sha is the remedy: it names WHICH RUN to describe, where branch asks about a series. Every
run-grain block re-anchors together (latest_run and its rollups, the five run-grain drill-ins —
spec_directory_files, spec_file_examples, repeated_description_examples, unannotated_examples,
slowest_examples — shards, both growth windows, previous_test_run); history does not, so the
history[0] == latest_run identity holds
on a default call and is not expected to hold under an explicit ask. Nor does unstable_test_runs,
which is read over the branch window rather than off the anchored run. (unannotated_directories,
below, re-anchors too and is not a fifth drill-in: this roster carries one representative key per
drill-in parameter, not one entry per response key — spec_directory opens three blocks and only
spec_directory_files is listed, with directory_run_file_growth and directory_runtime_file_growth
absent for the same reason. unannotated_directories is a second block of an existing parameter's ask
and adds no parameter, so it is covered above by latest_run and its rollups and the roster stays at
four.) A sha with no run — a stale
bookmark, a pruned run, a commit whose CI never reported — does not error: the endpoint
falls back to the newest run and says so, so read run_anchor.resolved rather than trusting that a
successful response is about the commit you named.
branch is also the gate on the three blocks read over that same window, and they are null without
it: unstable_tests (which tests failed intermittently across the window rather than consistently),
slowest_tests with its slowest_tests_window (which durable tests cost the most wall clock across
the window) and directory_growth (how each area moved between the two endpoints of the window).
layer_growth, with its contract block layer_growth_window (how the declared-layer mix moved between the
same two endpoints), is gated by branch in the same way and is null without it. The
per-area comparisons against the previous run — directory_run_growth at the example-count
grain, directory_runtime_growth at the runtime grain, and layer_run_growth for the declared-layer
mix, and layer_runtime_growth for the time each declared layer accounts for — are a different question and take no branch at all: they scope to the latest run's own branch by construction, so a plain unparameterised
call already carries them. The two grains are independent, which is why both ship: making an
existing spec slow adds zero examples and shows up only in the runtime pair, and splitting one slow
spec into four fast ones is +3 examples and less time.
spec_directories ranks the heaviest areas but stops at the area grain, so it says where the time
went and not which files spent it. spec_directory is the next question: pass a path exactly as
served in latest_run.spec_directories.rows[].path and latest_run.spec_directory_files opens with
the files in that one directory (total_seconds, recorded_count, timed_count, layer_counts
each), plus the area's own layer_counts, file_count/recorded_count/timed_count and the limit the row list was cut at —
those totals describe the whole area, not the returned page, so don't re-derive them from rows.
Omit the argument and the key is null, meaning you did not ask; an area the run recorded nothing
for answers rows: [] rather than an error, so a renamed or deleted directory is an empty result and
not a failure.
That one ask opens three blocks, each in its own grain: latest_run.spec_directory_files for
which files carry the area's wall clock, directory_run_file_growth for which of them changed size
since the previous run, and directory_runtime_file_growth for which of them changed time. The last
two are the answer to the question the area-grain comparisons dead-end on — spec/models 412 → 459
(+47), but which files did that — so they need no second parameter.
spec_files ranks the heaviest files (each row: path, total_seconds, recorded_count,
timed_count, layer_counts) but stops at the file grain, so it says which files cost the
most and not which examples inside them spent it. spec_file is the next question: pass a path
exactly as served in latest_run.spec_files.rows[].path and latest_run.spec_file_examples opens
with up to 50 of that file's individual examples, cut by duration (name, file_path,
line_number, spec_file_path, duration_seconds, outcome, intent_layer, declared_intent
each — eight fields), plus the file's own
recorded_count/timed_count and the limit the row list was cut at — those totals describe the
whole file, not the returned page, so don't re-derive them from rows. Omit the argument and the
key is null, meaning you did not ask; a path that matched nothing answers rows: [] rather than
an error, so a renamed or deleted spec file and a stale bookmark are empty results and not failures.
repeated_descriptions ranks the descriptions carried by the most examples — the overcoverage
ranking; each row: name, total_seconds, recorded_count, timed_count, files_seen,
layer_counts — but names the description and the files it was seen in, not which examples say the same
thing. repeated_description is the next question: pass a description exactly as served in
latest_run.repeated_descriptions.rows[].name and latest_run.repeated_description_examples opens
with up to 25 of that group's members (the same eight fields), plus the group's own
recorded_count/timed_count and the limit the row list was cut at — again totals for the whole
group and not for the returned page. This is the only way to reach a group's members:
slowest_examples is the run-wide top ten and rarely contains them, and walking spec_file over
each path in the row's files_seen is N unrelated lists each cut by duration, with no guarantee the
group's members survive the cut in any of them. Omit the argument and the key is null; a
description that matched nothing answers rows: [], so a test renamed since and an edited
description are empty results and not failures.
unstable_tests ranks the tests that failed intermittently across the branch window, but a row
carries run_count, failed_run_count and outcome_words — and those three figures are
identical for four failures in runs 27–30 and four failures in runs 3, 11, 19 and 26. The first is
a regression and the work is to find the commit; the second is genuine flakiness and the work
is quarantine or shared state. unstable_test is the next question: pass a description exactly as
served in unstable_tests.rows[].name and unstable_tests.unstable_test_runs opens with that
description's rows run by run in window order, newest run first, up to 200 (test_run_id,
commit_sha, branch, ingested_at, outcome, duration_seconds, spec_file_path,
line_number each), plus the description's own
recorded_count/reported_outcome_count/unreported_outcome_count, the window's run_count and
the limit the row list was cut at.
Every row of unstable_tests.rows[] and of unstable_tests.shared_description_rows[] also carries
declared_layers — the sorted distinct set of @intent layer: values that test's examples declared
anywhere in the window. [] means no run in the window declared one: it is not unreadable and not
a measured unit. A partially annotated test lists only what was declared, and more than one entry
means the test changed layer mid-window. It is declared-only — the stored column, never inferred from
the spec path or files_seen. The unstable_test_runs drill-in rows do not carry a layer, so
read declared_layers off the ranking row.
Note the two ways it differs from the drill-ins above. The answer lands inside the flakiness
block — unstable_tests.unstable_test_runs, not under latest_run.* — and branch is a hard
prerequisite rather than a suggestion: unstable_tests is null without it, so unstable_test sent
alone leaves no block to drill into at all, and not an empty rows: [] either. Omit the argument and
the key is null; a description the window recorded nothing for answers rows: [], so a renamed
test — which starts a new history under the project's semantic identity rule — is an empty result and
not a failure.
Mind the direction. The rows are newest run first: element 0 is the most recent run in the
window, so the run a failure started at is the last row of the leading failed block, not the
first. Read front-to-back as run 1 onwards and the regression above reads as four failures at the
start of the window that have passed since — a fixed flake, the exact inversion of the truth, and
nothing errors to signal it. The 200-row cap drops the oldest rows for the same reason, so a
truncated sequence is still the recent runs. Read the run off each row's commit_sha/test_run_id
and never off its index: a run that recorded nothing under the description contributes no row, and a
description carried by two examples in one run contributes two, so rows is not one entry per run
and its length is not the window's run_count.
slowest_tests_window + slowest_tests are the cross-run runtime ranking, a top-level pair beside
unstable_tests: one row per durable test (a moved or renamed test keeps its history), ranked by
its window total_seconds, nulls last. It is a different grain from latest_run.slowest_examples,
which is one run at coordinate grain (file and line). Both keys are null without branch
(slowest_tests_window.grouped is true exactly when slowest_tests is non-null), so branch is a
hard prerequisite here as it is for unstable_test. Add layer to narrow it to one declared layer (echoed as slowest_tests_window.layer).
slowest_tests.state is four-way and only the last value is a result: no_runs (the window is
empty), unrecorded (the anchor run wrote no per-example rows), unresolved (it wrote rows and none
has been matched to a durable test yet — the ordinary state for the seconds after an ingest, since
identity resolution is asynchronous) and ranked. Only ranked with rows: [] means nothing in the
suite is slow. anchor_run (test_run_id, commit_sha, branch, ingested_at; null only in
no_runs) names the run that decided membership: candidates are the newest run's slowest tests
and the window only supplies their history, so a test that ran earlier in the window but not in the
newest run is not listed. truncated, unexamined_count and limit disclose the candidate cap. recorded, resolved,
excluded_unresolved_rows, untimed_count, complete and run_count are the booleans and operands
beside them.
recorded_count, unresolved_count, candidate_count, resolved_count and timed_count are
null — not 0 — in every state that returned before the read that would have produced them; a 0
is a measured zero.
Each slowest_tests.rows[] row carries spec_identity_id, total_seconds / slowest_seconds
(null when untimed, never a zero), moved / renamed with their operands files_seen /
descriptions, and declared_layers — the sorted distinct set of @intent layer: values the test
declared in the window. [] means none declared; it is declared-only, never inferred from the spec
path.
annotated_ratio is the product's adoption metric and it was the one population on this endpoint
you could not walk down: the dashboard printed "SpecGuard cannot see the other N tests" and could
not name one of them either, so an agent told to raise annotation coverage learned how far it had to
go and not a single test to annotate. unannotated_examples is that rung.
Unannotated is not the same as unreadable, and the difference is on every response. A test
called Invoice#total sums the line items has an entity, an action and a behavior in its own
description, so SpecGuard reads it whether or not anybody annotated it.
latest_run.intent_readings splits the run's examples into authored (an @intent a human wrote),
derived (read from the description) and unreadable (neither), with the recorded population
they were counted from — no flag to pass. unreadable is the only figure on this endpoint that
means tests SpecGuard can say nothing about. total_specs - annotated_specs is annotation debt,
which on a suite that has never been annotated is the whole suite and almost all of it readable;
never render that subtraction as blindness. A derived reading is genuinely weaker than an authored
one — no preconditions, a behavior written for a test runner's output rather than declared, and a
layer inferred from the directory — so report it as inferred and never as equivalent. And
authored never replaces annotated_ratio: "how much of this suite has a human-written intent" is
still that figure, off the run's own counters.
Which layer the suite declares is a third, separate question, answered on every response by
latest_run.layer_counts, a sibling of intent_readings: five operands — unit, integration,
request, system and undeclared — that sum to intent_readings.recorded. It is null, not
five zeros, when the run stored no per-example rows, because a zero would read as a measured none.
The layers are declared only — an @intent annotation said so — and never inferred from the
directory path: a test under spec/models that declared nothing is undeclared, and undeclared
is not unreadable (a test can be perfectly readable and declare no layer). The operands carry no
pyramid verdict; reading the mix is yours. Where the machine time went by layer is
latest_run.layer_durations, a sibling of layer_counts with the same five keys, each
{total_seconds, timed_count}. total_seconds is null, not 0, for a layer with no timed example,
so an untimed layer is not a free one; timed_count (at most that layer's layer_counts entry) is the
real denominator for any average. The whole key is null when the run stored no per-example rows. It is
a sum of example durations — machine time and not wall clock — so it can exceed
duration_seconds on a parallel or sharded run. The layer is declared only, never inferred from the
path, and the operands carry no pyramid verdict. Which tests those are: pass layer
(unit, integration, request, system or undeclared) to narrow latest_run.slowest_examples (run
grain, moves with commit_sha, echoed as slowest_examples.layer) and, only when branch is also
asked, the cross-run slowest_tests (window grain, does not move with commit_sha, echoed as
slowest_tests_window.layer, which is absent when branch is not asked or the value is malformed) to one
declared layer. On slowest_tests the candidate step is narrowed (at least one anchor-run example in the
layer) while row totals stay the whole durable test's, so declared_layers may list several layers;
resolved_count, candidate_count, timed_count and untimed_count become the layer's anchor-run
figures while recorded_count and unresolved_count stay whole-run; a layer the anchor never declared
answers state: "ranked" with rows: [] — not "nothing is slow". It does not narrow spec_file_examples,
spec_directory_files, unannotated_examples or repeated_description_examples. Each echo is present only
when a layer was asked, slowest_examples.layer recorded_count/timed_count/reported_outcome_count
become that layer's own counts, any other value is read by the server as no ask (so it is forwarded
unvalidated — verify both echoes), and rows: [] with recorded_count: 0 means that layer has no
examples while slowest_examples: null means the run
recorded nothing. How the declared-layer mix moved since the previous
run is layer_run_growth, with its contract block layer_run_growth_window — both top-level
siblings of directory_run_growth, not inside latest_run (the delta is not in
latest_run.layer_counts). They compare the latest run with the previous run on the latest run's own
branch, so no branch is needed, and commit_sha re-anchors them with the rest of the run-grain
blocks. layer_run_growth is null in every non-comparable state — never a block of zeros, so
null is not "no change" — and layer_run_growth_window.state names why: latest_unmeasured,
previous_unmeasured, assembled_differently, neither_recorded, previous_unrecorded,
latest_unrecorded, or comparable, plus no_latest_run (CI never reported) and no_previous_run
(the first run on the branch). layer_run_growth_window.comparable is true exactly when
layer_run_growth is non-null; the window also carries basis, branch, anchor_commit_sha and
baseline_commit_sha. When comparable, layer_run_growth is
{layers, baseline_recorded_count, anchor_recorded_count}, where layers holds unit, integration,
request, system and undeclared, each {baseline_count, anchor_count, change}. A layer that did not
move reads change: 0 — a measured zero, not an absence — and the five change values sum to
anchor_recorded_count minus baseline_recorded_count. The layers are declared only, never inferred
from the path, undeclared is not unreadable, and the operands carry no pyramid verdict.
How the time each declared layer accounts for moved since the previous run is layer_runtime_growth,
with its contract block layer_runtime_growth_window — both top-level siblings of
directory_run_growth, not inside latest_run, exactly as the count pair is. It answers "did my push
make the request layer N seconds slower?", which layer_run_growth (counts) and
directory_runtime_growth (per path) cannot. They compare the latest run with the previous run on the
latest run's own branch, so no branch is needed, and commit_sha re-anchors them.
layer_runtime_growth is null in every non-comparable state — never a block of zeros — and
layer_runtime_growth_window.state names why: the layer_run_growth states (latest_unmeasured,
previous_unmeasured, assembled_differently, neither_recorded, previous_unrecorded,
latest_unrecorded), plus neither_timed, previous_untimed and latest_untimed (a run recorded
example rows but timed none of them), comparable, no_latest_run and no_previous_run.
layer_runtime_growth_window.comparable is true exactly when layer_runtime_growth is non-null; the
window also carries basis, branch, anchor_commit_sha and baseline_commit_sha. When comparable,
layer_runtime_growth is {layers}, where layers holds unit, integration, request, system and
undeclared, each {baseline_seconds, anchor_seconds, baseline_timed_count, anchor_timed_count,
baseline_count, anchor_count, change}. change is anchor minus baseline in seconds and is null —
not 0 — when either side's layer was untimed; a layer timed on both sides that did not move reads
change: 0. It is a sum of machine time, not wall clock; layers are declared only, a test
changing its declared @intent layer reads as one layer gaining time and another losing it, and no
verdict is given.
How the declared-layer mix moved across the branch window is layer_growth, with its contract block
layer_growth_window — both top-level siblings of directory_growth, not inside latest_run, and
branch-gated exactly as directory_growth is: without branch, layer_growth is null and
layer_growth_window reads grouped: false with state: null (no query ran, and there is no
all-branches fallback). It is the window grain of layer_run_growth, which compares only the latest run
with the previous one and needs no branch: use layer_run_growth for "what did my push change?" and
layer_growth for "how has the pyramid drifted over the window?" — an example added and another removed
on every push reads ±1 per push there and is never flagged. It compares the two endpoints of the
window (layer_growth_window.basis is "two_endpoints") and is not a series: an example added in
run 12 and removed in run 25 reads change: 0. The baseline is found by the same walk as
directory_growth, so for one window both carry the same baseline_commit_sha and runs_back. The window
block carries basis, branch_scope, branch, grouped, state, comparable, anchor_commit_sha,
baseline_commit_sha and runs_back. layer_growth is null in every non-comparable state —
never a block of zeros — and layer_growth_window.state names why, one of eight: anchor_unmeasured,
no_earlier_run, no_measured_baseline, no_comparable_composition, neither_recorded,
baseline_unrecorded, anchor_unrecorded or comparable. layer_growth_window.comparable is true
exactly when layer_growth is non-null. When comparable, layer_growth is
{layers, baseline_recorded_count, anchor_recorded_count}, where layers holds unit, integration,
request, system and undeclared, each {baseline_count, anchor_count, change}; a layer that did not
move reads change: 0 — a measured zero, not an absence — and the five change values sum to
anchor_recorded_count minus baseline_recorded_count. Layers are declared only, never inferred from
the path, undeclared is not unreadable, and no pyramid verdict is given.
The same
layer_counts rides each row of
spec_directories, each row of spec_files and each row of repeated_descriptions (the same five
operands, declared only and never inferred from the path, measured zeros, summing to that row's
recorded_count), the spec_directory_files area block and each of its file rows, and every
per-example row of slowest_examples, spec_file_examples and repeated_description_examples
carries intent_layer and declared_intent ({entity, action, behavior} or null, declared only).
The unannotated_examples flag is the one argument
here that is a flag rather than a name — pass true, not a value — because it opens a
population rather than a pick: total_specs minus annotated_specs is a subtraction, and a
subtraction has no line to name. Which population is still yours to choose: sent alone the flag
opens the whole run, and sent together with spec_file or spec_directory it narrows to that
file, that area, or the AND of the two — those two keep opening their own blocks as well, so
narrowing this one is additional rather than instead. latest_run.unannotated_examples opens with
up to 100 of the unannotated examples of whatever you asked for (name, file_path,
line_number, spec_file_path, reading and derived_intent each — six fields, unlike the
per-example drill-ins' eight: no duration_seconds, no outcome, no intent_layer), plus that
same population's own recorded_count, derived_count and unreadable_count, the limit the row
list was cut at, and
spec_file/spec_directory echoed back as the server read them — null for each one you did
not send. Read the echo before the count: the worklist's recorded_count — and only that one,
because the map below deliberately does not narrow — is the figure you would reconcile against
total_specs - annotated_specs, and it counts the narrowed population whenever either echo is
non-null, so that reconciliation is expected to hold only when both are null. Do not re-derive
that count from rows either way: un-narrowed, this population is routinely the entire run — a
repository that has just installed the gem has every test in it — so the cap fires as the normal
case here rather than the exotic one, and a narrowed ask is cut at the same 100.
That one ask opens two blocks, each in its own grain: latest_run.unannotated_examples for
which tests to go and annotate, and latest_run.unannotated_directories for where the debt is —
the run's annotation debt rolled up by code area, which is what you pick the next spec_directory
narrowing from. Both come from the one flag; there is no second argument to send and no new
value. Each worklist row carries reading — "derived" or "unreadable" — and derived_intent,
the entity/action/behavior SpecGuard got from the description or null; the unreadable rows
come first, so the 100-row cap cannot hide them. The map's rows carry path,
unannotated_count, the recorded_count that area was counted against, and the same three-way
split — authored_count, derived_count and unreadable_count, which sum to recorded_count
while the last two sum to unannotated_count (the operands, never a fraction), plus
directory_count — every area the run
touched, not every area with debt, and not rows.size — and its own limit, which is 10 and
not the worklist's 100. Two caps under one ask, and the difference is the kind of list: 100 caps a
worklist to work through, 10 caps a ranking to pick from. The orders differ for the same reason —
the worklist is file-navigable within each reading, the map is ranked unreadable_count descending,
then unannotated_count descending, with path as a tiebreak only — the areas SpecGuard cannot
read lead, because a ten-row ranking led by debt on an unannotated suite is a ranking by area size
and the dark corners never surface. A fully-annotated area is a real row with unannotated_count: 0, never an
omission; those rows sort last collectively, so on a run with more areas than the cap they are cut
and never seen, but on a run inside the cap they are listed and listed is correct. So rows.size is
not a count of areas with debt — read each row's unannotated_count. Both blocks are at run grain,
so both move with commit_sha.
The two blocks disagree in two places, on purpose — do not reconcile them by arithmetic.
Scope: spec_file/spec_directory narrow the worklist and its recorded_count, and the
map stays whole-run under both. So under a narrowing unannotated_examples.recorded_count is
not the sum of unannotated_directories.rows[].unannotated_count, and neither figure is wrong:
one counts the area or file you named, the other ranks the whole run. The map is whole-run by design
because it is the thing you choose a narrowing from — narrowed to the area you had already picked
it would answer nothing. The sum is short of the run's total whenever directory_count > rows.size
besides, narrowing or not. Null versus empty: on a run that recorded no per-example rows at all,
with the flag sent, unannotated_examples is a present block with rows: [] and
recorded_count: 0 while unannotated_directories is null. That is a signal rather than an
inconsistency — recorded_count: 0 on the worklist means both "fully annotated" and "recorded
nothing", and the map is how you tell them apart: a present map beside that zero means the zero is
the success state, a null map means the run recorded nothing and the zero is an absence of data.
false means the same as omitting it and sends nothing at all. That matters more here than
elsewhere: the server reads only whether the parameter was named, so ?unannotated_examples=false
on the wire would open the block for a caller who asked for it not to be — declining is not sending,
which is how every other argument here is declined too. Omit the flag and both keys are null,
meaning you did not ask. A fully-annotated run is not an error and not a null: the worklist
answers 200 with rows: [] and recorded_count: 0, because that is the state the metric exists to
reach — walk a repository to completion and the block goes empty rather than vanishing. A narrowing
that matched nothing reads the same way and is never a 404: an unknown or renamed path, an already
fully-annotated file, and a contradictory file-and-area pair all answer rows: [] with both
narrowings echoed, which is an empty intersection rather than a dropped parameter.
Two blocks come back on every response, and they take no argument at all. They answer what every
figure above silently depends on — is SpecGuard still being fed? delivery_health is why the data
may be stale: refusing compares stamps rather than reading a live wire — it is true when the
newest refusal is newer than the newest accepted run, and true when nothing has ever been
accepted, so a repository refused once and quiet since still answers true. Read it with
last_rejection_at and judge recency yourself. Each retained rejection carries the endpoint's own
reasons and, where the client reported one, the client
version that sent it, plus served_by — the build of the SpecGuard deployment that refused it,
stamped on the row at write time. That stamp answers a question the deployment's live version read
cannot: get_server_version reports which build is answering now, while retained refusals
outlive deploys, so for a past row the two routinely disagree — read served_by for which build
refused that row, never the live route. The row's two nulls mean different things and must not
be read as each other: a null reported_client means the client sent no User-Agent (the row
knows, and the client said nothing); a null served_by means the row carries no build identity, so
the platform cannot name which build answered it — the write path can land on that null in more
than one way (for example a row retained from before the stamp existed, a build whose VERSION file
is missing or unreadable, a column stored blank), so the null says only that no build is named,
never which way it got there. The window those rows sit in carries its own population and client
composition beside them: retained_total counts every rejection the retention window still holds
(up to retention_rows rows) and retained_clients splits that total by client, largest bucket
first with alphabetical ties. retained_total is the window's population, never the length of
the rejections array beside it — that list is capped at limit, and the whole point of serving
both is that they differ: a client whose refusals all sit beyond the page is named in
retained_clients and in no rejections row, which is how an upgrade-fleet question ("is it the
new gem or the old gem being refused?") gets answered off this body instead of from the page's
newest ten. A null reported_client inside retained_clients carries the row-level meaning — the
client sent no User-Agent — and is not the served_by null. A latest_run from days ago beside a live rejection stream is a suite
SpecGuard stopped accepting, not a suite nobody ran. credential_health covers the break that one
structurally cannot see: a rejected key resolves no repository and writes no rejection row, so an
authentication-broken pipeline is invisible to every rejection figure — it names any key that was
rotated and has not authenticated since, a secret some pipeline has not picked up.
A quiet answer is a finding, not a gap: refusing: false is "nothing was refused" and
rotated_and_unused: false is "no key is stranded", and neither means "SpecGuard does not track
that". Do not read api_key.last_used_at as evidence anything was accepted — it is stamped on
the way in, before the payload is looked at, so a repository having every run thrown away still
reports it seconds ago; the endpoint says so itself in acceptance_reported_by and
rotation_reported_by, which name these two blocks. Where a bound sits beside a list, the list is
a page and not the set: limit next to rows, or on that list's *_window block, which is
also where the order the cut was made in is named when the list has one. What announces the cut
varies too — truncated, bounded, returned short of limit, or a recorded_count larger than
the rows served — so read the bound beside the list in front of you and never take a full-looking
ranking for the whole set. Where no bound sits beside a list, it is everything there was:
credential_health.keys and both latest_run.shards lists are complete by construction, which is
a finding and not a disclosure someone forgot.
Figures are null where CI did not report them. A null means not measured; it is never a zero,
because a zero would read as a measurement that was taken. That rule is about nulls and does not
run backwards: a non-null figure is not thereby a measurement — a run that reported zero tests
serves a real total_specs: 0 beside suite_size_measured: false, so it is that boolean, never
the nullness, that says whether the row measured a suite.
Three more keys ride every response, with no parameter to pass, and together they decide whether
two history rows may be differenced at all — which is what the history is for. suite_size_measured
is served on latest_run and on every history[] row, from the same predicate at both sites so one
run cannot disagree with itself: false means that run reported no tests, so its total_specs
is a report, not a measurement — never difference across a row where it is false. shard_count
and timed_shard_count ride every history[] row and are the two denominators: difference
total_specs only across rows of equal shard_count, because that count is a sum over the shards
recorded; difference duration_seconds only across rows of equal timed_shard_count, because
that figure is a max over the shards that reported. Ignore the second and a run whose four
shards all reported, differenced against a run whose two slowest were cancelled — identical
shard_count, identical suite_size_measured, only timed_shard_count differing — reads as a 70%
speedup produced entirely by telemetry loss. A half-reported run is the ordinary state, not an
exotic one: every sharded run passes through it while its shards are still arriving, and a job
cancelled after two of four shards leaves a half-sized row in the history permanently.
get_server_version
Reports which build of the SpecGuard deployment is answering, read from the deployment's own
unauthenticated root-level GET /version — the figure the release bot's VERSION file carries,
served as a JSON object and passed through verbatim. Since SPGD-1316 the body also carries the
enforced contract's identity beside the build (schema_sha256 + schema_origin); this tool
passes the whole answer through without interpreting it — get_intent_schema is where the served
contract is read beside that identity and the two are compared.
This is the server's build, not this bridge's. The version in the initialize handshake's
serverInfo and the specguard-mcp/<version> User-Agent describe this bridge — a different
component, released on its own cadence — so never read one as the other. Pinning which build's
crafted 404/400 message sentence a contract suite asserts, verifying that a deployment upgrade took
effect, or naming the build that produced a behavior is this tool's question; the handshake cannot
answer it.
version: null is an honest answer, not an error. The deployment is up but cannot say its own
build — its VERSION file was missing or unreadable at boot — and the platform serves that
deliberately, because an unverifiable identity beats a 500 from the one endpoint whose whole job is
to be askable. The null is passed through exactly as served, never replaced with a guessed value.
A 404 means the deployment predates the route: the endpoint is right and the build is old. The error text names the endpoint as possibly misconfigured, because a non-contract 404 body names no cause — when the other tools work against the same endpoint, read that error as "the deployment needs upgrading", not as a wrong URL.
Needs no API key of any kind — the route is unauthenticated by design — so it works with
SPECGUARD_ENDPOINT alone, and it sends no Authorization header (as does get_intent_schema, the
other credential-free read here). Takes no arguments.
list_repositories
Lists the SpecGuard repositories this server's key may open — what can I ask about, which is the
one question no other tool here can answer. get_repository_overview without repository takes no
repository because its sgk_… key is the repository, so without this an agent can only report
on a repository somebody already named for it.
The credential decides which repositories are in the answer — a person's own-plus-shared set
under an sgu_… key, the agent key's mint-time granted set under an sga_… one; the three
optional asks below narrow within whichever set answered — never around it. The endpoint has
served them since SpecGuard's SPGD-940 (ef6236d), composed through the same RepositoryNarrowing
concern the web grid reads, and this tool forwards them rather than re-deriving them. A repository
the credential does not admit never enters the response, so no ask can filter it in either. A
blank value is no ask: passing an empty string and omitting the argument make the identical request.
| argument | |
| --- | --- |
| q | keep only repositories whose full_name (org/repo) contains this substring, case-insensitively. A plain substring, not a pattern — the LIKE wildcards %/_ are escaped server-side, so org/my_repo matches itself. A match-less ask is an empty list, not an error |
| role | "owned" or "shared" — one half of the list's mix: the repositories this person owns, or the ones shared with them. Mind the spelling: the ask values are owned/shared, while each entry's role field reads owner/member — role: "owner" is not a valid ask and settles to no ask (full list, no error). Under the sga_… agent key the ask settles to no ask for every value: ownership is a person fact and the key speaks for nobody, so the full granted list is served |
| sort | "stale" — re-order stalest-first: repositories CI has never ingested a run for first, then least-recently-ingested, with full_name breaking ties so two calls agree element for element. "annotated" — re-order least-annotated share first: a suite measured at 0-of-N leads, repositories with no run or an unmeasured suite come last (never ranked as 0%), and full_name breaks ties. The same entries, a different order; the default order stays full_name ascending |
The body comes back as SpecGuard serves it — {"repositories": […]}, each entry carrying id,
full_name, name, registered_at, role, delivery_health and latest_run, ordered by
full_name ascending unless sort asks otherwise. The identity fields are deliberately the same
fields, under the same names, that get_repository_overview serves in its own repository block,
so a client that reads one reads the other.
delivery_health is the entry's delivery verdict — refusing beside last_rejection_at — served
on every entry, so this one call triages ingest-pipeline health across the whole reachable set
instead of paying one get_repository_overview call per repository. It is the coarse sibling of
the overview's fuller per-repository block. A quiet verdict is a finding, not a gap:
refusing: false means nothing was refused, never that delivery is untracked.
latest_run is the entry's newest run — when CI last reported, on what branch and commit, how big
the suite is, how much of it SpecGuard can read, and the run-level cost scalars. null means CI
has never reported for that repository — never a run that found an empty suite.
role has one value per
credential kind: under a user key (sgu_…) it is owner or member — the list mixes
repositories this person owns with repositories somebody shared with them, and nothing else tells
them apart — while under an agent key (sga_…) every entry is agent, the value that says the
ownership question does not apply because the key speaks for nobody (branching on owner/member
correctly reads false for both). Read it before assuming a repository is one you may administer.
Under an agent key the top level also carries credential.capabilities — the calling key's own
grant, read through the server's policy, so an agent key learns what it was granted here instead of
discovering its permissions by hitting refusals; under a user key the block is absent, not
null — a person key has no mint-time permission set, and a null would assert one exists and is
empty.
An empty list means no access, not an error.
It reads a different key from get_repository_overview — either of two, whichever is set.
SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the answer is then the repository set granted
onto that key at mint time, the same set every other agent-keyed call answers inside, so discovery
and reach always agree. SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the answer is
what that person may open. With both set the agent key wins. Neither is ever a stand-in for
SPECGUARD_API_KEY (sgk_…) — SpecGuard refuses each credential kind in the other's place, the
prefix deciding which table is consulted before any of them is read — and every message this tool
produces names the variable it read, so a 401 here never sends you to check a key this tool
never used.
Registering a repository is add_repository, below — it reads SPECGUARD_USER_API_KEY (an
sgu_… key, which this tool also accepts but does not require) and takes the full_name this
tool reports. Removal and the key lifecycle (remove_repository,
create_repository_api_key, revoke_repository_api_key) are below too, on the same
either-credential rule; a tool
here is a promise the agent will act on, so each waits for the capability rather than the other way
round.
add_repository
Registers a GitHub repository with SpecGuard for the person behind SPECGUARD_USER_API_KEY, and
returns the repository together with its first CI API key — the sgk_… key that repository's CI
will use to ingest runs, minted in the same call so a fresh registration is usable without a second
trip through the browser.
| argument | |
| --- | --- |
| full_name | the repository to register, as org/repo (for example acme/billing) — the same handle list_repositories reports. Not a URL, not a bare repository name |
The body comes back as SpecGuard serves it: a repository block (id, full_name, name,
registered_at — deliberately the same four fields get_repository_overview serves in its own
repository block) and an api_key block (id, name, token, hint, created_at). id is
the key's durable handle (the token is reveal-once); every other key's id comes from
list_repository_api_keys.
⚠️
api_key.tokenis shown once and never again. Nothing stores it and no endpoint can re-serve it. Capture it from this response — an agent should hand it straight to the person it is working for. A key that is lost is replaced from SpecGuard's API-keys page in a browser, not from here.
⚠️ This tool is not idempotent, and it writes. If the call exceeds
SPECGUARD_TIMEOUT_MSthe bridge gives up, but the registration may still have succeeded on the server — taking its one-time token into a response nobody received. The retry is then refused withhas already been taken, which is the honest answer rather than a bug. Do not retry a timeout blindly; checklist_repositories, and recover the key in the browser.
It needs a current record of your GitHub permissions, and only a browser creates one. SpecGuard decides whether you may register a repository from a stored grant, and fails closed when that grant is missing or stale — which is every person who has not signed in and connected GitHub recently. That refusal arrives as SpecGuard's own sentence, verbatim, naming the fix: sign in to SpecGuard in a browser and reconnect GitHub, then try again. No argument to this tool substitutes for it. The same path carries the other refusals — a repository the SpecGuard GitHub App is not installed on, one you do not administer, one already registered.
The org/repo format is not re-checked here. This bridge verifies only that you passed a
non-blank string; SpecGuard validates the name and refuses an unusable one in its own words. A second
format rule on this side would be free to drift from the one that actually decides, and would surface
as this bridge rejecting a name the platform would have accepted.
It reads SPECGUARD_USER_API_KEY (sgu_…) — a person key, which list_repositories also
accepts but does not require — and a different one from the sgk_… key get_repository_overview
uses.
registrable_repositories
Lists the GitHub repositories the person behind SPECGUARD_USER_API_KEY could register with
SpecGuard — the set the registration gate would consult, read out loud in advance, so an agent can
pick a full_name for add_repository from a real answer rather than by guessing. list_repositories
reports what is registered; this reports what could be, and the two answer different questions.
The body comes back as SpecGuard serves it: {"repositories": […]} with each entry carrying
full_name and registered, ordered by full_name ascending, plus a grant block (captured_at,
expires_at, stale) describing the stored record of this person's GitHub permissions.
registered is asked globally, not just of your own repositories. An entry marked
registered: true was registered by somebody — possibly someone else — and a POST naming it will
be refused with has already been taken. That is exactly why entries are marked rather than
excluded: a reading scoped to what you can open would send you at a name nobody can register.
A name appearing here is not a promise the write will succeed. This is the set the gate would consult at the moment of the read; the repository may be registered by someone else between this call and your POST.
A missing or stale grant is not an error — it is the modal first answer. SpecGuard fails closed
when it has no current record of your GitHub permissions, which is every person who has not opened
SpecGuard in a browser recently. The call then answers 403 with SpecGuard's own sentence naming
the fix: sign in to SpecGuard in a browser and reconnect GitHub, then try again. On that refusal
the body still carries grant, and it distinguishes the two cases: grant: null means there never
was one (first-time setup), a populated grant with stale: true means an existing connection lapsed
— same remedy, very different urgency. Read it before telling the person what to do.
This tool takes no arguments — the credential is the whole of the scope. The endpoint takes no parameters; which repositories are in the answer is decided by SpecGuard from the person the key speaks for.
It reads SPECGUARD_USER_API_KEY (sgu_…) — the same person key add_repository reads, and
a different one from the sgk_… key `get_repo
