MCP inspector plugin
Use the MCP plugin to test an MCP server you build or operate. A flow connects to the server as one identity, reads its complete tool, resource, prompt and resource-template inventories, calls tools and asserts what came back, including JSON-RPC refusals. Tool inventories keep annotation hints and hashes of each tool’s input schema and description, so two runs show which tools were added, removed or changed.
This page is about the plugin that inspects your MCP server. To connect a coding agent to Reflow’s own MCP server, see MCP reference.
The MCP plugin is bundled with the public CLI. It is a single native executable and needs no Node or other runtime. It runs wherever the CLI runs (currently macOS on Apple silicon). Only OAuth sign-in also needs the Browser plugin.
Requirements
Section titled “Requirements”- The Reflow CLI installed and signed in to an approved team. See Get started.
- For OAuth sign-in only: a
reflow applyrun on your device with the Browser plugin in the same flow. - A server that speaks MCP over Streamable HTTP, at an
httporhttpsendpoint (httpsfor OAuth sign-in). Other transports are not supported (see limits). - The endpoint’s origin must be admitted by the host. The binding cannot widen
network access: the plugin may reach only the
reflow targetURL’s origin and any origins you grant with--allow-origin. With OAuth, also grant the authorization server’s origin when it differs from the endpoint’s origin. - A test identity for the server: a bearer token, private headers such as an API key, an OAuth client, or none for a public server.
Connect
Section titled “Connect”Select your application collection during onboarding. On an application’s first initialization, this installs all seven bundled plugins (Browser, LocalBash, PostgreSQL, DockerUbuntu, Mailbox, MCP and OpenAPI):
reflow init --collection <collection-id>An application that already has a plugin selection keeps it, and naming a
different set without --upgrade is refused. To add MCP to an existing
selection, stop active application executions, then pass the complete desired
set with --upgrade:
reflow init Browser LocalBash PostgreSQL DockerUbuntu Mailbox MCP OpenAPI --collection <collection-id> --upgrade--upgrade also moves every other listed plugin to this CLI’s bundled release,
so steps whose plugin changed run again. Check reflow plan before applying.
See one selection per application for how an upgrade applies across devices and branches. The examples on this page also use other plugins: OAuth sign-in needs Browser, and tokens minted during the run uses LocalBash. Keep them in the selection.
Save a private bindings.json. The flow alias api selects the connection with
the same name:
{ "api": { "provider": "MCP", "configuration": { "endpoint": "https://mcp.example.com/mcp", "bearerToken": "replace-with-test-token", "timeoutMs": 15000 } }}Select the endpoint as the target, so its origin is admitted:
chmod 600 /absolute/path/to/bindings.jsonreflow target https://mcp.example.com/mcp --group mcp-tests --collection <collection-id> --bindings /absolute/path/to/bindings.jsonAdd --allow-origin https://auth.example.com for each other origin the plugin
must reach, such as an OAuth issuer on a different host. See
auth redirects and navigation origins
for how grants are kept and replaced.
| Field | Required | Meaning |
|---|---|---|
endpoint | yes | Absolute HTTP or HTTPS Streamable HTTP endpoint, without user information or fragment. |
bearerToken | no | Sent as Authorization: Bearer. Cannot be combined with oauth. |
headers | no | Private request headers as a JSON object string, for example an API-key or session header. Authorization must use the Bearer scheme; Cookie and other reserved headers are refused. |
oauth | no | Private OAuth settings as a JSON object string. See OAuth sign-in. |
tokenDirectory | no | Private directory for per-run tokens. Cannot be combined with oauth. |
caFile | no | Absolute path to a PEM certificate authority added to system trust (up to 256 KiB). |
sessionCleanup | no | delete (default) or server-managed. See session cleanup. |
timeoutMs | no | Deadline in milliseconds for each request, including a streamed tool reply (default 15000, 100 to 600000). |
maxResponseBytes | no | Largest HTTP response and raw inventory (default and maximum 8 MiB). |
maxPages, maxItems | no | Inventory paging limits (defaults 50 and 5000). |
Without any credential the plugin connects anonymously. Use that for a public
server, or to assert that the server refuses an unauthenticated client with
anon.connect expect="{\"status\":401}". Give that check its own alias and
binding without credentials, such as anon: after a refused connection, later
commands on the same alias are refused.
Endpoint URLs, header values, tokens and session IDs are redacted from snapshots, artifacts and text before it is hashed. Keep them in the private bindings file, never in a flow. Binary resource contents (blobs) cannot be redacted: they are hashed as received, so if a blob embeds a private value, its hash can confirm a guess of that value.
Write a flow
Section titled “Write a flow”Copy this into a private draft outside your checkout, such as
/tmp/mcp-tools.md. Replace the tool names and arguments with your server’s, and point it
only at a test server: api.call really calls the tool.
---name: Inspect the support MCP serverproviders: api: MCP---
### Check what this identity can use
```rflapi.connectapi.tools expect="{\"present\":[\"search_tickets\"],\"absent\":[\"delete_workspace\"],\"noTrueAnnotations\":[\"destructive\"]}"api.call tool="search_tickets" arguments="{\"query\":\"refund\"}" expect="{\"isError\":false}"api.call tool="delete_workspace" arguments="{}" expect="{\"rpcError\":-32602}"api.disconnect```The last call asserts that the server refuses a tool this identity must not
use. Use the code your server actually returns: -32602 (invalid params) is
common for an unknown tool, but some servers use another code, and many return a
tool result with isError: true instead, which you assert with
expect="{\"isError\":true}". If the identity might be allowed to call the tool,
the call runs it, so assert refusals only with tools that are harmless on your
test server.
Create the test in the team’s chosen location, then run it:
reflow flows create --name mcp-tools --file /tmp/mcp-tools.mdreflow plan mcp-toolsreflow apply mcp-toolsreflow dashboardCommands
Section titled “Commands”The Effect column is the command’s declared effect.
A mutate command may change the server, so a reply that was lost or ambiguous
is recorded as unknown and is never replayed automatically.
| Command | Effect | What it does |
|---|---|---|
api.connect | observe | Initialize a session, or assert the HTTP status of a refused initialization with the status expectation. |
api.tools | observe | List every tool with annotation hints and schema and description hashes. |
api.resources | observe | List every resource and assert its URI set. |
api.resource_templates | observe | List every resource template and assert its URI template set. |
api.prompts | observe | List every prompt with its argument names. |
api.read_resource | observe | Read one resource (uri) and assert its text or a JSON-RPC refusal. |
api.get_prompt | observe | Render one prompt (name, optional arguments) and assert its messages or a refusal. |
api.call | mutate | Call one tool (tool, optional arguments) once and assert its result or a refusal. |
api.call_value | mutate | Call once and capture a public scalar selected by an RFC 6901 JSON Pointer. |
api.servers | mutate | Call a gateway’s list_servers tool and assert the exact server slug set. |
api.describe_server | mutate | Call a gateway’s describe_server tool and assert one member server’s tool names. |
api.describe_tools | mutate | Call a gateway’s describe_tools tool and assert the exact tool name set. |
api.disconnect | mutate | End the session with at most one DELETE and record the outcome. |
api.oauth_begin | mutate | Start one private OAuth attempt and open the consent page in the host’s Browser. |
api.oauth_finish | mutate | Wait for consent, exchange the code once and install the token in this binding. |
arguments and expect are JSON object strings, so quotes inside them are
escaped. Both may contain ${name} references to string inputs and captures, but
only inside a JSON string value, where the value is inserted as escaped text; see
references in JSON arguments. Every command except oauth_begin and oauth_finish also accepts
path and tokenFile (below); oauth_begin accepts path, and oauth_finish
takes no arguments. Use list_plugins or the application’s Plugins page to see
the exact argument schema of the release your application selected.
api.servers, api.describe_server and api.describe_tools are for MCP
gateways: servers that front several member servers and expose tools named
list_servers, describe_server and describe_tools. They call that tool (or
the one named by tool) and read the returned names, from structured content or
JSON text. Pass the member server for describe_server in arguments, for
example {"server":"billing"}. Skip them for an ordinary MCP server.
Calls and the gateway helpers always count as mutations, whatever annotations a tool declares. Annotations describe a tool; they do not restrict what a call does.
Assertions
Section titled “Assertions”expect accepts only the keys below; any other key is refused.
| Commands | Keys |
|---|---|
tools, resources, resource_templates, prompts | present, absent, only (exact set), count; tools also noTrueAnnotations (readOnly, destructive, idempotent, openWorld) |
call | rpcError (JSON-RPC error code), isError (tool result flag), textIncludes, textExcludes, redacted, json |
call_value | textIncludes, textExcludes, redacted, json; also requires a non-error result and a valid scalar selection |
servers, describe_server, describe_tools | present, absent, only, plus the call keys |
read_resource, get_prompt | rpcError, textIncludes, textExcludes, redacted, json, count |
connect | status (HTTP status, 100-599) |
rpcError matches a JSON-RPC refusal from the server, which is different from
a tool result with isError: true. Without expect, call and the gateway
helpers succeed only on a tool result with isError: false: a result with
isError: true or any JSON-RPC refusal fails the step. textIncludes searches the complete
redacted text before truncation. An inventory that could not be read completely
never satisfies an expectation.
textExcludes searches every decoded string in the complete result or error,
including structured content, metadata and embedded resources, before truncation.
It accepts 1–64 exact, case-sensitive strings. Binding private values have already
been redacted, so use "redacted": false to assert that no binding secret appeared.
Pair absence checks with "isError": false when you need a successful tool result.
Operands containing private values or terminal controls are refused before contact.
Gateway present and absent check the complete extracted name set. Unreadable
names, protocol errors, tool errors and failed member servers fail
gateway_names_unavailable; they cannot satisfy absence by returning no names.
Repeated or uncheckable JSON members fail closed for these reply checks.
For exact JSON assertions and capture examples, see MCP JSON captures and assertions.
Public connect headers and deadlines
Section titled “Public connect headers and deadlines”connect accepts public routing headers as a JSON object string:
api.connect headers="{\"X-Region\":\"eu\",\"X_Tenant\":\"test-tenant\"}"Put it before other session commands. Headers are sent on every MCP request in
this instance, including its GET stream and DELETE, and never to OAuth servers.
Reserved protocol or credential headers, private binding header names and private
values are refused. At most 32 names and 32,768 characters are accepted; each
value is 1–16,384 characters. Go canonicalizes names (x_tenant becomes X_tenant).
Evidence records sorted names, without values. Use private binding headers for credentials.
The binding’s timeoutMs defaults to 15,000. For a 75-second call, select a
binding with timeoutMs: 120000 and give the flow enough overall time, for
example budgets.minutes: 15. Keepalives do not extend the deadline. There is
no per-call timeout argument; a lost or ambiguous reply is never retried.
Results and evidence
Section titled “Results and evidence”Each step records a snapshot with status succeeded, failed or unknown, and
an observation.json artifact with the same state.
- Inventories are sorted by name, URI or URI template. A tool record keeps its annotation hints
(a missing hint is
null, neverfalse) and SHA-256 hashes of its input schema and description, not their text. - A call records one of: a tool result (
kind: "result"withisError, content types, redacted text truncated to 16,384 characters, a hash of structured content and failed expectations), a JSON-RPC refusal (kind: "rpc-error"with the code and redacted message), a confirmed reply that could not be observed safely (kind: "unavailable"), or a lost or ambiguous reply (kind: "unknown"). - An unknown call may have run, and the plugin never retries it. A lost or
ambiguous reply to any request, or an invalid inventory page, closes the
connection: later inspection commands in that binding fail, but
api.disconnectstill sends its one session DELETE. A single reply with invalid content tocall,read_resourceorget_promptfails only that step.
Comparing two successful tools snapshots reports added, removed and changed
tools, with the changed fields (annotation hints, schema hash, description
hash, and whether private values were redacted), and retains a tool-diff.json
artifact. See
snapshots and review.
Tokens minted during the run
Section titled “Tokens minted during the run”When an earlier step creates the credential, such as an API key for a test agent, the token must not travel as a flow value: captures and command arguments are retained evidence. Instead:
- Set
tokenDirectoryin the binding to an absolute directory with mode0700or stricter, owned by the user that runs Reflow. Dedicate it to run tokens: a flow may name any file in it. The example below keeps it inside the LocalBash working directory, created once withmkdir -m 700 .reflow-run-tokensand listed in.gitignore. - Have an earlier step write the token to a
0600file there without printing it. - Pass only the file name as
tokenFile.
The bindings give the minting step a LocalBash working directory and the MCP connection its token directory:
{ "shell": { "provider": "LocalBash", "configuration": { "cwd": "/absolute/path/to/your/app" } }, "api": { "provider": "MCP", "configuration": { "endpoint": "https://mcp.example.com/mcp", "tokenDirectory": "/absolute/path/to/your/app/.reflow-run-tokens" } }}---name: Inspect with a fresh agent keyproviders: shell: LocalBash api: MCP---
### Mint a key and inspect
```rflshell.bash command="umask 077 && ./scripts/mint-test-agent-key > .reflow-run-tokens/agent-key"api.connect tokenFile="agent-key"api.tools expect="{\"only\":[\"check_health\"]}"api.disconnect```The flow names the file relative to the LocalBash working directory, so no machine-specific path is written into it. Reflow redacts binding values, including both absolute paths, from run evidence, but a flow is stored and shared as written: keep absolute paths in the private bindings file.
The plugin reads the file once, before contacting the server, and never records
the token. The token takes precedence over the binding’s bearerToken or
Authorization header. The file name must be 1 to 128 letters, digits, dots,
underscores or hyphens. The file must be a regular file owned by you with
mode 0600 or stricter and a single link, at most 16 KiB, and hold one printable token of at least 8
characters. Name tokenFile on the alias’s first command: that command fixes
the file name, so naming one only later, or a different one, is refused. The writing step and the plugin must
run on the same device as the same user. If the writing step is reused instead
of run, the file is not written again: rerun that step.
Per-run servers and paths
Section titled “Per-run servers and paths”For servers created during a run, bind their common endpoint prefix and pass
each server’s slug as path on the alias’s first command, for example
api.connect path="support". That command fixes the path for the alias; a
different path later is refused.
path is refused when the bound endpoint has a query string. Evidence does not
depend on the path, so per-run slugs compare equal.
Session cleanup
Section titled “Session cleanup”api.disconnect sends at most one session DELETE and records its outcome; it
sends none when the server never issued a session. Only a successful response
confirms cleanup; a lost or refused DELETE is reported as unconfirmed. The
DELETE gets at most 1.5 seconds whatever timeoutMs is, so a slow server’s
cleanup is reported as unconfirmed. After
api.disconnect, later inspection commands on that alias are refused; another
api.disconnect sends nothing more and reports the recorded outcome. Some
servers refuse client termination with 405 and expire sessions
themselves. For those, set sessionCleanup: "server-managed" so a 405 counts as
no cleanup needed, and end the flow with api.disconnect to record it.
OAuth sign-in
Section titled “OAuth sign-in”With an oauth binding, a flow signs in through the authorization server’s
consent page in the host’s Browser. The flow never receives the token, the
authorization code or the authorization URL.
OAuth sign-in needs:
- A
reflow applyrun on your device, which hosts the private consent channel between the MCP plugin and the Browser plugin. - A Browser alias in the same flow, using Chromium (required for OAuth consent).
- An HTTPS MCP endpoint, selected with
reflow target, and the authorization server’s origin granted with--allow-originwhen it differs from the endpoint’s origin. - A flow that runs in full. A flow that would be partly reused and partly rerun is refused; invalidate the whole flow and plan it again.
{ "api": { "provider": "MCP", "configuration": { "endpoint": "https://mcp.example.com/mcp", "oauth": "{\"issuer\":\"https://auth.example.com\",\"scopes\":[\"tools.read\"]}" } }, "site": { "provider": "Browser", "configuration": { "browser": "chromium" } }}The documented flow shape is oauth_begin, then your Browser steps that
complete consent, then oauth_finish. Between the two, only Browser commands
written into the flow and oauth_finish may run: prose steps for the agent
and scripts are refused, and inspection commands are refused until
oauth_finish succeeds. Adapt the Browser steps to your consent page:
providers: api: MCP site: Browserapi.oauth_beginsite.click selector="[data-testid=approve]"api.oauth_finishapi.tools expect="{\"present\":[\"search_tickets\"]}"issueris the trusted HTTPS issuer that the server’s protected-resource metadata must list, orendpointfor a server that is its own authorization server. Discovery reads protected-resource metadata (RFC 9728), then authorization-server metadata (RFC 8414 or OpenID Connect).- Without
clientId, the plugin registers a public client dynamically. WithclientId, also set the exact registeredcallbackPath(a path of letters, digits,/,_and-, not/done). The callback ishttp://127.0.0.1:<random port><callbackPath>, so register a loopback redirect that accepts any port on127.0.0.1. - The authorization server’s metadata must support PKCE S256 and response type
code, allow theauthorization_codegrant when it lists grant types, and listnoneintoken_endpoint_auth_methods_supported. Consent hasconsentTimeoutMs(default 120000). oauth_beginaccepts the optionalpath;oauth_finishtakes no arguments.- Dynamically registered clients remain at the issuer: the plugin does not deregister them. Tokens are not refreshed or revoked.
Reflow’s OAuth tests run against local fixture servers. Treat OAuth with your own authorization server as early support and check its evidence closely.
Limits
Section titled “Limits”- Streamable HTTP only. Sessions start with protocol
2025-11-25and accept2025-06-18,2025-03-26,2024-11-05and2024-10-07. Server-sent events in replies are handled; the plugin answers serverpingand refuses other server requests with-32601. - Not supported: launching stdio servers, the legacy HTTP+SSE transport, resuming a lost stream, automatic reconnection, resource subscriptions, token refresh or revocation, and OAuth client deregistration.
timeoutMsis an absolute deadline per request; keepalive comments do not extend it. OAuth requests use at most 60 seconds.- Snapshot state and each JSON artifact are limited to 7 MiB.