A polished desktop app for testing and debugging Model Context Protocol (MCP) servers. Connect to any server — local or remote — inspect the tools, resources, and prompts it exposes, call them interactively from auto-generated forms, and watch the raw JSON-RPC messages stream by in real time.
Built with Tauri 2, React + TypeScript (Vite), and the official MCP Rust SDK (rmcp), targeting the 2026-07-28 MCP specification.
- Screenshots
- What it does
- Why it exists
- Mcpolis vs. the official MCP Inspector
- Tech stack
- Architecture
- How each feature works
- The lifecycle of one tool call
- Project structure
- The command surface
- Data & persistence
- Setup and running
- Servers to test against
- Troubleshooting
- Limitations & non-goals
- Distribution reality
- License
Connected — browse a server's capabilities. The three panes: servers (left), the tools / resources / prompts browser (middle), and the detail panel showing the server overview with its advertised capabilities and raw initialize result.
Call a tool from an auto-generated form. The tool's JSON Schema becomes labelled, typed fields (note the required marker); the result is shown both formatted and as collapsible raw JSON.
Watch the raw JSON-RPC traffic. Every frame in and out of the server, colour-coded by direction and kind, syntax-highlighted, with the selected message's full payload expanded — the feature that makes this a debugger.
Add a local or remote server. Switch between stdio (command + args + env) and Streamable HTTP (URL + bearer token / custom headers), with inline validation.
| Feature | Description |
|---|---|
| Manage servers | Add local (stdio) or remote (Streamable HTTP) servers. Edit, delete, persist across restarts. |
| Connect / disconnect | Performs the MCP initialize handshake and shows the server's reported name, version, protocol, and advertised capabilities. Connection state is glanceable at all times (disconnected / connecting / connected / errored). |
| Enumerate capabilities | Lists the server's tools, resources, and prompts in a browsable, tabbed panel with names and descriptions. |
| Inspect a tool | Renders the tool's JSON Schema as readable, labelled fields with types and required markers — not a raw blob. |
| Call a tool | Auto-generates an input form from the schema, submits it, and shows the result both formatted (text/images/embedded resources) and raw. Slow calls show a working state; failures show a specific, typed error. |
| Read a resource | Fetches and displays a resource. Text is shown readably; binary reports type + size instead of garbage. |
| View prompts | Lists prompts, renders their arguments as a form, fetches the templated result, and shows the returned messages. |
| Raw message inspector | A live, colour-coded, syntax-highlighted stream of every JSON-RPC frame exchanged with the server — the thing that makes this a debugger rather than just a caller. |
| Polished UX | Light/dark themes (OS-driven + manual toggle), resizable panes, syntax-highlighted collapsible JSON everywhere, friendly empty/error states, keyboard-friendly, accessible. |
There's already an official MCP Inspector. This app wins on exactly one axis: it's genuinely pleasant to use. The official tooling is functional but its UX is widely considered rough — so polish here isn't decoration, it's the entire reason the app has a right to exist. Every scope decision serves "this feels good": three confident panes, real theming, readable JSON, glanceable state, and errors that tell you what went wrong instead of freezing.
The official MCP Inspector is the reference tool, and it's broader than Mcpolis — it has OAuth flows, resource subscriptions, tasks, pagination, and CLI/TUI modes that Mcpolis doesn't (yet). Mcpolis deliberately trades that breadth for a focused, native, pleasant experience of the core loop: connect, inspect, call, debug.
| Mcpolis | Official MCP Inspector | |
|---|---|---|
| Form factor | Native desktop app, single window | Web app — spawns a local Node proxy and opens a browser tab |
| stdio + Streamable HTTP transports | ✅ | ✅ |
| List & call tools / resources / prompts | ✅ | ✅ |
| Raw JSON-RPC message inspector | ✅ | ✅ |
| Auto-generated form from tool JSON Schema | ✅ | ✅ |
| Light & dark themes | ✅ | ✅ |
| Server list managed in the UI and persisted | ✅ add / edit / delete, saved to disk | via config / catalog files |
| Resizable three-pane workspace | ✅ | — |
| stdio without opening a terminal or browser tab | ✅ | — |
| OAuth 2.1 authorization flows | — (manual bearer token) | ✅ |
| Resource subscriptions · Tasks · pagination | — (v2 roadmap) | ✅ |
| CLI / TUI modes | — | ✅ |
Short version: reach for the official Inspector when you need its advanced protocol features; reach for Mcpolis when you want a native app that's fast and pleasant for everyday server testing.
| Layer | Choice | Notes |
|---|---|---|
| Shell | Tauri 2 (v2.x) | Native webview + Rust backend. Small binaries, no Electron. |
| Frontend | React 18 + TypeScript, Vite 5 | Component-based, fully typed (no any in the MCP shapes). |
| MCP protocol | rmcp 3.1 (official Rust SDK) |
Handles JSON-RPC framing, the initialize handshake, and typed requests. |
| HTTP transport | reqwest (rustls TLS) |
Powers the Streamable HTTP client so https:// works out of the box. |
| Async | Tokio | Child-process spawning, timeouts, channels. |
| Spec target | MCP 2026-07-28 | Negotiates down to older revisions (e.g. 2025-11-25 / 2025-03-26) automatically. |
Zero UI dependencies beyond React — icons, the JSON viewer, and the syntax highlighter are all hand-rolled, so the app stays self-contained and the bundle stays small (~58 KB gzipped JS).
The single load-bearing idea: all MCP protocol logic lives in the Rust backend; the frontend only ever calls a small set of typed commands. A webview cannot spawn local processes, so stdio connections must happen in Rust — and rather than split the logic, every connection (stdio and HTTP alike) is owned by the backend.
┌────────────────────────────────────────────────────────────────────────┐
│ Webview (React + TS) │
│ │
│ Components / hooks ──▶ McpClient interface ──▶ mcpClient.ts │
│ (Sidebar, panels, (typed, transport- (THE boundary: │
│ detail views) agnostic) only file that │
│ touches Tauri) │
└─────────────────────────────────────────────┬────────────────────────────┘
invoke() │ ▲ emit("mcp://message")
▼ │
┌────────────────────────────────────────────────────────────────────────┐
│ Rust backend (src-tauri) │
│ │
│ commands.rs ──▶ McpManager (registry of live connections) │
│ │ │
│ ├─ stdio ─▶ TokioChildProcess ─┐ │
│ │ ├─▶ LoggingTransport ─▶ rmcp service │
│ └─ http ─▶ StreamableHttp ──┘ │ │
│ ▼ │
│ LogSink ──▶ Tauri event
└────────────────────────────────────────────────────────────────────────┘
│
▼
MCP server (subprocess or remote URL)
- Left — Servers. The configured-server list with glanceable status dots, add/edit/delete, and the theme toggle.
- Middle — Capabilities. The active server's name + connection badge + Connect/Disconnect, then tabbed Tools / Resources / Prompts lists.
- Right — Detail / Inspector. A segmented control switching between Inspect (the selected tool/resource/prompt, or a server overview) and Messages (the raw JSON-RPC inspector).
Panes are resizable by dragging the dividers; widths persist to localStorage.
The entire "how do I talk to a server" surface is isolated behind one frontend module: src/lib/mcpClient.ts. It exports a typed McpClient interface and a single implementation that calls Tauri invoke.
No other file in src/ may import @tauri-apps/* or call invoke/listen. Every component, hook, and view depends solely on the McpClient interface. Verify it yourself:
grep -rn "@tauri-apps" src | grep -v mcpClient.ts # → no matchesWhy this matters: a future web version of this UI only has to swap that one file (Tauri invoke → fetch to a hosted backend), and the entire UI comes along unchanged. The boundary is enforced strictly precisely so that port is a one-file change, not a rewrite.
src-tauri/src/mcp/mod.rs owns a McpManager — a registry of live connections keyed by server id, held in Tauri's managed state. Each connection stores:
- the running
rmcpservice (owns the session lifecycle / cancellation), - a cheap cloneable
Peerhandle (used to issue calls without holding the registry lock), and - a
LogSink(the message-capture handle).
Connecting:
- Build the transport for the chosen mode:
- stdio:
TokioChildProcessspawns the command as a subprocess. Arguments and environment variables are passed through via.configure(...). If the binary doesn't exist, this fails immediately with a clear error rather than hanging. - http:
StreamableHttpClientTransportover areqwestclient. The optional bearer token is sent asAuthorization: Bearer <token>(we set the header ourselves to avoid any prefix ambiguity), plus any custom headers, on every request.
- stdio:
- Wrap that transport in a
LoggingTransport(see below). - Run the
initializehandshake under a 20-second timeout — so a server that starts but never responds surfaces aTimeouterror instead of freezing the UI. - Store the connection and return the server's
InitializeResult(name, version, protocol, capabilities) to the frontend.
Robustness by design:
- Every request (
list_*,call_tool,read_resource,get_prompt) runs under a 60-second timeout. - Errors are a structured, typed
AppError(error.rs) with akind(connectionFailed/handshakeFailed/timeout/protocol/notConnected/badRequest/storage), a human message, and optional detail — never a stringified panic. The frontend switches onkindto pick phrasing. - The registry lock is never held across an
await: calls clone thePeerunder the lock, release it, then do async work — so one hanging call can't block every other operation.
rmcp abstracts JSON-RPC away behind typed calls, so to see the actual bytes we insert a thin wrapper — transport_log.rs — between the SDK and the real transport. LoggingTransport<T> implements rmcp's Transport trait and delegates to the inner transport, but on every frame it:
- serializes the message back to its real wire JSON,
- classifies it (request / response / notification / error),
- appends it to a bounded in-memory buffer (most recent ~3000 frames per connection), and
- emits a Tauri event (
mcp://message) so the inspector updates live.
Because the wrapper sits below .serve(), even the initialize handshake is captured. Two subtle correctness details:
- Outgoing sends are logged synchronously before delegating, because
rmcprequires thesendfuture to be'static(it can't borrow the logger). - Incoming receives are logged inline in the awaited path.
The frontend loads the backlog once via get_message_log and then streams new frames through the mcp://message subscription — de-duplicated and ordered by a monotonic per-connection sequence number.
WorkbenchProvider(state) is a reducer-backed context that owns the server list, per-server connection state, enumerated capabilities, and the current selection. All side effects go throughmcpClient.mcpClient.tsis the boundary (above).- Components (components/) render the shell, sidebar, capabilities panel, detail router, schema form, and JSON viewer.
- Views (views/) are the four "workspaces":
ToolDetail,ResourceDetail,PromptDetail, andMessageInspector. - Theming (styles/global.css) is a single set of CSS custom properties. Light is the default; dark applies via the OS
prefers-color-schemeor an explicitdata-themeattribute set by the toggle (the manual choice wins and persists).
Auto-generated tool forms. SchemaForm walks the tool's JSON Schema and renders a first-class widget per property — text, number, boolean checkbox, enum select, arrays of primitives, and nested objects — each labelled with its type and a required marker. Anything it can't model cleanly (unions, $ref, deeply nested shapes, or no schema at all) falls back to a raw-JSON editor for that field or the whole form, so the generator never crashes on an odd schema. It emits well-typed values (numbers as numbers, booleans as booleans) and prunes empty optional fields.
JSON everywhere. JsonView is a dependency-free recursive renderer: syntax-highlighted, collapsible per node, and it starts collapsing past a depth so a huge response doesn't wall you in. Used for schemas, results, resource contents, and every message payload.
Content rendering. ContentView handles MCP content blocks: text is shown readably, images/audio are previewed from their base64 payloads, embedded resources are expanded, binary blobs report type + size, and unknown block types fall back to the JSON viewer.
Capability enumeration. After connecting, the app calls only the lists the server advertises in its capabilities (so it doesn't fire unsupported methods), in parallel, tolerating partial failures — a list that errors is surfaced, the others still render.
Live inspector. MessageInspector shows frames colour-coded by direction (→ outgoing / ← incoming) and kind, with a filter, a "follow" toggle, a client-side "clear," and a detail pane showing the full highlighted payload of the selected frame.
- You fill the auto-generated form;
ToolDetailholds the argument object. - Click Call tool →
mcpClient.callTool(serverId, name, args). mcpClient.tscallsinvoke("call_tool", { serverId, name, arguments }). (Tauri maps JS camelCase → Rust snake_case automatically.)commands.rs→McpManager::call_toolclones thePeer, releases the lock, and issues the request under the 60s timeout.LoggingTransportcaptures the outgoingtools/callrequest → buffer +mcp://messageevent → the Messages tab shows it instantly.rmcpsends it over stdio/HTTP; the server responds;LoggingTransportcaptures the response the same way.- The typed
CallToolResultis serialized and returned; on failure a typedAppErroris returned instead. ToolDetailrenders the result (formatted content + structured content + raw JSON) with the elapsed time, or a specificErrorBox.
mcpolis/
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── main.rs # thin shim → lib::run()
│ │ ├── lib.rs # Tauri builder, state, command registration, TODO(v2)
│ │ ├── error.rs # structured AppError (typed, never a stringified panic)
│ │ ├── commands.rs # the Tauri command surface + server-list persistence
│ │ └── mcp/
│ │ ├── mod.rs # connection registry: connect/disconnect/list/call/read/get
│ │ └── transport_log.rs # raw JSON-RPC capture (wraps the rmcp transport)
│ ├── capabilities/default.json # Tauri v2 permissions (core defaults + events)
│ ├── icons/ # app icons (generated placeholders)
│ ├── tauri.conf.json
│ └── Cargo.toml
├── src/ # React + TS frontend
│ ├── lib/
│ │ ├── mcpClient.ts # ⚑ THE boundary — only file touching invoke/@tauri-apps
│ │ ├── types.ts # app + MCP protocol types (no `any`)
│ │ ├── jsonSchema.ts # JSON-Schema → form helpers
│ │ └── util.ts
│ ├── state/
│ │ ├── WorkbenchProvider.tsx # reducer-backed app state
│ │ └── useTheme.ts # OS-driven + manual light/dark
│ ├── components/ # Sidebar, CapabilitiesPanel, DetailPanel, ServerForm,
│ │ │ # SchemaForm, JsonView, ContentView, Status, Feedback, Icons
│ ├── views/ # ToolDetail, ResourceDetail, PromptDetail, MessageInspector
│ ├── styles/global.css # single source of truth for theming (CSS custom properties)
│ ├── App.tsx # three-pane shell + resizable panes
│ └── main.tsx
├── index.html
├── package.json
└── vite.config.ts
Every backend command is registered in lib.rs and mirrored 1:1 by the McpClient interface. This is the entire contract between frontend and backend:
| Tauri command | McpClient method |
Purpose |
|---|---|---|
load_servers / save_servers |
loadServers / saveServers |
Persist the server list |
connect / disconnect |
connect / disconnect |
Session lifecycle |
is_connected |
isConnected |
Liveness check |
list_tools / list_resources / list_prompts |
listTools / … |
Enumerate capabilities |
call_tool |
callTool |
Invoke a tool |
read_resource |
readResource |
Fetch a resource |
get_prompt |
getPrompt |
Fetch a templated prompt |
get_message_log |
getMessageLog |
Backlog for the inspector |
(event) mcp://message |
subscribeMessages |
Live message stream |
- Server list: stored as a plain JSON file (
servers.json) in the OS app-config directory, read/written by two Tauri commands. No database. The frontend owns the shape; the backend treats it as opaque JSON. - Pane widths & theme choice:
localStorage(plain web APIs, so no boundary violation). - Message log: in-memory only, bounded to the most recent ~3000 frames per connection; not written to disk.
- Secrets: bearer tokens / headers live only in the persisted server config on the local machine and are sent only to the server URL you configured.
Grab a prebuilt installer for your OS from the Releases page — no toolchain needed.
Builds are currently unsigned, so the OS will warn on first launch:
- macOS: right-click the app → Open (or run
xattr -dr com.apple.quarantine /Applications/Mcpolis.app). - Windows: on the SmartScreen prompt, click More info → Run anyway.
Releases are produced by
.github/workflows/release.yml. To cut one, bump the version inpackage.json+src-tauri/tauri.conf.json, then:git tag v0.1.0 && git push origin v0.1.0CI builds macOS (Apple Silicon + Intel), Windows, and Linux installers and attaches them to a draft release for you to review and publish.
- Node.js ≥ 18 and npm.
- Rust toolchain (stable) via rustup.
- Tauri 2 system dependencies for your OS — see https://v2.tauri.app/start/prerequisites/. On macOS that's just the Xcode Command Line Tools (
xcode-select --install); Linux needs WebKitGTK et al. - The Tauri CLI ships as a dev dependency, so no global install is needed.
npm install
npm run tauri devLaunches Vite (frontend, hot-reloading) and compiles + runs the Rust app. The first Rust build downloads and compiles the dependency tree (rmcp, reqwest, Tauri) and can take a few minutes; subsequent runs are fast. Frontend edits hot-reload without a Rust rebuild.
npm run tauri buildType-checks and bundles the frontend, compiles Rust in release, and produces platform installers under src-tauri/target/release/bundle/ (.dmg/.app on macOS, .msi/.exe on Windows, .deb/.AppImage on Linux).
To swap the placeholder icons for your own art:
npm run tauri icon path/to/your-1024px-icon.pngLocal (stdio) — the reference server that exposes every capability type:
command: npx
args: -y
@modelcontextprotocol/server-everything
Remote (Streamable HTTP), no auth — verified live; in the app choose Remote (HTTP), paste the URL, leave the bearer field empty:
| Server | URL |
|---|---|
| DeepWiki | https://mcp.deepwiki.com/mcp |
| Microsoft Learn | https://learn.microsoft.com/api/mcp |
| Cloudflare Docs | https://docs.mcp.cloudflare.com/mcp |
| GitMCP | https://gitmcp.io/docs (or https://gitmcp.io/<owner>/<repo>) |
| Hugging Face | https://huggingface.co/mcp |
| Context7 | https://mcp.context7.com/mcp |
| Symptom | Likely cause / fix |
|---|---|
| "Couldn't launch …" on connect | The stdio command isn't on PATH or the args are wrong. Use an absolute path, or a launcher like npx/uvx. |
| Handshake times out | The server started but never completed initialize. Check the Messages tab to see how far the handshake got. |
| Remote server returns 401 / auth error | It requires auth; add a bearer token or the appropriate custom header in the server config. |
| Tools/resources/prompts empty but capabilities advertised | The server may return empty lists to unauthenticated clients — confirm with the Messages tab (look for the tools/list response). |
| Nothing in the inspector | The inspector shows frames for the connected server; connect first, then interact. |
The Messages tab is your first debugging stop — it shows exactly what was sent and received, which almost always explains an unexpected result.
Deliberately out of scope (kept as a single TODO (v2): list in lib.rs):
- Security / vulnerability scanner ("is this server safe?" grading).
- Full OAuth 2.1 authorization flows (v1 ceiling is a manual bearer token / custom headers).
- Simultaneous multi-server dashboards.
- Saving / exporting call history or message logs to file.
- MCP Apps, Tasks, sampling, elicitation, resource subscriptions.
- Editing the server's own source.
- Resource-template browsing (concrete resources only for now).
- Auto-update infrastructure and code signing / notarization.
- Telemetry / analytics (intentionally none).
npm run tauri build produces working installers, but handing a binary to other people needs more:
- macOS: unsigned
.app/.dmgare blocked by Gatekeeper on other Macs. You need an Apple Developer account, a Developer ID Application certificate to code-sign, and Apple notarization. - Windows: unsigned
.exe/.msitrigger SmartScreen. You need an Authenticode code-signing certificate (OV, or EV for instant reputation) from a CA — increasingly on a hardware token / cloud HSM. - Linux:
.AppImage/.debgenerally run unsigned; distribution is via your own download, a repo, or Flathub. - Auto-updates: Tauri's updater plugin exists but isn't wired up here; it needs a hosted update feed and its own signing key.
None of this is implemented — it's out of scope for v1. This section is here so you know what stands between a local build and shipping to users.
MIT © Ahmad Suddle. Do what you like — just keep the copyright notice.




