Reference
Referencebeta

mcpg inspector

rc
v0.1.0-rc.14

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:

text
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

bash
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 at default-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/call requests 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.

VerbWhat 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:

bash
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

bash
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:

bash
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:

bash
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.

bash
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:

bash
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

bash
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:

FieldWhat it tells you
issuerThe authorization server's issuer — the audience an ID-JAG must name, byte for byte
enterprise_managed_authorizationadvertised (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_supportedThe raw metadata behind that verdict
authorization_endpoint, registration_endpoint, client_id_metadata_document_supportedWhether a browser sign-in is possible, and how a client can get an id
token_endpoint_auth_methods_supportedHow a client may authenticate at the token endpoint
dpop_signing_alg_values_supportedThe DPoP (RFC 9449) proof algorithms the server binds tokens with
authorization_details_types_supportedThe RAR (RFC 9396) types it takes
authorization_response_iss_parameter_supportedIt names itself in every authorization response (RFC 9207)
resource_token_requirementsWhat the protected-resource metadata says: dpop_bound_access_tokens_required, its DPoP algorithms and RAR types
dpop_challengeThe 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:

bash
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

bash
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 iss is checked (RFC 9207). A redirect that names another authorization server is refused before its code is used, and so is one without iss from a server that promises it — that is a mix-up attack, not a glitch.
  • A registered client can be confidential. --client-secret (or MCPG_INSPECTOR_CLIENT_SECRET) sends a secret, --client-key signs a private_key_jwt assertion, and --client-auth names the method when the key or secret alone does not decide it. A client registered for client_secret_post needs --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 (or MCPG_INSPECTOR_REGISTRATION_TOKEN).
  • --scope overrides what to ask for (the default is what the challenge named, then what the server advertises), --authorization-details asks for RFC 9396 details (a JSON array, or @file), and --no-browser prints 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:

bash
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.

  1. The exchange at the IdP (RFC 8693): the subject token for an ID-JAG, with requested_token_type the ID-JAG type, audience the authorization server's issuer exactly as its metadata states it, resource this server, and --scope.
  2. The redemption at the server (RFC 7523 JWT bearer grant): the ID-JAG as assertion, the client authenticated as it is registered there, and resource again.

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.

FlagWhat it does
--client-idThe client as registered at the MCP server's authorization server. Required: the ID-JAG names it
--client-secret, --client-key, --client-authHow that client authenticates; --client-assertion-audience is issuer (default) or token-endpoint
--idp-token-urlThe IdP's token endpoint. https://, or plain http:// to this machine only
--subject-token-file PATHThe user's ID token; - reads stdin. Or --subject-token / MCPG_INSPECTOR_SUBJECT_TOKEN
--subject-token-typeid_token (default), saml2 or refresh_token
--idp-client-idThe inspector's client at the IdP
--idp-client-secret, --idp-client-key, --idp-client-authHow it authenticates there. --idp-assertion-audience defaults to token-endpoint, which Okta expects; issuer needs --idp-issuer
--id-jag-file PATHRedeem 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:

bash
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:

bash
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:

bash
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-Digest is signed by default. Every MCP call is POST /mcp, so without it the signature would say nothing about which call was made.
  • EdDSA vs Ed25519. The JOSE alg is the fully-specified Ed25519. The polymorphic EdDSA is forbidden by the draft and refused by mcpg, so there is no switch for it. Not to be confused with the RFC 9421 alg inside Signature-Input, a different registry spelled lowercase ed25519 — 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