A single-binary bridge between stdio MCP clients (Claude Desktop, Cursor, Windsurf) and remote MCP servers over Streamable HTTP, with OAuth 2.1 (PKCE) handled locally. Zero runtime dependencies — no Node, no npx.
Claude Desktop ── stdio ── mcpurl ── HTTPS ── https://mcp.example.com/mcp
│
└─ browser PKCE ── Keycloak / authentik / any OAuth 2.1 AS
Claude Desktop's built-in remote connectors reach MCP servers from
Anthropic's backend — servers on private/corporate networks are unreachable.
A local stdio bridge is the only path in, and the incumbent (mcp-remote)
requires Node, stores tokens in plaintext and has a history of refresh-token
races. mcpurl is a clean-room Go replacement:
- Single static binary (darwin/linux/windows), MIT; macOS builds are signed and notarized.
- Tokens are encrypted at rest (AES-256-GCM) with the key in the OS keychain — never plaintext in config files.
- Refresh-rotation safe: atomic writes + cross-process file lock; Keycloak "Revoke Refresh Token" mode is the design assumption, not an edge case. "Both tokens expired" ends in exactly one browser login — never a loop.
- Zero IdP-specific code: full RFC 9728 / RFC 8414 / OIDC discovery with issuer & resource validation and a PKCE downgrade guard. Keycloak today, authentik tomorrow — same binary, different profile.
- Protocol-version-agnostic passthrough: JSON-RPC flows through untouched;
the bridge follows the MCP 2025-06-18 Streamable HTTP transport (SSE
streams, session re-init on 404,
Last-Event-IDresume, graceful drain).
brew install --cask vmkteam/tap/mcpurl
# or:
go install github.com/vmkteam/mcpurl/cmd/mcpurl@latest
# or from a checkout:
make installOne command — it persists a profile and writes the Claude Desktop config
entry (with a .bak backup, other servers untouched):
mcpurl install https://mcp.example.com/mcp --client-id mcp-cli --name mysrv
# restart Claude Desktop; the browser opens for login on first usePrefer to log in ahead of time:
mcpurl login @mysrv # browser → IdP → tokens in the keychain
mcpurl token @mysrv # print a live access token (auto-refresh) for curlUndo: mcpurl uninstall mysrv (profile and tokens stay; mcpurl logout @mysrv drops the tokens).
Claude Code doesn't need a bridge — it connects directly:
claude mcp add --transport http mysrv https://mcp.example.com/mcp
mcpurl [flags] <url | @profile> run the stdio bridge (default command)
mcpurl login <url | @profile> interactive browser PKCE login
mcpurl logout <url | @profile | --all> drop stored tokens
mcpurl token <url | @profile> print access_token (auto-refresh)
mcpurl show [name] list profiles / show one (no secrets)
mcpurl claude-config <url | @profile> print ready-to-paste client config
mcpurl install <url | @profile> [--client claude-desktop|cursor|windsurf]
write the entry into the client config
mcpurl uninstall <name> [--client ...] remove a previously installed entry
mcpurl version
The bare-URL form works with zero config against any spec-compliant server — discovery does the rest. A profile only pins what discovery can't provide (client_id, a non-default callback port, scope overrides).
~/.config/mcpurl/config.toml (MCPURL_CONFIG overrides the path). No
secrets live here — tokens are in the keychain/token store:
[profiles.mysrv]
url = "https://mcp.example.com/mcp"
clientID = "mcp-cli"
callbackPort = 18075
scopes = ["openid", "profile", "offline_access"]
# issuer = "https://auth.example.com/realms/main" # optional: skip discovery
[profiles.mysrv.headers]
# "X-Team" = "$MY_TEAM" # $VAR expanded from envResolution order for every setting: flag > profile > discovery > default.
| Flag | Default | Meaning |
|---|---|---|
--client-id ID |
— | OAuth client_id (overrides profile) |
--scopes "a,b" |
discovery | OAuth scopes override |
--callback-port N |
18075 |
Loopback port for http://127.0.0.1:N/callback; register it in the IdP |
--issuer URL |
— | Skip RFC 9728 discovery, use this authorization server |
--header K:V |
— | Extra HTTP header, repeatable; $VAR expanded |
--bearer-env NAME |
— | Static bearer from env (MY_<NAME> checked first); disables OAuth |
--no-oauth |
false | Never attempt OAuth (401 is fatal) |
--no-keychain |
false | Force the file token store |
--no-sse |
false | Don't open the GET listening stream |
--allow-http |
false | Permit plain http for non-loopback hosts |
--timeout 10s |
10s |
Connect/TLS timeout (no overall response timeout — tools can be slow) |
-v |
false | Debug log to stderr, Authorization redacted |
On the first 401 mcpurl discovers the authorization server (RFC 9728
WWW-Authenticate/well-known → RFC 8414 / OIDC metadata), validates the
issuer and resource identifiers, refuses PKCE-less servers, and runs the
Authorization Code + PKCE (S256) flow through your browser with the RFC 8707
resource parameter. Tokens are stored encrypted (AES-256-GCM) under
~/.config/mcpurl/tokens/, keyed by endpoint + client_id, with the
encryption key in the macOS keychain / Secret Service; --no-keychain (and
Windows in v1) falls back to plain 0600 files.
Refresh is proactive (60 s before expiry) and single-flight across processes: several bridge instances coordinate via a file lock, always persisting rotated refresh tokens. If refresh is rejected, mcpurl falls back to one interactive login — the pending MCP request just waits.
The IdP client must be public with PKCE S256 and have
http://127.0.0.1:<callbackPort>/callback registered as a redirect URI.
| Code | Meaning |
|---|---|
| 0 | stdin EOF / clean shutdown |
| 1 | fatal transport error |
| 2 | usage / bad profile |
| 3 | auth failed |
- stdout is exclusively the MCP channel; everything human-readable goes to
stderr. Claude Desktop keeps it in
~/Library/Logs/Claude/mcp-server-*.log. - Add
"-v"to the client'sargsfor a full trace (tokens are redacted). loginfails with "port busy or not registered"? Check the redirect URI in the IdP and that nothing else listens on the callback port.- Manual end-to-end check:
npx @modelcontextprotocol/inspectorwith commandmcpurl, args@mysrv.
make test # go test -race ./...
make lint # golangci-lint (config verified in CI)
make build # ./mcpurl with the version stamped from git
make dist # darwin arm64/amd64, linux amd64, windows amd64Layout: cmd/mcpurl (CLI shell) → pkg/app (profiles, wiring, commands) →
pkg/mcp (Streamable HTTP client, SSE parser, stdio bridge) + pkg/oauth
(discovery, PKCE login, refresh, token storage). pkg/mcp and pkg/oauth
are independent; the bridge sees auth only as a TokenProvider interface.