If you live in an editor, you can drive most of Catalio from there. Catalio exposes its domain actions as MCP tools, so an MCP client (Claude Code, Claude Desktop, Cursor, or your own) can start a scan, review what it found, and enrich the specification without you switching to the browser.
This guide walks the end-to-end loop and is explicit about the places where you still need the UI.
Finding tools: discover, describe, call
Catalio’s MCP tools/list returns three tools — discover, describe, and call — instead of the full catalog. Reach every other tool through them:
- discover — ask in plain words for what you want to do. It returns the matching tools, each with its input schema, plus a
discovery_token. - describe — optional. Pass tool names and the token to get the same schemas again. You don’t need it to call a tool.
- call — run a discovered tool with its arguments and the same token.
The Extract loop below names the tools you will use (start_extract_scan, get_extract_status, and the rest). Find each one with discover, then call it. A tools/list that asks for a specific domain still returns that domain’s full schemas.
What the loop looks like
Six steps, all reachable as tools:
- Point at an application and start a scan.
- Poll until the scan finishes.
- List the candidates it drafted.
- Confirm or dismiss each candidate.
- Enrich the requirements the scan produced or touched.
- Resolve change proposals — apply, dismiss, or restore.
Step 1: Start a scan
start_extract_scan starts an Extract scan for one application. Pass application_id. You can also pass stage, type_budget, and pull_request_number.
Leave stage and type_budget off for the default scan. That run drafts the application type, capabilities, and features, and the response reports stage as "thin_pack". Pass stage to scan a single type: app_type, capabilities, features, process, domain, usecases, requirements, or rules. Pass type_budget as a list of those same types when you want a specific set. An explicit type_budget overrides stage, and the response then reports stage as nil. Pass pull_request_number to scan that pull request’s head instead of the default branch.
start_extract_scan(application_id: "…")
→ %{status: :started, application_id: "…", stage: "thin_pack", extract_session_id: "…", type_budget: [:app_type, :capabilities, :features]}
A call while a scan is already running returns :already_running instead of starting another one, so your script can tell “I started it” from “it was already going.” A start that fails before the scan is running returns :failed with error_kind, and still includes extract_session_id.
Step 2: Poll for completion
get_extract_status takes application_id and returns counts plus extract_session for the live scan, or the most recent one.
get_extract_status(application_id: "…")
→ %{counts: %{"capabilities" => 12}, extract_session: %{extract_session_id: "…", status: :running, error_kind: nil}}
Poll extract_session.status. :running means the scan is still going, and :completed means it finished. On failure, status is :failed and error_kind says why. :stale means the scan stopped without finishing. extract_session is nil until the application has been scanned.
Step 3: List what it found
list_extract_candidates reads the candidates for a stage, filtered by status (detected, confirmed, or dismissed; defaults to detected).
list_extract_candidates(application_id: "…", stage: "capabilities", status: "detected")
→ [%{id: "…", name: "Invoice Approval", confidence: 0.82, …}, …]
Step 4: Review each candidate
One tool, triage_candidate, covers the review decision for every candidate type. Pass the type (capability, feature, entity, business_rule, requirement or use_case), the candidate id, and a decision:
| Decision | Effect |
|---|---|
confirm |
Marks the candidate confirmed (a capability also links to an org-level capability) |
dismiss |
Marks it dismissed, optionally recording a reason |
undismiss |
Returns a dismissed candidate to its detected state and clears the reason |
triage_candidate(type: "capability", id: "…", decision: "confirm", capability_id: "…")
triage_candidate(type: "feature", id: "…", decision: "dismiss", dismiss_reason: "Duplicate of Bulk Export")
The optional values (dismiss_reason, dismiss_category for a requirement, and capability_id) sit beside type, id and decision. An input the decision does not take is an error, not silently dropped.
Confirming a capability requires a capability_id. A confirmed candidate has to point at a capability in your organization’s catalog, so create or find that capability first (list_capabilities / create_capability), then pass its id.
The dismiss reason feeds back into the next scan so the detector stops re-suggesting the same thing. Recording why you rejected something is worth the extra argument.
Dismissing a candidate you already confirmed is rejected, and MCP has no unconfirm decision to reverse it — that action is only reachable from the UI.
The confirm boundary: MCP confirm is the light path
This is the one place where the MCP surface is genuinely narrower than the UI, and it is worth being precise about.
In the web app, approving a detected capability runs a promotion flow: it can mint the org-level capability for you, carry the candidate’s description and evidence across, and wire up the links in one action. Over MCP, confirming a capability with triage_candidate does the status flip and the link only. It does not promote.
So the MCP sequence is: create or pick the capability yourself, then confirm the candidate against it. Same end state, more steps, and you own the capability’s attributes rather than inheriting them from the candidate. If you want promotion-on-approve behavior, use the UI for that step.
Step 5: Enrich requirements
Standard authoring tools apply — list_requirements, list_requirements_filtered, get_requirement, create_requirement, update_requirement — plus link_requirement and unlink_requirement. Each takes a list of typed links, so one call can attach personas, policies, components, processes, business rules and use cases:
link_requirement(requirement_id: "…", links: [
{type: "persona", id: "…"},
{type: "component", id: "…", application_version_id: "…"},
{type: "policy", id: "…", rationale: "Retention rule"}
])
The links in one call succeed or fail together, and linking a pair that is already linked reports already_linked rather than an error, so a retry is safe. link_use_case does the same for a use case’s personas, requirements and business rules.
Batching several writes
batch runs a list of write-tool calls in one request. Each operation names a tool and passes that tool’s args exactly as you would send them. A later operation can use an earlier one’s result with {"$ref": "<label>", "field": "id"}, so a requirement, its links and its use cases can go in one call:
batch(operations: [
{id: "r", tool: "create_requirement", args: {input: {title: "…", user_want: "…", user_benefit: "…", personas: [{id: "…"}]}}},
{tool: "link_requirement", args: {input: {requirement_id: {"$ref": "r"}, links: [{type: "component", id: "…"}]}}},
{tool: "create_use_case", args: {input: {requirement_id: {"$ref": "r"}, title: "…"}}}
])
By default a batch is atomic: if any operation fails, none of them are kept, and the response marks each operation rolled_back, error or not_run. Pass atomic: false to keep the operations that succeed; the status is then partial. A batch takes up to 100 operations, runs write tools only, and returns ids and status rather than full records.
This part of the surface is essentially complete. If you can do it in the requirements UI, you can do it here.
Step 6: Resolve change proposals
Change proposals are Catalio’s suggested edits to your specification. The full lifecycle is now reachable:
| Tool | Effect |
|---|---|
list_change_proposals |
Read an initiative’s proposals |
mark_change_proposal_applied |
Record that you accepted it and made the change yourself |
dismiss_change_proposal |
Reject it, optionally with a reason and free-text feedback |
restore_change_proposal |
Undo a dismissal, putting it back in the queue |
delete_change_proposal |
Remove it entirely |
mark_change_proposal_applied stamps the proposal and nothing else. It does not touch the
entity the proposal targets, which is why it is named the way it is. Write the change
through its own tool first (update_requirement, create_requirement, and so on), then
mark the proposal so the Build gate stops counting it as pending.
dismiss_change_proposal accepts a dismissal_reason (a fixed set — not_relevant, inaccurate, duplicate, and others) and a dismissal_feedback string. Both are optional; dismissing with neither still works.
Restore clears the reason and the feedback along with the dismissal, because once a proposal is back in the queue the old rejection no longer describes it. An already-applied proposal cannot be dismissed or restored.
What stays in the UI
Two parts of the workflow are UI-first by design rather than oversight, and one is reachable only in a narrower form:
Document upload. Uploading a PDF, spec, or export means multipart file transfer, which MCP does not carry. Upload documents in the web app; everything downstream of the upload is reachable over MCP.
Interactive discovery sessions. Discovery is normally a guided conversation — Catalio asks follow-up questions and adapts based on your answers, with the session state driving the UI. But start_discovery_session and update_discovery_phase are exposed over MCP too, and can advance a session through its phases programmatically if you need to script it. Run discovery in the app for the guided experience, then work the resulting requirements from your editor.
Capability promotion, as covered above, is a third partial: reachable in a narrower form, full behavior in the UI only.
Authorization
Every tool runs as you, scoped to your organization. Catalio’s resources are tenant-isolated at the data layer, so the record a tool acts on is invisible if it belongs to another organization: the call fails as not-found rather than as a permission error. There is no way to reach across organizations by guessing an id, and no partial write happens on a rejected call.
Identifiers you pass inside a call are a separate check. Confirming a capability takes a capability_id naming the capability to link the candidate to, and tenant isolation on the candidate says nothing about where that second id points. It is validated explicitly: a capability_id from another organization fails as a validation error against that field, not as not-found, and it fails before any write. The distinction matters when you are reading errors programmatically — a missing candidate and a foreign capability_id surface differently.
Your own permissions still apply within your organization: the tools go through the same policies as the UI.