diff --git a/.dockerignore b/.dockerignore index ebbd623..7a69e0c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,9 +8,10 @@ __pycache__/ .mypy_cache/ .env .env.* -!.env.example docker-compose*.yml Dockerfile .dockerignore *.md !README.md +xmcp.yml +xmcp.example.yml diff --git a/.env.example b/.env.example deleted file mode 100644 index 43e2fe1..0000000 --- a/.env.example +++ /dev/null @@ -1,46 +0,0 @@ -# Copy to .env and adjust. All values are read at container/process start. - -# --- OpenAPI document (required) --------------------------------------------- -# http(s) URL of a JSON/YAML OpenAPI document, or a local file path -# (e.g. /spec/openapi.json when the document is mounted into the container). -OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json - -# Bearer token used only when fetching a remote spec (leave unset for public -# documents). -#OPENAPI_SPEC_TOKEN= - -# --- Target API --------------------------------------------------------------- -# Override the base URL taken from the spec's servers[0].url. Required when the -# spec only has a relative server URL and the spec is a local file, or when the -# API lives on a different host than the spec. -#API_BASE_URL=https://api.example.com - -# Credential sent to the target API on every tool call. -#API_TOKEN=change-me -#API_AUTH_HEADER=Authorization -# Send the raw token with no scheme prefix by setting this to an empty value: -#API_AUTH_SCHEME= - -# HTTP header treated as the per-request API key of the MCP client. When -# present it takes precedence over API_TOKEN. Set to an empty value to disable -# the mapping (default: X-API-Key). -#MCP_API_KEY_HEADER=X-API-Key - -# --- MCP server --------------------------------------------------------------- -# stdio or http (streamable-http is accepted as an alias); default: stdio -MCP_TRANSPORT=http -MCP_HOST=0.0.0.0 -MCP_PORT=8000 -#MCP_SERVER_NAME=My API - -# Comma-separated allowlists of tool names and OpenAPI tags to expose. Unset or -# empty disables that filter; unknown entries are ignored with a startup -# warning. When both are set, a tool must match both. -#MCP_TOOLS_INCLUDED=getPetById,findPetsByStatus -#MCP_TOOLS_TAGS_INCLUDED=pet - -# Timeout in seconds for fetching the spec and calling the API (default: 30). -#HTTP_TIMEOUT=30 - -# DEBUG, INFO, WARNING, ERROR, CRITICAL ... (default: INFO) -#LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index bba26ac..cdeeb50 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,8 @@ __pycache__/ # uv / virtual environments .venv/ -# Local configuration +# Local configuration (may contain secrets) +xmcp.yml +xmcp.local.yml .env .env.* -!.env.example diff --git a/Dockerfile b/Dockerfile index f7547c4..a432d46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,13 +28,11 @@ FROM python:3.14-alpine3.24 AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PATH="/app/.venv/bin:$PATH" \ - MCP_TRANSPORT=http \ - MCP_HOST=0.0.0.0 \ - MCP_PORT=8000 + PATH="/app/.venv/bin:$PATH" RUN addgroup -S -g 1001 xmcp \ - && adduser -S -D -H -u 1001 -G xmcp xmcp + && adduser -S -D -H -u 1001 -G xmcp xmcp \ + && mkdir -p /etc/xmcp WORKDIR /app COPY --from=builder --chown=xmcp:xmcp /app/.venv /app/.venv @@ -43,8 +41,8 @@ USER xmcp EXPOSE 8000 -# In HTTP mode, probe the MCP port; stdio containers are always healthy. +# Probes the MCP HTTP port when the mounted configuration uses transport http. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD ["python", "-c", "import os, socket, sys\nif os.environ.get('MCP_TRANSPORT', 'stdio') != 'http':\n sys.exit(0)\ns = socket.socket()\ns.settimeout(3)\nsys.exit(s.connect_ex(('127.0.0.1', int(os.environ.get('MCP_PORT', '8000')))))"] + CMD ["python", "-m", "xmcp.healthcheck"] CMD ["python", "-m", "xmcp"] diff --git a/README.md b/README.md index 26a2630..6211ee4 100644 --- a/README.md +++ b/README.md @@ -10,104 +10,148 @@ [![Docker](https://img.shields.io/badge/Docker-image-2496ED?logo=docker&logoColor=white&style=flat-square)](https://www.docker.com/) [![uv](https://img.shields.io/badge/uv-managed-DE5FE9?style=flat-square)](https://docs.astral.sh/uv/) -Turns any [OpenAPI](https://spec.openapis.org/oas/latest.html) document +Turns one or more [OpenAPI](https://spec.openapis.org/oas/latest.html) documents (JSON or YAML) into a ready-to-run [MCP](https://modelcontextprotocol.io) -server using [FastMCP](https://gofastmcp.com). One MCP tool is generated per -API operation, and requests to the target API are authenticated with a token -supplied via environment variables. +server using [FastMCP](https://gofastmcp.com). Each API operation becomes an MCP +tool; when several documents are configured they are mounted under a shared +server and served on a single transport. -Everything is configured through environment variables: the spec location, the -transport (stdio or streamable HTTP), the listen host/port, and the API token. +All configuration lives in a YAML file (JSON is also accepted). Only the +optional path to that file can come from the environment (`XMCP_CONFIG`). ## Quick start -### Docker Compose (HTTP) - ```sh -cp .env.example .env # then edit OPENAPI_SPEC_URL / API_TOKEN etc. +cp xmcp.example.yml xmcp.yml # then edit specs[].spec_url and credentials docker compose up --build ``` The MCP endpoint is served at `http://localhost:8000/mcp`. -### Docker (HTTP) +### Docker (without Compose) ```sh docker build -t xmcp . docker run --rm -p 8000:8000 \ - -e OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json \ + -v "$PWD/xmcp.yml:/etc/xmcp/xmcp.yml:ro" \ xmcp ``` -### Docker (stdio) - -For stdio the container must run with an open stdin (`-i`); logs go to stderr, -so stdout stays protocol-clean: +For stdio, mount the config and run the container with an open stdin: ```sh docker run -i --rm \ - -e MCP_TRANSPORT=stdio \ - -e OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json \ + -v "$PWD/xmcp.yml:/etc/xmcp/xmcp.yml:ro" \ xmcp ``` -Example MCP client configuration for the stdio container: - -```json -{ - "mcpServers": { - "petstore": { - "command": "docker", - "args": [ - "run", "-i", "--rm", - "-e", "MCP_TRANSPORT=stdio", - "-e", "OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json", - "xmcp:latest" - ] - } - } -} -``` - ### Local development with uv ```sh uv sync -OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json uv run xmcp -# equivalent: uv run python -m xmcp +uv run xmcp --config xmcp.yml ``` +The configuration file is located, in order, via: + +1. `--config PATH` +2. the `XMCP_CONFIG` environment variable +3. the default `/etc/xmcp/xmcp.yml` + ## Configuration -| Variable | Required | Default | Description | -| --- | --- | --- | --- | -| `OPENAPI_SPEC_URL` | yes | – | `http(s)://` URL or local file path of the OpenAPI document (JSON or YAML) | -| `OPENAPI_SPEC_TOKEN` | no | – | Bearer token used only when fetching a remote spec document | -| `API_BASE_URL` | no | spec `servers[0].url` | Overrides the target API base URL | -| `API_TOKEN` | no | – | Credential sent to the target API on every tool call | -| `API_AUTH_HEADER` | no | `Authorization` | Header used to carry the token (e.g. `X-API-Key`) | -| `API_AUTH_SCHEME` | no | `Bearer` | Scheme prefix; set to an empty value to send the raw token | -| `MCP_API_KEY_HEADER` | no | `X-API-Key` | HTTP header treated as the per-request API key (empty disables the mapping) | -| `MCP_TRANSPORT` | no | `stdio` (`http` in the image) | `stdio` or `http` (`streamable-http` is an alias) | -| `MCP_HOST` | no | `0.0.0.0` | Listen host for the HTTP transport | -| `MCP_PORT` | no | `8000` | Listen port for the HTTP transport | -| `MCP_SERVER_NAME` | no | spec `info.title` | Name advertised by the MCP server | -| `MCP_TOOLS_INCLUDED` | no | – (all tools) | Comma-separated allowlist of tool names to expose | -| `MCP_TOOLS_TAGS_INCLUDED` | no | – (all tags) | Comma-separated allowlist of OpenAPI tags to expose | -| `HTTP_TIMEOUT` | no | `30` | Timeout in seconds for spec fetch and API calls | -| `LOG_LEVEL` | no | `INFO` | Python log level | +```yaml +server: + name: xmcp # optional; single-spec default is the spec title + transport: http # stdio | http + host: 0.0.0.0 + port: 8000 + log_level: INFO + http_timeout: 30 + client_api_key_header: X-API-Key # "" disables per-request key forwarding -Authentication examples: for a bearer token leave the defaults; for an API key -header use `API_AUTH_HEADER=X-API-Key` with `API_AUTH_SCHEME` set to an empty -string. At startup the generated tool list (name, tags and description) is -logged at INFO; set `LOG_LEVEL=WARNING` to suppress it. +api: # defaults applied to every OpenAPI document + # base_url: https://api.example.com + # token: ${API_TOKEN} + auth_header: Authorization + auth_scheme: Bearer # "" sends the raw token -### Per-client API keys (HTTP transport) +tools: # optional global filters + include: [getPetById] # namespaced names in multi-spec mode + tags_include: [pet] # OpenAPI operation tags + +specs: + - spec_url: https://petstore3.swagger.io/api/v3/openapi.json + api: + base_url: https://petstore3.swagger.io/api/v3 +``` + +| Key | Description | +| --- | --- | +| `server.name` | Name advertised by the parent server (multi-spec default: `xmcp`) | +| `server.transport` | `stdio` or `http` (default `stdio`) | +| `server.host`, `server.port` | HTTP listen address (defaults `0.0.0.0`, `8000`) | +| `server.log_level` | Python log level (default `INFO`) | +| `server.http_timeout` | Timeout in seconds for spec fetch and API calls (default `30`) | +| `server.client_api_key_header` | Header carrying a per-request API key (default `X-API-Key`, empty disables) | +| `api.base_url`, `api.token` | Defaults for every spec's target API | +| `api.auth_header`, `api.auth_scheme` | Default credential header and scheme | +| `tools.include` | Allowlist of tool names (namespaced in multi-spec mode) | +| `tools.tags_include` | Allowlist of OpenAPI tags | +| `specs[].namespace` | Tool-name prefix; required when more than one spec is configured | +| `specs[].spec_url` | HTTP(S) URL or local file path of the document | +| `specs[].spec_token` | Bearer token used only to fetch the document | +| `specs[].server_name` | MCP server name for this document | +| `specs[].api.*` | Per-spec overrides of the `api` defaults | + +Notes: + +- `${VAR}` in any string value is replaced with the environment variable. A + missing variable is a startup error; the error names the exact key. +- Unset or empty `tools` filters expose everything. `tools.include` and + `tools.tags_include` combine with AND semantics; entries that match nothing + are reported with a startup warning. +- Unknown keys, invalid values, duplicate namespaces and empty `specs` fail + fast with a clear message. +- Relative local paths in `spec_url` resolve against the config file's + directory, which keeps mounted deployments predictable. +- The generated tool list (name, tags, description) is logged at startup at + INFO; set `server.log_level: WARNING` to suppress it. + +## Multiple OpenAPI documents + +Give each entry a unique namespace: + +```yaml +tools: + include: [pet_getPetById, billing_listInvoices] + tags_include: [pet, billing] + +specs: + - namespace: pet + spec_url: https://petstore3.swagger.io/api/v3/openapi.json + api: + base_url: https://petstore3.swagger.io/api/v3 + token: ${PETSTORE_TOKEN} + - namespace: billing + spec_url: ./specs/billing.yaml + api: + base_url: https://billing.example.com + token: ${BILLING_TOKEN} + auth_header: X-API-Key + auth_scheme: "" +``` + +Tools are exposed as `pet_getPetById`, `billing_listInvoices`, and so on; +resources and prompts are namespaced too. A single document without a namespace +keeps its original, unprefixed tool names. + +## Per-client API keys (HTTP transport) When serving over HTTP, clients can send their own API key on each request -instead of sharing the server's `API_TOKEN`. The value is treated as a raw -token and forwarded to the target API using `API_AUTH_HEADER` / -`API_AUTH_SCHEME` (by default `Authorization: Bearer `): +instead of using the configured `api.token`. The value is treated as a raw +token and forwarded using each spec's `api.auth_header` / `api.auth_scheme` +(by default `Authorization: Bearer `): ```sh curl -X POST http://localhost:8000/mcp \ @@ -117,68 +161,51 @@ curl -X POST http://localhost:8000/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize",...}' ``` -Precedence: the request header wins, then `API_TOKEN`, then no authentication. -The header name is configurable with `MCP_API_KEY_HEADER`; set it to an empty -value to disable the mapping. When the key is mapped to a differently named -target header, the source header is removed from the outgoing API request so -the credential is not sent twice. +Precedence: the request header wins, then `api.token`, then no authentication. +The header name is `server.client_api_key_header`; an empty value disables the +mapping. When mapped to a differently named target header, the source header is +removed from the outgoing API request so the credential is not sent twice. In +multi-spec mode the same request header is used for every mounted API, each +formatting it according to its own settings. Note: FastMCP independently passes other incoming HTTP headers through to the API request, except credential headers such as `Authorization` and `Cookie`. -MCP clients that support custom transport headers should send the key on every -request (at least on `tools/call`). - -### Tool allowlists - -By default every operation in the OpenAPI document is exposed as an MCP tool. -Two environment variables can restrict the exposed set; each accepts a -comma-separated list, and unset or empty disables that filter: - -```sh -# Only these operations -MCP_TOOLS_INCLUDED=getPetById,findPetsByStatus - -# Only operations tagged "pet" in the OpenAPI document -MCP_TOOLS_TAGS_INCLUDED=pet -``` - -When both are set, a tool must match both filters (name **and** tag). Entries -that do not match anything are ignored with a startup warning, and the active -selection is reported at startup -(`Tool filters active (names: ...; tags: ...): exposing N of M tool(s): ...`). - -### Serving a local spec file - -```sh -docker run -i --rm \ - -v "$PWD/openapi.json:/spec/openapi.json:ro" \ - -e OPENAPI_SPEC_URL=/spec/openapi.json \ - xmcp -``` - -If the document has no absolute `servers[0].url` (for example it only contains -a relative path such as `/v1`), set `API_BASE_URL` to the API root explicitly. - -### Base URL resolution - -The base URL for generated tools is chosen as follows: - -1. `API_BASE_URL`, if set. -2. The first entry of the spec's `servers` list. -3. If that server URL is relative (e.g. `/api/v3`) and the spec was fetched - over HTTP(S), it is resolved against the spec's origin. -4. Otherwise startup fails with a message asking for `API_BASE_URL`. ## Security notes -- The API token is attached to every request the generated tools make to the - target API. It is never logged. +- The configuration file may contain API tokens. Keep it out of version + control (it is gitignored), mount it read-only, and restrict its permissions. +- `${VAR}` interpolation keeps secrets out of the file (e.g. `token: ${API_TOKEN}`). - The MCP HTTP endpoint (`/mcp`) itself is **not** authenticated. Run it on a private network or behind a reverse proxy / gateway that enforces - authentication before exposing it publicly. -- Per-request API keys (the `X-API-Key` header by default) are forwarded only to - the target API and are never logged. Terminate TLS in front of the MCP - endpoint so client keys are not sent in clear text. + authentication, and terminate TLS there so client keys are not sent in clear + text. +- Per-request API keys are forwarded only to the target API and are never + logged. + +## Migrating from environment variables + +Configuration is now file-based. Legacy environment variables are ignored (a +startup warning lists any that are set); only `XMCP_CONFIG` (the config file +path) is still read. + +| Previous environment variable | Configuration key | +| --- | --- | +| `OPENAPI_SPEC_URL` | `specs[].spec_url` | +| `OPENAPI_SPEC_TOKEN` | `specs[].spec_token` | +| `API_BASE_URL` | `api.base_url` / `specs[].api.base_url` | +| `API_TOKEN` | `api.token` / `specs[].api.token` | +| `API_AUTH_HEADER` | `api.auth_header` | +| `API_AUTH_SCHEME` | `api.auth_scheme` | +| `MCP_API_KEY_HEADER` | `server.client_api_key_header` | +| `MCP_TRANSPORT` | `server.transport` | +| `MCP_HOST` | `server.host` | +| `MCP_PORT` | `server.port` | +| `MCP_SERVER_NAME` | `server.name` | +| `MCP_TOOLS_INCLUDED` | `tools.include` | +| `MCP_TOOLS_TAGS_INCLUDED` | `tools.tags_include` | +| `HTTP_TIMEOUT` | `server.http_timeout` | +| `LOG_LEVEL` | `server.log_level` | ## Releasing @@ -186,20 +213,22 @@ Pushing a `release/*` tag triggers the Gitea Actions pipeline, which builds the Docker image and pushes it to the Gitea container registry: ```sh -git tag release/0.1.0 -git push origin release/0.1.0 +git tag release/0.2.0 +git push origin release/0.2.0 ``` -This publishes `gitea.woggioni.net/woggioni/xmcp:0.1.0` and +This publishes `gitea.woggioni.net/woggioni/xmcp:0.2.0` and `gitea.woggioni.net/woggioni/xmcp:latest`. ## Project layout ``` -src/xmcp/config.py env var parsing and validation +src/xmcp/config.py configuration loading and validation src/xmcp/spec.py spec loading, base URL resolution, API client src/xmcp/auth.py per-request API key forwarding (HTTP transport) -src/xmcp/server.py FastMCP server construction and entrypoint +src/xmcp/server.py server construction, mounting, lifecycle +src/xmcp/healthcheck.py container healthcheck Dockerfile multi-stage build (uv builder -> alpine runtime, non-root) .gitea/workflows/build.yaml release pipeline (tag push -> docker build & push) +xmcp.example.yml annotated configuration example ``` diff --git a/docker-compose.yml b/docker-compose.yml index 6f2d2e7..aa747d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,16 +3,9 @@ services: build: context: . image: xmcp:latest - env_file: - - .env - environment: - MCP_TRANSPORT: http - MCP_HOST: 0.0.0.0 - MCP_PORT: "8000" + volumes: + - ./xmcp.yml:/etc/xmcp/xmcp.yml:ro + # The container port must match server.port in xmcp.yml. ports: - - "${MCP_PORT:-8000}:${MCP_PORT:-8000}" + - "8000:8000" restart: unless-stopped - # To serve a locally stored OpenAPI document, mount it and point - # OPENAPI_SPEC_URL at the in-container path: - # volumes: - # - ./openapi.json:/spec/openapi.json:ro diff --git a/pyproject.toml b/pyproject.toml index 2d1c883..bc055ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xmcp" -version = "0.1.0" +version = "0.2.0" description = "MCP server generated from an OpenAPI specification, built with FastMCP" readme = "README.md" requires-python = ">=3.14" diff --git a/src/xmcp/__init__.py b/src/xmcp/__init__.py index 8c27b07..4b2eed4 100644 --- a/src/xmcp/__init__.py +++ b/src/xmcp/__init__.py @@ -1,3 +1,3 @@ """xmcp: an MCP server generated from an OpenAPI specification with FastMCP.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/xmcp/config.py b/src/xmcp/config.py index 4f4496c..b3d32cd 100644 --- a/src/xmcp/config.py +++ b/src/xmcp/config.py @@ -1,10 +1,20 @@ -"""Environment-driven configuration for xmcp.""" +"""File-based configuration for xmcp.""" from __future__ import annotations import logging import os +import re +from collections.abc import Mapping from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import yaml + +DEFAULT_CONFIG_PATH = Path("/etc/xmcp/xmcp.yml") +CONFIG_PATH_ENV = "XMCP_CONFIG" VALID_TRANSPORTS = ("stdio", "http") _TRANSPORT_ALIASES = { @@ -13,124 +23,407 @@ _TRANSPORT_ALIASES = { "streamablehttp": "http", } +_NAMESPACE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") +_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + +_SERVER_KEYS = { + "name", + "transport", + "host", + "port", + "log_level", + "http_timeout", + "client_api_key_header", +} +_API_KEYS = {"base_url", "token", "auth_header", "auth_scheme"} +_TOOLS_KEYS = {"include", "tags_include"} +_SPEC_KEYS = {"namespace", "spec_url", "spec_token", "server_name", "api"} +_TOP_LEVEL_KEYS = {"server", "api", "tools", "specs"} + +LEGACY_ENV_VARS = ( + "OPENAPI_SPEC_URL", + "OPENAPI_SPEC_TOKEN", + "API_BASE_URL", + "API_TOKEN", + "API_AUTH_HEADER", + "API_AUTH_SCHEME", + "MCP_API_KEY_HEADER", + "MCP_TRANSPORT", + "MCP_HOST", + "MCP_PORT", + "MCP_SERVER_NAME", + "MCP_TOOLS_INCLUDED", + "MCP_TOOLS_TAGS_INCLUDED", + "HTTP_TIMEOUT", + "LOG_LEVEL", +) + class ConfigError(ValueError): - """Raised when the environment configuration is invalid.""" + """Raised when the configuration file is invalid.""" -def _get(name: str) -> str | None: - """Read an environment variable, treating blank values as unset.""" - value = os.environ.get(name, "").strip() - return value or None +@dataclass(frozen=True) +class SpecSource: + """One OpenAPI document and its per-spec overrides.""" - -def _get_int(name: str, default: int, *, minimum: int, maximum: int | None = None) -> int: - raw = _get(name) - if raw is None: - return default - try: - value = int(raw) - except ValueError: - raise ConfigError(f"{name} must be an integer, got {raw!r}") from None - if value < minimum or (maximum is not None and value > maximum): - bounds = f"at least {minimum}" if maximum is None else f"between {minimum} and {maximum}" - raise ConfigError(f"{name} must be {bounds}, got {value}") - return value - - -def _get_float(name: str, default: float, *, minimum: float) -> float: - raw = _get(name) - if raw is None: - return default - try: - value = float(raw) - except ValueError: - raise ConfigError(f"{name} must be a number, got {raw!r}") from None - if value < minimum: - raise ConfigError(f"{name} must be at least {minimum}, got {value}") - return value - - -def _parse_set(name: str) -> frozenset[str] | None: - """Parse a comma-separated environment variable into a set of names.""" - raw = _get(name) - if raw is None: - return None - values = frozenset(value.strip() for value in raw.split(",") if value.strip()) - return values or None + spec_url: str + namespace: str | None = None + spec_token: str | None = None + server_name: str | None = None + api_base_url: str | None = None + api_token: str | None = None + api_auth_header: str | None = None + api_auth_scheme: str | None = None @dataclass(frozen=True) class Config: """Resolved runtime configuration.""" - spec_url: str - spec_token: str | None - api_base_url: str | None - api_token: str | None - api_auth_header: str - api_auth_scheme: str - mcp_api_key_header: str | None - tools_included: frozenset[str] | None - tools_tags_included: frozenset[str] | None - transport: str - host: str - port: int - server_name: str | None - timeout: float - log_level: str + path: Path + specs: tuple[SpecSource, ...] + transport: str = "stdio" + host: str = "0.0.0.0" + port: int = 8000 + server_name: str | None = None + timeout: float = 30.0 + log_level: str = "INFO" + client_api_key_header: str | None = "X-API-Key" + tools_include: frozenset[str] | None = None + tools_tags_include: frozenset[str] | None = None + api_base_url: str | None = None + api_token: str | None = None + api_auth_header: str = "Authorization" + api_auth_scheme: str = "Bearer" - @classmethod - def from_env(cls) -> "Config": - """Build a Config from environment variables, raising ConfigError.""" - spec_url = _get("OPENAPI_SPEC_URL") - if spec_url is None: - raise ConfigError( - "OPENAPI_SPEC_URL is required: set it to an http(s) URL or a local " - "path of an OpenAPI document (JSON or YAML)" - ) - - transport = (_get("MCP_TRANSPORT") or "stdio").lower() - transport = _TRANSPORT_ALIASES.get(transport, transport) - if transport not in VALID_TRANSPORTS: - raise ConfigError( - f"MCP_TRANSPORT must be one of {'/'.join(VALID_TRANSPORTS)} " - f"(or 'streamable-http'), got {transport!r}" - ) - - log_level = (_get("LOG_LEVEL") or "INFO").upper() - if log_level not in logging.getLevelNamesMapping(): - raise ConfigError(f"LOG_LEVEL is not a valid log level: {log_level!r}") - - # Read without the blank-means-unset helper on purpose: an explicitly - # empty value means "send the raw token with no scheme prefix". - raw_scheme = os.environ.get("API_AUTH_SCHEME") - auth_scheme = "Bearer" if raw_scheme is None else raw_scheme.strip() - - # Explicitly empty disables forwarding of per-request API keys. - raw_key_header = os.environ.get("MCP_API_KEY_HEADER") - key_header = "X-API-Key" if raw_key_header is None else raw_key_header.strip() - key_header = key_header or None - - # Comma-separated allowlists of tool names / OpenAPI tags; unset or - # empty means no filtering on that dimension. - included = _parse_set("MCP_TOOLS_INCLUDED") - tags_included = _parse_set("MCP_TOOLS_TAGS_INCLUDED") - - return cls( - spec_url=spec_url, - spec_token=_get("OPENAPI_SPEC_TOKEN"), - api_base_url=_get("API_BASE_URL"), - api_token=_get("API_TOKEN"), - api_auth_header=_get("API_AUTH_HEADER") or "Authorization", - api_auth_scheme=auth_scheme, - mcp_api_key_header=key_header, - tools_included=included, - tools_tags_included=tags_included, - transport=transport, - host=_get("MCP_HOST") or "0.0.0.0", - port=_get_int("MCP_PORT", 8000, minimum=1, maximum=65535), - server_name=_get("MCP_SERVER_NAME"), - timeout=_get_float("HTTP_TIMEOUT", 30.0, minimum=0.1), - log_level=log_level, + def api_for(self, spec: SpecSource) -> tuple[str | None, str | None, str, str]: + """Return (base_url, token, header, scheme) for a spec, applying defaults.""" + base_url = spec.api_base_url if spec.api_base_url is not None else self.api_base_url + token = spec.api_token if spec.api_token is not None else self.api_token + header = ( + spec.api_auth_header + if spec.api_auth_header is not None + else self.api_auth_header ) + scheme = ( + spec.api_auth_scheme + if spec.api_auth_scheme is not None + else self.api_auth_scheme + ) + return base_url, token, header, scheme + + +def resolve_config_path(cli_path: str | None = None) -> Path: + """Resolve the configuration path: --config, then XMCP_CONFIG, then default.""" + if cli_path: + return Path(cli_path).expanduser() + env_path = os.environ.get(CONFIG_PATH_ENV, "").strip() + if env_path: + return Path(env_path).expanduser() + return DEFAULT_CONFIG_PATH + + +def legacy_environment_variables() -> list[str]: + """Return legacy env vars that are set but no longer used.""" + return [name for name in LEGACY_ENV_VARS if os.environ.get(name, "").strip()] + + +def load_config(cli_path: str | None = None) -> Config: + """Load and validate the configuration file.""" + path = resolve_config_path(cli_path) + document = _read_document(path) + return _parse_config(document, path) + + +# --------------------------------------------------------------------------- # +# File loading and interpolation +# --------------------------------------------------------------------------- # + + +def _read_document(path: Path) -> dict[str, Any]: + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ConfigError( + f"configuration file not found: {path} " + f"(use --config, ${CONFIG_PATH_ENV}, or create the default file)" + ) from exc + except OSError as exc: + raise ConfigError(f"could not read configuration file {path}: {exc}") from exc + + try: + document = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise ConfigError(f"invalid YAML/JSON in {path}: {exc}") from exc + + if not isinstance(document, dict): + raise ConfigError(f"configuration file {path} must contain a mapping at the top level") + + return _interpolate(document, "") + + +def _interpolate(value: Any, location: str) -> Any: + if isinstance(value, dict): + return { + key: _interpolate(item, f"{location}.{key}" if location else str(key)) + for key, item in value.items() + } + if isinstance(value, list): + return [ + _interpolate(item, f"{location}[{index}]") + for index, item in enumerate(value) + ] + if isinstance(value, str): + def replace(match: re.Match[str]) -> str: + name = match.group(1) + if name not in os.environ: + where = location or "" + raise ConfigError( + f"environment variable {name!r} referenced at {where} is not set" + ) + return os.environ[name] + + return _ENV_PATTERN.sub(replace, value) + return value + + +# --------------------------------------------------------------------------- # +# Document parsing +# --------------------------------------------------------------------------- # + + +def _parse_config(document: Mapping[str, Any], path: Path) -> Config: + _reject_unknown(document, _TOP_LEVEL_KEYS, "the top level") + + server = _mapping(document.get("server"), "server") + _reject_unknown(server, _SERVER_KEYS, "server") + + transport = (_string(server, "transport", "server", "stdio") or "stdio").lower() + transport = _TRANSPORT_ALIASES.get(transport, transport) + if transport not in VALID_TRANSPORTS: + raise ConfigError( + f"server.transport must be one of {'/'.join(VALID_TRANSPORTS)} " + f"(or 'streamable-http'), got {transport!r}" + ) + + log_level = (_string(server, "log_level", "server", "INFO") or "INFO").upper() + if log_level not in logging.getLevelNamesMapping(): + raise ConfigError(f"server.log_level is not a valid log level: {log_level!r}") + + port = _int_value(server.get("port"), "server.port", 8000, 1, 65535) + timeout = _float_value(server.get("http_timeout"), "server.http_timeout", 30.0, 0.1) + host = _string(server, "host", "server", "0.0.0.0") or "0.0.0.0" + server_name = _blank_to_none(_string(server, "name", "server")) + + if "client_api_key_header" in server: + raw_header = _string(server, "client_api_key_header", "server") + header = raw_header.strip() if raw_header is not None else "" + client_api_key_header = header or None + else: + client_api_key_header = "X-API-Key" + + api = _mapping(document.get("api"), "api") + _reject_unknown(api, _API_KEYS, "api") + api_base_url = _blank_to_none(_string(api, "base_url", "api")) + api_token = _blank_to_none(_string(api, "token", "api")) + api_auth_header = _blank_to_none(_string(api, "auth_header", "api")) or "Authorization" + if "auth_scheme" in api: + raw_scheme = _string(api, "auth_scheme", "api") + api_auth_scheme = raw_scheme.strip() if raw_scheme is not None else "" + else: + api_auth_scheme = "Bearer" + + tools = _mapping(document.get("tools"), "tools") + _reject_unknown(tools, _TOOLS_KEYS, "tools") + tools_include = _name_set(tools.get("include"), "tools.include") + tools_tags_include = _name_set(tools.get("tags_include"), "tools.tags_include") + + specs_raw = document.get("specs") + if specs_raw is None: + raise ConfigError("specs is required: add at least one OpenAPI document") + if not isinstance(specs_raw, list) or not specs_raw: + raise ConfigError("specs must be a non-empty list") + + base_dir = path.parent.resolve() + specs: list[SpecSource] = [] + seen_namespaces: set[str] = set() + for index, raw_spec in enumerate(specs_raw): + location = f"specs[{index}]" + spec_map = _mapping(raw_spec, location) + _reject_unknown(spec_map, _SPEC_KEYS, location) + + spec_url = _string(spec_map, "spec_url", location) + if not spec_url: + raise ConfigError(f"{location}.spec_url is required") + + namespace = _blank_to_none(_string(spec_map, "namespace", location)) + if namespace is not None: + if not _NAMESPACE_RE.match(namespace): + raise ConfigError( + f"{location}.namespace must match [A-Za-z0-9][A-Za-z0-9_-]*, " + f"got {namespace!r}" + ) + if namespace in seen_namespaces: + raise ConfigError(f"duplicate namespace {namespace!r} in {location}") + seen_namespaces.add(namespace) + + spec_api = _mapping(spec_map.get("api"), f"{location}.api") + _reject_unknown(spec_api, _API_KEYS, f"{location}.api") + spec_api_scheme: str | None = None + if "auth_scheme" in spec_api: + raw_spec_scheme = _string(spec_api, "auth_scheme", f"{location}.api") + spec_api_scheme = ( + raw_spec_scheme.strip() if raw_spec_scheme is not None else "" + ) + + specs.append( + SpecSource( + spec_url=_resolve_spec_location(spec_url, base_dir), + namespace=namespace, + spec_token=_blank_to_none(_string(spec_map, "spec_token", location)), + server_name=_blank_to_none(_string(spec_map, "server_name", location)), + api_base_url=_blank_to_none( + _string(spec_api, "base_url", f"{location}.api") + ), + api_token=_blank_to_none(_string(spec_api, "token", f"{location}.api")), + api_auth_header=_blank_to_none( + _string(spec_api, "auth_header", f"{location}.api") + ), + api_auth_scheme=spec_api_scheme, + ) + ) + + if len(specs) > 1: + missing = [ + f"specs[{index}]" + for index, spec in enumerate(specs) + if spec.namespace is None + ] + if missing: + raise ConfigError( + "namespace is required for every entry when multiple specs are " + f"configured (missing in {', '.join(missing)})" + ) + + return Config( + path=path.resolve(), + specs=tuple(specs), + transport=transport, + host=host, + port=port, + server_name=server_name, + timeout=timeout, + log_level=log_level, + client_api_key_header=client_api_key_header, + tools_include=tools_include, + tools_tags_include=tools_tags_include, + api_base_url=api_base_url, + api_token=api_token, + api_auth_header=api_auth_header, + api_auth_scheme=api_auth_scheme, + ) + + +# --------------------------------------------------------------------------- # +# Small parsing helpers +# --------------------------------------------------------------------------- # + + +def _mapping(value: Any, location: str) -> Mapping[str, Any]: + if value is None: + return {} + if not isinstance(value, dict): + raise ConfigError(f"{location} must be a mapping") + return value + + +def _reject_unknown( + mapping: Mapping[str, Any], allowed: set[str], location: str +) -> None: + unknown = sorted(str(key) for key in set(mapping) - allowed) + if unknown: + raise ConfigError(f"unknown key(s) in {location}: {', '.join(unknown)}") + + +def _string( + mapping: Mapping[str, Any], + key: str, + location: str, + default: str | None = None, +) -> str | None: + if key not in mapping or mapping[key] is None: + return default + value = mapping[key] + if not isinstance(value, str): + raise ConfigError(f"{location}.{key} must be a string") + return value + + +def _blank_to_none(value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + return value or None + + +def _int_value( + value: Any, location: str, default: int, minimum: int, maximum: int +) -> int: + if value is None: + return default + if isinstance(value, bool): + raise ConfigError(f"{location} must be an integer") + try: + parsed = int(value) + except (TypeError, ValueError): + raise ConfigError(f"{location} must be an integer, got {value!r}") from None + if not minimum <= parsed <= maximum: + raise ConfigError( + f"{location} must be between {minimum} and {maximum}, got {parsed}" + ) + return parsed + + +def _float_value( + value: Any, location: str, default: float, minimum: float +) -> float: + if value is None: + return default + if isinstance(value, bool): + raise ConfigError(f"{location} must be a number") + try: + parsed = float(value) + except (TypeError, ValueError): + raise ConfigError(f"{location} must be a number, got {value!r}") from None + if parsed < minimum: + raise ConfigError(f"{location} must be at least {minimum}, got {parsed}") + return parsed + + +def _name_set(value: Any, location: str) -> frozenset[str] | None: + if value is None: + return None + if isinstance(value, str): + items = value.split(",") + elif isinstance(value, list): + if not all(isinstance(item, str) for item in value): + raise ConfigError(f"{location} must be a list of strings") + items = value + else: + raise ConfigError( + f"{location} must be a list of strings or a comma-separated string" + ) + names = frozenset(item.strip() for item in items if item.strip()) + return names or None + + +def _resolve_spec_location(raw: str, base_dir: Path) -> str: + """Resolve local spec paths relative to the configuration file.""" + scheme = urlparse(raw).scheme.lower() + if scheme in ("http", "https", "file"): + return raw + candidate = Path(raw).expanduser() + if not candidate.is_absolute(): + candidate = base_dir / candidate + return str(candidate) diff --git a/src/xmcp/healthcheck.py b/src/xmcp/healthcheck.py new file mode 100644 index 0000000..a921a0b --- /dev/null +++ b/src/xmcp/healthcheck.py @@ -0,0 +1,31 @@ +"""Container healthcheck: probe the MCP HTTP port when that transport is configured.""" + +from __future__ import annotations + +import socket +import sys + +from .config import ConfigError, load_config + + +def main() -> None: + try: + config = load_config() + except ConfigError as exc: + print(f"healthcheck: configuration error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + if config.transport != "http": + return + + host = "127.0.0.1" if config.host in ("0.0.0.0", "::") else config.host + try: + with socket.create_connection((host, config.port), timeout=3): + return + except OSError as exc: + print(f"healthcheck: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/src/xmcp/server.py b/src/xmcp/server.py index 4d58f1d..30d9008 100644 --- a/src/xmcp/server.py +++ b/src/xmcp/server.py @@ -1,59 +1,91 @@ -"""Build and run the FastMCP server generated from the OpenAPI document.""" +"""Build and run the FastMCP server(s) generated from OpenAPI documents.""" from __future__ import annotations +import argparse import asyncio import logging import os from fastmcp import FastMCP -from .config import Config, ConfigError +from .config import ( + CONFIG_PATH_ENV, + DEFAULT_CONFIG_PATH, + Config, + ConfigError, + SpecSource, + legacy_environment_variables, + load_config, +) from .spec import SpecError, build_api_client, load_spec, resolve_base_url logger = logging.getLogger("xmcp") -def build_server(config: Config) -> FastMCP: - """Load the OpenAPI document and build the FastMCP server.""" - spec = load_spec(config.spec_url, token=config.spec_token, timeout=config.timeout) - base_url = resolve_base_url(spec, override=config.api_base_url, spec_url=config.spec_url) +def _build_subserver( + config: Config, spec: SpecSource, default_name: str | None +) -> FastMCP: + """Build one FastMCP server from a single OpenAPI document.""" + document = load_spec(spec.spec_url, token=spec.spec_token, timeout=config.timeout) + api_base_url, api_token, api_auth_header, api_auth_scheme = config.api_for(spec) + base_url = resolve_base_url( + document, override=api_base_url, spec_url=spec.spec_url + ) client = build_api_client( base_url=base_url, - token=config.api_token, - header=config.api_auth_header, - scheme=config.api_auth_scheme, + token=api_token, + header=api_auth_header, + scheme=api_auth_scheme, timeout=config.timeout, - forwarded_header=config.mcp_api_key_header, + forwarded_header=config.client_api_key_header, ) - info = spec.get("info") if isinstance(spec.get("info"), dict) else {} - name = config.server_name or str(info.get("title") or "OpenAPI MCP Server") + info = document.get("info") if isinstance(document.get("info"), dict) else {} + name = ( + spec.server_name + or default_name + or str(info.get("title") or "OpenAPI MCP Server") + ) - server = FastMCP.from_openapi(openapi_spec=spec, client=client, name=name) + server = FastMCP.from_openapi(openapi_spec=document, client=client, name=name) logger.info( "Generated MCP server %r from %s: %d path(s), target API %s", name, - config.spec_url, - len(spec.get("paths") or {}), + spec.spec_url, + len(document.get("paths") or {}), base_url, ) - if config.api_token: - logger.debug( - "Fallback API token configured for the %s header (%s)", - config.api_auth_header, - config.api_auth_scheme or "raw token", - ) - if config.mcp_api_key_header: - logger.debug( - "Per-request API keys accepted from the %s header", - config.mcp_api_key_header, - ) - elif not config.api_token: - logger.debug("No API token configured: requests are sent unauthenticated") + if api_token is not None: + logger.debug("API token configured for %r (%s header)", name, api_auth_header) return server +def build_server(config: Config) -> FastMCP: + """Build the MCP server from the configured OpenAPI documents.""" + single = len(config.specs) == 1 and config.specs[0].namespace is None + default_name = config.server_name if single else None + + subservers = [ + (spec, _build_subserver(config, spec, default_name)) + for spec in config.specs + ] + + if single: + return subservers[0][1] + + parent = FastMCP(config.server_name or "xmcp") + for spec, subserver in subservers: + parent.mount(subserver, namespace=spec.namespace) + logger.info( + "Mounted %d OpenAPI server(s) under %r: %s", + len(subservers), + parent.name, + ", ".join(f"{spec.namespace} <- {spec.spec_url}" for spec, _ in subservers), + ) + return parent + + async def _prepare_tools( server: FastMCP, included: frozenset[str] | None, @@ -71,7 +103,7 @@ async def _prepare_tools( unknown = sorted(included - known_names) if unknown: logger.warning( - "MCP_TOOLS_INCLUDED lists unknown tool(s): %s", + "tools.include lists unknown tool(s): %s", ", ".join(unknown), ) names = set(included & known_names) @@ -81,7 +113,7 @@ async def _prepare_tools( unknown_tags = sorted(tags_included - known_tags) if unknown_tags: logger.warning( - "MCP_TOOLS_TAGS_INCLUDED lists unknown tag(s): %s", + "tools.tags_include lists unknown tag(s): %s", ", ".join(unknown_tags), ) tags = set(tags_included & known_tags) @@ -120,9 +152,27 @@ async def _prepare_tools( logger.info(" - %s%s%s", tool.name, tags, suffix) -def main() -> None: +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="xmcp", + description="Expose one or more OpenAPI documents as MCP servers.", + ) + parser.add_argument( + "-c", + "--config", + metavar="PATH", + help=( + f"Configuration file (default: ${CONFIG_PATH_ENV} " + f"or {DEFAULT_CONFIG_PATH})" + ), + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + args = _parse_args(argv) try: - config = Config.from_env() + config = load_config(args.config) except ConfigError as exc: raise SystemExit(f"Configuration error: {exc}") from exc @@ -130,29 +180,49 @@ def main() -> None: level=config.log_level, format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", ) - # Keep FastMCP's own logging in sync with LOG_LEVEL unless set explicitly. + # Keep FastMCP's own logging in sync with the configured level. os.environ.setdefault("FASTMCP_LOG_LEVEL", config.log_level) + legacy = legacy_environment_variables() + if legacy: + logger.warning( + "Ignoring legacy environment variable(s): %s " + "(configuration is file-based: %s)", + ", ".join(legacy), + config.path, + ) + if config.client_api_key_header: + logger.debug( + "Per-request API keys accepted from the %s header", + config.client_api_key_header, + ) + try: server = build_server(config) except SpecError as exc: - raise SystemExit(f"Failed to load the OpenAPI document: {exc}") from exc + raise SystemExit(f"Failed to load an OpenAPI document: {exc}") from exc if ( - config.tools_included is not None - or config.tools_tags_included is not None + config.tools_include is not None + or config.tools_tags_include is not None or logger.isEnabledFor(logging.INFO) ): asyncio.run( - _prepare_tools( - server, config.tools_included, config.tools_tags_included - ) + _prepare_tools(server, config.tools_include, config.tools_tags_include) ) # Logs go to stderr so the stdio transport's stdout stays protocol-clean. if config.transport == "http": - logger.info("Serving MCP over streamable HTTP at http://%s:%d/mcp", config.host, config.port) + logger.info( + "Serving MCP over streamable HTTP at http://%s:%d/mcp", + config.host, + config.port, + ) server.run(transport="http", host=config.host, port=config.port) else: logger.info("Serving MCP over stdio") server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index e628228..dd36227 100644 --- a/uv.lock +++ b/uv.lock @@ -1231,7 +1231,7 @@ wheels = [ [[package]] name = "xmcp" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "fastmcp" }, diff --git a/xmcp.example.yml b/xmcp.example.yml new file mode 100644 index 0000000..97fa661 --- /dev/null +++ b/xmcp.example.yml @@ -0,0 +1,47 @@ +# xmcp configuration. Copy to xmcp.yml and adjust. +# Settings are read from this file only; ${VAR} placeholders are filled from +# the environment (a missing variable is a startup error). + +server: + name: xmcp + transport: http # stdio | http + host: 0.0.0.0 + port: 8000 + log_level: INFO + http_timeout: 30 + # Header from which MCP clients may supply a per-request API key. + # Empty string disables forwarding. + client_api_key_header: X-API-Key + +# Defaults applied to every OpenAPI document (per-spec `api` keys override). +api: + # base_url: https://api.example.com + # token: ${API_TOKEN} + auth_header: Authorization + auth_scheme: Bearer # "" sends the raw token + +# Optional global tool filters. +# tools: +# include: [getPetById, findPetsByStatus] +# tags_include: [pet] + +specs: + # A single document needs no namespace; tool names stay unprefixed. + - spec_url: https://petstore3.swagger.io/api/v3/openapi.json + api: + base_url: https://petstore3.swagger.io/api/v3 + +# Multiple documents: give every entry a unique namespace. Tools are then +# exposed as "_". +# specs: +# - namespace: pet +# spec_url: https://petstore3.swagger.io/api/v3/openapi.json +# api: +# base_url: https://petstore3.swagger.io/api/v3 +# - namespace: billing +# spec_url: ./specs/billing.yaml +# api: +# base_url: https://billing.example.com +# token: ${BILLING_TOKEN} +# auth_header: X-API-Key +# auth_scheme: ""