The Vera MCP server
Give an AI coding agent credentialed access to your tests over MCP: minting a token, pointing a client at the server, the tool set, and the limits.
Using this from Claude Code? The Vera plugin wraps this surface in a skill plus
/vera:verify,/vera:cover,/vera:fix,/vera:repro,/vera:checkand/vera:status, so the procedure does not have to be re-explained each session.
Vera exposes its projects, tests, runs, and AI repair/generation as an MCP server so any MCP client — Claude Code, Claude Desktop, another agent — can drive Vera directly: run a test, read why it failed, propose a fix, apply it (with your approval), and re-run. This is the practical "self-fixing" loop: point your coding agent at both your app's repo and Vera, and it can close the loop from a failing test to a green one.
- Endpoint:
POST /api/mcp(Streamable HTTP, stateless — no session to manage). - Auth: a
vera_…bearer token (the same tokens the CI API uses), scoped to your organization and fenced to/api/mcp. - Isolation: every tool is org-scoped by the token — a token for org A can never see or touch org B's data.
1. Mint a token
In the dashboard: Settings → CI Tokens → name it → Create. Optionally set an expiry; leave org-wide unchecked for a personal token (audit rows and usage counters then name a human rather than a shared actor).
The plaintext token (vera_…) is shown once — copy it now. Revoking it in
the dashboard takes effect immediately (the next MCP call with it returns 401),
and so does an expiry passing.
Plans: the MCP surface is the free tier's floor (mcp-basic), so a free
cloud workspace can mint a token and use it here. The /api/v1 CI surface is
separate and needs Pro (api-access) — the same token, fenced by URL. Self-hosted
installs have no billing and hold everything.
2. Point your client at it
Claude Code (recommended): let vera-agent init do it
npx vera-agent init --url https://your-vera-host --token vera_your_tokenThis registers a vera HTTP MCP server for the repo you run it in — along with
linking the repo to a project and writing the agent-instructions block. See
Getting started for the whole flow, including the
.vera/config.json (committed) vs ~/.vera/cli.json (token, chmod 600) split.
Two registration scopes:
--mcp-scope local(default) —claude mcp addat local scope. That entry lives in~/.claude.json, private to you and outside the repo, so it carries the real token and needs nothing else set up.--mcp-scope project— merges averaentry into the repo's committed.mcp.json, so everyone who clones gets the tools. The entry carries the literal${VERA_TOKEN}, never a secret; each developer exports their own (export VERA_TOKEN=vera_…). Claude Code asks once to approve a project-scope server before connecting.
init never clobbers: it only ever touches the vera key under mcpServers,
leaves every other server alone, refuses (rather than overwrites) a .mcp.json
it cannot parse, and reports "already up to date" on a re-run. If a vera server
is already registered in any scope, it leaves it as it is and tells you the
fix-it (claude mcp remove vera, then re-run).
Already using the Vera plugin? It ships its own vera
server entry driven by VERA_MCP_URL + VERA_TOKEN — you don't need both, so
run init --no-mcp and set those two variables instead.
Manual — other MCP clients, or no claude CLI
init prints this command when the claude CLI isn't on PATH:
claude mcp add --transport http vera https://your-vera-host/api/mcp \
--header "Authorization: Bearer vera_your_token"Or write the config by hand — a project .mcp.json, or Claude Desktop's MCP
config, or any other Streamable-HTTP MCP client:
{
"mcpServers": {
"vera": {
"type": "http",
"url": "https://your-vera-host/api/mcp",
"headers": {
"Authorization": "Bearer vera_your_token_here"
}
}
}
}For a local server the URL is http://localhost:4545/api/mcp. In a committed
file use "Bearer ${VERA_TOKEN}" and export the token in your environment.
Restart the client and the vera tools appear.
3. Tools
Read-only:
| Tool | Arguments | Returns |
|---|---|---|
list_projects | — | every project in your org (id, name, base URL) |
list_tests | projectId | the project's tests (id, name, step count, source, quarantine/flake) |
get_test | testId | the test with its full steps, config and source |
get_run | runId | a run: status, duration, error, per-step results |
get_failure_details | runId | failed step(s) + errors, a pass/fail/healed summary, artifact paths — the debugging payload |
get_load_run | loadRunId | a load run: status, threshold verdict and the measured p50/p95/p99, request rate and error rate. Figures that were not measured are null, never 0. |
list_flows | projectId | the project's reusable flows (id, name, description, step count) |
list_rules | projectId | the project's approved business rules (id, entity, kind, statement, file:line evidence). Rules awaiting human approval are deliberately not listed; the ids are what ruleIds on create_test/generate_tests takes. |
list_issues | projectId?, status?, severity?, source?, limit?, offset? | issues in your org (newest first), paginated. Each row adds bugConfirmed (its repro reproduced the bug) and reproProposedBy (auto-repair or null). Needs issues:read. |
get_chain_run | chainRunId | a cross-project chain run: status, duration, per-segment statuses, masked final variables |
check_coverage | projectId, changedFiles[], alwaysRunTag? | the change impact map: which tests cover these files, which touched business rules nothing protects, which files could not be placed, and mapCoverage. Deterministic and un-metered — no AI call, no browser. Same joins, bounds and refusal codes as POST /api/v1/projects/:id/impact; see below. |
get_app_changes | projectId, since? | the app-change radar's open feed: what changed in the app itself since the accepted baseline, with the tests each change puts at risk. Deterministic and un-metered. Pro+ (app-radar). Read state before the list — see below. |
source — who wrote the test. list_tests and get_test report it: manual (a
person, in the builder or through an importer), ai (Vera's generators, including an
AI-assisted import), mcp (created or edited through these tools), or absent for a
row created before Vera recorded provenance, or holding a value this build does not
recognise. Absent means not stated — never assume manual. It is also not a review
signal: a test can be ai and approved, or manual and never looked at by anyone but
its author. recorded is defined but nothing writes it today, so a test captured in the
recorder reports manual.
Actions:
| Tool | Arguments | Notes |
|---|---|---|
run_test | testId, environment? | starts the run and returns {runId, status:'running', url?} immediately — it does NOT wait. Call wait_for_run for the outcome. Design-review / notify hooks are skipped for MCP runs. |
wait_for_run | runId, timeoutSeconds? | the observation: status, per-step results, finalUrl, url?, warnings?, plus console/network tails and artifact refs on failure. Returns status:'running' past its timeout instead of blocking — call again. |
cancel_run | runId, kind?, scope? | asks a run to stop — a single run, or (with kind) a chain, suite or saved Actor Scenario run as a whole. Cooperative, not a kill: an executing run ends at its next STEP BOUNDARY (cancelled: false in the reply means the stop was asked for and not yet observed — poll wait_for_run), and a run leased by a local agent takes up to one 20s heartbeat on top. A run that already passed or failed is not cancelled — that is an error result (not_cancellable), because a verdict is evidence. Safe to call twice. scope: "group" stops every row of the run's DATASET group and reports each one; it is refused for a run with no group rather than stopping a single row. kind defaults to run; chain / suite / scenario stop the aggregate (remaining members are not started, ones in flight stop at their next step). scope: "group" applies to kind: "run" only and is refused on an aggregate. A scenario stop needs the scenarios:run permission — MCP tokens carry it, but a personal token never exceeds its owner's role, so a member without it is refused with a message naming it. |
inspect_page | projectId, url?, environment?, role?, maxElements?, includeAria?, ariaMaxChars? | loads the page as the configured role and returns its interactive elements with stable selectors. Every selector rung except the CSS-path fallback is checked to match exactly one element; a duplicate (two buttons sharing a data-testid, a repeated aria-label or button text) falls through to the next rung. includeAria: true adds ariaSnapshot — the page's accessibility tree (roles and accessible names, field values redacted), capped at ariaMaxChars (default 8,000) on a line boundary with a # … truncated marker, or null when the engine could not produce one. Note: interactive only — it does not return arbitrary text nodes, so title/url are the assertable values it hands you directly. |
verify_selector | projectId, selectors[]?, locators[]? (≤20 in total), url?, environment?, role? | checks selectors you wrote against the live page: per selector, how many elements it matches, unique (exactly one — what a step needs), whether the first match is visible, and the parser's error if it is not a valid selector. A locator is {role, name?}, {text}, {label}, {placeholder} or {testId}, each with optional exact; Vera writes it as the selector its own recorder would (role=button[name="Save"], text=…, [placeholder="…"], data-testid=…) and probes that. exact only changes text (quoted, case-sensitive whole text); the others are whole-value matches already. A label is resolved as role + accessible name across the form-control roles, because label= is not a selector engine in Vera. When a locator matches exactly once the result carries a step ({id, action, selector, value?} — fill for text inputs, check for checkboxes/radios, otherwise click) that create_test accepts as-is; otherwise it carries count and runOutcome, what a run would do (0 matches fails on timeout; several matches does not fail — it acts on the first). Same page load and guards as inspect_page; free, no AI call. |
run_scenario | projectId, steps[] (max 30), environment?, role?, label? | runs a one-off, unsaved step list in one project, returns {runId} immediately. Not an Actor Scenario despite the name — see Journeys, chains and Actor Scenarios. One in flight per workspace plus an hourly cap; both refuse with a named code. |
save_scenario_as_test | runId, name, tags? | promotes a scenario run into a durable test using the steps that actually ran. Returns warnings? when those steps prove nothing. |
list_runs | projectId, limit?, offset? | the project's unified run feed — test-backed AND scenario runs. |
list_suites | projectId | the project's suites. Not org-wide: from cold, list_projects first. |
list_chains / run_chain / get_chain_run | — / chainId / chainRunId | cross-project chains (Team+). |
list_project_roles | projectId | the project's auth roles (id + name), its default role and its named environments — the ids roleId / environmentName / roleBindings / run_scenario's role expect. Never a credential. |
get_chain | chainId | one chain's full definition (segments + starting variables). Read before update_chain. Team+. |
create_chain | name, segments[], description?, variables? | saves a chain of existing tests, possibly across projects. Every projectId/testId is checked before the save. Start it with run_chain. Team+. |
update_chain | chainId, name?, description?, segments?, variables?, confirm | any field passed replaces the stored one (segments is the whole list). Requires confirm: true. Team+. |
list_scenarios / get_scenario | — / scenarioId | saved Actor Scenarios: a summary list, and one full definition. Team+. |
create_scenario | name, actors[], stages[], description?, variables?, cleanup?, execution? | saves an Actor Scenario. Validated exactly as the editor validates it; a refusal carries the list of problems. Inline stage steps go through the step schema. Team+. |
update_scenario | scenarioId + the full definition, confirm | replaces the scenario — not a patch. Requires confirm: true. Team+. |
run_actor_scenario | scenarioId, resumeFrom? | starts a saved Actor Scenario and returns {scenarioRunId} immediately. Needs scenarios:run. Team+. |
get_scenario_run | scenarioRunId | the run's status, chronological timeline and per-actor lanes. Poll it. Team+. |
list_journeys | projectId | the project's coverage-map journeys, each with linked tests and a status/health derived from their latest runs. |
create_journey | projectId, name, entryPage?, criticality?, roles?, recommendedScenarios? | adds a journey to the coverage map (confirmed, no tests linked yet). Needs the coverage-map entitlement. |
update_journey | journeyId, name?, criticality?, linkedTestIds?, confirmed?, … | edits a journey. linkedTestIds replaces the list, and every id must be a test of the journey's own project. |
run_suite | suiteId, environment? | runs every test in a suite and returns a per-test summary. Can take minutes. Quarantined tests run but never fail the suite. |
run_load_test | projectId, name, scenario, profile, thresholds? | queues a k6 load run against the project's base URL and returns its id straight away — poll get_load_run. Needs tests:run, and a plan whose monthly load-run allowance is above zero (Team and up): any other workspace gets a structured plan_limit refusal naming monthlyLoadRuns, where limit: 0 means the plan includes none and a positive limit means this month's allowance is spent. |
suggest_fix | runId | proposes an AI step-diff for a failed run. Proposal only — applies nothing. |
apply_repair | testId, runId, operations, confirm | applies approved operations and records the repair audit. Requires confirm: true. |
generate_repro_test | issueId, mode?, url?, hint?, authRoleId?, startLoggedOut? | turns a bug report into a proposed failing test. Proposal only — persisted: false, nothing is saved. BLOCKS while it loads the page and calls the AI (typically 20–60s, up to ~4 min). The MCP SDK's default request timeout is 60s and this transport sends no progress notifications, so raise MCP_TOOL_TIMEOUT rather than retrying — a retry spends AI budget again and returns nothing extra. |
approve_repro_test | issueId, steps, confirm, name?, tags?, authRoleId?, environment?, replace? | saves the reviewed steps as a regression-tagged test, links it to the issue, and runs it once. Requires confirm: true. Pass provingStepId to name the assertion that encodes the correct behaviour (omit it and Vera picks the last outcome or visible/hidden assertion; null names none). Read repro.status, never didNotReproduce: the values are reproduced (red at the proving step, on a value or state mismatch — this is the success case), inconclusive (red, but not for the stated reason: another step failed first, the element never resolved, or the run died — repro.detail names the failing step), not_reproduced (it passed first run, so it captures nothing), pending (the run outlived the request deadline — no verdict yet, and the payload's didNotReproduce: false does not mean it reproduced; call wait_for_run), and not_run (executed elsewhere: approve starts the first run there and returns its id as repro.runId, so call wait_for_run on it; its completion writes the verdict on the issue). A second approve returns 409 repro_already_linked unless you pass replace: true. |
generate_tests | projectId, goal, count? | AI-generates scenarios from a goal and saves them as tests (does not run them). Crawls the project base URL. |
create_test | projectId, name, description?, steps | creates a test from validated steps, saved with source: 'mcp' (not run). Steps may include intent blocks. |
update_test_steps | testId, steps, confirm | replaces a test's full step list. Requires confirm: true; the previous steps are snapshotted to the repair audit so the change is revertible. Steps may include intent blocks. |
create_flow | projectId, name, description?, steps | creates a reusable flow from validated steps. Reference it from tests with a use-flow step. Needs flows:manage. |
update_flow | flowId, name?, description?, steps?, confirm | updates a flow. Requires confirm: true — every dependent test runs the new steps. Call without confirm first: the refusal names the dependents. Needs flows:manage. |
delete_flow | flowId, confirm | deletes a flow. Requires confirm: true. Tests still referencing it fail at flow resolution rather than silently skipping the steps. Needs flows:manage. |
report_issue | title, severity?, description?, projectId?, testId?, runId?, stepIndex?, chainRunId?, labels? | files a Vera issue with source: 'mcp'. Needs issues:manage. |
update_issue | id, status?, title?, description?, severity?, labels?, confirm | transitions / edits an issue. Requires confirm: true. Needs issues:manage. |
Intent blocks
An intent block keeps the goal of a stretch of steps next to the steps that
achieve it. It opens with an intent step and closes with end-intent:
[
{ "id": "s1", "action": "goto", "url": "/login" },
{ "id": "s2", "action": "intent", "goal": "Sign in as the demo user", "values": ["email", "password"] },
{ "id": "s3", "action": "fill", "selector": "#email", "value": "{{email}}" },
{ "id": "s4", "action": "fill", "selector": "#password", "value": "{{password}}" },
{ "id": "s5", "action": "click", "selector": "button[type=submit]" },
{ "id": "s6", "action": "end-intent" },
{ "id": "s7", "action": "assert", "assertType": "url", "condition": "contains", "value": "/dashboard" }
]goalis free text (up to 500 characters).valueslists the names of the variables the goal may type (email, never{{email}}and never a value).- Blocks must balance and cannot nest; an
iforloopinside one is fine.create_testandupdate_test_stepsrefuse an unclosed, stray or nested block with the step schema's own message, and save nothing. - Every run replays the steps between the markers as ordinary steps. When they
break (or the block has none yet), a model may re-derive them from the goal
in exactly two places: an explicit resolve (
POST /api/tests/:id/resolve, which runs the test once on the cloud with the block allowed to resolve) and the repair ladder's self-heal re-run (the gardener, or Auto-repair on a failed run). Every other run — the builder's Run, CI, scheduled, MCPrun_testandvera-agent— replays only, never calls a model, and fails the stepintent-stale. - A resolution chooses each action from the controls on the live page first
(TypeSafe, when enabled for the workspace) and asks your AI model only when
that cannot decide. It may only drive the page on the project's own origin,
and it types nothing but the block's declared
values, by name. Both are plan-gated like auto-repair. - A resolution is saved into the block only when the run confirmed it: the
block did what it did on its last green run, or an outcome assertion after
end-intentpassed. Then the steps that broke are replaced with the ones that worked, as a repair with strategyintentthat Revert undoes. Anything else is staged in Staged repairs for review. The resolve call answers with the run plusresolution: { outcome: "written" | "proposed" | "none", steps?, proposalId?, reason? }. Once saved, every later run replays the new steps with no model call. - Follow each block with an assertion that proves its outcome. Without one the block can never be verified, and the assertion lint says so.
Journeys, chains and Actor Scenarios
Three words that are easy to cross, so here is what each one is and which tool makes it:
| You want | It is | Tools |
|---|---|---|
| An end-to-end journey that runs, one persona, tests in order (possibly across projects) | a chain — dashboard: Journeys → Chains | create_chain, run_chain, get_chain_run |
| A journey where different people act (an applicant applies, staff approve, the applicant sees it) | an Actor Scenario — dashboard: Journeys → Scenarios | create_scenario, run_actor_scenario, get_scenario_run |
| A named business flow on the coverage map, with the tests that protect it | a journey — dashboard: Coverage. It is a label over tests and never runs | create_journey, update_journey |
| To try some steps once, saving nothing | a scenario run | run_scenario → save_scenario_as_test |
Build bottom-up: create_test for each leg, list_project_roles for the role ids, then
create_chain or create_scenario over those tests, then create_journey +
update_journey { linkedTestIds } so the coverage map shows what now protects the flow.
Two things are stricter here than in the dashboard, because the caller types ids instead of
picking them from a list: every project, test and flow a definition names must exist in your
workspace in the project the definition says (code: "unresolved_references", nothing
saved), and a stage's inline steps must pass the step schema.
Running one. run_actor_scenario { scenarioId } returns { scenarioRunId } immediately;
poll get_scenario_run (a timeline plus per-actor lanes, status: "running" until it ends) and
stop it with cancel_run { kind: "scenario" }. resumeFrom keeps an earlier run's passed stages.
It needs scenarios:run rather than tests:run — the permission exists so a workspace can
withhold "drive several personas across projects". MCP tokens carry it, but a personal token
is always intersected with its owner's current role, so a member whose role lacks
scenarios:run does not see the tool.
check_coverage — a pointer, not a pipeline
check_coverage is the agent-facing half of vera check. It reports: it runs no
test, generates nothing, and writes nothing. What it adds on top of the report is
nextActions — one suggestion per finding, with the arguments already filled in
(run_test for the impacted set, run_scenario for what nothing maps to,
generate_tests for the gaps, carrying the ruleIds that make the result count as
coverage next time). It makes none of those calls itself, deliberately: chaining a
metered generation off a heuristic's guess is not something a tool named "check"
should do on its own.
Four things it refuses to blur:
gapsvspartial. A gap is a touched rule with no adequate protection (uncovered/insensitive).partialis a rule that does have a covering test whose assertions were never proven sensitive to it — a weaker claim, not a failure. Asensitiveverdict requires a dataset, so treatingpartialas a gap would paint every healthy project red.vera check --strictnever fails onpartial.impactedTestsvsalwaysRunTests. The tag join is reported in its own key, so "the map found these" stays distinguishable from "you told me to always run these".- Evidence
confidence.high= a framework route file (Next app/pages, SvelteKit, Remix, Express) or a rule's ownfile:line.low= a route inferred from a filename — a guess. Each impacted test also carries a rolled-uphigh/mixed/guess-only. - Cold start. On a project whose tests carry no URLs and no rule links,
coldStart.coldistrueandcoldStartFixnames the single command that fixes it. An empty report on a cold project means the map is blind, not that the change is safe.
Bounds behave exactly as on the HTTP route, and all of them are reported (never
silent): over 5000 paths or a path over 500 characters is a refusal
(too_many_changed_files / path_too_long, nothing computed); list caps and the
25-line-per-item evidence cap truncate and say so in truncated[] /
evidenceTruncated / warnings. One difference worth knowing: /api/mcp carries a
single JSON-RPC body limit for every tool (1 MiB by default) where the HTTP impact
route allows 4 MiB — the effective number comes back in bounds.maxRequestBodyBytes.
get_app_changes — read state before you read the list
check_coverage answers "I changed these files"; get_app_changes answers the other
direction — the app changed and nobody told you. The radar re-fingerprints the
project's pages on a cadence (structure only: tag/type/role/name/data-testid/id,
never copy, so a wording change is not a change) and every difference from the
accepted baseline becomes one row. Each row names the URL, the element-level diff, and
impactedTests — the tests that click a moved element or navigate to the changed
page. That last field is the reason to call it.
An empty list is two different answers, and state is which one:
state | What an empty list means |
|---|---|
clean | Genuinely nothing open. The last sweep found nothing new. |
never-swept | The radar exists but has never run — no baseline, so no change can be detected. |
not-configured | This project has no radar at all. Nobody has looked. |
changes-open | Not empty: openChanges rows are waiting. |
Dispositions are not symmetric, and neither the tool nor the dashboard presents them as two equal buttons:
expected— intended. Accepting advances the fingerprint baseline, so the row leaves the queue and that diff never fires again. This is the only way the feed reaches zero.suspect— a possible regression. The row stays open and the baseline does not move, on purpose. Marking suspect is not a way to clear the feed.
The tool is read-only: it sets no disposition (that is the project's Radar panel, or
POST /api/radar/changes/:id/disposition) and, unlike the HTTP GET, it never creates
a radar row as a side effect. Bounds are reported, never silent: at most 50 rows
(limit, with notShown when the feed is longer), 10 impacted tests and 10 elements
per row (each list carries its own notShown), plus any warnings the sweep recorded.
An invalid since is refused (invalid_since) rather than silently compared as a
string, which would return a plausible but wrong window.
The confirm-to-apply rule
Vera never rewrites a test or mutates an issue without explicit approval.
apply_repair, update_test_steps, and update_issue all refuse unless you
pass confirm: true — call once to review, then again with confirm: true to
persist. The typical repair loop:
run_test → (failed) → get_failure_details → suggest_fix
→ review the operations → apply_repair(confirm: true) → run_test → greenWhen a green fix can't be found, close the loop by filing a tracked issue instead of leaving the failure to rot:
run_test → (failed) → get_failure_details → suggest_fix
→ apply_repair(confirm: true) → run_test → (still failing)
→ report_issue(testId, runId, severity: 'major')4. Permissions & limits
- A token grants the MCP surface
projects:read,tests:read,tests:run,runs:read,issues:read,issues:manage,ai:use(the AI tools), and the authoring grantstests:manage+flows:manage. It cannot read or change organization or platform settings, and it can never create a project. The same token on/api/v1carries a narrower set — noai:use, noflows:manage(it does holdtests:manage, which is what the CI importer route needs). - Tool availability follows the token's permissions:
list_issuesappears only withissues:read;report_issue/update_issueonly withissues:manage;create_test/update_test_stepswithtests:manage;create_flow/update_flow/delete_flowwithflows:manage;check_coverageandget_app_changeswithtests:read(the same grant their HTTP twins require). - Plan-gated tools mirror their HTTP twin's entitlement and return the same 403
payload (
code,plan,feature,requiredPlan) as a tool error rather than a re-phrasing:get_app_changesneeds Pro+ (app-radar);list_chains/run_chainneed Team+ (chains). - AI tools (
suggest_fix,apply_repairproposals,generate_tests,generate_repro_test) are metered to your org's AI usage/budget, exactly like the dashboard.check_coverage,get_app_changesandinspect_pageare not: they call no model. - The endpoint is covered by the same per-org rate limit as the rest of the API
(
QA_RATE_LIMIT_PER_MIN, off by default in local dev).
See also
- Getting started —
npx vera-agent init: link the repo, register this server, and the ritual the tools exist for. - The Vera Claude Code plugin — the skill and
/vera:*commands wrapped around these tools. - The local agent — running the browser on your own machine,
which is what makes
localhosttestable (scenario_cloud_only's fix).