From 382206373c1a245cb495cd79eb69c79a0b527960 Mon Sep 17 00:00:00 2001 From: hexonal <38680988+hexonal@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:51:15 +0800 Subject: [PATCH] Add Docker Compose deployment and auth routing --- .dockerignore | 8 ++++ .env.deploy.example | 52 +++++++++++++++++++++++++ DEPLOYMENT.md | 83 +++++++++++++++++++++++++++++++++++++++ Dockerfile | 15 +++++++ bin/set-x-secrets.sh | 32 +++++++++++++++ docker-compose.yml | 22 +++++++++++ server.py | 93 ++++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.deploy.example create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100755 bin/set-x-secrets.sh create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d2ed17 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.venv +__pycache__ +*.pyc +.env +.env.* +!env.example +!.env.deploy.example diff --git a/.env.deploy.example b/.env.deploy.example new file mode 100644 index 0000000..247338e --- /dev/null +++ b/.env.deploy.example @@ -0,0 +1,52 @@ +# Required from X Developer Portal. +X_OAUTH_CONSUMER_KEY= +X_OAUTH_CONSUMER_SECRET= +X_BEARER_TOKEN= + +# One-time OAuth1 callback. +# Keep callback URL in the X app exactly as: +# http://127.0.0.1:8976/oauth/callback +X_OAUTH_CALLBACK_HOST=127.0.0.1 +X_OAUTH_CALLBACK_BIND_HOST=0.0.0.0 +X_OAUTH_CALLBACK_PORT=8976 +X_OAUTH_CALLBACK_PATH=/oauth/callback +X_OAUTH_CALLBACK_TIMEOUT=600 + +# X API settings. +X_API_BASE_URL=https://api.x.com +X_API_TIMEOUT=30 +X_API_DEBUG=1 +X_B3_FLAGS=1 +# Keep output validation off because some X API responses differ from the +# published OpenAPI response schema while still being valid API responses. +X_API_VALIDATE_OUTPUT=0 + +# X endpoints that reject OAuth1 user signing and must use X_BEARER_TOKEN. +X_API_BEARER_AUTH_PATH_PREFIXES=/2/news + +# MCP server settings inside the container. +MCP_HOST=0.0.0.0 +MCP_PORT=8000 +MCP_SERVER_URL=http://127.0.0.1:8010/mcp + +# Optional MCP transport auth. If set, clients must send: +# Authorization: Bearer +MCP_AUTH_TOKEN= + +# Leave empty to expose every supported non-streaming/non-webhook X API tool. +X_API_TOOL_ALLOWLIST= + +# Fill these after the first OAuth approval so restarts do not need approval again. +X_OAUTH_ACCESS_TOKEN= +X_OAUTH_ACCESS_TOKEN_SECRET= + +# Keep this enabled so the first successful approval is saved into .env. +X_OAUTH_PERSIST_TOKENS=1 + +# Set to 1 only when you explicitly need to print token values in logs. +X_OAUTH_PRINT_TOKENS=0 +X_OAUTH_PRINT_AUTH_HEADER=0 + +# Optional Grok test client. +XAI_API_KEY= +XAI_MODEL=grok-4-1-fast diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..853e4d1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,83 @@ +# XMCP Docker Compose Deployment + +## Endpoints + +- Public MCP endpoint: `http://:8010/mcp` +- Host-only MCP endpoint: `http://127.0.0.1:8010/mcp` +- Dify Docker-network endpoint: `http://xmcp:8000/mcp` + +The MCP port requires a Bearer token because this server has full X write/DM +permissions. + +## X Developer Portal + +In the X app, enable user authentication and set: + +- App permissions: `Read and write and Direct message` +- OAuth 1.0a: enabled +- Callback URL: `http://127.0.0.1:8976/oauth/callback` +- Website URL: any valid URL accepted by the X console + +Copy these values into `/opt/xmcp/.env`: + +- `X_OAUTH_CONSUMER_KEY` +- `X_OAUTH_CONSUMER_SECRET` +- `X_BEARER_TOKEN` + +Or run the interactive helper on the server: + +```bash +/opt/xmcp/bin/set-x-secrets.sh +``` + +Leave `X_API_TOOL_ALLOWLIST` empty to load every supported XMCP tool. + +`/2/news...` endpoints use `X_BEARER_TOKEN` because X rejects OAuth1 user +signing for these endpoints. Other user-write endpoints continue to use OAuth1. +`X_API_VALIDATE_OUTPUT=0` is intentional: X's live News responses can differ +from the published OpenAPI response schema. + +## MCP Access Token + +If `MCP_AUTH_TOKEN` is set in `/opt/xmcp/.env`, MCP clients must send: + +```text +Authorization: Bearer +``` + +- URL: `http://:8010/mcp` +- Header: `Authorization: Bearer ` + +Read the token on the server: + +```bash +grep '^MCP_AUTH_TOKEN=' /opt/xmcp/.env +``` + +## First Authorization + +1. On your local machine, keep this tunnel open: + + ```bash + ssh -L 8976:127.0.0.1:8976 root@ + ``` + +2. On the server, start XMCP and follow the logs: + + ```bash + cd /opt/xmcp + docker compose up -d --build + docker compose logs -f xmcp + ``` + +3. Open the printed `OAuth1 authorization URL` in your local browser. + +4. After approval, the container writes `X_OAUTH_ACCESS_TOKEN` and + `X_OAUTH_ACCESS_TOKEN_SECRET` back to `/opt/xmcp/.env`. Verify it: + + ```bash + grep '^X_OAUTH_ACCESS_TOKEN' /opt/xmcp/.env + ``` + +After that, container restarts use the persisted OAuth1 token and do not require +browser approval again unless the token is revoked or the X app keys change. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8180676 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py . + +EXPOSE 8000 8976 + +CMD ["python", "server.py"] diff --git a/bin/set-x-secrets.sh b/bin/set-x-secrets.sh new file mode 100755 index 0000000..f0d9c40 --- /dev/null +++ b/bin/set-x-secrets.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-/opt/xmcp/.env}" + +if [ ! -f "$ENV_FILE" ]; then + echo "Missing env file: $ENV_FILE" >&2 + exit 1 +fi + +set_env() { + local key="$1" + local value="$2" + if grep -q "^${key}=" "$ENV_FILE"; then + sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE" + else + printf "%s=%s\n" "$key" "$value" >> "$ENV_FILE" + fi +} + +read -r -p "X_OAUTH_CONSUMER_KEY: " consumer_key +read -r -s -p "X_OAUTH_CONSUMER_SECRET: " consumer_secret +printf "\n" +read -r -s -p "X_BEARER_TOKEN: " bearer_token +printf "\n" + +set_env X_OAUTH_CONSUMER_KEY "$consumer_key" +set_env X_OAUTH_CONSUMER_SECRET "$consumer_secret" +set_env X_BEARER_TOKEN "$bearer_token" + +chmod 600 "$ENV_FILE" +echo "Updated $ENV_FILE" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..85509f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + xmcp: + build: . + container_name: xmcp + restart: unless-stopped + env_file: + - .env + ports: + - "0.0.0.0:8010:8000" + - "127.0.0.1:8976:8976" + volumes: + - ./.env:/app/.env + networks: + default: {} + dify: + aliases: + - xmcp + +networks: + dify: + external: true + name: docker_default diff --git a/server.py b/server.py index 7930e87..82f1c54 100644 --- a/server.py +++ b/server.py @@ -47,6 +47,18 @@ def parse_csv_env(key: str) -> set[str]: return {item.strip() for item in raw.split(",") if item.strip()} +def parse_csv_env_list(key: str, default: list[str] | None = None) -> list[str]: + raw = os.getenv(key, "") + if not raw.strip(): + return default or [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def path_matches_prefix(path: str, prefix: str) -> bool: + normalized_prefix = prefix.rstrip("/") + return path == normalized_prefix or path.startswith(f"{normalized_prefix}/") + + def should_join_query_param(param: dict) -> bool: if param.get("in") != "query": return False @@ -158,6 +170,7 @@ def run_oauth1_flow() -> tuple[str, str]: ) callback_host = os.getenv("X_OAUTH_CALLBACK_HOST", "127.0.0.1") + callback_bind_host = os.getenv("X_OAUTH_CALLBACK_BIND_HOST", callback_host) callback_port = _get_env_int("X_OAUTH_CALLBACK_PORT", 8976) callback_path = os.getenv("X_OAUTH_CALLBACK_PATH", "/oauth/callback") callback_timeout = _get_env_int("X_OAUTH_CALLBACK_TIMEOUT", 300) @@ -176,11 +189,13 @@ def run_oauth1_flow() -> tuple[str, str]: raise RuntimeError("Failed to obtain OAuth request token.") authorization_url = oauth.authorization_url(AUTHORIZE_URL) + print("OAuth1 authorization URL:", authorization_url, flush=True) + print("OAuth1 callback URL:", callback_url, flush=True) OAUTH_LOGGER.info("Opening browser for OAuth1 consent.") webbrowser.open(authorization_url) oauth_token, oauth_verifier = _wait_for_callback( - callback_host, callback_port, callback_path, callback_timeout + callback_bind_host, callback_port, callback_path, callback_timeout ) if oauth_token != resource_owner_key: raise RuntimeError("OAuth callback token does not match request token.") @@ -211,6 +226,40 @@ def load_env() -> None: load_dotenv(env_path, override=True) +def persist_oauth1_tokens(access_token: str, access_secret: str) -> None: + if not is_truthy(os.getenv("X_OAUTH_PERSIST_TOKENS", "0")): + return + + env_path = Path(__file__).resolve().parent / ".env" + existing_lines = env_path.read_text().splitlines() if env_path.exists() else [] + updates = { + "X_OAUTH_ACCESS_TOKEN": access_token, + "X_OAUTH_ACCESS_TOKEN_SECRET": access_secret, + } + seen: set[str] = set() + next_lines: list[str] = [] + + for line in existing_lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + next_lines.append(line) + continue + key = line.split("=", 1)[0].strip() + if key in updates: + next_lines.append(f"{key}={updates[key]}") + seen.add(key) + else: + next_lines.append(line) + + for key, value in updates.items(): + if key not in seen: + next_lines.append(f"{key}={value}") + + env_path.write_text("\n".join(next_lines) + "\n") + env_path.chmod(0o600) + LOGGER.info("Persisted OAuth1 access token to %s", env_path) + + def setup_logging() -> bool: debug_enabled = is_truthy(os.getenv("X_API_DEBUG", "1")) if debug_enabled: @@ -219,6 +268,24 @@ def setup_logging() -> bool: return debug_enabled +def build_mcp_auth_provider(): + token = os.getenv("MCP_AUTH_TOKEN", "").strip() + if not token: + return None + + from fastmcp.server.auth.providers.jwt import StaticTokenVerifier + + return StaticTokenVerifier( + tokens={ + token: { + "client_id": "xmcp-client", + "scopes": ["xmcp:access"], + } + }, + required_scopes=["xmcp:access"], + ) + + def should_exclude_operation(path: str, operation: dict) -> bool: if "/webhooks" in path or "/stream" in path: return True @@ -307,11 +374,18 @@ def build_oauth1_client() -> OAuth1Client: raise RuntimeError( "Missing X_OAUTH_CONSUMER_KEY or X_OAUTH_CONSUMER_SECRET for OAuth1 signing." ) - access_token, access_secret = run_oauth1_flow() + access_token = os.getenv("X_OAUTH_ACCESS_TOKEN", "").strip() + access_secret = os.getenv("X_OAUTH_ACCESS_TOKEN_SECRET", "").strip() + if access_token and access_secret: + LOGGER.info("Using OAuth1 access token from environment.") + else: + access_token, access_secret = run_oauth1_flow() + persist_oauth1_tokens(access_token, access_secret) + if is_truthy(os.getenv("X_OAUTH_PRINT_TOKENS", "0")): print("OAuth1 access token:", access_token) print("OAuth1 access token secret:", access_secret) - LOGGER.info("OAuth1 access token: %s", access_token) + return OAuth1Client( client_key=consumer_key, client_secret=consumer_secret, @@ -354,6 +428,7 @@ def create_mcp() -> FastMCP: filtered_spec = filter_openapi_spec(spec) comma_params = collect_comma_params(filtered_spec) print_tool_list(filtered_spec) + auth_provider = build_mcp_auth_provider() async def normalize_query_params(request: httpx.Request) -> None: if not comma_params: @@ -387,9 +462,19 @@ async def normalize_query_params(request: httpx.Request) -> None: request.url = request.url.copy_with(params=normalized) b3_flags = os.getenv("X_B3_FLAGS", "1") + bearer_auth_path_prefixes = tuple( + parse_csv_env_list("X_API_BEARER_AUTH_PATH_PREFIXES", ["/2/news"]) + ) async def sign_oauth1_request(request: httpx.Request) -> None: request.headers["X-B3-Flags"] = b3_flags + if any(path_matches_prefix(request.url.path, prefix) for prefix in bearer_auth_path_prefixes): + bearer_token = os.getenv("X_BEARER_TOKEN", "").strip() + if not bearer_token: + raise RuntimeError("X_BEARER_TOKEN is required for bearer-auth X API paths.") + request.headers["Authorization"] = f"Bearer {bearer_token}" + return + headers = dict(request.headers) content_type = headers.get("Content-Type", "") body: str | None = None @@ -448,6 +533,8 @@ async def log_response(response: httpx.Response) -> None: openapi_spec=filtered_spec, client=client, name="X API MCP", + auth=auth_provider, + validate_output=is_truthy(os.getenv("X_API_VALIDATE_OUTPUT", "0")), )