MCP服务器代理网关

该项目通过Docker Compose将仅支持stdio的MCP服务器包装为OAuth认证的SSE/Streamable HTTP端点,使远程MCP客户端(如Claude Desktop)无需本地安装即可安全连接,适用于自托管VPS环境。

  • by iu3qez
  • Python
  • 混合部署
  • 更新于
在 GitHub 查看 iu3qez/mcp-proxy

使用说明

MCP Proxy

Personal project — Docker Compose setup to expose stdio-only MCP servers as OAuth-authenticated SSE / Streamable HTTP endpoints from a self-hosted VPS.

What it does

Many MCP servers only support stdio transport and cannot be consumed by remote clients directly. This project wraps them with mcp-proxy and exposes them over HTTPS, so any remote MCP client (Claude Desktop, Claude Code, claude.ai, …) can connect without local installations.

Authentication is OAuth 2.1 via Scalekit, implemented by auth_proxy.py — Claude Desktop does not support static bearer tokens for remote MCP servers.

Architecture

Client (Claude Desktop / claude.ai)
  → HTTPS — Traefik (external, TLS)
    → auth_proxy.py  :8080   Starlette — OAuth discovery + Scalekit token validation
      → mcp-proxy    :9090   (127.0.0.1 only) named-server mode
        → stdio child processes: parcel, trillium, brightdata, inventree

Traefik is not part of this compose file: the container joins a pre-existing external Docker network named traefik and is routed via labels.

Quick start

  1. Copy .env.example to .env and fill in the values:

    cp .env.example .env
    
    Variable Description
    DOMAIN Hostname pointed at this server (e.g. mcp.example.com)
    SCALEKIT_ENVIRONMENT_URL Scalekit environment URL (e.g. https://you.scalekit.dev)
    SCALEKIT_CLIENT_ID Scalekit client ID
    SCALEKIT_CLIENT_SECRET Scalekit client secret
    SCALEKIT_RESOURCE_ID Scalekit MCP resource ID (res_…) — must match the token audience
    TRACKING_API_TOKEN API key from 17track.net (parcel)
    TRILLIUM_API_TOKEN TriliumNext ETAPI token (Options → ETAPI → Create Token)
    BRIGHTDATA_TOKEN Bright Data API token
    INVENTREE_TOKEN InvenTree API token (Settings → User → API Tokens)

    All of them are required: entrypoint.sh fails fast on missing server tokens and auth_proxy.py raises on missing SCALEKIT_* vars.

  2. Start the stack:

    docker compose up -d --build
    
  3. Verify:

    curl https://$DOMAIN/status                                   # 200, lists configured servers
    curl https://$DOMAIN/.well-known/oauth-protected-resource      # OAuth discovery document
    curl -si https://$DOMAIN/servers/parcel/sse | head -5          # 401 + WWW-Authenticate
    

Endpoints

Path Transport Auth
/servers/parcel/sse SSE OAuth 2.1
/servers/parcel/mcp Streamable HTTP OAuth 2.1
/servers/trillium/sse · /servers/trillium/mcp SSE / Streamable HTTP OAuth 2.1
/servers/brightdata/sse · /servers/brightdata/mcp SSE / Streamable HTTP OAuth 2.1
/servers/inventree/sse · /servers/inventree/mcp SSE / Streamable HTTP OAuth 2.1
/.well-known/oauth-protected-resource HTTP None
/status HTTP None

Client configuration

Add the server URL as a remote MCP connector; the client discovers the authorization server from /.well-known/oauth-protected-resource and runs the OAuth flow itself — no token to paste.

https://mcp.example.com/servers/parcel/mcp

Configured servers

Name Package / target Config source
parcel parcel-mcp (17track) config.json written next to index.mjs by entrypoint.sh
trillium @aimbitgmbh/trillium-mcp (TriliumNext Notes) per-server env block injected into servers.json
brightdata remote MCP at https://mcp.brightdata.com/mcp, bridged by mcp-remote Bearer token injected into the --header arg
inventree syntaxerr66/inventree-mcp (Go binary built in the Dockerfile) per-server env block injected into servers.json

Packages are installed with bun into /opt/mcp-packages (not globally) and launched as bun /opt/mcp-packages/node_modules/<pkg>/….

Adding a new MCP server

  1. Add an entry to servers.json (top-level key is mcpServers):

    {
      "mcpServers": {
        "new-server": {
          "command": "bun",
          "args": ["/opt/mcp-packages/node_modules/new-mcp-server/dist/index.js"]
        }
      }
    }
    
  2. Add the package to the bun add line in the Dockerfile.

  3. Wire up its configuration:

    • env vars → add them to the container in docker-compose.yml, then inject them into that server's env block in servers.json from entrypoint.sh. mcp-proxy runs without --pass-environment, so stdio children do not inherit the container environment (see below).
    • config file → generate it in entrypoint.sh (see the parcel case below).
    • remote HTTP MCP → bridge it with mcp-remote, following the brightdata entry.
  4. Add secrets to .env and .env.example.

  5. docker compose up -d --build

Gotchas

Child processes don't inherit container env. mcp-proxy runs without --pass-environment, so its base_env is empty. A server reading FOO_TOKEN from the environment gets undefined unless it has a per-server env block in servers.json. This is deliberate: it keeps the Scalekit secrets out of every child process. The MCP SDK still merges get_default_environment() (PATH, HOME, …), so bun stays resolvable.

config.json path. parcel-mcp reads its config from path.join(__dirname, "config.json") — the file must sit next to index.mjs inside the package directory, not in the working directory. entrypoint.sh writes it to /opt/mcp-packages/node_modules/parcel-mcp/config.json at container startup. Any server with the same behaviour needs the same treatment.

Scalekit audience. The token audience must be the resource ID (res_…), not the public URL.

SSE streaming. Starlette's BaseHTTPMiddleware buffers responses and breaks SSE — auth_proxy.py uses raw ASGI middleware instead.

Historical docs

docs/superpowers/ contains the original design spec and implementation plan from April 2026. They describe the pre-OAuth architecture (Traefik traefik-plugin-apikey bearer auth, npm global installs) and are kept only as a record — this README and CLAUDE.md are the current documentation.