diff --git a/Dockerfile b/Dockerfile index 2edc0f1..27f8dbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-install-project --no-dev # Install project -COPY pyproject.toml uv.lock ./ +COPY pyproject.toml uv.lock README.md ./ COPY src ./src RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev --no-editable diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..85ea1f2 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,22 @@ +# Development proxy for mcvsphere running on host +# Usage: docker compose -f docker-compose.dev.yml up -d + +services: + # Proxy container - just provides caddy labels for host-running server + mcvsphere-proxy: + image: alpine:latest + container_name: mcvsphere-proxy + command: ["sleep", "infinity"] + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - caddy + labels: + # Caddy reverse proxy to host-running mcvsphere server + caddy: mcp.l.supported.systems + # Use caddy network gateway (172.18.0.1) to reach host services + caddy.reverse_proxy: "172.18.0.1:8080" + +networks: + caddy: + external: true diff --git a/docker-compose.oauth.yml b/docker-compose.oauth.yml index d79d92c..c1a6f73 100644 --- a/docker-compose.oauth.yml +++ b/docker-compose.oauth.yml @@ -5,6 +5,38 @@ # Requires AUTHENTIK_* environment variables to be set. services: + # ───────────────────────────────────────────────────────────────────────── + # mcvsphere MCP Server (with OAuth + HTTPS via Caddy) + # ───────────────────────────────────────────────────────────────────────── + mcvsphere: + build: + context: . + dockerfile: Dockerfile + container_name: mcvsphere + restart: unless-stopped + volumes: + - ./logs:/app/logs + env_file: + - .env.oauth + environment: + - MCP_TRANSPORT=streamable-http + - MCP_HOST=0.0.0.0 + - MCP_PORT=8080 + networks: + - mcvsphere-network + - caddy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + labels: + # Caddy reverse proxy via caddy-docker-proxy + caddy: mcp.localhost + caddy.reverse_proxy: "{{upstreams 8080}}" + caddy.tls: internal + # ───────────────────────────────────────────────────────────────────────── # PostgreSQL for Authentik # ───────────────────────────────────────────────────────────────────────── @@ -76,6 +108,7 @@ services: networks: - authentik-internal - mcvsphere-network + - caddy healthcheck: test: ["CMD", "ak", "healthcheck"] interval: 30s @@ -83,8 +116,8 @@ services: retries: 3 start_period: 60s labels: - # Caddy reverse proxy (if using caddy-docker-proxy) - caddy: ${AUTHENTIK_HOST:-auth.localhost} + # Caddy reverse proxy via caddy-docker-proxy + caddy: ${AUTHENTIK_HOST:-auth.l.supported.systems} caddy.reverse_proxy: "{{upstreams 9000}}" # ───────────────────────────────────────────────────────────────────────── @@ -119,6 +152,8 @@ networks: mcvsphere-network: external: true name: ${COMPOSE_PROJECT_NAME:-mcvsphere}_default + caddy: + external: true volumes: authentik-db-data: diff --git a/src/mcvsphere/auth.py b/src/mcvsphere/auth.py index 580d896..500f5e9 100644 --- a/src/mcvsphere/auth.py +++ b/src/mcvsphere/auth.py @@ -36,25 +36,34 @@ def create_auth_provider(settings: Settings): config_url = issuer_url # Build base URL for the MCP server - base_url = f"http://{settings.mcp_host}:{settings.mcp_port}" + # This URL is used for OAuth callbacks and must be HTTPS and externally accessible + if settings.oauth_base_url: + base_url = settings.oauth_base_url.rstrip("/") + else: + # Default: construct from host/port (for direct HTTPS access) + host = "localhost" if settings.mcp_host in ("0.0.0.0", "127.0.0.1") else settings.mcp_host + base_url = f"https://{host}:{settings.mcp_port}" logger.info("Configuring OAuth with OIDC provider: %s", issuer_url) + logger.info("OAuth base URL: %s", base_url) try: + # Note: Authentik's access tokens are opaque and don't include scope claims. + # We request scopes during authorization but don't validate them in the token. + # Authentication is sufficient - Authentik enforces scope grants at the IdP level. auth = OIDCProxy( config_url=config_url, client_id=settings.oauth_client_id, client_secret=settings.oauth_client_secret.get_secret_value(), base_url=base_url, - required_scopes=settings.oauth_scopes, - # Allow Claude Code localhost redirects + required_scopes=[], # Don't validate scopes in token (Authentik uses opaque tokens) allowed_client_redirect_uris=[ "http://localhost:*", "http://127.0.0.1:*", ], - # Skip consent screen for MCP clients (they're already trusted) require_authorization_consent=False, ) + logger.info("OAuth authentication enabled via OIDC") return auth diff --git a/src/mcvsphere/config.py b/src/mcvsphere/config.py index 1191a44..f087f83 100644 --- a/src/mcvsphere/config.py +++ b/src/mcvsphere/config.py @@ -49,8 +49,8 @@ class Settings(BaseSettings): ) mcp_host: str = Field(default="0.0.0.0", description="Server bind address") mcp_port: int = Field(default=8080, description="Server port") - mcp_transport: Literal["stdio", "sse", "http"] = Field( - default="stdio", description="MCP transport type (http required for OAuth)" + mcp_transport: Literal["stdio", "sse", "http", "streamable-http"] = Field( + default="stdio", description="MCP transport type (http/streamable-http required for OAuth)" ) # OAuth/OIDC settings @@ -75,6 +75,10 @@ class Settings(BaseSettings): default_factory=list, description="OAuth groups required for access (empty = any authenticated user)", ) + oauth_base_url: str | None = Field( + default=None, + description="External base URL for OAuth callbacks (e.g., https://mcp.localhost). Auto-generated if not specified.", + ) # Logging settings log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field( @@ -109,7 +113,7 @@ class Settings(BaseSettings): # OAuth requires HTTP transport if self.mcp_transport == "stdio": raise ValueError( - "OAuth requires HTTP transport. Set mcp_transport='http' or 'sse'" + "OAuth requires HTTP transport. Set mcp_transport='http', 'sse', or 'streamable-http'" ) return self diff --git a/src/mcvsphere/server.py b/src/mcvsphere/server.py index f031a55..d998286 100644 --- a/src/mcvsphere/server.py +++ b/src/mcvsphere/server.py @@ -120,7 +120,7 @@ def run_server(config_path: Path | None = None) -> None: settings = Settings.from_yaml(config_path) if config_path else get_settings() # Only print banner for HTTP/SSE modes (stdio must stay clean for JSON-RPC) - if settings.mcp_transport in ("sse", "http"): + if settings.mcp_transport in ("sse", "http", "streamable-http"): try: from importlib.metadata import version @@ -130,7 +130,7 @@ def run_server(config_path: Path | None = None) -> None: print(f"mcvsphere v{package_version}", file=sys.stderr) print("─" * 40, file=sys.stderr) - transport_name = "HTTP" if settings.mcp_transport == "http" else "SSE" + transport_name = "HTTP" if settings.mcp_transport in ("http", "streamable-http") else "SSE" print( f"Starting {transport_name} transport on {settings.mcp_host}:{settings.mcp_port}", file=sys.stderr, @@ -144,7 +144,7 @@ def run_server(config_path: Path | None = None) -> None: # Create and run server mcp = create_server(settings) - if settings.mcp_transport == "http": + if settings.mcp_transport in ("http", "streamable-http"): mcp.run(transport="streamable-http", host=settings.mcp_host, port=settings.mcp_port) elif settings.mcp_transport == "sse": mcp.run(transport="sse", host=settings.mcp_host, port=settings.mcp_port)