ShakerScan MCP
Status: shipped and contract-tested as of 2026-08-29.
ShakerScan includes a fail-closed MCP stdio adapter over the REST Command Arsenal and canonical
Hunt V2. Arsenal tools remain read-only. Hunt tools wrap /hunts directly and inherit its exact
target binding, approval, budget, evidence, and proof enforcement; arbitrary shell is never exposed.
Start it from the source/runtime directory:
./scanner.sh mcp
The same adapter runs without the engine from the shakerscan client
(pipx install shakerscan, uv tool install shakerscan, or the Homebrew tap; see
client.md):
shakerscan mcp --url https://scanner.example.com --token-file ./token
The scanner API must already be available at http://127.0.0.1:8080. Override
the origin with SHAKERSCAN_API_URL. Non-loopback origins are rejected unless
SHAKERSCAN_MCP_ALLOW_REMOTE_API=true is explicitly set.
Behind an authenticating gateway (for example a self-hosted Enterprise deployment that fronts
the API with service tokens), set SHAKERSCAN_API_TOKEN as well: the adapter sends it as
Authorization: Bearer on every call. A token is only ever sent over https://; the adapter
refuses to start with a token and a plain-http origin, and it never prints the token. The
product CLI (./scanner.sh hunt ..., scripts/v2_cli.py) honours the same variable with the
same rule.
SHAKERSCAN_API_URL=https://scanner.example.com \
SHAKERSCAN_MCP_ALLOW_REMOTE_API=true \
SHAKERSCAN_API_TOKEN=<service token> ./scanner.sh mcp
Example client configuration:
{
"mcpServers": {
"shakerscan": {
"command": "/absolute/path/to/shakerscan/scanner.sh",
"args": ["mcp"]
}
}
}
The adapter exposes two deliberately different trust levels:
Read-only Arsenal inspection:
shakerscan_targetsshakerscan_asm_gapsshakerscan_findingsshakerscan_evidence_manifestshakerscan_timelineshakerscan_plansshakerscan_tool_statusshakerscan_captures: browser captures recorded withshakerscan record(see below)
Target-bound Hunt V2 (including state-changing and target-facing operations):
shakerscan_hunt_skillsfor catalog metadata andshakerscan_hunt_skillfor global detailshakerscan_hunt_skill_suggestions,shakerscan_hunt_skill_read,shakerscan_hunt_skill_bind,shakerscan_hunt_skill_unbind, andshakerscan_hunt_skill_usagefor progressive, audited methodology useshakerscan_hunt_start,shakerscan_hunt_get, andshakerscan_hunt_queryshakerscan_hunt_capabilityfor capabilities returned by that Hunt's manifestshakerscan_hunt_candidate,shakerscan_hunt_candidate_update, andshakerscan_hunt_candidate_deleteshakerscan_hunt_verify,shakerscan_hunt_finish, andshakerscan_hunt_cancel
Arsenal tools read GET /arsenal/commands, require the mapped command to remain read_only risk,
and dispatch through the audited Arsenal endpoint.
Every list tool (shakerscan_targets, _findings, _evidence_manifest, _timeline, _plans)
takes limit (default 20, at most 100) and cursor, the previous answer's next_cursor, and
answers with returned, has_more, next_cursor, total where the engine states it, and
mcp_view. Findings and plans are summary rows unless view: "full". The cursor is the
adapter's own and is bound to the tool and its filters (another filter set is refused). When the
command's catalog entry takes limit and offset (target.list; finding.list from 2.11) the engine
pages; otherwise (finding.list on an older engine, which refuses unknown parameters; the manifest,
the timeline and the plans) the adapter pages over the first rows the engine answers (its limit
maximum, the manifest's newest 200, or 100 findings) and mcp_view.note says when there are more
than it can reach and which filters narrow the list.
shakerscan_captures reads the request-collection routes directly (not Arsenal). action: "list"
calls GET /request-collections?source_format=capture (optionally for one target_id) and pages
with the adapter's own cursor; each row carries the capture's capture block (open or closed,
counts, last_seq). action: "read" calls GET /request-collections/{capture_id}/requests with
since_seq, limit (at most 200) and the engine's list/v1 cursor, and answers the redacted index
rows with their seq plus last_seq: pass last_seq back as since_seq to read only what was
recorded since, so an agent can follow a capture while it is open. Rows hold methods, URLs with
query values withheld, body field names and resource:/status: labels; no cookie, token,
header or body value is ever in an answer.
No tool answer is larger than 64 KB (MCP_ANSWER_BYTES). A list page that would be is cut on a
row boundary and next_cursor resumes at the first row left out; any other answer keeps the
fields that fit whole and mcp_view.omitted names the others with their sizes. A
shakerscan_hunt_capability answer is compact by default: the observations appear once (under
action_result; the raw result points there), strings over 16,000 characters are cut, and
observations that still do not fit are left out, all named in mcp_view.reduced. view: "full"
needs the caller's idempotency_key and reads back what the server recorded for that action
(nothing runs again): the canonical action_result with every recorded observation and the
stored, redacted result summary, not the raw worker output of the first answer, so a raw field
the server did not store cannot be read back. It is paged (page, mcp_view.pages) from one
projection of the record; each part carries its index and mcp_view.parts the total, so a gap
or repeat across pages shows. With a key no action was recorded under yet, the call runs the
action and answers compact, saying to call again for the record. Hunt discovery reads GET /hunts/contract and
generates the start schema from the live authority contract. The MCP boundary uses only the
canonical goal name and sends the complete V2 body: schema version, target kind, policy, budgets,
credential references, capability allowlist, request-collection references, and optional
skill_ids. A run normally starts without one. Adaptive suggestions return at most three compact
records and no bodies; the planner loads exactly one relevant method through the Hunt-specific read
tool before binding it. MCP never auto-binds a methodology or changes authority.
shakerscan_hunt_start takes the same optional idempotency_key as POST /hunts
(Idempotency-Key) and shakerscan hunt start --idempotency-key. When it is omitted the adapter
generates one, sends it as the header and returns it as mcp_idempotency_key; a start whose answer
was lost is retried with that key and unchanged input, and the server returns the Hunt it already
started instead of starting a second one.
Hunt lifecycle tools (shakerscan_hunt_start, _get, _finish, _cancel, _skill_bind,
_skill_unbind, _skill_usage) answer with a compact projection of the Hunt record by default:
identity, status, budget and use, next action, policy and policy_adjustments, each capability's
name, input fields and budget cost, each bound skill with its withheld_capabilities and
missing_capabilities, the last few actions, and counts. mcp_view.omitted names what was left
out (the context pack and any field over 4 KB that cannot be cut down) and mcp_view.reduced
names each field that was cut down and what it kept; a field over 4 KB such as outcome_summary
keeps its counters and its ID lists are cut to their first 20. The full record is 70-150 KB,
which agents' tool output truncates (OpenCode near 50 KB). view: "full" returns a record that
fits 32 KB unchanged; a larger one is paged into parts, each one whole field, list item or object
key named by its path (actions[3], context_pack.prior_knowledge[12]), and
shakerscan_hunt_get takes page to read on (mcp_view.pages, next_page); the other lifecycle
tools answer page 1 and point at shakerscan_hunt_get, so reading on never repeats a start, finish
or cancel. shakerscan_hunt_get with capability: "<name>" adds that capability's full manifest
entry. None of these arguments is sent to the server. shakerscan_hunt_query asks for 20 rows when
no limit is given (at most 100), and a page larger than 32 KB is cut to fit, with next_cursor cleared and
mcp_view naming the smaller limit to read it with.
The start answer carries budget_warnings (every limit lowered below what a capability in the
manifest reserves, and a web Hunt's lowered max_http_requests below one crawl plus one content
discovery; a limit at its profile default is the most a start allows and is not warned about) and
verification (the families candidate.verify can prove); a start that proposed bounds adds
mcp_permission_requests, the shakerscan approve command to give the user. The candidate tool's
schema lists the published locus keys, the evidence-reference forms and the verifiable families
from GET /hunts/contract. Started from shakerscan agent --allow, the adapter sends the
person's launch bounds as allow (with the gateway's pre-authorization id in
X-ShakerScan-Preauthorization) on every start; the agent's own allow stays proposed_allow.
Before capability execution, the adapter reloads GET /hunts/{id}, requires an active or
awaiting-planner run, finds the capability in that Hunt's returned manifest, and validates input
against its published schema. The client may provide an idempotency_key; if omitted, the adapter
generates one and returns it as mcp_idempotency_key so a retry can reuse the exact action identity.
A capability runs synchronously on the engine and can take minutes (a crawl, content discovery).
When its answer is lost -- the adapter's own request timeout, or a gateway's 502/503/504 -- the
adapter replays the same key and unchanged input, which the engine answers with the action's
current state without starting it again, until the action is final or
SHAKERSCAN_MCP_ACTION_WAIT_SECONDS (default 120, at most 3600) passes; only then does it report
the unknown outcome and its recovery identity. shakerscan hunt call does the same, bounded by
SHAKERSCAN_HUNT_ACTION_WAIT_SECONDS. The adapter classifies by the HTTP status it received, never
by the text of the reason. A definite refusal (a 4xx other than 408, 425 and 429) is never replayed
and reports outcome: "refused": its status and the server's stated reason are in the error message
the agent reads (error.data keeps the body). 408, 425 and 429 mean "not now": they report
outcome: "retry_later" with any Retry-After seconds, and the message names the key to call again
with after waiting. A 4xx is a refusal only before dispatch: once the server has reported the action
dispatched (execution_started: true, or the action's in-flight state), a later 4xx -- a settling
replay answered 409 because the Hunt finished meanwhile, say -- reports outcome: "unknown" with
indeterminate: true, the action_id and the key, because the action may have run.
Every refusal a person could resolve carries a stable reason_code from the engine's closed list
(permission_reason_codes in GET /hunts/contract); the adapter keeps it, with any slot and
profile_id, in error.data. A capability outside the Hunt's manifest is sent to the engine,
which says what is missing (for example capability_requires_active_testing) instead of an
opaque local refusal. When a person could allow the refused action, the engine parks it under its
idempotency key and the adapter reports outcome: "awaiting_permission" with the
permission_request_id, the server-rendered title and this recovery text: tell the user to run
shakerscan approve <id> in their own terminal, continue other work, check with
shakerscan_hunt_permission_wait (granted, denied, expired, withdrawn or still_pending,
long-polled within the call bound), and on granted call the same tool again with the same key and
input. No MCP tool can approve, deny or revoke a permission. Bounds passed to the start tool's
allow are sent as proposed_allow and become one pending request the person approves; the agent
cannot pre-authorize itself. See docs/hunt-permission-requests.md.
The capability request may run as long as the server's wall time for it
(budget_cost.tool_wall_seconds in the Hunt manifest) plus a margin, never only the adapter's
20-second request timeout. MCP clients usually end a request after 60 seconds, so the adapter never outlives it: a
client that sent a progress token receives progress notifications every 10 seconds while the
action runs (clients that reset their timeout on progress, such as OpenCode, keep waiting), for at
most SHAKERSCAN_MCP_ACTION_WAIT_SECONDS (default 120); any other client gets an answer within
SHAKERSCAN_MCP_CALL_SECONDS (default 45, at most 55). The bound matters because a client keeps
the agent's step open while any of its calls runs, and OpenCode starts nothing else meanwhile: a
verifier that runs for twenty minutes (sqli.verify) answers outcome: "running" after two, and
the agent works on and collects it later. An action that ends within the wait settles in the call
(web.crawl and content discovery hold 75 s); one whose budget_cost.tool_wall_seconds is longer
(ports.discover, web.browser_crawl, candidate.verify, browser.login_check,
templates.scan, service.nse_check, sqli.verify, xss.verify) may answer running. A running
action keeps its wall reservation until it settles, so two in flight can hold most of a short
Hunt's duration (on fast, two sqli.verify hold 2,400 of its 3,600 s); a call the remaining
duration cannot fund asks for a budget raise, or waits for one to settle.
A Hunt has at most two long actions in flight: a worker action whose wall, as the registry gives
it or as the call reserves it, exceeds 60 s (sqli.verify, xss.verify, templates.scan,
ports.discover, service.fingerprint, service.nse_check, subdomains.discover, web.crawl,
web.content_discover, web.browser_crawl, browser.login_check). The agent-tool worker runs
three actions at once and long ones in at most two of them, so a short call (http.request,
artifact.inspect, a browser step) does not wait behind long actions: it waits only for short
work, each at most 60 s, or for an SSH command, which runs on the worker that holds its session
and takes a slot without counting as long. The engine counts a call's own time, its timeout plus
30 s, from when a worker starts it; a call no worker starts while its reservation's lease lasts
(at least 90 s) is released without running or a charge and ends worker_result_timeout. A third
long call is refused with HTTP 429 and reason code long_actions_in_flight (the actions in flight
are listed in in_flight), reserving and recording nothing, and the adapter answers it as
outcome: "retry_later": collect one of the others or work with short calls, then send it again
with the same key. Other Hunts on the same instance share the worker: a long action queued while
theirs hold both long slots waits for one, without taking the slot short calls run in, and runs
its whole wall once it starts (one still waiting when its reservation's lease ends is released
without running). Once eight long actions wait on one worker, it leases nothing more, short calls
included, until one of them starts.
The engine answers the first request only when the action is done, so that request ends 10 seconds
before the call's wait does, and a replay of the same key learns the recorded action's state. An
action still running then is returned as a normal result with outcome: "running" and its
mcp_idempotency_key, and the continue text names that key: calling the tool again with the same
key and unchanged input collects the result, because the engine replays the recorded action and
never runs it twice. A collect the engine answers at once with "still running" waits at most 10
more seconds for it, not the whole action wait. shakerscan_hunt_get lists, as
mcp_uncollected_actions, each action this adapter answered outcome: "running" for that no later
call has collected (at most 20, each with its capability, key and input, or the input's digest when
it is over 2 KB), and as in_flight_actions every action the server still runs, from this session
or an earlier one (at most 20, with its capability, status, start and idempotency_key_sha256: an
earlier session's action is collected with the key that session sent, or read in the Hunt once it
settles). When no answer arrives at all, the error message itself names the key to call again
with.
The runtime still revalidates target binding, approval, budgets, evidence, and proof contracts.
Catalog/contract drift, redirects, oversized responses, unavailable APIs, and unexpected dispatch
results fail closed. A redirect names the origin it points at and how to connect there (usually an
http:// address for an instance served over HTTPS). After a Hunt finished or was cancelled, a
capability call with the agent's own idempotency_key still goes to the server, which replays an
action it recorded under that key, as over REST; a call without a key is refused locally, since
nothing new can start.
Framing follows JSON-RPC 2.0: a message without an id is a notification and is never answered,
an id that is not a string or an integer is refused with -32600, and a line over the input cap
gets exactly one -32700 error.
Tool annotations reflect these boundaries: Arsenal inspection and Hunt get/query are read-only; capability and verification operations are conservatively marked destructive and open-world; start, candidate create/update/delete, finish, and cancel are state-changing. Candidate updates cannot change identity or proof-owned fields; deletion expires the candidate while retaining its immutable audit record. Raw secrets, target-address overrides, planner argv, and arbitrary shell commands are not representable. Input schemas enforce UUIDs, enums, required/nested fields, patterns, uniqueness, and numeric bounds before dispatch. The transport also caps request and response sizes and rejects redirects.