Skip to content
Open
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
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git
.venv
__pycache__
*.pyc
.env
.env.*
!env.example
!.env.deploy.example
52 changes: 52 additions & 0 deletions .env.deploy.example
Original file line number Diff line number Diff line change
@@ -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>
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
83 changes: 83 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# XMCP Docker Compose Deployment

## Endpoints

- Public MCP endpoint: `http://<server-ip>: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 <MCP_AUTH_TOKEN>
```

- URL: `http://<server-ip>:8010/mcp`
- Header: `Authorization: Bearer <MCP_AUTH_TOKEN>`

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@<server-ip>
```

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.
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
32 changes: 32 additions & 0 deletions bin/set-x-secrets.sh
Original file line number Diff line number Diff line change
@@ -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"
22 changes: 22 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
93 changes: 90 additions & 3 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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.")
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")),
)


Expand Down