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.
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. 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:
GET /.well-known/oauth-protected-resource→ find the authorization server (RFC 9728).GET /.well-known/oauth-authorization-server→ findauthorization_endpoint,token_endpoint,registration_endpoint(RFC 8414).POST /register→ Dynamic Client Registration, get aclient_idautomatically (RFC 7591). ← without this you get “Automatic client registration isn’t supported”.GET /oauth/authorize(PKCE S256) → auth code.POST /oauth/token→ access token.POST /mcp(Streamable HTTP) withAuthorization: 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)
-
Healthcheck: use
127.0.0.1, neverlocalhost. busyboxwget(alpine) resolveslocalhost→ IPv6[::1], but nginxlisten 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. -
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 andinitializefails → “authorized but returned an error when connecting”. Fix:await transport.handleRequest(req, res, req.body). -
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' } }. -
Advertise https, not http. The reverse proxy terminates TLS and forwards over http, so nginx’s
$schemeishttp. If you build OAuth metadata URLs from$schemethey come outhttp://and Claude rejects them. Preserve the real scheme: nginxmap $http_x_forwarded_proto $fwd_proto { default $scheme; https https; http http; }and setX-Forwarded-Proto $fwd_proto. In node, build base URL fromx-forwarded-proto/x-forwarded-host. -
Proxy ALL the auth paths + build memory. nginx must proxy
/oauth/and/register(not just/mcpand/.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 oneCOPY --from=other /markerto force ordering) or add swap. Note: exit 255 with no “Killed” is often OOM; confirm withdmesg | grep -i oomandfree -h.
Build steps
- Copy
templates/into your repo. Rename/extendmcp-server/index.jstools for your use case (keep the OAuth/transport plumbing intact). - Put your real tools in
createMcpServer()usingserver.tool(name, desc, { zodShape }, handler). - Set the container’s public port in
nginx.conf(listen PORT),Dockerfile(EXPOSE PORT, healthcheck), and the reverse proxy. - Build & run:
docker compose up -d --build(or via Coolify).
Coolify deploy specifics
- Build Pack = Dockerfile (if a
docker-compose.ymlexists 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
.devdomain 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/healthzport<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_protomap;/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_KEYenv to also allow header/bearer auth for direct REST calls; the OAuth flow works alongside it. read_doc/tool arguments: prefer a single opaquelocation/idstring returned by your list/search tools over multi-field addressing — simpler for the model.