self-hosted-mcp-for-claude

Build and deploy a self-hosted remote MCP (Model Context Protocol) server that Claude.ai / Claude Code custom connectors accept, without a Cloudflare Worker. Use when you need to expose your own tools/data to Claude over HTTP behind a domain (Coolify/Traefik, plain nginx, or any reverse proxy). Covers the Streamable HTTP transport, OAuth 2.1 with Dynamic Client Registration (DCR, RFC 7591/8414/9728), an nginx + supervisor + Node single-container packaging, and the exact gotchas that make self-hosted MCP fail (IPv6 healthcheck, express body parsing, Zod tool schemas, forwarded https scheme, build OOM). Includes copy-paste templates.

License: MIT Tools: Bash, Read, Write, Edit

Cài skill này

Chạy trong terminal, sau đó restart Claude Code.

curl -fsSL https://raw.githubusercontent.com/kaiitc/helloai-kit/main/install.sh | bash -s -- skill self-hosted-mcp-for-claude

Paste prompt dưới vào Claude Code / Codex CLI chat, AI sẽ chạy cài giúp.

Cài giúp mình skill "self-hosted-mcp-for-claude" từ HelloAI Kit nhé. Chạy trong terminal:

curl -fsSL https://raw.githubusercontent.com/kaiitc/helloai-kit/main/install.sh | bash -s -- skill self-hosted-mcp-for-claude

Xong check folder ~/.claude/skills/self-hosted-mcp-for-claude/ có tồn tại không rồi báo lại.

View source on GitHub →

Self-hosted MCP for Claude

Recipe for a remote MCP server Claude’s custom connector accepts — no Cloudflare Worker needed. Battle-tested; every gotcha below cost a real debugging cycle.

When to use

  • You want Claude (claude.ai or Claude Code) to call your own tools/data over the internet.
  • You control a domain + a reverse proxy (Coolify/Traefik, Caddy, or nginx).
  • You do NOT want a Cloudflare Worker in front (self-contained deployment).

Architecture (single container)

