diff --git a/Dockerfile.mcp b/Dockerfile.mcp new file mode 100644 index 000000000..a15f57024 --- /dev/null +++ b/Dockerfile.mcp @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +COPY --from=ghcr.io/astral-sh/uv:0.8.17 /uv /uvx /bin/ + +RUN apt-get update \ + && apt-get install --no-install-recommends -y git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY pyproject.toml uv.lock README.md /app/ +COPY airbyte/ /app/airbyte/ + +RUN uv sync --frozen --no-dev + +RUN useradd -m -u 10001 appuser \ + && chown -R appuser:appuser /app + +ENV PATH="/app/.venv/bin:$PATH" + +EXPOSE 8080 + +USER appuser + +CMD ["airbyte-mcp-http"] diff --git a/airbyte/mcp/http_main.py b/airbyte/mcp/http_main.py new file mode 100644 index 000000000..d73a8ac9e --- /dev/null +++ b/airbyte/mcp/http_main.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025 Airbyte, Inc., all rights reserved. +"""HTTP transport entry point for the Airbyte MCP server. + +Starts the MCP server with HTTP transport, suitable for hosted deployment +behind a load balancer. When OIDC env vars are set, authentication is +handled by `OIDCProxy` (configured in `server.py`). + +Environment variables: + +- `MCP_SERVER_URL`: Public base URL (for OIDC redirect callbacks) +- `OIDC_CONFIG_URL`: Keycloak OIDC discovery URL (enables auth with all three OIDC vars) +- `OIDC_CLIENT_ID`: OIDC client identifier +- `OIDC_CLIENT_SECRET`: OIDC client secret +""" + +from __future__ import annotations + +import logging + +from airbyte.mcp.server import DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, app + + +logger = logging.getLogger(__name__) + + +def main() -> None: + """Start the Airbyte MCP server with HTTP transport.""" + logging.basicConfig(level=logging.INFO) + logger.info( + "Starting Airbyte MCP HTTP server on %s:%d", + DEFAULT_HTTP_HOST, + DEFAULT_HTTP_PORT, + ) + + app.run( + transport="streamable-http", + host=DEFAULT_HTTP_HOST, + port=DEFAULT_HTTP_PORT, + stateless_http=True, + ) + + +if __name__ == "__main__": + main() diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index e7ad36748..abe661095 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -1,12 +1,30 @@ # Copyright (c) 2024 Airbyte, Inc., all rights reserved. -"""Experimental MCP (Model Context Protocol) server for PyAirbyte connector management.""" +"""MCP (Model Context Protocol) server for PyAirbyte connector management. + +Supports two transport modes: + +- **stdio** (default): For local MCP clients (Claude Desktop, etc.) +- **HTTP**: For hosted deployment. Start via `airbyte-mcp-http` entry point or + `poe mcp-serve-http`. When `OIDC_CONFIG_URL`, `OIDC_CLIENT_ID`, and + `OIDC_CLIENT_SECRET` are all set, enables Keycloak OIDC authentication + via `OIDCProxy`. +""" from __future__ import annotations import asyncio +import logging +import os import sys +from typing import TYPE_CHECKING +from fastmcp.server.auth.oidc_proxy import OIDCProxy from fastmcp_extensions import mcp_server +from starlette.responses import JSONResponse + + +if TYPE_CHECKING: + from starlette.requests import Request from airbyte._util.meta import set_mcp_mode from airbyte.mcp._config import load_secrets_to_env_vars @@ -65,6 +83,50 @@ - Read-only mode: Disables all write operations for cloud resources """.strip() +logger = logging.getLogger(__name__) + +OIDC_CONFIG_URL_ENV = "OIDC_CONFIG_URL" +OIDC_CLIENT_ID_ENV = "OIDC_CLIENT_ID" +OIDC_CLIENT_SECRET_ENV = "OIDC_CLIENT_SECRET" +MCP_SERVER_URL_ENV = "MCP_SERVER_URL" + +DEFAULT_HTTP_HOST = "0.0.0.0" +DEFAULT_HTTP_PORT = 8080 + + +def _create_oidc_auth() -> OIDCProxy | None: + """Create an `OIDCProxy` auth provider when OIDC env vars are configured. + + When `OIDC_CONFIG_URL`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` are all + set, returns an `OIDCProxy` that handles the Keycloak Authorization Code + + PKCE flow. Otherwise returns `None` (no auth, standard local behavior). + """ + config_url = os.getenv(OIDC_CONFIG_URL_ENV, "") + client_id = os.getenv(OIDC_CLIENT_ID_ENV, "") + client_secret = os.getenv(OIDC_CLIENT_SECRET_ENV, "") + + if not config_url or not client_id or not client_secret: + return None + + server_url = os.getenv( + MCP_SERVER_URL_ENV, + f"http://localhost:{DEFAULT_HTTP_PORT}", + ) + + logger.info( + "OIDC auth enabled (issuer=%s, client_id=%s, base_url=%s)", + config_url, + client_id, + server_url, + ) + return OIDCProxy( + config_url=config_url, + client_id=client_id, + client_secret=client_secret, + base_url=server_url, + ) + + set_mcp_mode() load_secrets_to_env_vars() @@ -89,6 +151,7 @@ airbyte_module_filter, airbyte_ui_support_filter, ], + auth=_create_oidc_auth(), ) """The Airbyte MCP Server application instance.""" @@ -100,6 +163,12 @@ register_prompts(app) +@app.custom_route("/health", methods=["GET"]) +async def health_check(request: Request) -> JSONResponse: # noqa: ARG001, RUF029 + """Health check endpoint for load balancer probes.""" + return JSONResponse({"status": "ok"}) + + def main() -> None: """@private Main entry point for the MCP server. diff --git a/pyproject.toml b/pyproject.toml index 276d7fa53..e02b1af0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "uuid7>=0.1.0,<1.0", "fastmcp>=3.0,<4.0", "fastmcp-extensions>=0.5.0,<1.0.0", + "starlette", "uv>=0.5.0,<0.9.0", "prefab-ui>=0.20.1,<0.21", ] @@ -81,6 +82,7 @@ dev = [ pyairbyte = "airbyte.cli.pyab:cli" pyab = "airbyte.cli.pyab:cli" airbyte-mcp = "airbyte.mcp.server:main" +airbyte-mcp-http = "airbyte.mcp.http_main:main" source-smoke-test = "airbyte.cli.smoke_test_source.run:run" [build-system] @@ -206,3 +208,4 @@ DEP002 = [ # Used as subprocess tool, not imported directly: "uv", ] +DEP003 = [] diff --git a/uv.lock b/uv.lock index 5b5305d6d..294db41b5 100644 --- a/uv.lock +++ b/uv.lock @@ -158,6 +158,7 @@ dependencies = [ { name = "snowflake-sqlalchemy" }, { name = "sqlalchemy" }, { name = "sqlalchemy-bigquery" }, + { name = "starlette" }, { name = "structlog" }, { name = "typing-extensions" }, { name = "uuid7" }, @@ -224,6 +225,7 @@ requires-dist = [ { name = "snowflake-sqlalchemy", specifier = ">=1.6.1,<2.0" }, { name = "sqlalchemy", specifier = "==2.0.43" }, { name = "sqlalchemy-bigquery", specifier = "==1.12.0" }, + { name = "starlette" }, { name = "structlog", specifier = ">=24.4.0,<25.0" }, { name = "typing-extensions" }, { name = "uuid7", specifier = ">=0.1.0,<1.0" },