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:
@@ -10,104 +10,148 @@
|
||||
[](https://www.docker.com/)
|
||||
[](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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user