feat!: file-based configuration and multiple OpenAPI specs

Configuration moves from environment variables to a YAML/JSON file,
located via --config, XMCP_CONFIG, or /etc/xmcp/xmcp.yml. String values
support ${VAR} interpolation for secrets.

One or more OpenAPI documents can be configured as specs[]; multiple
documents are mounted under unique namespaces and served on a single
transport, on one HTTP port. Tool filters, per-request API keys and
per-spec base URL/auth overrides live in the same file.

The Docker image no longer sets MCP_* defaults and expects the config
mounted at /etc/xmcp/xmcp.yml; the healthcheck reads the config.
This commit is contained in:
2026-10-22 01:56:10 +00:00
parent 72bdbdb3b2
commit 9185b5fa05
13 changed files with 757 additions and 340 deletions
+150 -121
View File
@@ -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 <key>`):
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 <key>`):
```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
```