ערכת פריסה לשרת MCP

תארו את שרת ה-MCP שלכם וקבלו את הקבצים להריץ אותו על VPS מאחורי proxy עם TLS, שמזרים ובודק bearer token: ‏Compose, ‏Dockerfile, תצורת proxy, ‏.env.example ו-Runbook. נכתב בדפדפן שלכם; אף טוקן לא נכתב.

ה-Dockerfile נכתב ל-runtime הזה. עם image קיים הוא רק מצוין.
הפורט שהשרת מאזין לו בתוך ה-container, על 0.0.0.0.
היכן השרת עונה לבקשות MCP, בדרך כלל ‎/mcp.
שם ה-host הציבורי שהלקוחות מתחברים אליו, למשל mcp.example.org.
כל בקשה צריכה Authorization: Bearer <token>; הטוקן נקרא מ-.env.
לכל כתובת לקוח. ריק פירושו בלי מגבלה. ‏nginx בלבד: ב-build הרגיל של Caddy אין מגבלת קצב.
לחשבון Let's Encrypt.

הערכה שלכם

docker-compose.yml
# Generated by the ANAROQ MCP Server Deploy Kit: https://anaroq.com/agents/mcp-server-deploy-kit
# Kit 1.0.0: node, build, caddy, bearer.
# Only the proxy publishes ports; the MCP server is reached on the Docker network.
# The bearer token comes from .env (MCP_BEARER_TOKEN); Compose stops if it is empty.

name: "mcp-server"
services:
  mcp:
    build:
      context: "./server"
    restart: "unless-stopped"
    expose:
      - "8000"
    security_opt:
      - "no-new-privileges:true"
    cap_drop:
      - "ALL"
    networks:
      - "mcp"
  proxy:
    image: "caddy:2.11.7-alpine"
    restart: "unless-stopped"
    depends_on:
      - "mcp"
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      MCP_BEARER_TOKEN: "${MCP_BEARER_TOKEN:?set MCP_BEARER_TOKEN in .env}"
    volumes:
      - "./Caddyfile:/etc/caddy/Caddyfile:ro"
      - "caddy_data:/data"
      - "caddy_config:/config"
    networks:
      - "mcp"
networks:
  mcp: {}
volumes:
  caddy_data: {}
  caddy_config: {}
server/Dockerfile
# syntax=docker/dockerfile:1
# השורה שמעל חייבת להישאר ראשונה: היא גורמת ל-BuildKit לקרוא את הקובץ בתחביר
# ה-Dockerfile העדכני, שנדרש ל-cache mounts שבהמשך.

# שלב 1 מתקין את התלויות ב-node:24-alpine, תג גרסה ולא latest, כך שבנייה חוזרת
# משתמשת באותה גרסת Node.js.
FROM node:24-alpine AS deps
# עבודה בתיקייה /app בשלב הזה.
WORKDIR /app
# קודם רק package.json ו-package-lock.json: השכבה הזו, וההתקנה שאחריה, נבנות
# מחדש רק כשהתלויות משתנות.
COPY package.json package-lock.json ./
# מתקין בדיוק את מה שבקובץ הנעילה, בלי devDependencies. ה-cache של npm הוא cache
# mount של BuildKit, כך שהוא מאיץ את הבנייה הבאה בלי להישאר ב-image.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

# ה-image הסופי מתחיל שוב מ-node:24-alpine, בלי ה-caches והכלים של שלב ה-build.
FROM node:24-alpine
# מצב production לספריות Node.js שקוראות את NODE_ENV (Express, למשל, שומר אז
# views ב-cache ומסתיר stack traces).
ENV NODE_ENV=production
# הפורט שהאפליקציה צריכה להאזין לו. מסגרות רבות קוראות את PORT; אם שלכם לא, יש
# להגדיר בתצורה שלה את הפורט 8000.
ENV PORT=8000
# האפליקציה נמצאת ב-/app.
WORKDIR /app
# התלויות שהותקנו בשלב 1. מנהל החבילות וה-cache שלו נשארים מאחור.
COPY --from=deps /app ./
# קוד המקור, אחרי התלויות כדי ששינוי בקוד לא יתקין אותן מחדש. הקבצים נשארים
# בבעלות root, כך שהאפליקציה לא יכולה לשנות את הקוד של עצמה; .dockerignore משאיר
# בחוץ את .git, .env ו-builds מקומיים.
COPY . .
# ב-image הרשמי של Node.js יש משתמש בלי הרשאות, node (uid 1000); התהליך רץ
# כמשתמש הזה ולא כ-root.
USER node
# מתעד שהאפליקציה מאזינה לפורט 8000. זה לא מפרסם כלום: את זה עושים docker run -p
# או ports: ב-Compose.
EXPOSE 8000
# אין בדיקת בריאות ב-image: הבריאות נבדקת במקום אחר (probe של orchestrator או
# קובץ ה-Compose). השורה מציינת שזו החלטה.
HEALTHCHECK NONE
# מפעיל את server.js בצורת exec: התהליך הוא PID 1 ומקבל SIGTERM ב-docker stop.
CMD ["node", "server.js"]
server/.dockerignore
# היסטוריית Git: גדולה, ועלולה להכיל סודות שהוסרו מהקוד.
.git

