# xmcp [![Last commit](https://img.shields.io/gitea/last-commit/woggioni/xmcp?gitea_url=https%3A%2F%2Fgitea.woggioni.net&style=flat-square)](https://gitea.woggioni.net/woggioni/xmcp/commits/branch/main) [![Stars](https://img.shields.io/gitea/stars/woggioni/xmcp?gitea_url=https%3A%2F%2Fgitea.woggioni.net&style=flat-square)](https://gitea.woggioni.net/woggioni/xmcp) [![Forks](https://img.shields.io/gitea/forks/woggioni/xmcp?gitea_url=https%3A%2F%2Fgitea.woggioni.net&style=flat-square)](https://gitea.woggioni.net/woggioni/xmcp) [![Python 3.14](https://img.shields.io/badge/python-3.14-3776AB?logo=python&logoColor=white&style=flat-square)](https://www.python.org/) [![FastMCP 4](https://img.shields.io/badge/FastMCP-4.x-6B46C1?style=flat-square)](https://gofastmcp.com) [![Alpine 3.24](https://img.shields.io/badge/Alpine-3.24-0D597F?logo=alpinelinux&logoColor=white&style=flat-square)](https://alpinelinux.org/) [![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 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). 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. 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 ```sh 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 (without Compose) ```sh docker build -t xmcp . docker run --rm -p 8000:8000 \ -v "$PWD/xmcp.yml:/etc/xmcp/xmcp.yml:ro" \ xmcp ``` For stdio, mount the config and run the container with an open stdin: ```sh docker run -i --rm \ -v "$PWD/xmcp.yml:/etc/xmcp/xmcp.yml:ro" \ xmcp ``` ### Local development with uv ```sh uv sync 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 ```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 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 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 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 \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'X-API-Key: sk-client-secret' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize",...}' ``` 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`. ## Security notes - 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, 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 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.2.0 git push origin release/0.2.0 ``` This publishes `gitea.woggioni.net/woggioni/xmcp:0.2.0` and `gitea.woggioni.net/woggioni/xmcp:latest`. The Python package version is derived from the pushed `release/*` tag with [setuptools-scm](https://setuptools-scm.readthedocs.io): `release/0.2.0` produces package version `0.2.0`. Builds from untagged commits carry a PEP 440 development version (for example `0.2.1.dev3+gabc1234`). ## Project layout ``` 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 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 ```