Claude ──https──► Reverse proxy (443, TLS)  ──http :PORT──►  Container
(MCP connector)   (Coolify/Traefik/nginx)                    ├─ nginx (:PORT)
 OAuth + DCR                                                  │   /mcp            → node MCP (:3001)
                                                             │   /oauth/, /register, /.well-known/*
                                                             │   (optional) /     → static site / app
                                                             └─ supervisor runs nginx + node

One container: nginx (public port, proxies MCP + serves anything static) + supervisor (runs nginx and the node MCP together) + node MCP on an internal port (3001). TLS is terminated by the reverse proxy in front; the container speaks plain http.

What Claude’s connector requires (the spec)

Claude follows the MCP Authorization spec. To connect to https://domain/mcp it will:

  1. GET /.well-known/oauth-protected-resource → find the authorization server (RFC 9728).
  2. GET /.well-known/oauth-authorization-server → find authorization_endpoint, token_endpoint, registration_endpoint (RFC 8414).
  3. POST /register → Dynamic Client Registration, get a client_id automatically (RFC 7591). ← without this you get “Automatic client registration isn’t supported”.
  4. GET /oauth/authorize (PKCE S256) → auth code.
  5. POST /oauth/token → access token.
  6. POST /mcp (Streamable HTTP) with Authorization: Bearer <token> → the MCP session.

All of /.well-known/*, /oauth/*, /register, /mcp must be reachable through your reverse proxy, and the metadata URLs must be https.

THE 5 GOTCHAS (this is the value)

  1. Healthcheck: use 127.0.0.1, never localhost. busybox wget (alpine) resolves localhost → IPv6 [::1], but nginx listen PORT; is IPv4-only → connection refused → container marked unhealthy → proxy won’t route. Also give it --start-period. Point the healthcheck at an nginx-only endpoint (/healthz) so it passes even while the MCP process boots.

  2. Pass the parsed body to the transport. With Express + express.json(), the request stream is already consumed. transport.handleRequest(req, res) then reads an empty stream and initialize fails → “authorized but returned an error when connecting”. Fix: await transport.handleRequest(req, res, req.body).

  3. Tool params must be Zod schemas. MCP SDK ≥1.x rejects plain JSON-schema objects: “expected a Zod schema or ToolAnnotations, but received an unrecognized object”. Use { q: z.string(), limit: z.number().optional() }, not { q: { type: 'string' } }.

  4. Advertise https, not http. The reverse proxy terminates TLS and forwards over http, so nginx’s $scheme is http. If you build OAuth metadata URLs from $scheme they come out http:// and Claude rejects them. Preserve the real scheme: nginx map $http_x_forwarded_proto $fwd_proto { default $scheme; https https; http http; } and set X-Forwarded-Proto $fwd_proto. In node, build base URL from x-forwarded-proto / x-forwarded-host.

  5. Proxy ALL the auth paths + build memory. nginx must proxy /oauth/ and /register (not just /mcp and /.well-known/) or DCR/authorize/token 404. And on a small build server, multi-stage Docker builds run stages in parallel → RAM spike → silent OOM; serialize heavy stages (make one COPY --from=other /marker to force ordering) or add swap. Note: exit 255 with no “Killed” is often OOM; confirm with dmesg | grep -i oom and free -h.

Build steps

  1. Copy templates/ into your repo. Rename/extend mcp-server/index.js tools for your use case (keep the OAuth/transport plumbing intact).
  2. Put your real tools in createMcpServer() using server.tool(name, desc, { zodShape }, handler).
  3. Set the container’s public port in nginx.conf (listen PORT), Dockerfile (EXPOSE PORT, healthcheck), and the reverse proxy.
  4. Build & run: docker compose up -d --build (or via Coolify).

Coolify deploy specifics

  • Build Pack = Dockerfile (if a docker-compose.yml exists Coolify may pick Compose mode and hide the port field).
  • Ports Exposes = (e.g. 5031). Do NOT put the port in the Domain field — Domain is just https://your-domain.
  • Enable SSL (Let’s Encrypt). For a .dev domain HTTPS is mandatory (HSTS preload).
  • Using Cloudflare in front: set DNS DNS-only (grey) during first cert issuance (proxied/orange blocks the ACME HTTP-01 challenge). After the cert works, you may switch to proxied with SSL/TLS mode Full (strict).
  • Coolify uses the Dockerfile HEALTHCHECK. If you also enable Coolify’s UI healthcheck, point it at /healthz port <PORT> — never / (Basic-Auth → 401 → unhealthy).

Verify

# Metadata must be https and include registration_endpoint
curl https://your-domain/.well-known/oauth-authorization-server

# End-to-end MCP handshake (should return serverInfo, not 500)
curl -sN -X POST https://your-domain/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'

Then in Claude: Settings → Connectors → Add custom connector → https://your-domain/mcp. It auto-registers (DCR); no manual Client ID.

Templates (in templates/)

  • Dockerfile — multi-stage, serialized, nginx-only healthcheck on 127.0.0.1.
  • nginx.conf — proxies /mcp, /oauth/, /register, /.well-known/; $fwd_proto map; /healthz.
  • supervisord.conf, start.sh — run nginx + node together.
  • docker-compose.yml — local run.
  • mcp-server/index.js — Streamable HTTP transport (body fix) + OAuth 2.1 + DCR + one example Zod tool.
  • mcp-server/package.json — deps (@modelcontextprotocol/sdk, express, zod).

Notes

  • In-memory OAuth stores (codes/tokens/clients) are fine for a single instance; use a shared store (Redis) if you run multiple replicas.
  • Set API_KEY env to also allow header/bearer auth for direct REST calls; the OAuth flow works alongside it.
  • read_doc/tool arguments: prefer a single opaque location/id string returned by your list/search tools over multi-field addressing — simpler for the model.