# קובצי סביבה ומפתחות: אחרת COPY . . מכניס אותם ל-image, שם כל מי שמושך אותו
# יכול לקרוא אותם.
.env
.env.*
*.pem
*.key

# קובצי ה-Docker עצמם: האפליקציה לא צריכה אותם, ושינוי בהם היה מבטל את ה-cache
# של שכבת קוד המקור.
Dockerfile
.dockerignore
compose*.y*ml
docker-compose*.y*ml

# לוגים וקובצי עורך.
*.log
.DS_Store
.idea
.vscode

# תלויות שהותקנו במחשב שלכם: ה-image מתקין משלו, עבור הפלטפורמה שלו.
node_modules
npm-debug.log*
coverage
Caddyfile
# Generated by the ANAROQ Reverse Proxy Generator: https://anaroq.com/tools/reverse-proxy-generator
# Engine @anaroq/compose, reverse proxy 1.1.0, caddy.
# Caddy gets and renews each site's certificate on its own (automatic HTTPS).

mcp.example.org {
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		X-Frame-Options "SAMEORIGIN"
		-Server
	}
	request_body {
		max_size 10MiB
	}
	@no-token not header Authorization "Bearer {$MCP_BEARER_TOKEN}"
	respond @no-token 401
	reverse_proxy mcp:8000 {
		flush_interval -1
	}
}
.env.example
# Generated by the ANAROQ MCP Server Deploy Kit 1.0.0: https://anaroq.com/agents/mcp-server-deploy-kit
# Copy to .env on the server (cp .env.example .env; chmod 600 .env). Never commit .env.

# The token every MCP client sends as `Authorization: Bearer <token>`. Make one with:
#   openssl rand -hex 32
# Letters and digits only: a space or a quote breaks the proxy's check.
MCP_BEARER_TOKEN=

# The MCP server's own settings (API keys, OAuth issuer…) go below, and into the
# `environment` of the `mcp` service in docker-compose.yml.
RUNBOOK.md
# שרת MCP ב-mcp.example.org: ‏Runbook

