Files
xmcp/README.md
T
woggioni 5db454f8c8 build: derive package version from git via setuptools-scm
Replace hatchling and the static version with setuptools + setuptools-scm,
following Kaya's pre-uv packaging setup: dynamic version, generated
_version.py, and release/* tag prefix. The Docker builder now includes git
and the .git directory so the image is versioned from the checkout. CI
checks out full history and tags for the tag-triggered builds.
2026-10-22 04:03:08 +00:00

240 lines
9.5 KiB
Markdown

# 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 <key>`):
```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
```