mcp-repl
An interactive terminal REPL for any MCP server. The server's surface IS the command set: every tool becomes a top-level command, prompts and resources get built-ins, tab completion is powered by the server itself where the protocol allows, and the command table refreshes live when the server's surface changes.
The editor is reedline (nushell's line editor): a columnar completion menu with per-candidate descriptions, live input highlighting, and fish-style history hints.
Run
# Against the bundled in-process demo router (no external server):
# Spawn any stdio MCP server as a child process:
# Connect to a streamable HTTP server:
# Opt into the final, sessionless 2026-07-28 lifecycle:
The binary compiles both stable and final protocol support. Runtime selection
is explicit: --protocol stable (the default) uses
initialize/notifications/initialized, while --protocol 2026-07-28
(--protocol final is an alias) uses server/discover and sends the selected
protocol metadata on every request. Keeping stable as the default means an
mcp-repl upgrade cannot silently change an existing server's lifecycle.
Try it against a live server
cratesio-mcp (an MCP server for the crates.io registry, also built on tower-mcp) runs a public instance:
cratesio-mcp> search_crates query=tower-mcp per_page=3
cratesio-mcp> get_crate_health name=serde
cratesio-mcp> read crates://tokio/info
cratesio-mcp> prompt analyze_crate crate_name=axum
Authenticated servers
Attach credentials to an --http connection:
# Bearer token. Prefer MCP_BEARER: a --bearer on the command line is visible
# in `ps` and shell history.
MCP_BEARER=""
# Arbitrary headers, repeatable (split on the first colon):
--bearer and --header apply only to HTTP connections; they are ignored
(with a warning) for the demo and stdio-child transports.
Profiles
A config file names servers so a connection is mcp-repl <name> instead of a
URL plus repeated auth flags, and tokens stay out of shell history. The file
lives at $XDG_CONFIG_HOME/mcp-repl/config.toml, falling back to
~/.config/mcp-repl/config.toml; --config <path> reads a different one.
[]
= "http" # http | stdio
= "https://cratesio-mcp.fly.dev/"
= "CRATESIO_TOKEN" # read the token from the environment
= { = "abc" }
[]
= "stdio"
= ["cargo", "run", "--example", "getting_started"]
transportis optional: a profile with aurlis HTTP, one with acommandis stdio. A profile with both must say which.- Explicit flags override profile fields.
--http <url>retargets the URL while keeping the profile's auth;--bearerreplaces the profile's token; each--headeroverrides the profile header of the same name. - The bare-name form only resolves when the single positional matches a
configured profile, so spawning a stdio server by bare name still works.
Because everything after the first positional belongs to the spawned
command, use
--server <name>when other flags follow. - Secrets:
bearer_envnames an environment variable holding the token. An unset variable is an error rather than a silent anonymous connection. An inlinebearer = "..."works but warns, since it puts the token in the file. - An unknown profile name errors with the list of known names, and a missing
--configfile is an error. A missing file at the default location is not: profiles are opt-in.
Reconnecting
A remote server that restarts, OOMs, or sits behind an edge returning 502/503
can interrupt a connection. On an --http connection the REPL notices this,
creates a fresh transport, repeats the selected stable or final handshake,
re-fetches the surface, and retries the command once. For stable servers this
also replaces the lost session; final connections are sessionless.
> search query=tower
[reconnected]
... results ...
The retry is bounded to a single attempt, so a server that is really down
fails fast with its original error rather than hanging the prompt. Task ids
do not survive a reconnect (they belong to the session that created them), so
task, wait, and cancel never trigger one.
Pass --no-reconnect to turn this off and see session-loss errors as they
arrive. stdio children and --demo are never reconnected: there, a lost
session means the server process itself is gone.
Aliases
Frequent commands get short names, kept in the same config file as the profiles:
cratesio> alias dl=get_downloads
dl = get_downloads (profile cratesio)
cratesio> dl crate=serde
...
cratesio> alias
dl get_downloads (profile cratesio)
t tools (global)
cratesio> unalias dl
removed dl (profile cratesio)
aliaslists what is in effect,alias <name>shows one,alias <name>=<expansion>defines, andunalias <name>removes.- Expansion is a literal substitution of the first word with whatever
followed the alias appended: with
dl = "get_downloads",dl crate=serderunsget_downloads crate=serde. An expansion that itself starts with an alias expands again; a cycle is reported rather than looped. - An expansion can end in
&, so an alias can run its tool task-augmented. - Scope: an alias defined while connected through a profile belongs to that
profile; otherwise it is global.
alias --global <name>=<expansion>forces the file-level table. A profile alias shadows a global one of the same name, andunaliasremoves the definition that is actually in effect (--globalreaches past a profile alias to the global one). - Aliases cannot be named after a built-in, since expansion happens before dispatch and the built-in would become unreachable. An alias that shadows a tool is allowed, and says so when defined.
- Every change is written back to the config file through
toml_edit, so comments, key order, and formatting elsewhere in the file survive. Removing the last alias leaves the (now empty) table, because a comment above[aliases]belongs to that table and would go with it.
[] # every server
= "tools"
[] # only through this profile
= "get_downloads"
With no config file location at all (no $HOME, no --config), aliases
still work for the session and the REPL says they were not saved.
What to try
getting-started> help # built-ins plus the server's tools
getting-started> add a=2 b=3 # tools are commands; args coerced by inputSchema
getting-started> echo message="hi there" # tab-completes argument names
getting-started> find note # keyword search across the surface
getting-started> describe add # input/output schemas, colored
getting-started> read source://getting_started.rs
getting-started> prompt greet name=World # prompt args tab-complete via completion/complete
getting-started> info # replay the startup banner (identity, instructions, counts) + capabilities
resources lists concrete resources and templates lists parameterized
({variable}) ones; each points at the other so a server that splits its
resources across the two MCP lists is not confusing.
Task-capable tools support shell-style backgrounding (SEP-2663):
demo> slow_add a=2 b=3 &
[task task-1] started
demo> jobs
task-1 slow_add working
demo> wait task-1
task task-1 status=completed
5
Progress and log notifications print inline as they arrive, and
list_changed notifications refresh the command table mid-session, so
dynamic servers (see the dynamic_capabilities example) grow and shrink the
REPL's vocabulary live.
bench
The [142ms] annotation answers "how slow was that call". bench answers
"how slow is this tool", which is the question behind a server sitting on a
network, a cold cache, or a rate limiter.
cratesio> bench get_downloads crate=serde --n 50
50 calls ok=50 err=0 min=88ms p50=104ms p95=190ms max=311ms
[5.42s]
cratesio> bench get_downloads crate=serde --n 50 --concurrency 8
50 calls ok=50 err=0 concurrency=8 min=91ms p50=127ms p95=402ms max=655ms
[892ms]
bench <tool> [k=v...] [--n N] [--concurrency C]. Arguments are coerced against the tool'sinputSchemaexactly as a direct call is, sobench <tool> a=1benchmarks the request<tool> a=1would send. Flags may appear anywhere after the tool name, in either spelling (--n 50,--n=50).--ndefaults to 20 and is capped at 100000;--concurrencydefaults to 1 (serial) and never exceeds--n. Workers pull from a shared counter, so one slow call does not leave a worker's remaining share queued behind it.- Percentiles are nearest-rank over the calls that succeeded, so every number reported is a latency that actually happened. Failures are counted separately, with the first message shown, rather than folded into the distribution: a fast rejection is not a fast call.
- A tool result with
isErrorcounts as a failure, and any failure makes the command exit non-zero, somcp-repl -e "bench <tool> --n 20" <server>works as a scripted health check. - Under
--json, an object withcalls,ok,errors,concurrency,firstError, andminMs/p50Ms/p95Ms/maxMs/totalMs. The latency fields arenullwhen nothing succeeded, so a failed run cannot be read as an instant one.
Resource subscriptions
A server that supports resources.subscribe will push
notifications/resources/updated for the resources you ask about. The REPL
prints those inline, the way progress and log lines arrive:
demo> subscribe note://status
subscribed note://status
demo> subscriptions
note://status
[resource updated] note://status
demo> unsubscribe note://status
unsubscribed note://status
subscribe <uri>andunsubscribe <uri>complete from the surface and from what is actually subscribed, respectively.- The local set is only updated once the server agrees, so
subscriptionslists what the server is sending updates for, not what was asked for. Re-subscribing to something already held says so rather than double-counting. - A server that does not advertise
resources.subscribegets a warning before the request goes out, so the rejection is explained rather than bare. - An update for something this session did not subscribe to is still printed,
tagged
(not subscribed here). - The resource is not re-read on an update: reading may be expensive, and the
point is to know it moved. Follow with
read <uri>when you want the content.
Wire tracing
Half of any "is it the client, the server, or the network?" question is
answered by the raw JSON-RPC frames. --trace prints every frame from the
start; wire on / wire off toggles it mid-session.
demo> wire on
wire tracing on (frames print to stderr)
demo> echo message=hi
[wire ->] +4.512s
{
"id": 6,
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "arguments": { "message": "hi" }, "name": "echo" }
}
[wire <-] +4.524s [12ms]
{
"id": 6,
"jsonrpc": "2.0",
"result": { "content": [ { "text": "hi", "type": "text" } ] }
}
Each frame carries its direction, a session-relative timestamp, and, on a response, the time its request was outstanding.
last reprints the previous request and its response whether or not tracing
was on: frames are always recorded, so the exchange you did not think to
trace is still there. Under --json it prints a
{"request": ..., "response": ...} object instead.
Frames print to stderr, so --json output on stdout stays pipeable with
tracing on.
Secrets are masked before a frame is stored, so nothing unmasked reaches the
trace or last: values under authorization, token, apiKey, secret,
password and similar keys (separators and case ignored), and anything
following Bearer inside a string. The HTTP Authorization header itself
never appears here, since it is not part of a JSON-RPC frame.
Completion
Tab opens a columnar menu. What gets completed:
- The command word: built-ins, aliases (shown with what they expand to), and every tool, each with its description.
- Tool argument names from the tool's
inputSchemaproperties (with type, required flag, and description), and enum values afterkey=when the property declares anenum. read <uri>: resource URIs and template URI templates. When the partial reaches a template's{variable}, the server'scompletion/completeis asked to complete the variable (2s timeout, best-effort). Tryread note://<Tab>in--demo.prompt <name> <arg>=: argument values viacompletion/complete, and argument names from the prompt definition.describe <name>: everything on the surface, labeled by kind.bench <tool> ...: tool names in the first position, then that tool's argument names, and--n/--concurrencyafter a leading-.unalias <name>: the aliases in effect, with their scope.
find
A server with dozens of tools is not navigable by listing it. find <keyword> searches names and descriptions across tools, prompts, resources,
and templates, grouped by kind:
cratesio> find download
tools:
get_downloads Get download statistics
get_version_downloads Daily download stats for a specific version
2 matches
Matching is case-insensitive. Results rank an exact name match first, then a
name prefix, then a name substring, then a description match, and last a
subsequence (gvd reaches get_version_downloads) so a loose match never
buries a literal one. The search runs against the cached surface, so it
issues no request.
Under --json it prints an array of {kind, name, description, score}
objects. A search that matched nothing exits non-zero, following grep.
A mistyped command word gets the nearest built-in, tool, or prompt name by edit distance:
cratesio> serch_crates query=serde
unknown command: serch_crates; did you mean `search_crates`?
The tolerance scales with the length of what you typed, so a short word does
not collect a suggestion from across the surface. When nothing is close
enough, the message points at help as before.
describe
describe <name> looks up a tool, prompt, resource, or template by name:
- Tools: behavior hints, task support, and the input/output schemas as syntax-colored JSON.
- Prompts: the argument table (name, required/optional, description).
- Resources and templates: URI, name, MIME type, size, and description.
Output rendering
- JSON output (schema dumps,
infocapabilities, non-text content) is pretty-printed with a small built-in syntax colorizer. - Text content that looks like markdown gets a light terminal rendering: bold headings, dimmed code fences, styled inline code and bold spans, colored bullets.
- Progress, log, and task lines are tagged with dim brackets; task statuses are colored (working=yellow, completed=green, failed/cancelled=red).
- Every tool call,
read, andpromptprints a dimmed[142ms]/[1.23s]annotation with the round-trip time, so a slow (or timing-out) call is visible at a glance.
All styling degrades to plain text when NO_COLOR is set or stdout is not
a terminal. --color always|never|auto overrides the detection.
Elicitation
Tools that request user input via elicitation/create prompt for each
field at the terminal during a foreground call: the field's type, default,
and description are shown, empty input accepts the default, and EOF
cancels. Try test_elicitation against the conformance server. If a
background task elicits while the editor owns the terminal, the request is
declined rather than fighting the editor for stdin.
One-shot / scripting
-e/--exec <COMMAND> runs a command and exits instead of opening the prompt.
Repeatable; commands run in order against the same session. The exit status is
non-zero if any command errored, so it drops into scripts and CI.
# One call, pretty output:
# Raw JSON for piping to jq (--json also silences the banner and timings):
|
# Several commands in one session:
The banner and surface listing are suppressed in --exec mode (pass
--verbose to keep them). --json applies to tool calls, read, prompt,
tools/prompts/resources/templates, and errors ({"error": "..."}).
Capture and filtering
The REPL is a small shell. A command's result can be captured into a variable, referenced in later arguments, or filtered inline.
demo> x = search_crates query=serde
$x = {2 fields}
demo> get_crate_info name=$x.crates[0].name
...
demo> get_crate_info name=serde | crates[0].downloads
11897234
- Capture:
name = <command>binds the command's result to$name. The spaces around=distinguish it from ak=vargument and fromalias name=.... - Reference:
$nameand$name.path[i].fieldexpand in later command arguments before the command runs. - Filter:
<command> | <path>prints just the selected value. A scalar prints bare; an object or array prints as JSON. - Paths are a small selector:
.field,[index], chained (crates[0].name). An undefined variable or a missing path is an error, so a typo fails an-echain rather than passing silently. JMESPath is a possible future addition. varslists what is bound;unset <name>clears one. Variables live for the session, so they persist across an-echain:
Capture and filtering currently act on tool-call results.
Related tools
- mcp-probe: a Rust TUI debugging toolkit for MCP servers (ratatui dashboard, protocol analysis, timing metrics, compliance checks). Complementary rather than overlapping: mcp-probe is a debugging platform you inspect a server with; mcp-repl is a shell you drive one from.
Notes
- Tab completion for prompt argument values and resource template variables
calls the server's
completion/complete, one of the least-exercised capabilities in the protocol. Servers that do not implement it simply contribute nothing. - A spawned stdio child's stderr passes through to the terminal, which keeps server-side tracing visible while you explore.
call <tool> <json>is the escape hatch whenkey=valuecoercion is not enough.- When stdin is not a tty, the REPL reads lines directly (no editor), so
piping a script of commands works:
printf 'echo message=hi\nquit\n' | mcp-repl --demo. - Command history persists to
~/.mcp-repl_history(up to 1000 entries), so up-arrow recalls commands from previous sessions. Pass--no-historyto keep it in-memory only.