MCP (Model Context Protocol)¶
reyn speaks MCP in both directions: it can call out to external MCP servers (as a client), and it can expose its own agents to external LLM clients (as a server). The two roles are distinct and both are implemented.
What is MCP¶
MCP is a JSON-RPC protocol for AI agents to connect to "servers" that expose tools. The spec is published by Anthropic at modelcontextprotocol.io. Many official server implementations exist (filesystem, git, github, fetch, brave-search); third parties ship dozens more. A server advertises its tool list (tools/list) and executes calls (tools/call); the agent stays generic.
The point: your workflow says "call the read_text_file tool on the filesystem server", not "shell out to cat". Swapping the backend is a config change, not a code change.
Two roles Reyn plays¶
| Role | Direction | How |
|---|---|---|
| MCP client — Reyn calls external servers | Outbound | The mcp Control IR op + permissions.mcp: declaration in a phase. A workflow says "call this tool on this server"; the OS dispatches via MCPClient (stdio / streamable-http / sse). Example: a workflow reads files through the filesystem MCP server. |
| MCP server — external clients call Reyn | Inbound | reyn mcp serve --project . launches Reyn as a JSON-RPC server. Claude Code, Cursor, OpenAI Agents SDK, or any MCP-aware client can then call INTO Reyn's agents using two tools: list_agents() and send_to_agent(agent_name, message). |
The rest of this page covers each role in turn.
Quick start: from zero to working MCP in three commands¶
The recommended first-time flow uses reyn mcp install — no manual YAML editing required:
# 1. Discover available servers
reyn mcp search "github"
# 2. Install (handles config + credentials + permission gate)
reyn mcp install io.github.modelcontextprotocol/server-github
# 3. Start using it immediately
reyn chat
> このリポジトリの最近の PR を一覧して
reyn mcp install fetches the server manifest from the MCP registry, checks that the required runtime (npx, uvx, etc.) is installed, prompts for any credentials (storing them securely in ~/.reyn/secrets.env), and writes the mcp.servers.* entry into your config with ${VAR} references for secrets — all in one step.
For servers not in the registry (including Anthropic's official reference servers like @modelcontextprotocol/server-filesystem), use --source:
reyn mcp install --source npm:@modelcontextprotocol/server-filesystem
reyn mcp install --source pypi:mcp-server-fetch
reyn mcp install --source docker:mcp/playwright
reyn mcp install --source https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem
--source skips the registry fetch and resolves install metadata from the source specifier directly. Permission gate, credentials, config write, and audit event are identical to the registry path.
For the full reyn mcp CLI reference, see Reference: reyn mcp.
Quick start: try MCP from reyn chat (manual config path)¶
If you prefer to configure a server manually (or are adding a server not in the public registry), add it directly to reyn.yaml. reyn chat exposes verb actions under the mcp category:
| Action | What it does |
|---|---|
mcp_search_registry({text}) |
Search the official MCP registry for matching servers |
mcp_install_registry({server_id}) |
Install a server from the official MCP registry |
mcp_install_package({kind, identifier, version?}) |
Install via a third-party package channel (npm / pypi / docker / GitHub URL) |
mcp_install_local({name, command, args}) |
Register a local command (e.g. LLM-authored script) as an MCP server |
list_mcp_servers() |
Returns the names of all servers configured in .reyn/config/mcp.yaml |
list_mcp_tools({server}) |
Returns the tools exposed by one server (each entry has name="<server>__<tool>", description, inputSchema) |
mcp_call_tool({tool, tool_args}) |
Call a tool by <server>__<tool> identifier (from list_mcp_tools) with its declared tool_args |
mcp_drop_server({server}) |
Remove an installed server from the config |
The LLM router can call these directly during a chat turn. Typical first-time flow:
# 1. Add a server entry to reyn.yaml (one-time)
mcp:
servers:
filesystem:
type: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
# 2. Pre-approve in reyn.yaml or accept the prompt on first use
permissions:
mcp:
filesystem: allow
# 3. Just chat
reyn chat
> このディレクトリにある README.md を要約して
The router invokes list_mcp_tools → mcp_call_tool automatically; no permissions.mcp: declaration in any workflow is required. Workflow authoring is for when you want to formalize a recurring workflow (= validation, retry policy) — not a prerequisite to using MCP. The deep-dive below is for that case; if you only need ad-hoc invocation, you can stop reading here.
Role 1: MCP client — Reyn calls external servers¶
When a workflow needs an external tool, the flow is:
phase frontmatter LLM emits Control IR OS dispatches
permissions: → {kind: mcp, → MCPClient
mcp: [filesystem] server: filesystem, (stdio | streamable-http | sse)
tool: read_text_file,
args: {path: ...}}
- The workflow's phase declares
permissions.mcp: [server_name]in frontmatter — without this, the runtime refuses every call to that server. - The LLM emits an
mcpControl IR op:{server, tool, args}. It cannot invent server names; only servers configured inreyn.yamland declared in the phase's permissions are reachable. - The OS resolves the server's transport (
stdio,streamable-http,sse), dispatches viaMCPClient, and returns the tool result to the phase loop. - Every call emits events —
mcp_calledbefore,mcp_completed(ormcp_failed) after. The audit trail is identical to any other op.
The boundary is sharp on purpose: workflows describe what they want, the OS decides how to get it. Adding a new MCP server doesn't touch any OS code (P7).
Resources: list + read¶
Alongside tools, a server can expose resources (server-hosted content addressed by URI — files, database rows, generated documents) and resource templates (parameterized URI patterns the LLM fills in). Reyn's chat surface mirrors the tools flow:
list_mcp_resources(server)/list_mcp_resource_templates(server)— discovery, unpermissioned (mirrorslist_mcp_tools; no Control IR op kind, nopermissions.mcpgate — resource metadata carries no more risk than a tool's name/description).read_mcp_resource(server, uri)— reads one resource's contents. This one IS gated: it's amcp_read_resourceControl IR op, requiring the samepermissions.mcp: [server_name]grant as a tool call, because a resource's contents are external, potentially sensitive server-authored data — the same reasoningcall_mcp_toolalready applies to a tool result. Every read emitsmcp_resource_readbefore,mcp_resource_read_completed(or_failed) after.
Both list and read are additionally gated by the server's negotiated capabilities: a server that never advertised resources in its initialize handshake fails fast with a clear error (require_capability in reyn/mcp/client.py) instead of a raw protocol error.
Resource subscriptions: the async push event-source¶
resources/subscribe is a state-sync/watch mechanism, not a message queue: subscribe to a URI, and the server pushes a thin notifications/resources/updated {uri} signal (no content) whenever that resource changes — the client re-reads (read_mcp_resource) to see what changed.
subscribe_mcp_resource(server, uri)/unsubscribe_mcp_resource(server, uri)— both gated the same wayread_mcp_resourceis (mcp_subscribe_resource/mcp_unsubscribe_resourceControl IR ops,permissions.mcp: [server_name]), plus an ADDITIONAL gate on the server's negotiatedresources.subscribesub-capability — a server can advertiseresources(list/read work) without advertisingsubscribe(e.g. every server built with FastMCP's high-levelFastMCP()class today — the underlying SDK hard-codesresources.subscribe=Falseregardless of what handlers a FastMCP server registers).- Persistent connection required. A subscription only makes sense on a held (session-lifetime) connection — the subscribed-URI set lives in-memory on
MCPConnectionService(runtime-only; a subscription carries no data of its own, so it's fully re-establishable, never WAL'd). An ephemeral chat session refuses both ops with a clear error rather than accept a subscription that dies the instant its one-shot connection closes. - Survives a transport-death reconnect. A dropped connection (subprocess death, broken HTTP) re-opens a fresh MCP session with no memory of prior subscriptions;
MCPConnectionServiceautomatically re-issues every tracked subscription against the fresh connection, so a subscription set up before a drop keeps delivering pushes after reyn heals the connection. - The push lands on the EventLog, not in a tool result.
mcp_resource_updated(server,uri,resync) is emitted asynchronously whenever the notification arrives — independent of any Control IR op call. It is also wired into the hook dispatcher as an external-event hook-point, so a hook can react to it directly — see Hooks § External-event points — while the EventLog remains an audit-trail signal a workflow author can read back independent of any hook. list_mcp_subscriptions()(#4686) — discovery, unpermissioned (mirrorslist_mcp_servers; no Control IR op kind, nopermissions.mcpgate — reports session-local tracking state, never touches the network). One entry per HELD connection with at least one subscribed URI:{server, mode: "legacy" | "listen" | None, uris, unhonored}.urisis the REQUESTED set this session is trying to maintain, not the server-confirmed set — a URI the server declined (unhonored) stays in the list rather than disappearing, andunhonoredisNonewhen honored-ness can't be determined at all (every Legacy connection today;resources/subscribepredates any per-URI ack). Deliberately per-connection, never aggregated across servers — the TUI's MCP pane (Menu → mcp) reads the SAMEMCPConnectionService.subscription_summary()this tool calls, rendering it as an indented URI row under each server row (· subscribed/· unconfirmed/· not honored), so the operator-visible and LLM-visible views can't drift.
Prompts: list + get¶
Alongside tools and resources, a server can expose prompts (named, server-authored prompt templates the LLM can render with arguments). Reyn's chat surface mirrors the resources flow exactly:
list_mcp_prompts(server)— discovery, unpermissioned (mirrorslist_mcp_resources/list_mcp_tools; no Control IR op kind, nopermissions.mcpgate). Returns each prompt'sname+description+argumentsschema.get_mcp_prompt(server, name, arguments?)— fetches one rendered prompt's messages. This one IS gated: it's amcp_get_promptControl IR op, requiring the samepermissions.mcp: [server_name]grant as a tool call / resource read, because a rendered prompt's messages are external, potentially sensitive server-authored content. Every get emitsmcp_prompt_getbefore,mcp_prompt_get_completed(or_failed) after.
Both list and get are additionally gated by the server's negotiated capabilities: a server that never advertised prompts in its initialize handshake fails fast with a clear error (require_capability in reyn/mcp/client.py) instead of a raw protocol error.
Prompts have no subscribe concept — MCP defines no per-prompt push notification (only the coarser notifications/prompts/list_changed, already bridged to an mcp_prompt_list_changed EventLog event); there is no subscribe_mcp_prompt.
Elicitation: structured input requests from a server¶
A server can ask the user a flat/primitive-schema question through reyn's own consent path — the server issues an elicitation/create request, and reyn turns it into a user intervention prompt.
- Server attribution. The prompt always names the asking server and states this is not reyn (e.g.
⚠️ MCP server 'github' asks (this is NOT reyn): ...); individual field prompts carry the same[MCP server '<name>']prefix. - One prompt for a single closed-set field. A question that resolves to a single yes/no or enum choice (e.g. a
confirm) is shown as ONE prompt — the attributed banner, whose choices are the answer plus an explicitdecline. It is not split into a separate accept/decline gate followed by a redundant value prompt. Multi-field questions still show the accept/decline gate first, then one prompt per field; a single free-text field also keeps its gate. - Sensitive-field warning. A field whose name or description contains
password,token,key,secret, orcredentialgets an extra confirmation step first, stating explicitly that the answer will be sent to the server and that reyn never autofills it from env vars or stored secrets. - No autofill. Every answer is human-typed — this path never reads env vars or the secrets store to prefill a field.
- Configuration (per server, under
mcp.servers.<name>):elicitation(prompt(default) |auto_decline),elicitation_timeout_seconds(default 120). - Semantics: a timeout returns
cancel; an explicit human decline, anauto_decline-configured server, or a headless context (no live listener) all returndecline. - Audit records field keys only, never values. The
mcp_elicitation_requested/_answered/_timed_out/_auto_declinedevents carry only the requested schema's property names (e.g.field_keys: ["reason", "priority"]) — never the human's typed answer.
Transport choice (stdio vs HTTP vs SSE)¶
Most official MCP servers are local processes you launch over stdio. A few hosted services expose HTTP endpoints, or the streaming SSE variant.
| Transport | When | How reyn launches it |
|---|---|---|
stdio |
Local CLI server (most official servers — filesystem, git, github, fetch) |
Spawns command with args and env; speaks JSON-RPC over stdin/stdout |
http |
Hosted service (your own backend, an org-internal tool registry) | POSTs to url with headers; reuses one session per run |
sse |
Streaming HTTP variant; rare | Same as http plus an event stream |
Pick stdio for anything you npx or pip install locally. Pick http when the server is operated by someone else and you've been handed a URL.
Configuration¶
MCP servers are declared under mcp.servers: in reyn.yaml. Every entry has a type; the rest depends on the transport.
# reyn.yaml
mcp:
servers:
# stdio: local process, speaks JSON-RPC over stdin/stdout
filesystem:
type: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
env:
# Optional. ${VAR} expands from os.environ at startup.
FS_LOG_LEVEL: "info"
# http: hosted server, JSON-RPC over Streamable HTTP
internal_tools:
type: streamable-http
url: https://tools.example.internal/mcp
headers:
Authorization: "Bearer ${INTERNAL_TOOLS_TOKEN}"
| Field | stdio | http | Description |
|---|---|---|---|
type |
required | required | stdio | streamable-http | sse |
command |
required | — | Executable to spawn (e.g., npx, python, an absolute path) |
args |
optional | — | Argument list passed to command |
env |
optional | — | Extra environment variables for the spawned process |
url |
— | required | Endpoint URL |
headers |
— | optional | Static headers; values support ${VAR} expansion |
${VAR} expansion resolves from os.environ (which is pre-loaded from ~/.reyn/secrets.env at startup — see Concepts: secret handling). Missing variables expand to "" and emit a warning — never a hard error, so a missing optional token doesn't crash the run.
The ${VAR} syntax works in all YAML string fields, not just mcp.servers. This means models.<name>.api_key, litellm.api_base, and future fields all use the same mechanism. See Reference: reyn.yaml — ${VAR} interpolation for the full picture.
API keys and tokens belong in ~/.reyn/secrets.env (managed via reyn secret set), referenced as ${VAR} in reyn.yaml — never as literal values inline. See Concepts: secret handling.
OAuth¶
For a server that requires OAuth 2.1 rather than a static bearer token, add
auth to its http-transport entry — OAuth is only supported over
Streamable HTTP; stdio/sse servers reject an auth key outright.
mcp:
servers:
hosted_tool:
type: streamable-http
url: https://tools.example.com/mcp
auth: oauth # shorthand for {type: oauth}
# or the long form, when you need scopes / a specific client:
# auth:
# type: oauth
# scopes: [read, write]
# client_id: ${HOSTED_TOOL_CLIENT_ID}
# client_secret: ${HOSTED_TOOL_CLIENT_SECRET}
- First auth is interactive: reyn opens a browser and a localhost callback server to complete the authorization code flow. A headless run (no interactive session, no cached token yet) fails with a clear error instead of hanging on a browser round-trip nobody can complete — run reyn interactively once against the server first.
- Tokens are cached in
~/.reyn/oauth_tokens.json(mode0600, per-server) — the same store reyn's device-grant OAuth already uses, in the "outside" bucket of the.reyn/layout: operator/user-owned, never written through a WAL-emitting op, never captured by rewind/PITR. Once cached, subsequent runs — including headless ones — reuse the token without a browser round-trip. - Static bearer auth is unaffected: a server that just needs
headers: {Authorization: "Bearer ${TOKEN}"}keeps working exactly as before —authis only for the OAuth 2.1 flow.
Mid-session server refresh (refresh_mcp_servers)¶
Session.refresh_mcp_servers (FP-0037 #164) lets a running chat session pick up an MCP server that was installed (mcp_install) or reconfigured during the session, without waiting for reyn mcp refresh or a config-file mtime advance to be noticed passively. Use cases: chat turns that install a new MCP server and want it usable within the same session, and tests that change MCP config mid-test.
Roster re-read is required, not optional (#2372). The LLM-facing tool enumeration (_get_mcp_servers_for_router → _mcp_servers_flat) gates on the server roster, which is otherwise frozen at Session construction (self._mcp_servers → the router adapter's copy). Refreshing only the tools cache is insufficient: a server installed mid-session writes the IN-set .reyn/config/mcp.yaml, but without a roster re-read there is no roster entry for that server's tools to attach to, so they are never enumerated to the LLM — no matter how many times the tool-probe chain below runs. refresh_mcp_servers therefore re-reads the config cascade via load_config FIRST (which merges the IN-set dynamic_mcp layer), and performs a multi-holder swap: both the Session's own self._mcp_servers field and the router adapter's roster are updated (mirrors _reapply_per_agent_capability), since the LLM-facing enumeration reads the adapter's copy. The re-read is best-effort — a failure logs a warning and keeps the old roster rather than breaking the refresh chain.
Cache-swap detection compares content, not identity. After the roster re-read, the method chains yaml-mtime re-probe → disk-reload → lazy first-call probe, then compares a snapshot of the tools cache taken before the chain against one taken after to compute the returned "refreshed" flag. It compares the two snapshots' content (snapshot_before != snapshot_after), not id(). The underlying adapter replaces _mcp_tools_cache with a new dict object on every reload/probe, and mcp_tools_cache_snapshot returns a fresh copy each time it is read — so id(snapshot_before) != id(snapshot_after) is true on literally every call, refresh or not. An identity comparison would therefore report refreshed=True unconditionally, making the flag meaningless; the content comparison is what makes it actually reflect whether the visible cache changed.
Security model¶
MCP operations are gated at two points:
Install-time gate: file.write + http.get¶
Before any MCP server can be added to the configuration, the install op's writes go through the OS's standard list-axis gates. The legacy permissions.mcp_install: ask | allow | deny bool axis was removed in the collapse arc — install gating now flows through:
file.writeon.reyn/config/mcp.yaml(= the canonical mutation target).startup_guardprompts the operator once per workflow+path; runtime is silent after approval.http.getonregistry.modelcontextprotocol.io(= the registry fetch). Same prompt model.secret.writeon the env-var keys the registry declares asisSecret(= wildcard"*"because the key set is runtime-determined).
Enterprise teams point reyn at private / corporate registries via either of two equivalent mechanisms:
A. reyn.yaml mcp.registries: list config — declarative, project-scoped, version-controlled:
# reyn.yaml (project scope — committed to git)
mcp:
registries:
- https://mcp-registry.internal.acme.com # private registry (tried first)
- https://registry.modelcontextprotocol.io # public fallback
permissions:
web.fetch: allow # blanket allow for registry fetches
file.write: allow # blanket approval for .reyn/config/mcp.yaml writes
B. REYN_MCP_REGISTRY_URLS (plural) env var — explicit operator override, useful for CI / per-shell config:
# operator's shell rc / systemd unit / CI runner env
export REYN_MCP_REGISTRY_URLS="https://mcp-registry.internal.acme.com,https://registry.modelcontextprotocol.io"
The env var wins when both are set (= explicit operator override beats declarative config). The legacy singular REYN_MCP_REGISTRY_URL is honored as a one-item list for backward compat.
Both the async op-handler client (reyn.core.registry.client) and the safe-mode skill-internal lookup (reyn.api.safe.mcp.registry) iterate the list in order with the following fallback semantics:
| Operation | Behavior |
|---|---|
lookup(server_id) |
Try each URL in order; first non-404 hit wins; all 404 → None; non-404 error after 404 re-raises |
search(query) |
Try each URL in order; first non-empty result wins; all empty → [] |
This implements the "private first, public fallback" pattern: servers from the private registry shadow same-named public entries, and the public registry serves as a discoverability fallback for unrelated names.
See Concepts: permission model → "Collapse arc" for the full migration story.
Legacy
permissions.mcp_install: ...keys in olderreyn.yamlfiles are accepted with aDeprecationWarningand translate to the equivalent gates during the migration window.
Pre-commit probe gate: permissions.mcp (#3552)¶
A chat-driven install with a live per-session reloader does not just write config — it PROBES the server first (spawn/connect + list_tools) so a failed/cancelled/denied probe leaves .reyn/config/mcp.yaml unchanged (probe-then-commit; see the CLI reference → "Persistence asymmetry"). That probe is a LIVE connection to a model/plugin-supplied server name, made before the config becomes authoritative — before #3552, file.write + http.get above were the only gates on the install, and neither is the MCP axis, so the probe reached the network with no MCP-axis check at all.
The probe now calls require_mcp FIRST — the exact same gate (interactive "Allow access to MCP server X?" approval, persisted the same way in .reyn/approvals.yaml, plus any per-session ContextualLayer narrowing) documented below under "Runtime gate", applied to the server name about to be probed rather than one already configured. So a network reach to an as-yet-unapproved server can no longer happen at either time — pre-commit probe or post-install use — without crossing this same gate. A deny raises a decision-enabling PermissionError (naming the server and how to grant it), surfaced as status:"denied" (the same shape a permission denial anywhere else in op_runtime produces), never a silent skip.
Runtime gate: permissions.mcp¶
MCP tool calls cross two checks before they leave the process:
- Phase declaration. A phase MUST list each server it intends to use under
permissions.mcpin its frontmatter. The runtime callsrequire_mcp(decl, server, ...); ifserver not in decl.mcp, the call fails with a clear error pointing at the missing declaration. - Approval. Like every other capability, the first invocation per workflow prompts (
y/j/r/N). Persistent approvals land in.reyn/approvals.yamlkeyed by<skill>/mcp.<server>. Pre-approve project-wide withpermissions.mcp: allowinreyn.yamlif you trust the project broadly, or grant one server at a time withpermissions.mcp: {<server>: allow}— see the permissions reference → "Granting an MCP server permission" for the full per-server-vs-approvals-file breakdown. A denied call's error names the server and points at both grant routes directly.
This matches reyn's general permission model — see ../runtime/permission-model.md. One skill's MCP approval doesn't leak to another skill, and a sub-skill invoked via run_skill has to ask for its own permissions.
reyn pipe run (a one-shot, non-interactive CLI command with no one to answer the approval prompt above) auto-grants permissions.mcp for every server already present in the merged MCP config — an operator who configured a server AND explicitly ran the pipeline is trusted for that invocation. An unconfigured server still denies; the gate itself, and reyn chat's own interactive prompt, are unchanged. Full detail: permissions.md → "Granting an MCP server permission".
Three audit events are emitted per call:
| Event | When | Payload |
|---|---|---|
mcp_called |
Before the request leaves the process | server, tool, args |
mcp_completed |
On normal return | server, tool, is_error |
mcp_failed |
On transport / protocol error | server, tool, error |
Filter for them with reyn events tail | grep mcp_ or grep '"mcp_called"' .reyn/events.jsonl.
Skills that use MCP¶
A skill declares permissions.mcp: [<server>] in the phase, emits mcp ops with tool: <name> (or whatever the server advertises), and lets the OS handle the rest. See the how-to for a full quickstart on authoring your own MCP-backed skill.
Role 2: MCP server — external clients call Reyn¶
When you run reyn mcp serve, Reyn becomes an MCP server. External MCP-aware clients — Claude Code, Cursor, OpenAI Agents SDK, or anything that speaks the MCP protocol — can then submit messages to your Reyn agents as if they were just another MCP tool.
Starting the server¶
--project points at the directory containing reyn.yaml. Because MCP clients typically spawn the server process with cwd=/, this flag is required in most client configs — the server has no other way to locate your project. --timeout (default 60 s) controls how long send_to_agent blocks before returning a partial reply; the agent keeps working in the background.
The server speaks JSON-RPC over stdio. There is no port. The MCP client launches the process itself and owns the transport.
Tools exposed¶
Two tools are registered:
| Tool | Signature | What it does |
|---|---|---|
list_agents |
() |
Returns a JSON array of {name, role} objects — one entry per agent declared in reyn.yaml. |
send_to_agent |
(agent_name, message) |
Submits one message to the named agent as a conversational turn and blocks (up to --timeout seconds) for the final reply text. Returns {reply, partial, agent}. If partial=true, the agent is still working; call again to receive more. |
Multi-turn continuity is preserved: each agent's Session keeps its history.jsonl between calls, so a conversation that starts in Claude Code can be resumed from reyn chat — or vice versa.
message is content the model reads, never an operator command¶
A send_to_agent message rides the external_message inbox kind: the OS's record that a counterparty outside this process wrote it, which is what it is. It is not the user kind, which means "a human typed this at a first-party client" and is the only kind Reyn interprets as an operator command line.
The visible consequence: Reyn's slash commands are not available over MCP. A message reading /reset, /model, /visibility or any other registered command is read by the LLM as text; nothing is executed.
This is a decision, not an omission (#3595 step 1b, owner ruling 2026-08-01: 「現時点では slash に公開不要」 — "no need to expose slash at present"). Until that arc, send_to_agent claimed the user kind, so any MCP client could run any registered slash command — including reset, rewind and plugin — by sending one as its message. If Reyn ever does expose slash to a peer, the route is a shared client-side slash layer, not a transport re-inspecting the message for a leading /.
The same holds for the A2A router, which shares this implementation, and for chat-gateway plugins — see the gateway authoring guide.
Progress notifications¶
send_to_agent blocks for the whole turn, which can be a long time. A client that sets _meta.progressToken on the call receives a notifications/progress for each of these audit-events as the turn runs:
| Audit-event | Progress message |
|---|---|
turn_started |
turn: <inbox trigger kind> — turn: external_message for a send_to_agent call (#3595 step 1b: an MCP peer's message is not the operator's typing, so it does not claim the user kind) |
llm_called |
llm: <model> |
tool_returned |
tool: <name> |
tool_failed |
tool: <name> (failed) |
progress is a monotonic ordinal (1, 2, 3, …) and total is always absent — the turn's length is not known in advance, so clients should render a counter or a spinner rather than a percentage. Only the audit-event kind and the line above are sent; a tool's arguments and its result body never leave the process on this channel.
Delivery is best-effort: if the notification cannot be pushed, the turn continues and the final send_to_agent reply is unaffected. The server advertises this surface as the experimental reyn.progress.skill_lifecycle capability in its initialize response, whose events field lists exactly the kinds above.
What "via MCP" means for your workflows¶
External clients see agents, not the workflow graph. From the outside, there are only two operations: list agents and send a message. The OS contract still applies on Reyn's side: permissions are checked, events are emitted, and all the normal validation runs. Workflows can be approved non-interactively if permissions: allow is set in reyn.yaml (the MCP server runs without a human at stdin, so interactive prompts would block indefinitely).
This is part of Reyn's "talks-out + talked-to" multi-agent surface. See ../multi-agent/multi-agent.md for how agents relate to each other within a single Reyn process.
What MCP is NOT for¶
MCP is the right tool for external capability access. Don't reach for it when:
- You need heavy compute. Use a Python preprocessor (
pythonop). MCP calls cross a process boundary on every invocation; an inline NumPy step is much faster. - You're encoding a reusable workflow. That's a skill, not an MCP server. Use
skill_builderto author a new skill, not a new MCP tool. - You want cross-agent messaging. Use
messages_to_agentsand topology rules. MCP doesn't model agent identity or chains. - You need state across invocations. MCP servers can be stateless or stateful, but reyn treats each call as independent. Persistent state belongs in the workspace.
If you find yourself wishing MCP could do one of these, you're at the wrong layer.
See also¶
- Reference:
reyn mcp— full CLI reference forsearch,install,list,remove,set-secret,clear-secret - Reference:
reyn secret— universal secret management - Concepts: secret handling —
~/.reyn/secrets.envand${VAR}interpolation - Reference:
reyn.yaml— fullmcp.servers:schema - Concepts: permission model —
file.write/http.get/permissions.mcpand the collapse arc - Concepts: hooks — the
mcp_resource_updatedexternal-event hook-point - modelcontextprotocol.io — the spec, server registry, official SDKs