Add uv cache-keys for the commit and tags so the editable install is refreshed when the git state changes, and copy the complete checkout (with .git) into the builder so setuptools-scm sees a clean worktree. Previously the partial copy made git describe report a dirty tree and tagged Docker builds got a development version suffix.
xmcp
Turns one or more OpenAPI documents (JSON or YAML) into a ready-to-run MCP server using FastMCP. 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
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)
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:
docker run -i --rm \
-v "$PWD/xmcp.yml:/etc/xmcp/xmcp.yml:ro" \
xmcp
Local development with uv
uv sync
uv run xmcp --config xmcp.yml
The configuration file is located, in order, via:
--config PATH- the
XMCP_CONFIGenvironment variable - the default
/etc/xmcp/xmcp.yml
Configuration
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
toolsfilters expose everything.tools.includeandtools.tags_includecombine with AND semantics; entries that match nothing are reported with a startup warning. - Unknown keys, invalid values, duplicate namespaces and empty
specsfail fast with a clear message. - Relative local paths in
spec_urlresolve 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: WARNINGto suppress it.
Multiple OpenAPI documents
Give each entry a unique namespace:
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 <key>):
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:
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: 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