Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ The equivalent repeatable CLI options are `--allowed-host` and
After making changes, quickly verify everything works:

```bash
# Match pyproject.toml exactly; older uv versions reject the locked setup.
uv --version # expected: uv 0.11.28
uv sync --locked --extra test

# Install the repository pre-push dependency audit once per clone
./scripts/setup-hooks.sh

Expand All @@ -223,6 +227,9 @@ make unit-test

# Run all tests
make test

# Equivalent direct locked test run
uv run pytest src/tests/ -q
```

The smoke test verifies:
Expand Down Expand Up @@ -272,7 +279,9 @@ curl http://localhost:8000/health

1. **For CodeAlive Cloud (default):**
- Remove `CODEALIVE_BASE_URL` environment variable (uses default `https://app.codealive.ai`)
- Clients must provide their API key via `Authorization: Bearer YOUR_KEY` header
- For remote clients with OAuth support, configure only `https://mcp.codealive.ai/api` and
complete the browser sign-in when prompted
- Existing API-key clients remain supported via `Authorization: Bearer YOUR_KEY`

2. **For Self-Hosted CodeAlive:**
- Set `CODEALIVE_BASE_URL` to your CodeAlive instance URL (e.g., `https://codealive.yourcompany.com`)
Expand All @@ -281,6 +290,49 @@ curl http://localhost:8000/health

See `docker-compose.example.yml` for the complete configuration template.

For example, current Codex and Claude Code clients can use browser OAuth without storing a
CodeAlive API key:

```bash
codex mcp add codealive --url https://mcp.codealive.ai/api
codex mcp login codealive

claude mcp add --transport http codealive https://mcp.codealive.ai/api
# Start Claude Code and run /mcp to authenticate.
```

Cursor and OpenCode also discover OAuth automatically from the same URL. Use
`cursor-agent mcp login codealive` or `opencode mcp auth codealive` when their UI does not prompt
automatically. API-key configuration remains available as a compatibility option.

### OAuth 2.1 deployment profile

Remote HTTP deployments can enable browser authorization while keeping legacy API-key clients
working during rollout. OAuth mode publishes MCP Protected Resource Metadata, validates exact
issuer/resource-bound JWTs, and exchanges them for a separate short-lived Tool API token. The
incoming MCP bearer token is never forwarded downstream.

| Environment variable | Purpose |
|---|---|
| `CODEALIVE_MCP_OAUTH_ENABLED=true` | Enables OAuth validation and MCP authorization discovery for HTTP transport |
| `CODEALIVE_OAUTH_ISSUER` | Exact OpenIddict issuer, with a trailing slash |
| `CODEALIVE_MCP_RESOURCE` | Exact public MCP resource URL; its path is also the HTTP MCP path |
| `CODEALIVE_TOOL_API_RESOURCE` | Downstream audience; defaults to `urn:codealive:tool-api` |
| `CODEALIVE_OAUTH_INTERNAL_CLIENT_ID` | Confidential resource-server client used only for token exchange |
| `CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET` | Required secret for that internal client; startup fails closed when it is missing |

The authorization server and MCP service values must match exactly. In CodeAlive Web.Server the
corresponding settings live under `McpOAuth` (`Enabled`, `Issuer`, `Resource`,
`ToolApiResource`, `InternalClientId`, and `InternalClientSecret`). Persist the Web.Server Data
Protection key ring and OpenIddict signing/encryption certificates across replicas and restarts.
For a zero-downtime internal credential rotation, give the new credential a new client ID, deploy
Web.Server with both current and `PreviousInternalClientId`/`PreviousInternalClientSecret`, roll
MCP replicas to the new current pair, then remove the previous pair. Web.Server deliberately fails
startup instead of changing a secret in place under an existing client ID.
Enable the Web.Server and MCP flags in the same rollout; a half-enabled deployment is not a valid
steady state. API-key credentials retain their explicit legacy grammar and are never used as a
fallback after OAuth validation fails.

### Connecting MCP Clients to Your Deployed Instance

Use the same generic connection details as CodeAlive Cloud, replacing the endpoint with your deployment's `/api` URL:
Expand Down
43 changes: 34 additions & 9 deletions src/codealive_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
import sys
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from urllib.parse import urlsplit

from dotenv import load_dotenv
from fastmcp import FastMCP
from loguru import logger
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import JSONResponse

Expand All @@ -26,7 +28,7 @@
sys.path.insert(0, str(Path(__file__).parent))