נכתב על ידי ‏MCP Server Deploy Kit של ANAROQ, גרסה 1.0.0 ‏(https://anaroq.com/agents/mcp-server-deploy-kit). קראו כל צעד לפני שאתם מריצים אותו.

## מה רץ

- ‏`mcp`: שרת ה-MCP שלכם (Dockerfile, node), נגיש רק מה-proxy ברשת ה-Docker, בפורט 8000.
- ‏`proxy`: ‏Caddy, ה-container היחיד עם פורטים ציבוריים (80 ו-443). הוא מסיים TLS ומעביר את /mcp לשרת עם streaming.
- גישה: ה-proxy דוחה כל בקשה בלי `Authorization: Bearer <token>` ‏(401). הטוקן נמצא רק ב-`.env` בשרת.

## לפני שמתחילים

- שרת עם Docker Engine ו-Compose plugin, ופורטים 80 ו-443 פתוחים לאינטרנט.
- ‏DNS: רשומת A (ו-AAAA, אם לשרת יש IPv6) עבור mcp.example.org שמצביעה לשרת.
- שרת חדש יכול לקבל Docker וחומת אש באתחול הראשון: https://anaroq.com/he/tools/cloud-init-generator

## צעדים

1. העתיקו את קבצי הערכה כמו שהם לתיקייה אחת בשרת, ועבדו ממנה:

   - `docker-compose.yml`
   - `server/Dockerfile`
   - `server/.dockerignore`
   - `Caddyfile`
   - `.env.example`
   - `RUNBOOK.md`

שימו את הקוד של השרת ב-`server/`, ליד ה-Dockerfile שלו: `package.json`, `package-lock.json`, `server.js`. רק `server/` נשלח ל-build, אף פעם לא `.env` או התעודות.

2. צרו `.env` מהדוגמה, שימו בו טוקן ארוך ואקראי, והגבילו את הקריאה שלו אליכם בלבד:

```sh
cp .env.example .env
chmod 600 .env
sed -i "s/^MCP_BEARER_TOKEN=.*/MCP_BEARER_TOKEN=$(openssl rand -hex 32)/" .env
```

3. בנו (כשיש בערכה Dockerfile) והפעילו, ואז בדקו ששני ה-containers רצים:

```sh
docker compose up -d --build
docker compose ps
```

## בדיקת חיבור

עם הטוקן, ‏`initialize` חייב לענות HTTP 200 עם `result` שמכיל `protocolVersion` (כ-JSON או כשורת `data:` של SSE):

```sh
set -a; . ./.env; set +a
curl -sS -i https://mcp.example.org/mcp \
  -H "Authorization: Bearer $MCP_BEARER_TOKEN" \
  -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":"curl","version":"1.0"}}}'
```

בלי הטוקן, אותה בקשה חייבת לקבל HTTP 401:

```sh
curl -sS -i https://mcp.example.org/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":"curl","version":"1.0"}}}'
```

## חיבור לקוח MCP

‏Claude Code:

```sh
claude mcp add --transport http mcp-server https://mcp.example.org/mcp \
  --header "Authorization: Bearer $MCP_BEARER_TOKEN"
```

לקוח שקורא תצורת JSON (שמות המפתחות שונים בין לקוחות; בדקו בתיעוד של הלקוח שלכם):

```json
{
  "mcpServers": {
    "mcp-server": {
      "type": "http",
      "url": "https://mcp.example.org/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_BEARER_TOKEN}"
      }
    }
  }
}
```

## החלפת הטוקן

שימו טוקן חדש ב-`.env`, צרו מחדש את ה-proxy (הוא קורא את המשתנה בעלייה), ועדכנו כל לקוח:

```sh
sed -i "s/^MCP_BEARER_TOKEN=.*/MCP_BEARER_TOKEN=$(openssl rand -hex 32)/" .env
docker compose up -d --force-recreate proxy
```

## חידוש תעודות

‏Caddy מחדש תעודות בעצמו. שמרו את ה-volume ‏`caddy_data`: התעודות נמצאות בו.

## כשמשהו נכשל

- ‏502 או 504: השרת לא מאזין ל-0.0.0.0:8000 בתוך ה-container. ‏`docker compose logs mcp`.
- ‏401 עם הטוקן: ‏`.env` השתנה אחרי שה-proxy עלה; צרו אותו מחדש. טוקן עם רווחים או מרכאות שובר את הבדיקה: השתמשו ב-`openssl rand -hex 32`.
- הזרם נעצר: זרם שלא שולח דבר נסגר אחרי ה-read timeout של ה-proxy. שלחו keep-alive מהשרת.
- אין תעודה: ה-DNS עוד לא מצביע לשרת, או שפורט 80 סגור. ‏`docker compose logs proxy`.

## מגבלות

- הערכה לא מארחת ולא מריצה שום דבר עבורכם, ולא מממשת OAuth.
- ה-bearer token הוא סוד משותף אחד: כל מי שמחזיק בו הוא לקוח מלא. השתמשו בטוקן לכל צוות לקוחות והחליפו אותו כשמישהו עוזב.
- השרת חייב לבדוק את כותרת Origin ואת הקלט שלו; ה-proxy לא עושה זאת.

נבדק בדפדפן שלכם

  • ‏Compose Analyzer על docker-compose.yml: ‏0 שגיאות, ‏0 גבוהה, ‏0 בינונית, ‏8 נמוכה.
  • ‏Dockerfile Linter על server/Dockerfile: ‏0 ממצאים.
  • ערכה 1.0.0, ‏reverse proxy 1.1.0, ‏Dockerfile Generator 1.0.0.

מה לעשות עם הקבצים

  • העתיקו את .env.example ל-.env בשרת ומלאו אותו שם. הערכה לא כותבת שום סוד: ‏MCP_BEARER_TOKEN ריק, ו-Compose מסרב להפעיל את ה-proxy עד שהוא מוגדר.
  • השרת חייב להאזין ל-0.0.0.0:8000 בתוך ה-container (לא 127.0.0.1), אחרת ה-proxy לא יגיע אליו.
  • הפרויקט צריך package.json ו-package-lock.json שמורים ליד ה-Dockerfile, ואת server.js בתיקייה הראשית.
  • האפליקציה צריכה להאזין ב-0.0.0.0, פורט 8000 (המשתנה PORT), ולא ב-127.0.0.1, אחרת שום דבר מחוץ ל-container לא מגיע אליה.
  • התגים מקבלים עדכוני אבטחה של קו הגרסה שלהם. כדי להקפיא את ה-image המדויק, יש להוסיף את ה-digest שלו (image:tag@sha256:…) ולעדכן אותו במכוון.
  • כוונו את רשומות A/AAAA של mcp.example.org לשרת הזה לפני שה-proxy עולה: רשות התעודות בודקת אותן ב-HTTP.
  • פורטים 80 ו-443 חייבים להגיע ל-proxy מהאינטרנט: ‏80 לאתגר ה-HTTP של ACME ולהפניה, ‏443 ל-HTTPS.
  • בקשות בלי Authorization: Bearer <token> מקבלות 401. ה-proxy קורא את הטוקן מ-MCP_BEARER_TOKEN בסביבה שלו, כך שהקבצים אף פעם לא מכילים אותו: הגדירו אותו ב-.env, השאירו את .env מחוץ ל-repository, ובחרו טוקן ארוך ואקראי (openssl rand -hex 32).
  • תגובות בהזרמה (Server-Sent Events, ‏MCP Streamable HTTP) עוברות בלי buffering. ‏nginx סוגר זרם ששותק במשך 1h, ‏Caddy משאיר אותו פתוח; שרת שמחזיק זרם פתוח צריך לשלוח keep-alive לעתים קרובות יותר.
  • ‏RUNBOOK.md מכיל את הצעדים לפי הסדר, בדיקת חיבור מלקוח MCP, החלפת טוקן ומה לעשות כשמשהו נכשל.

מה הכלי עושה

שרת MCP מרוחק הוא שירות HTTP: לקוחות כמו Claude Code מתחברים אליו ב-Streamable HTTP. כדי להעלות אחד על VPS צריך כל פעם את אותם חלקים: השרת ב-container, ‏proxy לפניו עם תעודה, תגובות שמוזרמות בלי buffering, ומשהו שמשאיר זרים בחוץ.

הערכה כותבת את החלקים האלה מהמנועים שהאתר כבר משתמש בהם: ה-Reverse Proxy Generator כותב את ה-proxy, מנוע ה-Compose כותב את docker-compose.yml וה-Analyzer שלו בודק אותו, ומחולל ה-Dockerfile כותב את ה-Dockerfile, שה-Dockerfile Linter בודק.

איך זה עובד

  1. הקבצים נכתבים בדפדפן שלכם. אותן בחירות תמיד נותנות אותם bytes; שום דבר שאתם מקלידים לא נשלח, לא נשמר ולא נכנס לקישור.
  2. רק ה-proxy מפרסם פורטים (80 ו-443). מגיעים לשרת לפי שם ה-service שלו ברשת Docker פרטית, בלי פורט משלו באינטרנט.
  3. עם bearer token, ה-proxy דוחה ב-401 כל בקשה בלי Authorization: Bearer <token>. הטוקן נקרא מסביבת ה-proxy, מתוך .env: הערכה כותבת משתנה ריק, ו-Compose מסרב לעלות עד שתמלאו אותו בשרת.
  4. ‏Streaming: ה-proxy מעביר Server-Sent Events ברגע שהם מגיעים (ב-Caddy ‏flush_interval -1, ב-nginx ‏proxy_buffering off) עם timeouts ארוכים לקריאה ולשליחה.

הקבצים

docker-compose.yml
השרת וה-proxy ברשת פרטית אחת; רק ל-proxy יש פורטים.
server/Dockerfile, server/.dockerignore
כשבונים מהקוד: ‏multi-stage, משתמש שאינו root, בלי HEALTHCHECK (נקודת קצה של MCP לא עונה ל-GET פשוט).
Caddyfile, templates/anaroq-proxy.conf.template
‏TLS, הפניה מ-HTTP ל-HTTPS, כותרות אבטחה, בדיקת הטוקן, ‏streaming ומגבלת הקצב. ‏nginx קורא את הטוקן דרך ה-templates של ה-image הרשמי.
.env.example
משתנה הטוקן, ריק, ואיך יוצרים טוקן: ‏openssl rand -hex 32.
RUNBOOK.md
הצעדים לפי הסדר, התעודה הראשונה ל-nginx, בדיקת החיבור עם curl ועם לקוח MCP, החלפת טוקן, חידוש תעודות ומה לעשות כשמשהו נכשל.

דוגמה

שרת Node.js שנבנה מהקוד שלו, מאחורי Caddy עם bearer token, שנכתב על ידי אותו קוד בזמן build:

docker-compose.yml
# Generated by the ANAROQ MCP Server Deploy Kit: https://anaroq.com/agents/mcp-server-deploy-kit
# Kit 1.0.0: node, build, caddy, bearer.
# Only the proxy publishes ports; the MCP server is reached on the Docker network.
# The bearer token comes from .env (MCP_BEARER_TOKEN); Compose stops if it is empty.

name: "mcp-server"
services:
  mcp:
    build:
      context: "./server"
    restart: "unless-stopped"
    expose:
      - "8000"
    security_opt:
      - "no-new-privileges:true"
    cap_drop:
      - "ALL"
    networks:
      - "mcp"
  proxy:
    image: "caddy:2.11.7-alpine"
    restart: "unless-stopped"
    depends_on:
      - "mcp"
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      MCP_BEARER_TOKEN: "${MCP_BEARER_TOKEN:?set MCP_BEARER_TOKEN in .env}"
    volumes:
      - "./Caddyfile:/etc/caddy/Caddyfile:ro"
      - "caddy_data:/data"
      - "caddy_config:/config"
    networks:
      - "mcp"
networks:
  mcp: {}
volumes:
  caddy_data: {}
  caddy_config: {}
Caddyfile
# Generated by the ANAROQ Reverse Proxy Generator: https://anaroq.com/tools/reverse-proxy-generator
# Engine @anaroq/compose, reverse proxy 1.1.0, caddy.
# Caddy gets and renews each site's certificate on its own (automatic HTTPS).

mcp.example.org {
	header {
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		X-Frame-Options "SAMEORIGIN"
		-Server
	}
	request_body {
		max_size 10MiB
	}
	@no-token not header Authorization "Bearer {$MCP_BEARER_TOKEN}"
	respond @no-token 401
	reverse_proxy mcp:8000 {
		flush_interval -1
	}
}

מגבלות

  • ‏ANAROQ לא מארחת ולא מריצה את השרת שלכם. הערכה כותבת קבצים; אתם מריצים אותם בשרת שלכם.
  • ‏OAuth לא ממומש: בחירה בו רק מתעדת מה השרת צריך לעשות ומשאירה את ה-proxy בלי בדיקה. שרת בלי הרשאה עובדת פתוח לכל מי שמוצא אותו.
  • ‏bearer token אחד הוא סוד משותף אחד: כל מי שמחזיק בו הוא לקוח מלא. החליפו אותו כשמישהו עוזב, והשאירו את .env מחוץ לכל repository.
  • הגבלת הקצב היא של nginx (לכל כתובת לקוח). ב-build הרגיל של Caddy אין כזו; מאחורי CDN כל הבקשות מגיעות מהכתובת של ה-CDN.
  • הערכה נבדקת בהפעלת שרת MCP לדוגמה מאחורי כל proxy ‏(initialize עם הטוקן ובלעדיו, ‏streaming, מגבלת קצב). ההתנהגות של השרת שלכם עצמו היא שלכם לבדוק.

לפני הערכה

מתודולוגיה: איך מחושבים המספרים

מקורות

  1. Model Context Protocol: Transports (Streamable HTTP)
  2. Model Context Protocol: Authorization
  3. Caddy: reverse_proxy (flush_interval)
  4. nginx Docker image: using environment variables in configuration
  5. nginx: ngx_http_limit_req_module
  6. Claude Code Docs: Connect to MCP servers

עודכן

הצעד הבא

מחולל הרשאות לסוכני קוד

כלי ציבורי חינמי של אנרוק, חברה שבונה תשתיות מחשוב. מה אנרוק בונה