Findings
A finding is recorded once, as structured data. That record is the source of truth: rupu renders the report page, the Markdown, HTML and PDF exports, and every list and query from it, so an agent never writes a separate report file.
Findings are written by one tool, findings.report, which an agent calls directly, a workflow action: step calls as findings.record, and an outside MCP client calls on rupu cp serve’s /mcp. This page covers where a finding lands, the findings tools, what a report contains, how to query, tag, export and import findings, and how the control plane and the MCP surface serve them. The coverage harness page covers the ledgers around them.
Where a finding lands
Findings live in the workspace they were recorded in, as lines of findings.jsonl under <workspace>/.rupu/coverage/<target_id>/. The target id is derived deterministically from the workspace and the run’s scope name, so:
- The same agent against the same repo accumulates into one target across standalone runs, while different workspaces stay distinct.
- A workflow’s steps record under one scope name, the workflow’s
name, so every step pools into the same target rather than one per agent. - An agentiflow that starts a workflow as a unit overrides the scope name, so those steps pool into the agentiflow’s scope.
- A finding filed over
/mcprecords under themcpscope.
Every record carries its run id, model and surface. The run: query key and rupu findings export --run select the findings a run or agentiflow declared and, on the CLI and in the control plane, everything that ran under it (steps, units, launched children). A report’s cross_references may only name findings in the reporting run’s own scope; findings.query lists findings from every scope in the workspace, so to relate one from another scope, name its id in references or description.
The findings tools
| Tool | Also accepted | What it does |
|---|---|---|
findings.report | report_finding, findings.record | Records an issue: a complete report under the full profile, or summary / severity / evidence under summary. Takes an optional tags array. Injected automatically when the agent has a concerns: block; otherwise an explicit tools: grant. |
findings.verify | finding.verify | Records a verification verdict on a finding that has a full report. Never granted automatically, not even to a concerns: agent: write a verifier agent that lists it. A run can never verify its own finding. |
findings.query | query_findings | Lists the agent’s own workspace’s findings matching a query ({q, limit, cursor}, or all: true). Returns {rows, next_cursor, total, tags_in_use}. Explicit tools: grant. |
findings.tag | tag_findings | Adds or removes tags on one or many findings and returns each finding’s tags before and after. Explicit tools: grant; allowed in readonly mode. |
--- name: finding-verifier description: Re-checks a finding another run filed and records a verdict. tools: [read_file, grep, glob, findings.verify] ---
The same tool bodies are reachable from three places:
- An agent’s
tools:grant, by canonical name, alias orfindings.*.findings.report,findings.verifyandfindings.tagarerecord-effect tools andfindings.queryisread, so all four run in every permission mode:readonlymeans no change to the workspace or the outside world, and rupu’s own ledgers are bookkeeping. - A workflow
action:step:findings.record,findings.verify,findings.queryandfindings.tagare action-eligible (GET /api/toolsmarks them), withwith:checked against the tool’s schema at parse time. - An MCP client on
rupu cp serve’s/mcp, wherefindings.reportandfindings.verifytake a requiredworkspace(a project id or the absolute path of a registered project) andfindings.query/findings.tagare served too — see Over MCP.
Finding profiles
Every finding is written under one of two profiles:
| Profile | What findings.report / findings.record accepts |
|---|---|
full (default) | A complete report object. summary, severity and evidence are rejected: rupu derives them (summary ← title, severity ← rating.risk_rating, evidence.rationale ← root_cause). |
summary | The lightweight summary / severity / evidence record. A report is refused, never silently dropped. |
The profile is chosen, highest precedence first, by:
- a workflow step’s
findings_profile, orrupu run --findings-profilefor a standalone run; - the workflow’s
defaults.findings_profile; - the agent’s
findingsProfilefrontmatter; - the built-in default,
full.
Remote workflow units (host: / distribute:) resolve the same way; the value travels to the host as --findings-profile. The profile is stored on each finding; records written before profiles existed read back as summary.
--- name: quick-triage findingsProfile: summary # this agent records thin findings concerns: - include: owasp-top10-2021 ---
full, an existing agent that records thin findings is rejected until it declares findingsProfile: summary or its prompt is updated to send a report; agents outside the repo (under ~/.rupu/agents/) need that one-line change. When a full-profile run can record findings (a concerns: block, or findings.report in tools:), finding-writing guidance is appended to its system prompt, so no external reporting-standard file is needed.What a full report contains
A full report has these fields; every one is required except cwe (which may be an empty list) and artifacts. Typed blocks and classifications are optional additions.
| Group | Fields |
|---|---|
| Identity | title, category, attack_vector, cwe (CWE-<n> ids) |
| Ownership | ownership (owner, product, affected_component, source_repository), tickets |
| Rating | rating: impact, risk_rating, risk_factor (Low/Medium/High/Critical), likelihood (Low/Medium/High), cvss_v3 |
| Narrative | description, impact, location (input / output), root_cause, call_chain (source → hop → sink) |
| Proof | evidence (at least one claim, each optionally pinned to a workspace-relative file + lines), replication_steps (at least one), artifacts, blocks |
| Fix | remediation, recommended_patch (a unified diff), ci_cd_detection, regression_test |
| Links | cross_references (finding ids in the reporting run’s own scope), references |
Rules are enforced at write time, and a rejected call returns every problem at once with its field path, so the agent can fix them all in one retry. Unknown keys are rejected at the top level of the call, in the report and in each evidence block. The serialized report must fit [findings].report_max_bytes (default 256 KiB). Some fields are set by rupu, never by the agent, and are left out of the schema the agent is shown: the verification status (unverified / confirmed / disputed / inconclusive, set by verification runs — a call that supplies it is rejected), each evidence claim’s sha256 (rupu hashes the claim’s file at write time; a claim whose file is not in the workspace is stored without one), and an artifact’s hash, size, kind, storage and host.
Sentinels
Where information genuinely cannot be determined, a field uses a sentinel instead of being omitted or guessed. On the structured fields only the exact sentinels listed for that field are accepted.
| Sentinel | Used for |
|---|---|
Unknown | owner, product, affected component, source repository, attack vector, CVSS score, tickets |
Not Applicable | source repository, when no source-controlled code is involved |
None Provided | tickets, when none are mentioned |
None | cross references, when there is no related finding |
Not Provided — <justification> | call chain, recommended patch, CI/CD detection, regression test (the justification must be non-empty) |
Unknown is not accepted for the patch, CI/CD detection or regression test: provide the item or say why it could not be produced.
Evidence blocks
Alongside the prose evidence claims, a report may carry typed blocks, each an object tagged by kind and accepting exactly its own fields:
text code_slice diff table image hexdump disasm decompile http_exchange scan_output pcap_ref
Three of them name a file: image, hexdump and pcap_ref. That file, in the block’s artifact.path, is verified and stored exactly like a proof-of-concept artifact (below) and counts against the same per-finding limits, but it doesn’t appear in the PoC list. A bad path is rejected like an artifact path, with the field named report.blocks[<i>].artifact.path. The control plane renders every block kind in an “Evidence blocks” section, with image blocks shown inline.
{ "kind": "http_exchange",
"request": "GET /api/notes?sort=title%27-- HTTP/1.1\nHost: notes.example.test",
"response": "HTTP/1.1 500 Internal Server Error\n..." }
{ "kind": "image",
"artifact": { "path": "poc/error-page.png" },
"caption": "Stack trace leaked by the sort parameter" }
Artifacts and the content-addressed store
report.artifacts[].path lists proof-of-concept files (scripts, outputs, harnesses) as workspace-relative paths; the agent supplies only the path. At write time rupu hashes each one:
- A file up to
[findings].artifact_max_bytes(default 500 MiB) is copied into a content-addressed store at<RUPU_HOME>/findings/artifacts/<aa>/<sha256>and recordedstored: copied. Identical content is stored once across runs and projects, and the store outlives the workspace. - A larger file is recorded
stored: externalwith its path, size and sha256; it does not count toward the copied total, so it never rejects the finding. - A directory expands to the files inside it (symlinks inside are skipped).
- One report is bounded, before anything is copied, to
artifact_max_filesfiles (default 500) andartifact_total_max_bytescopied (default 2 GiB). A larger set rejects the finding with the count or total named; list specific files instead of large directories. Evidence-block files are ingested afterwards against what is left of the same limits. - A path that escapes the workspace, names the workspace root itself, doesn’t exist, or isn’t a regular file rejects the finding, so a typo is never silently dropped.
[findings] artifact_max_bytes = 524288000 # copy cap per artifact file (500 MiB) artifact_max_files = 500 # files per report artifact_total_max_bytes = 2147483648 # bytes copied per report (2 GiB) report_max_bytes = 262144 # serialized report budget (256 KiB) ticket_patterns = ["ABC-[0-9]+"] # extra ticket formats for the guidance export_id_prefix = "VULN" # export numbering prefix (global config only)
Findings from remote units
A remote workflow unit (host: / distribute:) runs findings.report on its host, so its artifacts are copied into that host’s store. Every rupu run also streams its coverage — runs, file touches, concern assertions, findings, engagement assets and tag events — to $RUPU_HOME/runs/<run_id>/coverage.jsonl, which the host’s connector delivers to the coordinator when the unit ends, success or failure, on every transport. The coordinator merges it under its own workspace’s targets and records the unit’s artifacts stored: external with the host’s id (a unit placed on the coordinator’s own local host shares its store, so its artifacts stay copied).
When no stream arrives, it can’t be collected or merged, or it may be incomplete (read while the unit may still have been running), the step shows a StepWarning — in the control plane’s run view, the Situation Room, the CLI live view and rupu workflow show-run — and the unit is not failed. With workspace: sync the unit’s returned delta still carries its .rupu/coverage/: the coordinator drops it when the complete stream merged every line, and applies it otherwise (a duplicate beats a loss).
Remote artifacts, pulled on view
The bytes of a remote unit’s artifact stay on the host until someone views them: the first preview or download through the control plane pulls the blob over whatever transport the host uses (SSH runs the hidden rupu __findings artifact; an HTTP host serves GET /api/findings/artifacts/:sha256; a tunnel node streams it; a bucket worker uploaded it to artifacts/<sha256> when its run finished), checks it against the recorded size and sha256, and keeps it in the coordinator’s store. A recorded size over the coordinator’s own artifact_max_bytes is refused without contacting the host. Concurrent first views share one pull. If the bytes can’t be had (host unreachable, node offline, blob missing, host too old, failed verification), the response is a 404 with {"unavailable": "<reason>"}, nothing is stored, and the next view tries again.
Artifacts are served with nosniff and a sandboxing Content-Security-Policy: text inline as plain text, PNG/JPEG/GIF/WebP images inline (recognised by their bytes, never by name), everything else as an attachment. Opening a finding never pulls from a host on its own; a remote image block loads only when you click it, and a binary artifact only when you download it.
rupu findings schema
rupu findings schema Print the embedded draft-07 JSON Schema of a finding report rupu findings schema --advertised Print the simplified copy used in tool definitions
The schema is embedded in the binary and held in lockstep with the validator by a test, so external prompts and tools can be generated from rupu rather than maintained by hand. Over MCP the same schema is the findings.schema operator tool’s answer and the rupu://schema/finding-report resource.
Querying findings
One single-line query language filters findings on every surface: rupu findings list and tags, the agent tool findings.query, the same tool over MCP, and the control plane’s Findings page. One evaluator serves all of them, so a query means the same thing everywhere.
severity>=high tag:class:sqli -tag:false-positive -has:poc "sql injection"
Grammar
- Tokens are separated by whitespace and every token must match (AND). Repeating a key ANDs too:
tag:a tag:bneeds both tags. key:valuefilters a field;key:a,bmatches any of the values (OR within one token);-key:valuenegates it.- Only
severitytakes>=,>,<=,<, with a single value. - A bare word or quoted phrase is free text, matched case-insensitively against title, summary, id and file path;
-wordnegates it. - A token shaped like a key plus an operator always names a key, so an unknown key is an error, never text. Quote text that contains a colon:
"http://host". - Quotes open only at the start of an item;
\escapes the next character. Only the first:after the key splits, sotag:class:sqliworks.
An invalid query never runs. It is refused with the offending token’s index and a code: unknown_key, empty_value, bad_value, bad_operator, unclosed_quote or bad_quote.
Fields
| Key | Matches |
|---|---|
severity (sev) | info, low, medium, high, critical; also >=, >, <=, < |
tag | an effective tag (normalized, so Class:SQLi is class:sqli) |
has | tags, report, poc (the report has artifacts), cwe |
project | workspace name or id (CLI and control plane only) |
cwe | 79 or CWE-79, compared by number: the report’s CWEs, the one the concern names or, when it names none, the first MITRE reference URL — CWE-79 never matches CWE-798 |
owner, product | the report’s ownership, case-insensitive |
verified | unverified, confirmed, disputed, inconclusive (no verification counts as unverified) |
profile | full, summary |
scope | line, file, repo, host, endpoint, resource |
concern | concern id, case-insensitive |
agent | agent name or codename, case-insensitive |
workflow | workflow name, case-insensitive (CLI and control plane only) |
file | path prefix |
run | run or agentiflow id; the CLI and control plane also match everything that ran under it (steps, units, launched children, down the tree) |
id | finding id |
Examples
# quote anything your shell would treat as a redirect: > and < are redirects rupu findings list 'severity>=high' tag:needs-poc -has:poc rupu findings list 'owner:"payments team"' run:run_01J… rupu findings list --limit 20 cwe:89,79 file:src/api/ rupu findings list --ids-only -has:tags rupu --format json findings list verified:confirmed project:notes-service
list spans every registered project; put --limit N and --ids-only before the query words. An agent with findings.query in its tools: (or a workflow action: findings.query step) sends {q, limit, cursor}; limit defaults to 50 (at most 500), cursor is the previous page’s next_cursor, and all: true returns every match unpaged — handy for fanning out over the results. Each row is slim: id, title, severity, scope, location, concern_id, tags, declared_at, run_id.
steps: - id: tagged action: findings.query with: { q: "tag:needs-poc severity>=high", all: true } - id: poc agent: poc-writer for_each: "{{ (steps.tagged.output | fromjson).rows | tojson }}" max_parallel: 4 prompt: | Write a proof of concept for finding {{ item.id }}: {{ item.title }} ({{ item.location }}). When it works, tag it `has-poc` with findings.tag.
project: and workflow: need provenance that only the CLI and control plane have, so the agent tool and MCP refuse a query that uses them; there run: is an exact match on the run id.
Tagging findings
A finding can carry free-form tags (needs-poc, class:sqli, status:triaged) used to sort, filter and queue work. Tags never change a finding’s report, severity or id.
- Syntax. Trimmed and lowercased, then checked:
a-z,0-9and. _ : / -, starting with a letter or digit, at most 64 characters (Needs-POCis stored asneeds-poc). An invalid tag is rejected, never rewritten.namespace:valueis a convention, not something rupu interprets. - Cap. At most 32 tags per finding; a change that would pass it is refused. A finding already over the cap (two remote units’ tags can add up past it) can still lose tags, just not gain any.
- Where they live. Tags a finding was declared with sit on its record in
findings.jsonl. Every later add or remove is an event in one append-only file per workspace,.rupu/coverage/finding_tags.jsonl, folded on read in file order. Each event records who made it: an agent’s run/agent/model, or an operator via the CLI or the control plane. A change that alters nothing writes no event.
Who can tag
| Who | How |
|---|---|
| Agents | findings.report takes an optional tags array, so a finding is tagged as it is declared. findings.query and findings.tag are explicit tools: grants (tools: [read_file, findings.query, findings.tag]); findings.tag is allowed in readonly mode since it annotates the ledger and never touches the workspace’s files. findings.query returns tags_in_use, so agents reuse tags instead of inventing near-duplicates. Both work only on the agent’s own workspace, and a batch with an unknown id or a finding that would pass the cap is rejected whole. |
| Workflow steps and MCP | An action: step, and an MCP client on rupu cp serve’s /mcp, call the same findings.query and findings.tag tools (one implementation, same inputs and results), and findings.record accepts tags. |
| Operators | rupu findings tag / tags, the control plane’s Findings page and finding pages, or the findings.tag operator tool over MCP (recorded client: "mcp" beside via: "cp"). |
rupu findings tag fnd_01J… --add needs-poc --add class:sqli rupu findings tag fnd_01J… --remove needs-poc rupu findings tags # tags in use, with counts rupu findings tags severity:critical # ...over the findings a query selects # tag everything a query matches: a lone `-` reads ids from stdin rupu findings list --ids-only tag:class:sqli | rupu findings tag - --add needs-poc
# a workflow action step - id: mark action: findings.tag with: { finding_ids: ["fnd_01J…"], add: ["needs-poc"] }
rupu findings tag works across projects, atomically per workspace: an unknown id or a workspace that couldn’t be changed makes it exit non-zero after the others have kept their changes. A placed unit tags findings in its own workspace; those events travel to the coordinator in the run’s coverage stream, as tags lines next to the findings lines, and are merged into the coordinator’s tag log by event id (a replayed event is dropped as a duplicate), so tags added on the coordinator while the unit ran survive. Tagging a finding that exists only in a remote host’s own ledgers, never ingested into the coordinator, is not supported.
Exporting reports
A stored finding exports as Markdown, self-contained HTML, or PDF — one finding, or a whole project. All three carry the same sections in the same order, and every field of the report: CWE, verification, ticket notes, claim-to-artifact links, each call-chain hop’s role, artifact kind and host, and provenance (surface, concern, scope, location). A summary-profile finding exports as a short document, marked as a summary finding.
rupu findings export [--id <fnd_…>]… [--project <ws_id|path>] [--run <run_id>]
[--severity <critical|high|medium|low|info>]
[--owner <name>] [--cwe <CWE-n>] [--include-summaries]
[--to md|html|pdf] [--split] [--title <text>] -o <path>
The document format is --to (default md), not the global --format. Filters combine; --severity keeps that severity and worse; --run keeps findings declared by that run (or agentiflow) and everything that ran under it; --cwe compares by number, as the query’s cwe: does. A single --id alone writes a stand-alone document; anything else writes a project report over the selection (title, summary, index, then one section per finding), or with --split a zip of index.md plus one file per finding. Summary-profile findings are left out unless you pass --include-summaries (naming one with --id counts as asking). -o may be an existing directory to receive the generated file name. Finding ids are unique within a project, not across projects: when a single --id exists in several, the export refuses and names them, and --project picks one.
# one finding as a PDF, into ./reports rupu findings export --id fnd_01J… --to pdf -o ./reports # everything high or worse in one project, as a single HTML report rupu findings export --project ~/src/notes-service --severity high --to html \ --title "Notes service assessment" -o assessment.html # one file per finding plus an index, for one run rupu findings export --run run_01J… --to pdf --split -o run-findings.zip
Numbering
Exports number findings within their project — by severity (critical first), then declaration time, then id — as <PREFIX>-NNN (SEC-001, SEC-002, …). Numbers are assigned before any filter applies, so a finding keeps its number whatever a report leaves out, and the CLI and the control plane agree. A number is a point-in-time display label (recording a new critical finding renumbers the ones after it); the fnd_ id is the stable handle, and a project report’s index lists both. Files are named <NUMBER> - <Short Title>.<ext>, e.g. SEC-003 - SQL injection in the search endpoint.pdf.
The prefix is [findings].export_id_prefix (default SEC), read from the global config only — a repo’s .rupu/config.toml never changes it, since it ends up in file names and document text. It must match ^[A-Za-z][A-Za-z0-9_-]{0,15}$; anything else is ignored with a warning.
Formats
| Format | Notes |
|---|---|
md | Plain Markdown. Report text is kept as Markdown but neutralised so it can’t restructure the document (line-leading < escaped, headings demoted, unclosed fences closed). image blocks are referenced by path. |
html | One self-contained file with inline CSS; loads nothing from the network and runs no script, with a strict embedded CSP. Raw HTML in report text is neutralised. image blocks are inlined as data: URIs. |
pdf | Rendered in-process with Typst — no headless browser, no external tools, bundled fonts (so no CJK or emoji glyphs). Behind the default-on pdf cargo feature; a build without it still exports Markdown and HTML. |
Only image blocks whose file is in this machine’s store, is a raster image by its leading bytes, is at most 4 MiB and still matches its hash are embedded; anything else is shown by path, sha256 and size with a note. An export never fetches from another host, even for a file the finding page would pull on view.
Importing older reports
Findings recorded before the full profile existed are summary findings whose report was written separately as Markdown. rupu findings import attaches those reports to their findings — a one-time, best-effort migration aid, not an input path: nothing else writes a report onto an existing finding. Over MCP the findings.import operator tool does the same.
rupu findings import <PATH>… [--id <fnd_…>] [--dry-run]
- Input. Report files, or directories searched recursively for
*.md(hidden entries skipped, symlinks not followed). It reads the layoutrupu findings export --to mdwrites, and a plain-text layout of bare section headings andLabel: valuefields. Files that aren’t reports (a README, an index) are skipped; a file holding several findings is refused. - Which finding. The id on the report’s
Finding ID:line (also read:Native Finding:,Rupu Finding ID:and similar labels; an id mentioned only in prose is never used);--idnames it for a single file that has none, and is refused when the report names a different finding. The finding is looked up across the registered projects and must be in exactly one ledger. - Result. The finding becomes full-profile. The report goes through the same validation as
findings.reportand attaches whole or not at all; a finding that already has a report is skipped, never changed. Nothing is invented: a missing field with a sentinel gets the sentinel, a missing required section fails the file. Evidence claims are stored without a file hash (the control plane shows their status asunknown); artifacts are verified and copied from the workspace as it is at import. - Safety. Each changed ledger is backed up byte-for-byte to
findings.jsonl.pre-import-<UTC time>first, then replaced atomically under the ledger lock (findings.jsonl.lock).--dry-runvalidates everything and printswould attachwithout writing anything, taking no lock. - Output. One line per file —
attached,skippedorfailedwith the reason and each validation problem — then abackupline per rewritten ledger and a totals line; non-zero exit when any file failed.
cp serve). Once you’ve checked the result, delete the backups — each is a full copy of the ledger, and a workspace sync or a commit of .rupu/ would carry it along.In the control plane
The control-plane web UI (rupu cp serve) renders stored reports directly; no export step is needed to read one.
- Report page (
/findings/:id). A section rail (description, impact, location, root cause, call chain, evidence, PoC artifacts, replication steps, remediation, patch, CI/CD and regression commands, references, provenance), CWE and verification chips over a ledger of ownership and ratings, and a completeness metern/11that lists the gaps (anUnknownorNot Providedcounts as a gap;None Provided,Not ApplicableandNonecount as answers). Each evidence claim carries a badge comparing the file hash at write time with the file now:current,changed,missingorunknown. Call-chain steps link into the project’s Code tab, the patch renders as a diff, commands have copy buttons, and Markdown / HTML / PDF buttons export the finding. A summary-profile finding opens a compact page. - Lists and triage. The global Findings page, a project’s findings tab, coverage detail and a run’s Findings tab show a Report column (
n/11plus a PoC marker, or “summary”). Expanding a full-profile row shows a triage card — root cause, attack path, owner and product with unknowns flagged, completeness, verification status — with an “Open full report” link. An Export report button exports exactly the rows listed (format, one file per finding, include summary findings, title). - Query bar. The Findings page takes the same query language: press
/to focus it, get fuzzy suggestions for keys and values (with counts), and see an invalid query’s error inline instead of stale results. The query lives in the URL, so/findings?q=severity%3Ahigh%20tag%3Aneeds-pocis a shareable link, and the severity tiles toggleseverity:<x>in it. - Findings in the code. A project’s Code tab shows each finding inline, under the lines it’s pinned to, with full-profile findings adding tabs for root cause, call chain, evidence, patch and repro. When the project’s git remote is on
github.com,gitlab.com, or a self-hosted host named by abase_urlin the global config’s[scm.<account>]tables (GitHub Enterprise, self-managed GitLab), a finding with a file path also gets a View on repository link to the file on the branch the project had checked out when rupu first registered it (mainif unknown), with the line range highlighted (#L10-L14on GitHub,#L10-14on GitLab). The link isn’t pinned to a commit: rupu doesn’t record the commit a run worked on, so the lines can drift as that branch moves. List rows carry the same link aspermalink, omitted when there is none. - Tag editing. On a finding’s page, tags show as removable chips with a
+ taginput that suggests tags already in use, plus a collapsible tag history (who, when). On the Findings table, select rows and use Tag… / Untag… for bulk changes (batches of 1000). Every change is recorded as an operator event,via: cp, in the same tag log the CLI writes. A project whose tag log can’t be read shows its tags read-only.
The same data is available over the API: GET /api/findings?q=… (slim rows plus facets and tags_unavailable; an invalid q is a 400 naming the token), GET /api/findings/:id (the full report with per-claim evidence_status, tag_history and tags_editable), GET /api/findings/:id/artifacts/:sha256, GET /api/findings/:id/export?format=md|html|pdf, POST /api/findings/export, and GET/POST /api/findings/tags. Because an id is unique only within its project, the per-finding routes take ?ws_id=, and answer 409 naming the projects when an id is ambiguous without it; see the HTTP API.
Over MCP
rupu cp serve’s /mcp gives an MCP client (Claude Code, Cursor, Claude Desktop) two ways at findings; both are described on the MCP & tools page.
- Operator tools, one per control-plane route, returning the API’s JSON:
findings.list,findings.get,findings.tags,findings.tag,findings.export,findings.export_many,findings.artifact,findings.importandfindings.schema.findings.export,findings.export_manyandfindings.artifactreturn an embedded resource (textfor text types, a base64blobotherwise) with the filename inresource._meta.name; a body over 16 MiB is an error naming the API path to fetch instead. A tag event written throughfindings.tagis recorded withclient: "mcp"besidevia: "cp". - Catalog tools, the same bodies agents call:
findings.reportandfindings.verify, which take a requiredworkspace(a project id, or the absolute path of a registered project) and run with that project’s layered config and findings ledger;findings.queryandfindings.tagare served as well. Findings filed over MCP record under themcpscope. waitlong-polls for new records:{kind: "findings", project?, run?, q?}fires when new finding records were recorded (a tag change on an older finding is not one);qis the findings query language. Local only. Its cursor is one key per ledger,findings:<ws_id>/<target>; without a cursor it starts at the ledger’s end, except when scoped to arun, which starts at the beginning.- Resources and prompts.
rupu://schema/finding-reportis the finding report JSON Schema,rupu://query/findingsthe query language’s field registry, andrupu://docs/coveragethe coverage document; thetriage-findingsprompt (with aprojectargument) names the resources and tools to use.
# serve the operator findings tools, wait and messages only rupu cp serve --no-web --tools 'runs.*,wait,messages.*,findings.*'