-
Notifications
You must be signed in to change notification settings - Fork 75
feat(mcp): Simplify deployment of Replication MCP server over HTTPS #1069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # Copyright (c) 2025 Airbyte, Inc., all rights reserved. | ||
| """HTTP transport configuration helpers for hosted MCP deployment.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
|
|
||
| from starlette.middleware import Middleware | ||
| from starlette.middleware.cors import CORSMiddleware | ||
|
|
||
|
|
||
| MCP_CORS_ORIGINS_ENV = "MCP_CORS_ORIGINS" | ||
|
|
||
| # Headers used by remote MCP clients over streamable HTTP. | ||
| _MCP_CLIENT_HEADERS = [ | ||
| "mcp-protocol-version", | ||
| "mcp-session-id", | ||
| "Authorization", | ||
| "Content-Type", | ||
| "X-Airbyte-Workspace-Id", | ||
| "X-Airbyte-Cloud-Client-Id", | ||
| "X-Airbyte-Cloud-Client-Secret", | ||
| "X-Airbyte-Cloud-Api-Url", | ||
| "X-Airbyte-Cloud-Config-Api-Url", | ||
| ] | ||
|
|
||
|
|
||
| def parse_cors_origins(raw: str | None) -> list[str]: | ||
| """Parse a comma-separated list of CORS origins.""" | ||
| if not raw: | ||
| return [] | ||
| return [origin.strip() for origin in raw.split(",") if origin.strip()] | ||
|
|
||
|
|
||
| def build_http_middleware( | ||
| *, | ||
| cors_origins: str | None = None, | ||
| ) -> list[Middleware]: | ||
| """Build ASGI middleware for the hosted MCP HTTP transport. | ||
|
|
||
| When ``MCP_CORS_ORIGINS`` is set (comma-separated), enables CORS for | ||
| remote MCP clients that connect from a browser context (for example, | ||
| browser-based OAuth flows). | ||
| """ | ||
| origins = parse_cors_origins(cors_origins or os.getenv(MCP_CORS_ORIGINS_ENV, "")) | ||
| if not origins: | ||
| return [] | ||
|
|
||
| return [ | ||
| Middleware( | ||
| CORSMiddleware, | ||
| allow_origins=origins, | ||
| allow_methods=["GET", "POST", "DELETE", "OPTIONS"], | ||
| allow_headers=_MCP_CLIENT_HEADERS, | ||
| expose_headers=["mcp-protocol-version", "mcp-session-id"], | ||
| ), | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Caddy reverse proxy for the Airbyte Replication MCP server. | ||
| # | ||
| # Usage (with docker-compose.mcp.yml): | ||
| # MCP_PUBLIC_HOST=mcp.example.com docker compose -f deploy/docker-compose.mcp.yml up -d | ||
| # | ||
| # Caddy obtains and renews TLS certificates automatically for MCP_PUBLIC_HOST. | ||
|
|
||
| {$MCP_PUBLIC_HOST} { | ||
| # Health checks for load balancers and uptime monitors. | ||
| handle /health { | ||
| reverse_proxy mcp:8080 | ||
| } | ||
|
|
||
| # Streamable HTTP MCP endpoint (default mount path is /mcp). | ||
| handle /mcp* { | ||
| reverse_proxy mcp:8080 | ||
| } | ||
|
|
||
| # Fallback for path-stripped deployments (MCP_SERVER_URL with a path prefix). | ||
| handle { | ||
| reverse_proxy mcp:8080 | ||
| } | ||
|
Comment on lines
+19
to
+22
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Strip the prefix before proxying this fallback.
🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # Self-hosted Airbyte Replication MCP server with automatic HTTPS via Caddy. | ||
| # | ||
| # Prerequisites: | ||
| # - DNS for MCP_PUBLIC_HOST must point to this host | ||
| # - A dotenv secrets file at ~/.mcp/airbyte_mcp.env (or override MCP_ENV_FILE) | ||
| # | ||
| # Usage: | ||
| # export MCP_PUBLIC_HOST=mcp.example.com | ||
| # docker compose -f deploy/docker-compose.mcp.yml up -d --build | ||
| # | ||
| # Public MCP endpoint (default mount): https://$MCP_PUBLIC_HOST/mcp | ||
|
|
||
| services: | ||
| mcp: | ||
| build: | ||
| context: .. | ||
| dockerfile: Dockerfile.mcp | ||
| restart: unless-stopped | ||
| environment: | ||
| AIRBYTE_MCP_ENV_FILE: /secrets/airbyte_mcp.env | ||
| MCP_SERVER_URL: https://${MCP_PUBLIC_HOST:-localhost} | ||
| MCP_CORS_ORIGINS: ${MCP_CORS_ORIGINS:-} | ||
| OIDC_CONFIG_URL: ${OIDC_CONFIG_URL:-} | ||
|
Comment on lines
+21
to
+23
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Don’t default the public hostname to With automatic HTTPS, Also applies to: 45-46 🤖 Prompt for AI Agents |
||
| OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} | ||
| OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} | ||
| AIRBYTE_CLOUD_MCP_SAFE_MODE: ${AIRBYTE_CLOUD_MCP_SAFE_MODE:-1} | ||
| AIRBYTE_CLOUD_MCP_READONLY_MODE: ${AIRBYTE_CLOUD_MCP_READONLY_MODE:-0} | ||
| volumes: | ||
| - ${MCP_ENV_FILE:-${HOME}/.mcp/airbyte_mcp.env}:/secrets/airbyte_mcp.env:ro | ||
| expose: | ||
| - "8080" | ||
| healthcheck: | ||
| test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"] | ||
| interval: 30s | ||
| timeout: 5s | ||
| retries: 3 | ||
| start_period: 15s | ||
|
|
||
| caddy: | ||
| image: caddy:2-alpine | ||
| restart: unless-stopped | ||
| ports: | ||
| - "80:80" | ||
| - "443:443" | ||
| environment: | ||
| MCP_PUBLIC_HOST: ${MCP_PUBLIC_HOST:-localhost} | ||
| volumes: | ||
| - ./Caddyfile:/etc/caddy/Caddyfile:ro | ||
| - caddy_data:/data | ||
| - caddy_config:/config | ||
| depends_on: | ||
| mcp: | ||
| condition: service_healthy | ||
|
|
||
| volumes: | ||
| caddy_data: | ||
| caddy_config: | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Falsy
""falls through to env var — intentional?cors_origins or os.getenv(...)treats an explicitly passed empty string the same as "unset", so it silently falls back toMCP_CORS_ORIGINSfrom the environment. That also makestest_build_http_middleware_without_origins(passingcors_origins="") implicitly depend on the env var being unset in CI. Want to switch to aNonesentinel so callers can explicitly opt out of CORS regardless of env state?💡 Proposed fix
def build_http_middleware( *, cors_origins: str | None = None, ) -> list[Middleware]: ... - origins = parse_cors_origins(cors_origins or os.getenv(MCP_CORS_ORIGINS_ENV, "")) + if cors_origins is None: + cors_origins = os.getenv(MCP_CORS_ORIGINS_ENV, "") + origins = parse_cors_origins(cors_origins)📝 Committable suggestion
🤖 Prompt for AI Agents