MCP server bridging SpecGuard's suite intelligence to AI coding agents.
Обзор
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. A small set of tools ships today, each wrapping a capability that already exists — see The tools for what is in it. The toolset — see Adding a tool. It is not published to npm yet; install from a checkout. 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.
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 build
Requires 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, layer_growth (with layer_growth_window), layer_runtime_window_growth (with layer_runtime_window_growth_window) and directory_runtime_window_growth (with directory_runtime_window_growth_window), which read the same window (layer narrows slowest_tests, unstable_tests, directory_growth and directory_runtime_window_growth there; layer_runtime_window_growth is not narrowed) — and, together with spec_directory, the window file drill-ins directory_file_growth (with directory_file_growth_window) and directory_runtime_window_file_growth (with directory_runtime_window_file_growth_window), both null without branch and also without spec_directory, and both narrowed by layer |
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 the run-grain blocks (slowest_examples, spec_files, spec_directories, repeated_descriptions, repeated_description_examples, spec_file_examples, spec_directory_files, the previous-run area-growth blocks directory_run_growth and directory_runtime_growth, and — with spec_directory — directory_run_file_growth and directory_runtime_file_growth) and — when branch is also asked — the window-grain slowest_tests, unstable_tests, directory_growth and directory_runtime_window_growth (plus, with spec_directory, directory_file_growth and directory_runtime_window_file_growth) to one declared layer (unit, integration, request, system or undeclared); unannotated_examples is not narrowed — 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 at run grain, 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. When branch is also asked it
opens two more, at window grain: directory_file_growth (which files gained or lost examples across the
branch window) and directory_runtime_window_file_growth (which got slower or faster across it), each with its
_window contract block; both bodies are null without branch.
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 a closed list of blocks, each of which
echoes layer on its own block (the key is absent when a layer was not asked or is malformed). Run
grain (moves with commit_sha): latest_run.slowest_examples (echoed as slowest_examples.layer),
spec_files and spec_directories (ranked by that layer’s time), repeated_descriptions and
repeated_description_examples (repeated within the layer), spec_file_examples and
spec_directory_files (which adds a layer-empty state), and the previous-run area-growth blocks
directory_run_growth and directory_runtime_growth plus, when spec_directory is also asked,
directory_run_file_growth and directory_runtime_file_growth (each echoed as .layer and
_window.layer; they need no branch, and every area count and time sum in them is that layer’s;
a layer empty in both runs stays comparable with no rows, empty in only one run reads as a new or removed
area); a layer with no rows answers rows: [] with the block still present. Window grain (needs branch, does not move with commit_sha): the cross-run
slowest_tests (echoed as slowest_tests_window.layer) and unstable_tests (echoed as
unstable_tests_window.layer; the candidate step is the tests that failed in a layer example while each
row’s history stays whole) and directory_growth (echoed as directory_growth.layer and
directory_growth_window.layer) and directory_runtime_window_growth (the per-area time movement between the
same two endpoints; echoed as directory_runtime_window_growth.layer and
directory_runtime_window_growth_window.layer) and, when spec_directory is also asked, the window file drill-ins
directory_file_growth (echoed as directory_file_growth.layer and directory_file_growth_window.layer) and
directory_runtime_window_file_growth (echoed as directory_runtime_window_file_growth.layer and
directory_runtime_window_file_growth_window.layer); layer_runtime_window_growth is not narrowed. All the window echoes are
absent when branch is not asked or the value is malformed.
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”. unannotated_examples and
unannotated_directories are not narrowed. On slowest_examples with a layer asked,
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 the per-block 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.
How the machine time moved across the branch window is two more pairs, the time siblings of
directory_growth and layer_growth: layer_runtime_window_growth with its contract block
layer_runtime_window_growth_window (declared-layer time) and directory_runtime_window_growth with
directory_runtime_window_growth_window (per-area time). All four are top-level siblings of
directory_growth, not inside latest_run, and branch-gated exactly as it is: without branch both growth
keys are null and both window blocks read grouped: false with state: null (no query ran, and there is no
all-branches fallback). They are the window grain of layer_runtime_growth and directory_runtime_growth, which
compare only the latest run with the previous one and need no branch. They compare the two endpoints of the
window (basis is "two_endpoints") and are not a series: a layer or area slowed in run 12 and sped up in run
25 reads change: 0. The baseline is found by the same walk as directory_growth, so for one window all of them
carry the same baseline_commit_sha and runs_back. directory_runtime_window_growth_window also carries order
(abs_change_desc_nulls_last,path_asc), tie_break_served and limit, and echoes layer only when layer was
asked (that pair is narrowed by layer; layer_runtime_window_growth is not). Each growth key is null in
every non-comparable state — never a block of zeros — and the window block’s state names why, one of
eleven: anchor_unmeasured, no_earlier_run, no_measured_baseline, no_comparable_composition,
neither_recorded, baseline_unrecorded, anchor_unrecorded, neither_timed, baseline_untimed,
anchor_untimed or comparable. comparable is true exactly when the growth key is non-null. When comparable,
layer_runtime_window_growth is {layers} with unit, integration, request, system and undeclared, each
{baseline_seconds, anchor_seconds, baseline_timed_count, anchor_timed_count, baseline_count, anchor_count, change};
directory_runtime_window_growth is {rows, directory_count, truncated, baseline_recorded_count, anchor_recorded_count, baseline_timed_count, anchor_timed_count, limit}. change is anchor minus baseline in
seconds and is null — not 0 — when either end was untimed; one timed on both ends that did not move reads
change: 0. It is a sum of machine time, not wall clock, layers are declared only, and no verdict is given.
Which files moved across the branch window is two more pairs, the file grain of directory_growth and
directory_runtime_window_growth: directory_file_growth with directory_file_growth_window (which spec files of
one area gained or lost examples) and directory_runtime_window_file_growth with
directory_runtime_window_file_growth_window (which got slower or faster). All four are top-level siblings, not
inside latest_run; the two bodies are null unless branch and spec_directory are both asked (the window
blocks are always present: otherwise they read grouped: false with state: null, and path restates the ask),
and layer narrows both. They compare the two endpoints of the window (basis is "two_endpoints") and are
not a series: a file that changed in run 12 and changed back in run 25 reads change: 0, and a moved file reads
as one removed file beside one new file. Each window block’s state and comparable are the parent area block’s
verdict, not re-derived from the file rows — the eight states of directory_growth_window for
directory_file_growth, the eleven of directory_runtime_window_growth_window for
directory_runtime_window_file_growth — and each carries path, order, tie_break_served, limit, basis,
branch_scope, branch, grouped, anchor_commit_sha, baseline_commit_sha and runs_back, plus layer only when
layer was asked. Each body is null in every non-comparable state — never a block of zeros. When comparable,
directory_file_growth is {rows, file_count, truncated, baseline_recorded_count, anchor_recorded_count}, each row
{path, baseline_count, anchor_count, change, moved, new_file, removed_file}; directory_runtime_window_file_growth
adds baseline_timed_count and anchor_timed_count (the four denominators are the area’s own, not the whole
run’s) and its rows are {path, baseline_seconds, anchor_seconds, change, comparable, moved, new_file, removed_file, timing_gap}, where change is null — not 0 — for a new, removed or untimed end. file_count is counted
before the limit, so truncated is judged against it and never against rows.length.
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/ 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. "pyramid" — re-order lowest request+system share first: ascending (request+system)/(unit+integration+request+system) of the latest run’s layer_counts, with undeclared excluded from the denominator; a measured 0 leads, repositories with no run, a null layer_counts or a declared total of 0 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.
Each list entry’s latest_run also carries layer_counts — five operands, unit, integration,
request, system and undeclared, that sum to the run’s recorded examples. It is null, not five
zeros, when the run recorded no per-example rows (a zero would read as a measured none). The layers
are declared only — an @intent annotation said so — never inferred from a path, and
undeclared is not unreadable. The operands carry no pyramid verdict; reading the mix is yours.
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_repository_overview uses; list_repositories also
accepts that key but does not require it (its agent key wins when both are set).
near_duplicate_clusters
Runs SpecGuard’s near-duplicate census over a repository’s tests — which tests read alike (same body text, whatever file they sit in), clustered by similarity. Answers the refactoring question the overview’s per-run rankings cannot: where the same test is written twice, before you delete or merge anything.
This call is served stored (SPGD-1474): the platform computes the census once per write that
moves its inputs — at each ingest, after identity resolution settles the run’s identities, and
again when a run is deleted from the repository (which can move the run the weight figures are
weighed on) — and keeps it — so the answer returns in
milliseconds instead of running the minutes-scale computation that used to sit behind the ask and
never fit this bridge’s 30-second deadline. The opt-in ask is unchanged wire contract
(?near_duplicates=, shipped by SPGD-703, still opens the block; the plain overview still answers
near_duplicates: null without it, and get_repository_overview never sends the ask). Calling
this tool is the ask.
By default one call returns the whole census — the clusters are the repository’s, computed over
every run, with every cluster’s member list. (The server reads only that the near_duplicates key
is present — =false would open it too — so there is no value for that default ask to carry.)
Two optional arguments bound the view instead (SPGD-1714, over the platform’s SPGD-1712), and
repository chooses which repository is censused:
| argument | |
|---|---|
repository |
census 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 census |
summary |
true asks for the bounded summary view — the near_duplicates_summary block: the ranking with no member lists. false or omitted sends nothing (the server treats any value as an ask, so false is never sent) |
cluster |
an integer ≥ 1: ask for ONE cluster by its rank — the near_duplicate_cluster block, members listed once. Not range-checked by the bridge; the server answers an out-of-range rank with cluster: null |
Start with summary, drill in with cluster. With neither argument the request is exactly the
one this tool has always made (?near_duplicates=true). With summary and/or cluster, only
the asks named are sent — the whole-census near_duplicates block is not also opened; supplying
both sends both keys, once each. Argument shapes are validated before any request is made
(cluster must be an integer of at least 1; 0, -1, 2.5 and "3" are refused).
The bounded view’s response keys:
near_duplicates_summary— the ranking, head keys first, then one entry per cluster carryingrank,files_seen,file_count,declared_layersandoverlap_kind— and no member lists.near_duplicate_cluster— one cluster:requested(the rank you asked for, echoed),rank,cluster_count,weighed_run_id,computed_at,member_listing(membersorlayer_groups— which of the two member shapesclustercarries) andclusteritself.- Ranks are positions in a snapshot — read
computed_atbefore reusing a rank: an ingest or a run deletion recomputes the census, and the same rank can then name a different cluster. A rank pastcluster_countis not an error: the key is present withcluster: nullandrequestedechoing the ask. - A deployment that predates the bounded view silently ignores the ask. When an ask was sent and
its matching key is absent from the body (absent, not
null— a presentnullpasses through), the tool fails naming the missing key rather than returning a body that lacks it.
The tool stays a pass-through: no reshaping, filtering or ranking.
The credential changes with repository, exactly as on get_repository_overview: the plural
endpoint answers either member credential, whichever is set — the agent key preferred (its
mint-time granted repository set is the boundary the id is looked up inside), the user key when no
agent key is set (its accessible set is the boundary instead), the agent key winning when both are
present — and the credential kinds refuse each other’s tokens, so a
repository ask with neither member key set is refused here, naming both variables, before any
request is made. A repository outside the presented credential’s grant answers 404 at the server,
person and agent alike.
Read the response with its own rules in mind:
similarity_floorandsimilarity_basissit first in the block and qualify every figure below them — a cluster count without what “similar” meant is a count you cannot act on.ranking_basissits in the head of the block (afterlayer_source) and states the order the clusters are ranked in: clusters spanning more than one spec file first, then by summed wall clock, single-file groups following. So the top row is the costliest multi-file cluster, not necessarily the costliest overall. Present on current deployments; an absentranking_basismeans the census was stored under the older cost-only order (costliest first) — not an error.overlap_kindon each cluster (and on every summary row) ismulti_fileorsingle_file, naming which kind of group the cluster is. It describes where the members’ last-known paths sit; it is not a verdict (the census says “read as similar”, never “duplicate”), and it is independent oflayer_redundancy— a two-file group whose members declared different layers is bothmulti_fileandcross_layer. Absent on older stored censuses.computed_atis when the stored census was taken andweighed_run_idis the run its weight figures are from — read the stamp before acting on the figures. A census read shortly after an ingest or a run deletion may be the previous artifact, carrying its own stamp, while the recompute for the new state is still running; anear_duplicatesblock ofnullon this tool means no census has been computed for the repository yet (never a live computation, never zeros).member_count(texts in the repository, across every run) andexample_count(examples in the one runweighed_run_idnames) are different grains: a three-example table-driven loop is one member and three examples. Never fold them.truncated: truemeans theclusterslist was cut at the cap while the counts above it (cluster_count,identity_count,clustered_*) describe the whole census.unobserved_members: trueon a cluster says a member identity the weighed run did not observe (deleted, renamed, deselected) is still listed — do not reconcile the member list against that run’s examples.similarity_rangeis[strongest, weakest]: membership is transitive, similarity is not.total_secondsisnullwhere nothing was timed — never a zero that would read as free.layer_sourceat the head of the block states where the layer dimension comes from: declared via the intent protocol when at least one clustered member’s examples declared a layer,nullon a suite that declared nothing — absence stated, never an empty fiction. The layers are DECLARED, never inferred: no path or directory convention is consulted anywhere in the cut (arequest-declared test underspec/models/reportsrequest).layer_redundancyclassifies each cluster:cross_layerwhen its members declared two or more DISTINCT layers (the same behaviour covered on several levels — the test-pyramid question),same_layerwhen confined to one (a plain duplicate),nullwhen its members declared nothing — neither state, never folded intosame_layer.layer_groupsholds the members grouped by declared layer (alphabetically, the undeclared LAST as thelayer: nullgroup), each group’s members in the same five-field shape the flat list serves. A member whose own examples declared two layers appears in EACH group it declared, so the groups’ sizes can exceedmember_countby exactly those members — that is the finding, not an arithmetic bug — and a member that declared nothing stays in its cluster’s null group, never dropped.clusters: []with a realidentity_countis the success state (nothing reads alike); the three silences — nothing ingested, nothing embedded, nothing alike — are kept distinguishable byrecorded_count/identity_count/ the list itself.
Without repository, same credential and endpoint as get_repository_overview’s default
(sgk_… repository key on GET /api/v1/repository); the response is that body with the
asked-for block opened (near_duplicates by default), passed through unmodified. With repository, the plural endpoint
answers either member credential, exactly as on get_repository_overview — and carries that
path’s one deliberate omission: api_key is absent from the plural body rather than nulled,
because the 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).
find_tests_near_behavior
Asks a repository’s stored test suite map which tests are nearest a behavior phrase you give
it. The server embeds the phrase and ranks the repository’s stored test identities by similarity,
returning the top hits in the near block (GET /api/v1/repository?near=, or the plural
GET /api/v1/repositories/:id?near=) — each hit with its similarity, its signal_source,
its last-known path and the weight the latest run measured.
| argument | |
|---|---|
behavior |
required — the behavior phrase, in plain words (e.g. “rejects an expired password reset token”). Trimmed and sent as near. Blank or whitespace-only is refused before any request, and so is a phrase containing a NUL (\u0000) character: the server would read either as no ask and answer the whole overview with near: null, which would look like an answered ask |
repository |
ask 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 ask |
limit |
optional integer page size, 1–50 — omit it for the default page of 10. Sent as &limit= only when supplied, so an ask without it is the same request as before. The server clamps the ask to 50 and reports the value it applied as limit in the near block; a non-integer, zero or negative value is refused before any request |
The answer is a page, not the set. Read truncated and limit in the returned near block:
limit is the page size the server applied, and truncated: true means more matches exist than
were returned — a full page of 10 may be “10 of 37”. When truncated is true, re-ask the same
phrase with a larger limit (max 50) before paraphrasing it: a repeated phrase is cache-served and
free, while each paraphrase is a novel phrase and one billed embed.
What the answer is not. It ranks stored tests nearest the phrase and never answers “is this
already tested?” — it never gates a write and never issues a verdict. A hit near the phrase is not
coverage, and an empty answer is not proof of absence. similarity_floor is the near-duplicate
census’s redundancy bar, not the 0.95 matching threshold that decides whether two tests are the
same test. Read similarity_basis and similarity_floor before any figure.
The three silences differ — never collapse them, and null is never []:
statusofprovider_unconfiguredorembedding_failedwithranked: null— no ranking was attempted, or the provider refused (errorcarries its reason). This says nothing about the suite.identity_count: 0withranked: []— the repository holds no identities; nothing has been ingested.ranked: []withidentity_countabove zero andbest_below_floor_similarity— identities exist, the search ran, and none is near the phrase; the nearest one’s similarity is served so “nothing near” is a finding you can check.
signal_sources (the composition of the served page) and each hit’s signal_source (matched on
declared intent, or on name) are different evidence — read the source before leaning on a hit.
Cost. Each novel phrase costs one billed embedding call at the provider; a repeated phrase
is served from the cache and costs nothing (cache_served says which happened). The ask is live —
computed on the request — unlike the stored census near_duplicate_clusters returns, and it is a
tool of its own so that a get_repository_overview call never pays an embed by accident.
Same endpoints and credentials as get_repository_overview: without repository, an sgk_…
repository key on GET /api/v1/repository; with repository, either member credential on the
plural endpoint — the agent key preferred, the user key when no agent key is set. A repository
outside the presented credential’s grant answers 404 at the server. The response is that body with
the near block opened, passed through unmodified; on the plural path api_key is absent from
the body rather than nulled (the surface’s shape, never a dropped block).
remove_repository
Removes a repository from SpecGuard — and with it every key, run and intent on it. This is the
destructive gesture in this toolset: irreversible, no undo, and a 204 means the repository and its
history are gone short of re-registering from scratch. Confirm with the user before calling.
| argument | |
|---|---|
repository_id |
the repository to remove — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
Authorization is the repo.delete capability at either surface — an owner, or a member granted
repo.delete, may remove the repository. A member without it is refused 403 with SpecGuard’s own
sentence, verbatim. The repository’s CI keys stop authenticating the moment it succeeds.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
repo.delete — a repository outside that set is answered 404 in SpecGuard’s own words.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the removal answers inside the same set
list_repositories reports.
create_repository_api_key
Mints a new CI API key (sgk_…) for a SpecGuard repository, returning the new key alone in
an api_key block — the repository’s full key set (keys minted in the web panel or in an
earlier session included) is list_repository_api_keys’ answer, never this response’s.
Minting does not disturb existing keys — each key on a repository authenticates independently
until revoked.
| argument | |
|---|---|
repository_id |
the repository to mint the key for — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
name |
an optional label for the key; omit it to let SpecGuard use its default name |
The body comes back as SpecGuard serves it: an api_key block (id, name, token, hint,
created_at) — the same shape add_repository serves. 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. Hand it to the person you are working for in your reply. If it is dropped, the recovery is minting another key with this same tool — the platform has no regenerate — then revoking the orphaned one withrevoke_repository_api_key.
Authorization is the keys_manage capability; a member without it is refused 403 with SpecGuard’s
own sentence, verbatim.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
keys.manage — a repository outside that set is answered 404 in SpecGuard’s own words.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the mint answers inside the same set
list_repositories reports. Minting under the agent credential attributes the new key to the agent
key’s owner (the server’s attributed_user rule), so minted-key counts keep naming a person
rather than nobody.
list_repository_api_keys
Lists a SpecGuard repository’s CI API keys (sgk_… keys) — every row of the repository’s own key
set, live and revoked alike (unlike the sga_ listing list_repository_agent_keys, which
serves live rows only). One row per key: id, name, token_hint, created_at, created_by,
last_used_at — null meaning the key has never presented, the negative served rather than
omitted — rotated_at, and rotated_and_unused (live-scoped: the stranded shape
get_repository_overview’s credential_health reports, at row grain). status is
live/revoked, with revoked_at present only on revoked rows — a revoked sgk_ row is not a
credential either, but the rotation’s story lives in the population. A revoked row also
serves last_refused_at: null means offboarding took (nothing has presented the dead token
since the cut), a timestamp means the dead token is still arriving and that row’s token_hint
is what to hunt in the secret stores that may still hold it. Live rows carry no
last_refused_at key.
| argument | |
|---|---|
repository_id |
the repository whose API keys to list — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
This is the verify step of the mint-and-replace rotation. After minting a replacement with
create_repository_api_key and deploying it, list here and check the replacement’s
last_used_at BEFORE revoking the old key — null means the new token has not presented itself
yet, and cutting the old key then locks the repository’s CI out until a human mints in a browser.
Neither the mint response (point-in-time by construction: it describes only the key it just made)
nor credential_health (findings only — a never-presented replacement is neither stranded nor
presented-revoked) can answer this.
This is the id source for every row this session did not mint. The mint response carries the
id of the key IT minted, in its api_key block (SPGD-993); a key minted in the web panel, or in
an earlier session, has no other bridge-learnable id — list here to name a key_id for
revoke_repository_api_key.
token_hint is a hint, never the token — the plaintext existed for exactly one response at mint
time and nothing persisted it. There is no regenerate: rotation is mint-and-replace
(create_repository_api_key → deploy → verify here → revoke_repository_api_key).
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
keys.manage — a repository outside that set is answered 404 in SpecGuard’s own words.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key
add_repository reads. With both set the agent key wins, so the listing answers inside the
same set list_repositories reports.
revoke_repository_api_key
Revokes one CI API key on a SpecGuard repository. The key stops authenticating immediately; every other key on the repository keeps working, so CI keeps ingesting if it holds a surviving key.
| argument | |
|---|---|
repository_id |
the repository the key belongs to — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
key_id |
the id of the key to revoke — the mint response carries the id of the key it minted in its api_key block (create_repository_api_key, or add_repository for the registration key); every other key’s id comes from list_repository_api_keys |
The key_id is scoped to repository_id: a key id belonging to a different repository is refused
404, never a cross-repository delete. Authorization is the keys_manage capability; a member
without it is refused 403 with SpecGuard’s own sentence, verbatim.
Key rotation is mint-then-revoke, in that order. The platform has no regenerate, so mint a
replacement with create_repository_api_key and deploy it BEFORE revoking the old one — revoke
first and the repository’s CI is locked out until a human mints a new key in a browser. A 204
means the key is revoked.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
keys.manage — a repository outside that set is answered 404 in SpecGuard’s own words.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the revoke answers inside the same set
list_repositories reports.
list_repository_agent_keys
Lists the agent keys (sga_… keys) covering a SpecGuard repository — the keys minted for people
or automation principals from the /account page, each covering one or more repositories. One row
per live key: id, name, owner, token_hint, repository_count (how many repositories the
key’s grant covers — the blast radius), permissions, created_at and last_used_at — when the
key’s token last authenticated, null meaning the key has never been presented, so the inventory
answers “is this live key still being used” before you cut anything.
| argument | |
|---|---|
repository_id |
the repository whose agent keys to list — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
The id here is the only way to name an agent key on this bridge. Agent keys have no mint
tool — the platform mints them web-only at /account, deliberately — so unlike sgk_ keys there is
no create response to read an id from. List first, then revoke_repository_agent_key. Revoked keys
leave this listing (a retained revoked row is not a credential); whether a dead token is still
arriving is list_repository_agent_keys_presented_revoked’s question.
token_hint is a hint, never the token — the plaintext existed for exactly one response at mint
time and nothing persisted it. Authorization is the keys_manage capability; a caller without it
is refused 403 with SpecGuard’s own sentence, verbatim.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
keys.manage — a repository outside that set is answered 404 in SpecGuard’s own words, so an
agent credential administers the agent keys covering its own set without a person’s sgu_ key.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the answer stays inside the same set
list_repositories reports.
revoke_repository_agent_key
Revokes one agent key (sga_…) on a SpecGuard repository. Irreversible, and the blast radius is
the key’s whole stored set: an agent key’s grant (repository set + permission set) is stored once
at mint time, so this one call cuts the token on EVERY repository the key covers, not just
repository_id. Check repository_count on list_repository_agent_keys before cutting a
multi-repository key.
| argument | |
|---|---|
repository_id |
the repository whose key-administration scope the call is made under — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle. The cut itself lands on every repository in the key’s stored set |
key_id |
the id of the agent key to revoke, as served by list_repository_agent_keys — the only id source, because agent keys have no mint tool on this bridge |
This endpoint answers 200 with the disclosure body, not the empty 204 the sgk_ sibling
revoke_repository_api_key serves: an agent_key block carrying revoked_at, repository_count
and the still-existing repositories names (plus deleted_repository_count when repositories
have been deleted since mint) — the API’s substitute for the web confirm dialog, so the body
itself is the confirmation.
The key_id is scoped to repository_id: a key whose stored set does not cover this repository,
or one already revoked, is refused 404, never a cross-repository cut. Authorization is the
keys_manage capability; a caller without it is refused 403 with SpecGuard’s own sentence,
verbatim.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
keys.manage — a repository outside that set is answered 404 in SpecGuard’s own words, so an
agent credential can cut one of its own kind without a person’s sgu_ key.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the cut answers inside the same set
list_repositories reports.
list_repository_agent_keys_presented_revoked
Lists REVOKED agent keys whose token is still arriving at the deployment after revocation — the
verify half of offboarding. Run it after revoke_repository_agent_key to learn whether the dead
token is still being presented somewhere: each row carries id, name, owner, token_hint
(hunt this in whatever secret stores may still hold the dead token, and update them),
repository_count, revoked_at and last_refused_at.
| argument | |
|---|---|
repository_id |
the repository to triage — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
Two readings, stated plainly: last_refused_at is the last observed presentation — a recency,
never a claim that something is presenting the token right now. And an empty
{"agent_keys": []} is an answer — nothing is still arriving — not an absence of tracking; read
it as the offboarding having fully taken.
token_hint is a hint, never the token — the plaintext existed for exactly one response at mint
time and nothing persisted it. Authorization is the keys_manage capability; a caller without it
is refused 403 with SpecGuard’s own sentence, verbatim.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
keys.manage — a repository outside that set is answered 404 in SpecGuard’s own words, so an
agent credential can verify its own offboarding without a person’s sgu_ key.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the triage stays inside the same set
list_repositories reports.
list_repository_members
Lists who has access to a SpecGuard repository: one row per member with their id (the
membership id update_repository_member_permissions and remove_repository_member name),
handle, permissions, granted_by (who last set them) and created_at, ordered by handle.
| argument | |
|---|---|
repository_id |
the repository — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
The list answers memberships only and never reports how many CI keys a member has minted
(keys_minted) — that is a separate keys.manage disclosure this endpoint deliberately withholds;
the API-keys tools are the surface for it. Each row’s id is the membership id — the identifier
update_repository_member_permissions and remove_repository_member name; ids are scoped to the
repository, and a foreign id is refused 404.
Authorization is the members.manage capability: a caller who is not a member is refused 404
(the repository’s existence stays hidden), and a member without members.manage is refused 403
with SpecGuard’s own sentence, verbatim.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
members.manage — a repository outside that set is answered 404 in SpecGuard’s own words.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the answer stays inside the same set
list_repositories reports.
add_repository_member
Grants a person access to a SpecGuard repository by their GitHub handle, with an optional list of permissions.
| argument | |
|---|---|
repository_id |
the repository to grant access to — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
handle |
the person’s GitHub login — octocat, not a profile URL and not a display name; SpecGuard refuses both with its own sentence |
permissions |
an optional list of permission strings (view, keys.manage, members.manage, repo.delete); omit it to grant access with no additional permissions |
Each resolution failure — nobody has signed in as that handle yet, the account is archived, the
handle is ambiguous, the handle is not a login — arrives as a distinguishable 400 message
naming the exact next move. The grantor recorded on the membership is always the person behind
the credential — the one an sgu_… key speaks for, or the owner of the sga_… agent key.
The server stamps it; no argument can name a different one.
On success (201) the response carries a member block whose id is the membership id
(alongside handle, permissions, granted_by, created_at) — the identifier
update_repository_member_permissions and remove_repository_member name. Authorization is the
members.manage capability; a member without it is refused 403 with SpecGuard’s own sentence,
verbatim.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
members.manage — a repository outside that set is answered 404 in SpecGuard’s own words.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the grant answers inside the same set
list_repositories reports. Under the agent credential the grant is bounded twice: the key cannot
grant a permission it does not itself hold — SpecGuard answers 400 naming the over-reach — and
the membership is recorded against the key’s owner.
update_repository_member_permissions
Replaces one member’s permission set on a SpecGuard repository.
| argument | |
|---|---|
repository_id |
the repository the membership belongs to — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
member_id |
the id of the membership row to edit — not a user id, not the handle. Scoped to repository_id: a foreign membership id is refused 404 |
permissions |
the member’s complete new permission set (view, keys.manage, members.manage, repo.delete) — it replaces what they hold today, it is not merged |
permissions replaces the whole set — name every permission the member should end with.
Values are validated by SpecGuard; an unknown value is refused with its own sentence, verbatim.
The membership id: member_id comes straight off the API — every member response serves it
as id: the rows list_repository_members returns, the add_repository_member 201 body, and
this tool’s own response. The server serves that id as a JSON number (the column is a bigint),
while member_id is a string argument refused by name before any request is made — pass a
numeric-looking id such as 7 as a string, not as a number. There is no name-based lookup, and
ids are scoped to the repository (a foreign id is refused 404). Authorization is the
members.manage capability; a member without it is refused 403 with SpecGuard’s own sentence,
verbatim.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
members.manage — a repository outside that set is answered 404 in SpecGuard’s own words.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the edit answers inside the same set
list_repositories reports. Under the agent credential the replacement set is bounded twice: the
key cannot grant a permission it does not itself hold — SpecGuard answers 400 naming the
over-reach — and the re-stamped grantor is the key’s owner.
remove_repository_member
Revokes one person’s access to a SpecGuard repository by removing their membership. A 204 means
it is revoked.
| argument | |
|---|---|
repository_id |
the repository the membership belongs to — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
member_id |
the id of the membership row to revoke — not a user id, not the handle. Scoped to repository_id: a foreign membership id is refused 404 |
Revoking does NOT revoke that member’s minted CI keys. Any sgk_… keys they created on the
repository keep authenticating by design; the lever for those is the API-keys surface
(revoke_repository_api_key), not this one. Self-revocation is permitted — after it, the caller’s
next request to the member routes answers 404, not 403. The repository owner’s membership
cannot be removed at all. The same source as the edit tool serves member_id: it is the id
field on the member rows list_repository_members and add_repository_member return — a JSON
number on the wire, so pass it as a string, not as a number (see the edit tool above).
Authorization is the members.manage capability; a member without it is refused 403 with
SpecGuard’s own sentence, verbatim.
It reads either of two credentials, whichever is set — and a different one from the sgk_… key
get_repository_overview uses. SPECGUARD_AGENT_API_KEY (sga_…) when it is set: the call then
reaches only the repositories granted onto that key at mint time, and only where the grant carries
members.manage — a repository outside that set is answered 404 in SpecGuard’s own words.
SPECGUARD_USER_API_KEY (sgu_…) when no agent key is set: the same person key add_repository
reads. With both set the agent key wins, so the revoke answers inside the same set
list_repositories reports.
rename_repository
Renames a SpecGuard repository — changes the org/repo GitHub full name it is registered under —
keeping every API key, run and intent on it.
| argument | |
|---|---|
repository_id |
the id of the repository to rename — its numeric id, as add_repository returns and list_repositories reports, not the org/repo handle |
github_full_name |
the new GitHub full name, org/repo — sent top-level in the body, not nested under repository |
This is the alternative to the remove-and-re-register path: that one deletes every API key, run
and intent on the repository (remove_repository), while this keeps all of them. Authorization
is owner-only — deliberately narrower than removal: a member granted repo.delete may destroy
the repository but may not rename it. The owner check also redeems a browser-issued grant valid for
7 days; a nil or stale one is refused 403 with SpecGuard’s own sentence naming the fix
(re-grant via the browser). A name another repository already holds is refused 400 — taken, not
409 — verbatim. The 200 body is {repository: …} — the serializer list_repositories uses, but a
rename receipt, not a list entry: it carries the identity fields and delivery_health, and
latest_run is absent — an absent key, never a null — deliberately, so the receipt claims nothing
about the repository’s CI state. list_repositories is where a run summary comes from.
It reads SPECGUARD_USER_API_KEY (sgu_…) — the same person key add_repository reads, and
a different one from the sgk_… key get_repository_overview uses; list_repositories also
accepts that key but does not require it (its agent key wins when both are set).
How it works
agent ⇄ specguard-mcp ⇄ SpecGuard (HTTP, the same API the dashboard uses)
(stdio/MCP) ⇄ specguard-lint (subprocess, in your project)
The bridge is a thin client: it shells out and it calls the API, and it re-implements neither. It carries no knowledge of the OpenTestIntent schema, holds no copy of the linter’s rules, and reshapes no response — every tool returns the shape of the capability it wraps, so a field added upstream reaches the agent without a release here.
Authorization and project scoping are enforced by SpecGuard, never by this bridge, using keys you
issue there — the same sgk_… keys CI uses to ingest runs, an sgu_… user key for the tools that
answer to a person rather than to a repository, and an sga_… agent key for the tools that answer
for an automated agent — it speaks for nobody, and its reach is the repository set granted onto it
at mint time. Which of the three a request may carry is SpecGuard’s decision and it is taken from
the token’s prefix, before any credential is looked up, so the bridge cannot widen any key’s reach:
it forwards the key the tool’s own variable holds and reports what came back. It adds no
credentials of its own and stores nothing.
No argument ever reaches a shell: subprocesses are spawned with an argument list, so a path from a model is a path that does not exist rather than a command.
Adding a tool
The toolset fills in as more of SpecGuard lands. Adding one is two mechanical edits:
- a new file under
src/tools/that default-exports aToolDefinition; - one entry appended to the array in
src/tools/index.ts.
There is no third — no third wiring edit, at least: every tool also earns a ### section above,
and every argument earns a row in that section’s table, since this README ships as the package’s
published documentation, and test/readme.test.ts derives that obligation from the registry so a
missing section or an undocumented parameter fails the suite — as does a response key the tool’s
description names that the section never mentions. A tool that genuinely takes no
arguments still earns the section, and is named in that file’s ARGUMENT_LESS_TOOLS — a
deliberate line to add, rather than a floor relaxed for everyone. The set tracks the property,
not arrival order: an inputSchema that declares no properties, the shape
registrable_repositories has.
src/server.ts iterates that array and contains no per-tool code — no switch, no hard-coded name — and everything a tool
touches the world with (config, subprocesses, fetch) is injected, so a new tool is testable
without a live deployment for free. The property tests in test/tools/registry.test.ts run over
whatever the registry holds, so a tool added later is checked by tests written today.
Only wrap capabilities that exist. A tool in tools/list is a promise an agent acts on; one that
discovers cleanly and fails on use is worse than one that is absent, because the agent has already
committed to a plan by the time it finds out. Anything whose backing endpoint has not shipped stays
out — /check-intent is in that state today: the platform’s config/routes.rb carries it as a
comment and nothing more, so there is nothing for a tool to wrap. Duplicate clustering once sat under
this same rule; the endpoint it needed shipped, and near_duplicate_clusters moved inside. What
moved was the platform, not the bar.
Transport is chosen in bin/specguard-mcp.ts and nowhere else — stdio today, so an HTTP/SSE
entrypoint is a sibling of that file rather than a change to the server.
Development
npm run build # compile to dist/
npm test # typecheck, then run the suite
npm run typecheck # types only
Related repositories
specguard— the platform: ingest API + Hotwire dashboardspecguard-ruby— Ruby client (RSpec formatter, Minitest reporter +@intentlinter)open-test-intent— the annotation protocol SpecGuard consumes
License
ISC
Установка
This server does not publish a one-line install command.
Open the repository installation guideКонфигурация
{
"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"
}
}
}
}