mcpg inspector
Inspect any MCP server — web UI, terminal UI and scriptable CLI in one binary, pre-wired when run against a gateway.
mcpg inspector — inspect an MCP server
Every verb and flag. For the task-oriented walk-through — what to reach for when a tool is missing, a call is refused, or you need to know what the wire actually said — start with the Inspector section.
One binary, three faces over the same engine:
mcpg inspector serve Web UI + HTTP API on one origin (default 127.0.0.1:7846).
mcpg inspector tui Terminal UI, same engine, for SSH and terminal-first work.
mcpg inspector list … One-shot verbs that print JSON: list / call / read /
mcpg inspector call … prompt / complete / auth / login / check / snapshot /
mcpg inspector check … diff / gateway / bench / fuzz / events. Exit codes
are a contract, so CI can gate on them.
The web UI has a pane per surface — tools, resources (and templates), prompts, subscriptions, events, diagnose (the auth lab, the protocol checks and the gateway behind the target) and wire.
The TUI covers the same surfaces: targets, tools, resources,
prompts, subs, events, pending, diagnose and wire. tab (or h/l) moves
between them, j/k selects, c connects, a edits the arguments for
whatever is selected, and enter runs whatever the screen is about —
calling a tool, reading a resource, rendering a prompt, starting or
stopping the push stream, opening or closing an event stream, running the
protocol checks. The argument buffer
reseeds per screen, so a tool's arguments never follow you to a prompt; the
subscription screen keeps its own watch list, which survives the screens
you visit while it is listening.
Where a tool declares an input schema, arguments are a form: one control
per field, typed the way the schema declares it, required fields first, with
descriptions and enum choices in place. That typing is the point — a control
hands back text for everything, and {"count": "7"} is a different call from
{"count": 7}. The raw JSON is one toggle away and edits the same value, in
both faces (f in the TUI), because sending what a schema does not
describe is half of what an inspector is for. Nothing is ever blocked: a
value the schema disagrees with is reported and still sent, since finding out
how a server handles it is the job.
Where the protocol defines completions — prompt arguments and
resource-template variables, not tool arguments — the form can ask the server
what fits: suggest beside a field in the web UI, s in the TUI's form.
The values are the server's own, narrowed by whatever is already typed;
nothing is guessed locally, which is why the control only appears where a
completion surface exists.
A resource template's variables are fields too, and they keep the order the
URI names them in — {year}/{id} offers year first, because a template
carries its own order even though a schema's properties do not.
Results read the same way. A tool that declares an output schema gets its
structuredContent shown as labelled rows, content blocks are rendered as
the kind they say they are — text, image, embedded resource — and the raw
envelope stays one click (or J) away. Prompt arguments and resource-template
variables get the same form, shaped from what those surfaces declare instead
of from JSON Schema.
MCP runs in both directions: a server may answer a call by asking the client
to sample a model, elicit a value, or list its roots. Those land in
pending, and the call that triggered one waits for the answer. Both faces
surface it where you already are — the web UI as a banner above whatever pane
is open, the TUI by switching to the pending screen and badging the tab —
because a request filed behind a tab you have not opened is holding a call
open that you cannot see. enter sends the answer, x declines; declining
is a legitimate answer, not an error.
The answer is seeded per request kind and never generated: the inspector will
not call a model on your behalf. Set responder on a target to auto-decline
or mock to answer without a human, which is what the CLI and CI want — under
those policies nothing ever queues.
mcpg-inspector tui --attach <url> points the terminal at a running
inspector instead of dialing targets itself — its targets, its sessions, its
wire log. That is the difference that matters against a gateway sidecar: the
sidecar holds the frames of the gateway's own traffic, and a TUI dialing the
gateway directly opens a fresh session and sees none of them. Pass the token
in MCPG_INSPECTOR_TOKEN rather than in the URL; a command line is readable
by every process on the box.
On subs, an empty watch list is a real subscription — it follows
tools, prompts and resources list-changed notifications. Add
comma-separated resource URIs with a to watch those resources' contents
as well. diagnose runs the portable protocol checks against an HTTP
target and reports each as passed, failed, or skipped; skipped means the
check does not apply to the revision this target negotiated, which is not
a failure.
Reached as mcpg inspector … through the front door, or invoked
directly as mcpg-inspector. It speaks both MCP revisions mcpg speaks
— the sessionful 2025-11-25 wire and the stateless 2026-07-28 wire
— using the gateway's own client, so what the inspector negotiates is
what the gateway would negotiate.
Attached to a gateway
mcpg --config server.yml --inspector
The gateway supervises a sibling inspector process: readiness-polled, killed with its parent, and pre-registered against that gateway — nothing to copy, paste or configure. It hands the child:
- this gateway as a target, at the scheme and path it actually serves, with a per-boot identity that clears the default trust floor. On a stock config it lists tools immediately, where an anonymous caller would see none;
- every federated upstream, so one session can compare what mcpg re-serves against what the upstream itself offers — the question behind most "why is this tool missing" reports. Each carries the wire the federation pinned and the same private-address posture the gateway uses for it.
No credential rides on the command line. The gateway's own token travels by environment, because argv is readable by every process on the box; a federation's credential stays with the gateway, since the inspector dialling that upstream directly is a different caller.
The URL is printed once the gateway itself is answering, not when the sidecar comes up. The sidecar is ready in milliseconds while the gateway still has plugins to load, so announcing early would put the first click on a refused connection to the pre-wired target — which looks like the inspector being broken. If the gateway does not come up within a minute, the URL is printed anyway, with a note that its own target will not connect yet.
--inspector-bind, --inspector-auth-none and the rest pass through
to the child.
Getting around
The server you are looking at is a picker in the header, not a column — which server, the wire it negotiated, and whether it is up, in the width of its own label. Adding one lives inside it.
history keeps every tool call, resource read and prompt render: what was sent, what came back, how long it took, and whether it failed. "Load into tools" puts an old call's arguments back in the pane that sent them rather than re-running it, because re-running silently is how you lose the difference you were comparing.
A subscription outlives the tab that started it. The stream belongs to the target, so leaving for the tools pane is navigation rather than cancellation, and the subscriptions tab carries a count of what arrived while you were away. The pane says what is being watched and since when; pushes are grouped by what they are about, with the raw sequence one click away.
The TUI has the same two: a history screen (enter loads an entry back
into the screen that sent it) and a count on the subs tab.
MCP Apps
A tool may ship an HTML app (SEP-1865): _meta.ui.resourceUri names a ui://
resource whose contents are the page, and whose own _meta.ui declares the
network it wants and the browser permissions it asks for. The web UI renders
it; the TUI names it and points at the resource, since a terminal cannot run
a page.
The inspector is a host pointed at servers nobody trusts, so three rules hold:
- Nothing runs until you ask. The page renders on a click, and the click says what it is agreeing to.
- The frame is sandboxed without
allow-same-origin. That one attribute is what keeps the page out of this document, its storage, and your session token — with it, an untrusted server's HTML would have all three. The CSP starts atdefault-src 'none'and adds only what the app declared, so a page that declares nothing reaches nothing. - Everything the app asks for is shown. Its
tools/callrequests run against the same target under the same identity, so they appear in the wire pane like any other call — and the ones this host declines are listed too, with what was asked, because that is the more interesting half.
MCP Events
events reads the MCP Events extension of any server that offers it on the
2026-07-28 wire. The walk-through is
Watch MCP Events.
| Verb | What it does |
|---|---|
events list <target> | Every event type, with its schemas and delivery modes |
events poll <target> <name> | One events/poll; --cursor reads on from a saved cursor, --max-events caps the page |
events stream <target> <name> | Holds events/stream open and prints each frame; ends at --seconds, Ctrl-C or the server's end, then prints the stream checks |
events subscribe <target> <name> | Listens at --listen (default 127.0.0.1:0), subscribes with that listener as the webhook callback, prints each delivery, unsubscribes at the end, then prints the webhook checks |
events unsubscribe <target> <name> --url <callback> | Ends a webhook subscription made elsewhere |
Every verb takes --args (inline JSON or @file, an object). subscribe
takes --callback-base <url> for the address the server reaches the
listener at when that is not the listen address, such as a tunnel or an
https proxy, and --ttl-ms for the lifetime it asks for. mcpg sends
webhooks only to https callbacks.
Recordings
Any target's exchange can be written out as a recording — the header
button in the web UI, w in the TUI, or GET /api/v1/targets/{id}/export.
It is NDJSON: one header line saying which server and which wire, then the
frames as they crossed.
Replay one anywhere a target goes:
mcpg-inspector tui --target recording:./mcpg-inspector-gateway-1786.jsonl
mcpg-inspector list recording:./that-file.jsonl tools
Every surface works on it — the catalog, a call, the wire pane — because nothing above the session knows it is replaying. It dials nothing, so it works with the server gone, which is the point. A call the recording does not contain says so rather than inventing an answer.
A recording is a file; sharing one is sending it. The inspector stores none — no share links, no retention, no custody of anyone's traffic. Frames are run through the same credential redaction the gateway applies to audit records, and the target is described rather than copied, so a bearer token never reaches the file. What remains is your own traffic — including arguments you chose to send — so treat a recording as the traffic it is. Recordings covers this in full.
Standalone
mcpg inspector serve --target http://127.0.0.1:8787/mcp --open
The URL it prints carries a per-boot session token. The API can
spawn processes for stdio targets, so it binds loopback and requires
that token; --auth-none drops it but only on a loopback bind, and
reaching beyond this host needs the deliberately ugly
--dangerously-bind-all-interfaces.
Targets take three forms anywhere one is accepted:
http://host:port/mcp # Streamable HTTP
stdio:npx -y @modelcontextprotocol/server-everything
'{"url": "https://gw.example/mcp", "bearer": "…"}' # JSON, for the full spec
Scripting
Every one-shot verb takes --json and prints its result document as
the only thing on stdout:
mcpg inspector list http://127.0.0.1:8787/mcp tools --json | jq '.tools[].name'
mcpg inspector call http://127.0.0.1:8787/mcp dev.mock.echo --args '{"text":"hi"}'
mcpg inspector read http://127.0.0.1:8787/mcp 'docs://runbook'
mcpg inspector prompt http://127.0.0.1:8787/mcp greet --args '{"who":"world"}'
mcpg inspector complete http://127.0.0.1:8787/mcp prompt:greet who w
mcpg inspector snapshot http://127.0.0.1:8787/mcp --json > baseline.json
mcpg inspector diff http://127.0.0.1:8787/mcp --against baseline.json --mode strict
mcpg inspector gateway http://127.0.0.1:8787/mcp --json | jq '.plugins[] | select(.state != "active")'
mcpg inspector bench http://127.0.0.1:8787/mcp dev.mock.echo --args '{"text":"hi"}' -n 50
mcpg inspector fuzz http://127.0.0.1:8787/mcp
mcpg inspector events poll http://127.0.0.1:8787/mcp orders.created --json
Exit codes: 0 ok, 1 usage, 2 connect/probe, 3 auth required,
4 unreachable, 5 operation or tool error. diff exits 5 when the
diff fails its mode, which is how you gate a PR on capability drift.
A snapshot covers tools, resources, templates, prompts and MCP Events
types. --mode compatible passes additions; for an event type it also
passes a change every subscriber absorbs (kind extended): a new optional
argument or payload field, a new delivery mode. --mode strict passes
nothing.
check runs the portable protocol checks — the ones any conformant
server should pass — and reports each as pass or fail with the frame
that decided it.
When the tool is missing and the config looks right
gateway reads what the mcpg gateway serving this endpoint says about
itself: readiness, which plugins loaded, and which of them are not
running. A binding can resolve perfectly in config and still answer
nothing because the plugin behind it came up degraded — and the MCP
surface shows only the consequence, a tool that is not there.
mcpg inspector gateway http://127.0.0.1:8787/mcp
It exits 5 when something wants attention — a readiness check that is
not passing, or a plugin that loaded but is not active — so a smoke test
can gate on "the gateway is actually serving", not merely "it answered".
The report comes from the gateway's /runtime, which mcpg serves
without authentication and with operator configuration deliberately left
out of it (sink kinds, not sink settings). Against a server that is not
an mcpg gateway, the verb says so rather than guessing.
In the web UI it is the third diagnose tab; in the TUI, g on the
diagnose screen. Both lead with what is wrong: a gateway running twelve
healthy plugins lists none of them, because the one that matters is the
one that is not healthy.
How fast, and how strict
bench calls one tool -n times and reports min / p50 / p90 / p99 /
max in milliseconds. Calls go out one at a time: concurrency measures
something else, and an inspector pointed at someone else's server should
not be the thing that decides to load-test it. The first couple of calls
are warmup (--warmup), so a cold connection is not reported as the
server's latency. Timings are fractional, because a local stdio server
answers well inside a millisecond.
fuzz sends a tool the input its own schema forbids — a missing
required property, the wrong type, an oversized string, an unknown
property — and reports what came back. A server that enforces its schema
rejects the invalid cases and accepts the valid one; anything else is
flagged surprising, and the run exits 5.
It only touches tools that declare readOnlyHint: true. Everything else
is skipped by name, with the reason, because these are real calls:
mcpg inspector fuzz https://gw.example/mcp # read-only tools
mcpg inspector fuzz https://gw.example/mcp incident.file --include-writes
--include-writes is deliberately awkward to reach for. The tool that
forgot to annotate itself is often the one that moves something.
Working out why a server said no
mcpg inspector auth https://gw.example/mcp
auth sends one deliberately credential-free request and reports what
came back: the WWW-Authenticate challenge, every step of the RFC 9728
→ RFC 8414 discovery walk including the one that failed, and a
one-line verdict naming the next action. A half-walked chain reads as
"it advertises OAuth but its authorization server could not be read",
never as "no OAuth here" — those are different problems with different
fixes.
It also reports AAuth posture, which lives in a separate channel:
AAuth-Requirement, Signature-Error, and the target's
/.well-known/aauth-resource.json.
From the authorization server's metadata the report carries what decides how a client signs in:
| Field | What it tells you |
|---|---|
issuer | The authorization server's issuer — the audience an ID-JAG must name, byte for byte |
enterprise_managed_authorization | advertised (the urn:ietf:params:oauth:grant-profile:id-jag grant profile), jwt-bearer-only (the grant without the profile) or unsupported |
grant_types_supported, authorization_grant_profiles_supported | The raw metadata behind that verdict |
authorization_endpoint, registration_endpoint, client_id_metadata_document_supported | Whether a browser sign-in is possible, and how a client can get an id |
token_endpoint_auth_methods_supported | How a client may authenticate at the token endpoint |
dpop_signing_alg_values_supported | The DPoP (RFC 9449) proof algorithms the server binds tokens with |
authorization_details_types_supported | The RAR (RFC 9396) types it takes |
authorization_response_iss_parameter_supported | It names itself in every authorization response (RFC 9207) |
resource_token_requirements | What the protected-resource metadata says: dpop_bound_access_tokens_required, its DPoP algorithms and RAR types |
dpop_challenge | The WWW-Authenticate: DPoP challenge, when the 401 carries one beside the Bearer challenge |
The verdict says which logins the server takes — a browser sign-in, an ID-JAG, or both — and whether its tokens must be DPoP-bound.
Getting a token
Once auth finds an authorization server, login walks the rest. There
are two ways in: a browser sign-in (authorization code with PKCE) and
enterprise-managed authorization (an ID-JAG from your IdP, no
browser). Either way the access token goes to stdout, and to nothing else:
TOKEN="$(mcpg inspector login https://gw.example/mcp)"
mcpg inspector list https://gw.example/mcp tools --bearer "$TOKEN"
--token-file PATH writes it to a file only you can read and prints no
token at all. --json prints the whole outcome — the grant, how the
client identified and authenticated itself, the scope, the key a DPoP
token is bound to, and any warnings. A subject token, an ID-JAG, a client
secret or a key is never printed, and a server error that echoes one has
it replaced by [redacted].
Browser sign-in
mcpg inspector login https://gw.example/mcp
The client comes from, in this order: --client-id (a client you
registered, or the https:// URL of your client-metadata document); then
RFC 7591 dynamic registration, as a public client with a loopback
redirect. It opens a browser for an authorization-code grant with PKCE
(S256 only), catches the redirect on loopback and exchanges the code.
- The redirect's
issis checked (RFC 9207). A redirect that names another authorization server is refused before its code is used, and so is one withoutissfrom a server that promises it — that is a mix-up attack, not a glitch. - A registered client can be confidential.
--client-secret(orMCPG_INSPECTOR_CLIENT_SECRET) sends a secret,--client-keysigns aprivate_key_jwtassertion, and--client-authnames the method when the key or secret alone does not decide it. A client registered forclient_secret_postneeds--client-auth client_secret_post: server metadata lists methods for all clients, not for yours. - Registration may need a token. A server that does not take
anonymous registrations takes an initial access token:
--registration-token(orMCPG_INSPECTOR_REGISTRATION_TOKEN). --scopeoverrides what to ask for (the default is what the challenge named, then what the server advertises),--authorization-detailsasks for RFC 9396 details (a JSON array, or@file), and--no-browserprints the URL instead of opening one, for SSH sessions.
The token is audience-bound to this server via RFC 8707 resource indicators, so it is not replayable at another resource that trusts the same authorization server.
Against an mcpg gateway this is the gateway's own
interactive sign-in: the
consent page, then its upstream IdP, then back to the inspector. A
client registered with redirect_uris: ["http://127.0.0.1/callback"]
works on any port, so --client-id of a static command-line client needs
nothing else.
Enterprise-managed authorization (ID-JAG)
The MCP extension io.modelcontextprotocol/enterprise-managed-authorization
— Okta calls it Cross App Access — lets a client reach an MCP server with
the identity the enterprise IdP already holds. login runs it when you
give it an IdP token endpoint or a ready ID-JAG:
export MCPG_INSPECTOR_CLIENT_SECRET="$MCPG_TEST_SECRET" # mcpg-test at the MCP server
mcpg inspector login https://gw.example/mcp \
--client-id mcpg-test \
--idp-token-url https://acme.okta.com/oauth2/v1/token \
--idp-client-id "$TEST_AGENT_ID" --idp-client-key ./test-agent.pem \
--subject-token-file id_token.jwt
The ID token must be one the IdP issued to --idp-client-id.
Test with your own agent
shows how to get one from Okta, and the matching test client at an mcpg gateway.
- The exchange at the IdP (RFC 8693): the subject token for an
ID-JAG, with
requested_token_typethe ID-JAG type,audiencethe authorization server's issuer exactly as its metadata states it,resourcethis server, and--scope. - The redemption at the server (RFC 7523 JWT bearer grant): the
ID-JAG as
assertion, the client authenticated as it is registered there, andresourceagain.
Before redeeming, the inspector reads the ID-JAG's claims — it does not
verify them; that is the server's job — and warns about what the server
will refuse: an aud that is not the issuer (a trailing slash is another
issuer), a client_id that is not --client-id, a resource that is
not this server, a typ other than oauth-id-jag+jwt, an expired grant.
When the server refuses, the error carries those warnings.
| Flag | What it does |
|---|---|
--client-id | The client as registered at the MCP server's authorization server. Required: the ID-JAG names it |
--client-secret, --client-key, --client-auth | How that client authenticates; --client-assertion-audience is issuer (default) or token-endpoint |
--idp-token-url | The IdP's token endpoint. https://, or plain http:// to this machine only |
--subject-token-file PATH | The user's ID token; - reads stdin. Or --subject-token / MCPG_INSPECTOR_SUBJECT_TOKEN |
--subject-token-type | id_token (default), saml2 or refresh_token |
--idp-client-id | The inspector's client at the IdP |
--idp-client-secret, --idp-client-key, --idp-client-auth | How it authenticates there. --idp-assertion-audience defaults to token-endpoint, which Okta expects; issuer needs --idp-issuer |
--id-jag-file PATH | Redeem an ID-JAG you already have (- reads stdin), or --id-jag / MCPG_INSPECTOR_ID_JAG; skips the IdP |
A server that lists the JWT bearer grant without the ID-JAG grant profile is still tried, with a warning. So is one that lists neither — the answer is the server's to give. What an mcpg gateway checks on the other side is in enterprise-managed authorization.
DPoP-bound tokens
A server that binds tokens to a key (RFC 9449) is served with
--dpop-key PATH, in either login:
mcpg inspector login https://gw.example/mcp --dpop-key ./dpop.jwk
mcpg inspector list https://gw.example/mcp tools --bearer "$TOKEN" --dpop-key ./dpop.jwk
login creates the key when the file does not exist — ES256, or EdDSA
when the server lists EdDSA and not ES256 — as a private JWK only you can
read. An existing file may also hold a PKCS#8 PEM key.
It binds the authorization code to the key (dpop_jkt), sends a proof
with every token request, and retries once with the server's
DPoP-Nonce when the server demands one. The outcome names the key's
thumbprint; a server that answers a proof with a Bearer token gets a
warning, not a silent pass.
Every other verb then presents the token as Authorization: DPoP with a
fresh proof per request. A resource that requires nonces refuses the
first proof with one; the inspector learns it and connects again. The key
lives only in the file: a target object never names one, so the web UI
and its API cannot be made to read a key file.
A server whose protected-resource metadata says
dpop_bound_access_tokens_required: true is not worth a browser round
trip without a key, so login refuses up front and says which flag is
missing. The inspector signs proofs with ES256 and EdDSA; a server that
takes neither is reported as such.
AAuth
AAuth gives an agent a portable cryptographic
identity — aauth:local@domain bound to an Ed25519 key — and signs
every request with it, so a captured credential is useless without the
key. mcpg verifies it via the dev.mcpg.identity.aauth plugin; the
inspector can present it.
There is no registration step. Trust is domain control plus a published key, so getting an identity means generating one and serving two static documents:
mcpg inspector aauth-keygen \
--agent aauth:inspector@yourdomain.example \
--publish ./site
That writes ./site/.well-known/aauth-agent.json and
./site/.well-known/jwks.json, and prints the private seed. Serve the
two documents under https://yourdomain.example, then sign with:
mcpg inspector list https://gw.example/mcp tools \
--aauth-key "$MCPG_INSPECTOR_AAUTH_KEY" \
--aauth-agent aauth:inspector@yourdomain.example \
--aauth-issuer https://yourdomain.example
--aauth-key reads MCPG_INSPECTOR_AAUTH_KEY so the seed need not
appear in a process listing. If you already have an agent token from an
agent provider, pass it with --aauth-token and the token's own sub
names the agent.
The inspector implements the identity-based access mode — the agent
token is the credential — which is the mode mcpg verifies. The modes
that require a person server and a human approval are reported by
auth but not driven.
Two details worth knowing:
Content-Digestis signed by default. Every MCP call isPOST /mcp, so without it the signature would say nothing about which call was made.EdDSAvsEd25519. The JOSEalgis the fully-specifiedEd25519. The polymorphicEdDSAis forbidden by the draft and refused by mcpg, so there is no switch for it. Not to be confused with the RFC 9421alginsideSignature-Input, a different registry spelled lowercaseed25519— this is the most common interop failure in AAuth.
Hosted
The hosted inspector is free to use — there is no plan, tier or licence behind it, and the same is true of the CLI.
--hosted is the profile inspector.mcpg.cloud runs. It is a profile
rather than one switch: no stdio targets, no private-address egress
(overridden, not merely validated), per-address rate limits, and OIDC
sign-in — a hosted instance refuses to boot without an issuer, and
refuses --auth-none and --session-token outright, because one shared
credential would mean one shared workspace.
Each signed-in user gets their own workspace: targets, and the credentials attached to them, are never visible to anyone else. Nothing is persisted, so a restart signs everyone out.
Point it at a public server, or at a local one through
mcpg --tunnel.
See also
- The inspector — the guides this page is the reference for: debugging, auth, recordings, CI and running it
- Quickstart — bring up a gateway and point the inspector at it
mcpggateway CLI