MCP & tools
rupu cp serve serves an MCP endpoint at /mcp beside the web UI and the HTTP API. An MCP client such as Claude Code, Cursor or Claude Desktop connects to it and operates rupu: it lists and launches workflows and agents, watches runs, approves gates, drives sessions and messages a live run. It also gets rupu's tool catalog — files, shell, SCM, issues, findings — as plain tools.
What /mcp is
The endpoint is an adapter, not a second implementation. The operator tools call the
control plane's own endpoints in process and return the same JSON as the
HTTP API; the catalog tools are the same tool
bodies rupu's agents and workflow action: steps call. On top of those it serves
a wait tool that stands in for the API's SSE streams, rupu's own documentation as
read-only resources, and four prompts.
/mcp: it does not prompt
and it has no --mode. What runs is decided by the client's harness — Claude Code's
permission prompts and --allowedTools, Cursor's approvals. Each tool carries
read-only / destructive / open-world hints so a client's permission UI can tell runs.list
from runs.cancel. Treat the bearer token like a key to the whole control plane, and
narrow what is served with --tools if you want less.
Serve modes
| Command | Serves |
|---|---|
rupu cp serve | web UI + /api + /mcp |
rupu cp serve --no-web | /api + /mcp, no web UI (implies --no-open) |
rupu cp serve --no-mcp | web UI + /api, no /mcp |
--no-web --no-mcp is refused (nothing to serve), and so is --tools
with --no-mcp. A bad --tools grant fails before the socket is
bound. At startup the server prints the MCP URL and the command that connects Claude Code to it.
/mcp sits behind the same bearer token as /api
(--token), and the server checks two things on every request: the
Origin header, when present, must equal the Host; and on a
loopback bind the Host must be a loopback name (127.0.0.1,
localhost, [::1]), which closes DNS rebinding. Binding
beyond loopback is allowed; do it only with --token (see
Control plane).
Connecting a client
Claude Code
# no token (loopback, default bind) claude mcp add --transport http rupu http://127.0.0.1:7878/mcp # with a token claude mcp add --transport http rupu http://127.0.0.1:7878/mcp \ --header "Authorization: Bearer $CP_TOKEN"
claude mcp add --scope user makes it available in every project. Claude Code names
each tool mcp__rupu__<tool> with dots turned to underscores
(runs.list is mcp__rupu__runs_list).
claude mcp list shows whether it connected.
Cursor
~/.cursor/mcp.json (or the project's .cursor/mcp.json); leave headers out when the server runs without a token:
{
"mcpServers": {
"rupu": {
"url": "http://127.0.0.1:7878/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
Claude Desktop
Add the URL as a custom connector if your plan offers remote connectors that can reach it. Otherwise bridge it
with mcp-remote
in claude_desktop_config.json:
{
"mcpServers": {
"rupu": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://127.0.0.1:7878/mcp",
"--header", "Authorization: Bearer <token>"]
}
}
}
Authentication to GitHub, GitLab and the model providers is shared with the CLI through the same credential
store, so rupu auth login once unlocks the catalog tools for everything.
Operator tools
188 tools over the control plane's own endpoints, one per route (GET /api/tools and
tools/list give each tool's full input schema). Each takes the arguments of the
endpoint it wraps (path parameters, the handler's query struct, the JSON body), an optional
host where the API takes ?host=, refuses unknown arguments,
and returns the API's JSON as text. A JSON object is also sent as structuredContent;
a list is sent as text only, since MCP allows only an object there. Names are
<area>.<verb>; hints follow the HTTP method (GET read-only and idempotent,
PUT idempotent, DELETE destructive) and gate nothing.
| Area | Tools |
|---|---|
| Workflow runs | runs.list runs.list_all runs.archived runs.get runs.approve runs.reject runs.cancel runs.pause runs.resume runs.archive runs.restore runs.delete runs.graph runs.children runs.board runs.usage runs.usage_timeline runs.source runs.ast runs.autoflow runs.retry |
| Agent runs and transcripts | agent_runs.list agent_runs.continue transcripts.get transcripts.archive transcripts.delete events.recent |
| Autoflow runs | autoflow_runs.list autoflow_runs.problems autoflow_runs.cycles autoflow_runs.events |
| Sessions and messages | sessions.list sessions.get sessions.turns sessions.usage_timeline sessions.start sessions.send sessions.archive sessions.restore sessions.delete sessions.stop sessions.compact messages.list messages.send |
| Agentiflows | agentiflows.list agentiflows.get agentiflows.events agentiflows.children agentiflows.unit_output agentiflows.board agentiflows.generated agentiflows.steer agentiflows.run agentiflows.stop agentiflow_defs.list agentiflow_defs.get agentiflow_defs.create agentiflow_defs.save agentiflow_defs.delete agentiflow_defs.validate |
| Definitions | workflows.list workflows.get workflows.create workflows.save workflows.delete workflows.validate workflows.generate workflows.run workflows.run_yaml agents.list agents.get agents.create agents.save agents.delete agents.generate agents.run agents.validate agents.validate_all generate.models autoflows.list autoflows.get autoflows.enable autoflows.disable autoflows.run autoflows.explain autoflows.doctor autoflows.wakes claims.list claims.release claims.requeue claims.repair skills.list skills.get skills.install skills.update skills.validate tools.list models.list models.refresh |
| Findings, coverage, assets | findings.list findings.get findings.tags findings.tag findings.export findings.export_many findings.artifact findings.import findings.schema coverage.list coverage.get coverage.audit coverage.catalog coverage.runs coverage.diff coverage.templates coverage.template coverage.gap coverage.rerun assets.list query.fields query.check |
| Observability | netflow.run netflow.agentiflow netflow.project netflow.global netflow.graph netflow.explorer netflow.index netflow.latest usage.get usage.timeline usage.runs usage.outliers dashboard.get |
| Fleet, config, customers | hosts.list hosts.registered hosts.add hosts.add_ssh hosts.add_bucket hosts.enroll_node hosts.remove host.info workers.list config.get config.put_global config.put_customer config.put_project config.put_policy customers.list customers.create customers.get customers.update customers.delete customers.archive customers.unarchive customers.assign_project customers.unassign_project launch.preview repos.list repos.tracked repos.attach repos.prefer repos.forget scm_accounts.list scm_rules.list scm_rules.bind auth.status |
| Projects | projects.list projects.get projects.runs projects.sessions projects.agents projects.workflows projects.autoflows projects.coverage projects.coverage_assessed projects.tree projects.source projects.files projects.init |
| Setup and housekeeping | fleet.list fleet.install engagement_profiles.list engagement_profiles.get cron.list cron.events cron.tick maintenance.prune |
Every control-plane route is a tool except the SSE streams (wait replaces them), the
host-to-host transfer routes (workspace sync, skill bundles, coverage, netflow and artifact pulls), the node
WebSocket, /healthz, the web UI's filesystem browser and
POST /mcp itself.
Results
- Header notes. Any
x-rupu-*response header (for examplex-rupu-hosts-unfilterable, naming hosts a fan-out left out) comes back as an extra text note after the JSON and in the result's_meta["rupu/headers"]. - Files.
findings.export,findings.export_manyandfindings.artifactreturn an embedded resource:textfor text types, a base64blobotherwise,urirupu://api/...(the API path and query) and the filename inresource._meta.name. - Large bodies. A response over 16 MiB is an error that names the API path to fetch with the token instead.
- Route guard. A path argument that would reach a sibling route (an id spelled
workflowsfor/api/runs/:id) is refused with a 400 before the handler runs. - Host. The
/mcprequest'sHostis forwarded to the route, so a handler that derives an address from it (hosts.enroll_node) sees the one the client reached.
The wait tool
An MCP client cannot read the control plane's SSE streams, so wait long-polls:
{ "watch": [ {"kind": "run_settled", "run": "run_01J..."} ],
"cursor": {"run:run_01J...": 12}, "timeout_ms": 30000 }
| Condition | Fires when | host? |
|---|---|---|
{kind: "run_settled", run} | the run reached a terminal status | yes |
{kind: "run_gate", run} | the run has a parked approval gate | yes |
{kind: "run_events", run, types?} | the run wrote an event (optionally only these types) | yes |
{kind: "messages", root, kinds?} | the root's message log has a new message (optionally only these kinds) | yes |
{kind: "agentiflow", id, types?} | the agentiflow wrote a line (optionally only these kinds: round, run_stopped, …) | local only |
{kind: "agent_run_end", run} | a standalone agent run finished (read its final_answer from the reply) | local only |
{kind: "session_turn", session, run?} | a session turn ended; pass the run id sessions.send returned, else it waits for the active turn (or the next one if the session is idle) | local only |
{kind: "findings", project?, run?, q?} | new finding records were recorded (a tag change on an older finding is not one); q is the findings query language | local only |
- It checks state first, so a run that already settled or parked answers at once. Then it catches up from
cursor(ids are the SSEid:lines, so it mixes safely with the streams) and waits for a new line. - The timeout defaults to 30 s and is capped at 55 s.
- The reply carries
events, the advancedcursor, which conditionsfired,timed_out, and awarningslist when something needs saying (a remote host that stopped answering and is being retried). Pass the returnedcursorback on the next call. - Cursor keys name the watched source:
run:<id>forrun_events,messages:<root>,agentiflow:<id>, andfindings:<ws_id>/<target>(one key per ledger). A source on a remote host adds@<host>.run_settled,run_gate,agent_run_endandsession_turnwatch state, so they keep no cursor. - Cursor values are integers (line numbers) for local sources and for a remote run's events; a remote message space is polled, and its cursor value is the last message id, a string.
- Findings without a cursor start at the ledger's end (only records recorded after the call began), except when scoped to a
run, which starts at the beginning. - Remote hosts (
hoston the four run/message kinds): a remote run is read from the host's event stream; its gates and a remote message space are polled every 2 s, or every 10 s over SSH, where each poll is a newsshconnection. A remoterun_settledfires once the host's stream ends, about 2 s after the run's last event. A call watching a remote host can wait up to 5 s from its start for that host's first answer, even withtimeout_ms: 0. A host that stops answering after its first answer is retried and named inwarnings. - A condition error (a run, session or turn that does not appear within 10 s) fails the whole call.
- Progress. When the client sends a
progressTokenand accepts SSE, the call streamsnotifications/progressevery 5 s while it waits, so a client with an idle timeout keeps the connection. - Once
run_gatehas fired, drop it fromwatch(or approve / reject the gate): a parked gate is state, so it fires immediately on every call.
Operating rupu from an agent
A loop that launches a workflow with a gate and finishes it:
workflows.run {name: "release"}returns the run id.wait {watch: [{kind: "run_gate", run: ID}, {kind: "run_settled", run: ID}]}returns withrun_gatefired.runs.approve {id: ID}.wait {watch: [{kind: "run_settled", run: ID}], cursor}(norun_gate) returns when the run settles, or withtimed_out: true, in which case call it again.
And one that runs an agent: agents.run returns the run id, then
wait {watch: [{kind: "agent_run_end", run: ID}]} returns when it ends.
To steer a run while it works, messages.send reaches any participant of its
message space (see Messaging); to learn rupu's formats before
authoring, read the resources and use the
author-agent / author-workflow prompts.
Catalog tools
Beside the operator tools the endpoint serves the rupu-tools catalog: the fs/shell
tools (read_file, write_file, edit_file,
grep, glob, bash, …), the SCM and
issue tools (scm.*, issues.*, github.*,
gitlab.* — each described in Integrations)
and findings.report / findings.verify.
The fs/shell and findings tools take a required workspace argument:
a project id, or the absolute path of a registered project. The call runs with that project's layered config
(global, then customer, then project), its SCM registry and its findings ledger. A project's config and
registry are cached until the server restarts.
Where a catalog tool has the name of an operator tool (agents.list,
workflows.list, agents.get, …), the operator tool is the one
served. Every other catalog tool the server can serve is listed, findings.query,
findings.tag and catalog.search included. A catalog tool
whose service the server lacks is left out and named once in the server log at startup
(RUST_LOG=info). Findings filed over MCP record under the mcp
scope. A served bash's network flows are not captured: the call is no run, so there
is no run ledger for them.
Choosing the tools: --tools
rupu cp serve --tools <grant> narrows the MCP to the tools it names. It filters
every tool: the operator tools and wait as well as the catalog
tools. It uses the agent tools: grammar (exact names, legacy aliases,
ns.*, core.* and *, comma-separated
or repeated). Without --tools every tool is served.
# operator tools, wait, messages and findings only rupu cp serve --no-web --tools 'runs.*,wait,messages.*,findings.*' # a read-only code browser: no operator tools at all rupu cp serve --no-web --tools read_file,grep
A narrow grant such as read_file,grep therefore drops the operator tools and
wait; name them (runs.*,wait,…) to keep them. A name that
matches no tool fails startup, listing the namespaces served and, for a near miss, a did-you-mean
(--tools run.list suggests runs.list).
Resources and prompts
/mcp also serves read-only resources, built into the binary (never read from
RUPU_HOME), so an agent can learn rupu from the MCP alone and sees the docs of the
version it talks to:
| URI | Content |
|---|---|
rupu://docs/<name> | The 18 docs under docs/: using-rupu, agent-format, agent-authoring, workflow-format, workflow-authoring, agentiflows, triggers, engagement-profiles, coverage, messaging, skills, transcript-schema, cp-api, control-plane, response-outcomes, netflow, configuration, mcp (Markdown) |
rupu://schema/finding-report | The finding report JSON Schema (what findings.schema answers) |
rupu://query/<list> | A list query's field registry: findings, workflow_runs, agent_runs, sessions, autoflow_runs, events (what query.fields answers) |
rupu://profiles/<id> | Each built-in engagement profile, as TOML. engagement_profiles.list also lists layered ones |
rupu://tools | The tool catalog, the rows of GET /api/tools |
rupu://examples/<path> | The repo's examples/ agents, workflows and contracts |
resources/list and resources/templates/list are cursor-paged;
resources/read of an unknown URI answers -32002 with
data.uri.
Four prompts (prompts/list, prompts/get):
author-agent, author-workflow,
author-agentiflow and triage-findings (with a
project argument). Each is a short instruction that names the resources and tools to
use; the links are text, not embedded resources, because the docs run 5–73 KB.
Attribution
An operator message sent through messages.send, and a finding tag event written
through findings.tag, are recorded with client: "mcp" beside
via: "cp", so the Messages tab and the logs can tell an MCP client from the web UI.
A gate decision or cancel through runs.approve, runs.reject
or runs.cancel on the local host records the actor mcp
where the web UI records web. The mark comes from the /mcp
endpoint itself, never from a request header, so a plain HTTP call can't claim it.
Protocol
MCP streamable HTTP, JSON responses:
- POST carries one JSON-RPC message or a batch. A body of only notifications answers
202. GET and DELETE answer405(no server push, no session teardown). initializemints anMcp-Session-Id, sent back in the response header; a client echoes it on later calls. Anotifications/cancelledis scoped to the session that sent it.- Versions:
initializeanswers the client'sprotocolVersionwhen rupu speaks it (2024-11-05,2025-03-26,2025-06-18,2025-11-25), otherwise its newest. - Methods:
initialize,ping,tools/list,tools/call,resources/list,resources/templates/list,resources/read,prompts/list,prompts/get. A tool that fails, refuses its arguments or is unknown comes back as atools/callresult withisError: true, so the model sees why.
rupu mcp serve is deprecated
rupu mcp serve used to run a stdio server. It now prints a deprecation notice and runs
rupu cp serve --no-web on 127.0.0.1:7878;
--transport and --mode are ignored and
--tools is passed on. stdio is gone. Point clients at the HTTP URL as
above (for a client that only speaks stdio, use mcp-remote).
Troubleshooting
| Symptom | Likely cause |
|---|---|
401 from /mcp | The server runs with --token and the client sent none |
403 from /mcp | Origin differs from Host, or a loopback bind got a non-loopback Host (a proxy or tunnel rewriting it) |
405 on GET / DELETE | Expected: /mcp is POST only |
A fs / shell / findings tool says workspace is required | Pass a project id or the absolute path of a registered project |
wait returns instantly with run_gate | The gate is still parked; approve it or drop run_gate from watch |
wait on a findings, session_turn, agentiflow or agent_run_end condition with a host is refused | host applies only to run_settled, run_gate, run_events and messages |
wait with a remote host takes a few seconds even with timeout_ms: 0 | It waits up to 5 s for the host's first answer |
A result has an extra x-rupu-... text line | A fan-out left hosts out; the note names them |
| A tool errors with "over 16 MiB" | Fetch the named API path directly with the token |
Tool is missing from tools/list | --tools excludes it, or it is a catalog tool whose service the server lacks (logged at startup with RUST_LOG=info) |
--tools fails at startup with a "did you mean" | A name matches no tool; use the suggestion |
Tools inside rupu
The catalog served on /mcp is the same one rupu's agents use. An agent's frontmatter
tools: is its grant — what its model may call — and each entry is a canonical name
(bash, findings.report, scm.prs.get),
a legacy alias (report_finding, dispatch_agent), a namespace
(scm.*, board.*), core.* (the core
tools bash, read_file, write_file,
edit_file, grep, glob,
ast_grep) or *. There are no other wildcards. An unknown name
fails the agent's load with a did-you-mean; rupu agent validate checks every file.
Omitting tools: gives the default grant: core.*,
dispatch, join and every connector namespace.
# agent frontmatter tools: - read_file - grep - "scm.*" # every scm.* tool - issues.get # one exact tool
Every tool declares one effect — read, record
(rupu's own bookkeeping: findings, coverage, board posts), write (the workspace, or
running code), external (acting outside this machine) or spawn
(starting another run) — and the run's permission mode decides each call from the effect alone, never from
the tool's name:
| Effect ↓ / Mode → | bypass | ask (interactive) | ask (no operator) | readonly |
|---|---|---|---|---|
| read | allow | allow | allow | allow |
| record | allow | allow | allow | allow |
| write | allow | prompt | allow, with a notice | deny |
| external | allow | prompt | allow, with a notice | deny |
| spawn | allow; child keeps its own mode | allow; child capped at ask | allow; child capped at ask | allow; child capped at readonly |
A tool is offered only where it works — the connector tools need configured SCM accounts, the board and
mailbox tools a message space, coverage.* a concerns: block —
and every run's transcript opens with a tool_grant event listing exactly the tools
the model was offered and why, with every call audited as tool_audit. The authority
tools board.directive and board.retract are never granted
by *. The full grammar, the effect table and the alias list are in
Agents; the launch tools dispatch /
join / workflows.generate are described there too.