# Import core components
from core import codealive_lifespan, setup_logging, setup_debug_logging, init_tracing, normalize_base_url, _server_ready
from core import Config, MetadataAwareHostOriginGuardMiddleware, build_oauth_provider, codealive_lifespan, setup_logging, setup_debug_logging, init_tracing, normalize_base_url, _server_ready
import core.client as _client_module # for /ready flag access
from middleware import N8NRemoveParametersMiddleware, ObservabilityMiddleware
from tools import (
Expand Down Expand Up @@ -206,13 +208,11 @@ def main():
os.environ["CODEALIVE_BASE_URL"] = normalized_base_url
logger.info("Using base URL from command line: {url}", url=normalized_base_url)

# Disable SSL verification if explicitly requested or in debug mode
if args.ignore_ssl or debug:
# Debug logging must not weaken transport security. TLS verification is disabled only
# through the explicit opt-in flag used for local self-signed development endpoints.
if args.ignore_ssl:
os.environ["CODEALIVE_IGNORE_SSL"] = "true"
if args.ignore_ssl:
logger.warning("SSL certificate validation disabled by --ignore-ssl flag")
elif debug:
logger.warning("SSL certificate validation disabled in debug mode")
logger.warning("SSL certificate validation disabled by --ignore-ssl flag")

if debug:
logger.debug(
Expand Down Expand Up @@ -247,13 +247,24 @@ def main():
)
logger.info("HTTP mode: API keys extracted from Authorization: Bearer headers")

oauth_config = Config.from_environment()
if oauth_config.oauth_enabled:
if not oauth_config.oauth_internal_client_secret:
logger.error(
"OAuth mode requires CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET for downstream token exchange"
)
sys.exit(1)
mcp.auth = build_oauth_provider(oauth_config)

if not base_url:
logger.info(
"CODEALIVE_BASE_URL not set, using default: https://app.codealive.ai"
)

# Run the server with the selected transport
if args.transport == "http":
oauth_config = Config.from_environment()
mcp_path = urlsplit(oauth_config.mcp_resource).path or "/api"
allowed_hosts = args.allowed_host or [
value.strip()
for value in os.getenv("CODEALIVE_MCP_ALLOWED_HOSTS", "").split(",")
Expand All @@ -264,14 +275,28 @@ def main():
for value in os.getenv("CODEALIVE_MCP_ALLOWED_ORIGINS", "").split(",")
if value.strip()
]
transport_middleware = None
host_origin_protection = True
if oauth_config.oauth_enabled:
metadata_path = f"/.well-known/oauth-protected-resource{mcp_path}"
transport_middleware = [
Middleware(
MetadataAwareHostOriginGuardMiddleware,
metadata_path=metadata_path,
allowed_hosts=allowed_hosts or None,
allowed_origins=allowed_origins or None,
)
]
host_origin_protection = False
# Use /api path to avoid conflicts with health endpoint
mcp.run(
transport="http",
host=args.host,
port=args.port,
path="/api",
path=mcp_path,
stateless_http=True,
host_origin_protection=True,
middleware=transport_middleware,
host_origin_protection=host_origin_protection,
allowed_hosts=allowed_hosts or None,
allowed_origins=allowed_origins or None,
uvicorn_config={
Expand Down
16 changes: 16 additions & 0 deletions src/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
"""Core components for CodeAlive MCP server."""

from .client import CodeAliveContext, get_api_key_from_context, codealive_lifespan, _server_ready
from .oauth import (
MetadataAwareHostOriginGuardMiddleware,
ToolTokenExchangeCache,
build_oauth_provider,
exchange_for_tool_token,
invalidate_tool_token_exchange,
is_oauth_credential,
is_jwt_shaped,
)
from .config import Config, REQUEST_TIMEOUT_SECONDS, normalize_base_url
from .logging import setup_logging, setup_debug_logging, log_api_request, log_api_response
from .observability import init_tracing

__all__ = [
'CodeAliveContext',
'MetadataAwareHostOriginGuardMiddleware',
'get_api_key_from_context',
'build_oauth_provider',
'exchange_for_tool_token',
'invalidate_tool_token_exchange',
'is_oauth_credential',
'ToolTokenExchangeCache',
'is_jwt_shaped',
'codealive_lifespan',
'Config',
'REQUEST_TIMEOUT_SECONDS',
Expand Down
9 changes: 7 additions & 2 deletions src/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from loguru import logger

from .config import Config, REQUEST_TIMEOUT_SECONDS
from .oauth import ToolTokenExchangeCache


@dataclass
Expand All @@ -19,6 +20,8 @@ class CodeAliveContext:
client: httpx.AsyncClient
api_key: str
base_url: str
config: Config | None = None
tool_token_cache: ToolTokenExchangeCache | None = None


# Module-level readiness state for the /ready endpoint.
Expand Down Expand Up @@ -96,8 +99,10 @@ async def codealive_lifespan(server: FastMCP) -> AsyncIterator[CodeAliveContext]
yield CodeAliveContext(
client=client,
api_key="", # Will be set per-request in HTTP mode
base_url=config.base_url
base_url=config.base_url,
config=config,
tool_token_cache=ToolTokenExchangeCache(),
)
finally:
_server_ready = False
await client.aclose()
await client.aclose()
98 changes: 98 additions & 0 deletions src/core/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Configuration management for CodeAlive MCP server."""

import os
import ipaddress
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlsplit, urlunsplit
Expand All @@ -9,6 +10,77 @@
REQUEST_TIMEOUT_SECONDS = 300.0


def _is_loopback_host(host: str | None) -> bool:
if host is None:
return False
if host.lower() == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False


def validate_oauth_urls(issuer_value: str, resource_value: str) -> None:
issuer = urlsplit(issuer_value)
if (
issuer.scheme != "https"
or not issuer.netloc
or issuer.username is not None
or issuer.password is not None
or issuer.path not in {"", "/"}
or issuer.query
or issuer.fragment
or not issuer_value.endswith("/")
or (issuer.hostname or "").endswith(".")
):
raise ValueError("CODEALIVE_OAUTH_ISSUER must be a canonical HTTPS origin")
Comment thread
rodion-m marked this conversation as resolved.

resource = urlsplit(resource_value)
secure = resource.scheme == "https" or (
resource.scheme == "http" and _is_loopback_host(resource.hostname)
)
if (
not secure
or not resource.netloc
or resource.username is not None
or resource.password is not None
or resource.path in {"", "/"}
or resource.query
or resource.fragment
or resource_value.endswith("/")
or (resource.hostname or "").endswith(".")
):
raise ValueError("CODEALIVE_MCP_RESOURCE must be a canonical HTTPS URL with a path")


def _same_resource_identifier(left_value: str, right_value: str) -> bool:
left = urlsplit(left_value)
right = urlsplit(right_value)
if left.scheme.lower() != right.scheme.lower():
return False
if left.netloc or right.netloc:
left_port = left.port or (443 if left.scheme.lower() == "https" else 80 if left.scheme.lower() == "http" else None)
right_port = right.port or (443 if right.scheme.lower() == "https" else 80 if right.scheme.lower() == "http" else None)
return (
left.hostname == right.hostname
and left_port == right_port
and left.username == right.username
and left.password == right.password
and left.path == right.path
and left.query == right.query
and left.fragment == right.fragment
)
return left.path == right.path and left.query == right.query and left.fragment == right.fragment


def _is_absolute_resource_identifier(value: str) -> bool:
if not value or value != value.strip():
return False
parsed = urlsplit(value)
return bool(parsed.scheme) and bool(parsed.netloc or parsed.path)


def normalize_base_url(base_url: Optional[str]) -> str:
"""Normalize a CodeAlive base URL to the deployment origin.

Expand Down Expand Up @@ -41,6 +113,26 @@ class Config:
transport_mode: str = "stdio"
verify_ssl: bool = True
debug_mode: bool = False
oauth_enabled: bool = False
oauth_issuer: str = "https://auth.codealive.ai/"
mcp_resource: str = "https://mcp.codealive.ai/api"
tool_api_resource: str = "urn:codealive:tool-api"
oauth_internal_client_id: str = "codealive-mcp"
oauth_internal_client_secret: Optional[str] = None

def __post_init__(self) -> None:
if self.oauth_enabled:
validate_oauth_urls(self.oauth_issuer, self.mcp_resource)
if not _is_absolute_resource_identifier(self.tool_api_resource):
raise ValueError(
"CODEALIVE_TOOL_API_RESOURCE must be an absolute resource identifier"
)
if _same_resource_identifier(self.mcp_resource, self.tool_api_resource):
raise ValueError(
"CODEALIVE_MCP_RESOURCE and CODEALIVE_TOOL_API_RESOURCE must be distinct"
)
if not self.oauth_internal_client_id or not self.oauth_internal_client_id.strip():
raise ValueError("CODEALIVE_OAUTH_INTERNAL_CLIENT_ID must not be empty")

@classmethod
def from_environment(cls) -> "Config":
Expand All @@ -51,4 +143,10 @@ def from_environment(cls) -> "Config":
transport_mode=os.environ.get("TRANSPORT_MODE", "stdio"),
verify_ssl=not os.environ.get("CODEALIVE_IGNORE_SSL", "").lower() in ["true", "1", "yes"],
debug_mode=os.environ.get("DEBUG_MODE", "").lower() in ["true", "1", "yes"],
oauth_enabled=os.environ.get("CODEALIVE_MCP_OAUTH_ENABLED", "false").lower() in ["true", "1", "yes"],
oauth_issuer=os.environ.get("CODEALIVE_OAUTH_ISSUER", "https://auth.codealive.ai/"),
mcp_resource=os.environ.get("CODEALIVE_MCP_RESOURCE", "https://mcp.codealive.ai/api"),
tool_api_resource=os.environ.get("CODEALIVE_TOOL_API_RESOURCE", "urn:codealive:tool-api"),
oauth_internal_client_id=os.environ.get("CODEALIVE_OAUTH_INTERNAL_CLIENT_ID", "codealive-mcp"),
oauth_internal_client_secret=os.environ.get("CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET"),
)
Loading