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:
+2
-1
@@ -8,9 +8,10 @@ __pycache__/
|
|||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
|
||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
xmcp.yml
|
||||||
|
xmcp.example.yml
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# Copy to .env and adjust. All values are read at container/process start.
|
|
||||||
|
|
||||||
# --- OpenAPI document (required) ---------------------------------------------
|
|
||||||
# http(s) URL of a JSON/YAML OpenAPI document, or a local file path
|
|
||||||
# (e.g. /spec/openapi.json when the document is mounted into the container).
|
|
||||||
OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json
|
|
||||||
|
|
||||||
# Bearer token used only when fetching a remote spec (leave unset for public
|
|
||||||
# documents).
|
|
||||||
#OPENAPI_SPEC_TOKEN=
|
|
||||||
|
|
||||||
# --- Target API ---------------------------------------------------------------
|
|
||||||
# Override the base URL taken from the spec's servers[0].url. Required when the
|
|
||||||
# spec only has a relative server URL and the spec is a local file, or when the
|
|
||||||
# API lives on a different host than the spec.
|
|
||||||
#API_BASE_URL=https://api.example.com
|
|
||||||
|
|
||||||
# Credential sent to the target API on every tool call.
|
|
||||||
#API_TOKEN=change-me
|
|
||||||
#API_AUTH_HEADER=Authorization
|
|
||||||
# Send the raw token with no scheme prefix by setting this to an empty value:
|
|
||||||
#API_AUTH_SCHEME=
|
|
||||||
|
|
||||||
# HTTP header treated as the per-request API key of the MCP client. When
|
|
||||||
# present it takes precedence over API_TOKEN. Set to an empty value to disable
|
|
||||||
# the mapping (default: X-API-Key).
|
|
||||||
#MCP_API_KEY_HEADER=X-API-Key
|
|
||||||
|
|
||||||
# --- MCP server ---------------------------------------------------------------
|
|
||||||
# stdio or http (streamable-http is accepted as an alias); default: stdio
|
|
||||||
MCP_TRANSPORT=http
|
|
||||||
MCP_HOST=0.0.0.0
|
|
||||||
MCP_PORT=8000
|
|
||||||
#MCP_SERVER_NAME=My API
|
|
||||||
|
|
||||||
# Comma-separated allowlists of tool names and OpenAPI tags to expose. Unset or
|
|
||||||
# empty disables that filter; unknown entries are ignored with a startup
|
|
||||||
# warning. When both are set, a tool must match both.
|
|
||||||
#MCP_TOOLS_INCLUDED=getPetById,findPetsByStatus
|
|
||||||
#MCP_TOOLS_TAGS_INCLUDED=pet
|
|
||||||
|
|
||||||
# Timeout in seconds for fetching the spec and calling the API (default: 30).
|
|
||||||
#HTTP_TIMEOUT=30
|
|
||||||
|
|
||||||
# DEBUG, INFO, WARNING, ERROR, CRITICAL ... (default: INFO)
|
|
||||||
#LOG_LEVEL=INFO
|
|
||||||
+3
-2
@@ -5,7 +5,8 @@ __pycache__/
|
|||||||
# uv / virtual environments
|
# uv / virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
|
|
||||||
# Local configuration
|
# Local configuration (may contain secrets)
|
||||||
|
xmcp.yml
|
||||||
|
xmcp.local.yml
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
|
||||||
|
|||||||
+5
-7
@@ -28,13 +28,11 @@ FROM python:3.14-alpine3.24 AS runtime
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PATH="/app/.venv/bin:$PATH" \
|
PATH="/app/.venv/bin:$PATH"
|
||||||
MCP_TRANSPORT=http \
|
|
||||||
MCP_HOST=0.0.0.0 \
|
|
||||||
MCP_PORT=8000
|
|
||||||
|
|
||||||
RUN addgroup -S -g 1001 xmcp \
|
RUN addgroup -S -g 1001 xmcp \
|
||||||
&& adduser -S -D -H -u 1001 -G xmcp xmcp
|
&& adduser -S -D -H -u 1001 -G xmcp xmcp \
|
||||||
|
&& mkdir -p /etc/xmcp
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder --chown=xmcp:xmcp /app/.venv /app/.venv
|
COPY --from=builder --chown=xmcp:xmcp /app/.venv /app/.venv
|
||||||
@@ -43,8 +41,8 @@ USER xmcp
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# In HTTP mode, probe the MCP port; stdio containers are always healthy.
|
# Probes the MCP HTTP port when the mounted configuration uses transport http.
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD ["python", "-c", "import os, socket, sys\nif os.environ.get('MCP_TRANSPORT', 'stdio') != 'http':\n sys.exit(0)\ns = socket.socket()\ns.settimeout(3)\nsys.exit(s.connect_ex(('127.0.0.1', int(os.environ.get('MCP_PORT', '8000')))))"]
|
CMD ["python", "-m", "xmcp.healthcheck"]
|
||||||
|
|
||||||
CMD ["python", "-m", "xmcp"]
|
CMD ["python", "-m", "xmcp"]
|
||||||
|
|||||||
@@ -10,104 +10,148 @@
|
|||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://docs.astral.sh/uv/)
|
[](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)
|
(JSON or YAML) into a ready-to-run [MCP](https://modelcontextprotocol.io)
|
||||||
server using [FastMCP](https://gofastmcp.com). One MCP tool is generated per
|
server using [FastMCP](https://gofastmcp.com). Each API operation becomes an MCP
|
||||||
API operation, and requests to the target API are authenticated with a token
|
tool; when several documents are configured they are mounted under a shared
|
||||||
supplied via environment variables.
|
server and served on a single transport.
|
||||||
|
|
||||||
Everything is configured through environment variables: the spec location, the
|
All configuration lives in a YAML file (JSON is also accepted). Only the
|
||||||
transport (stdio or streamable HTTP), the listen host/port, and the API token.
|
optional path to that file can come from the environment (`XMCP_CONFIG`).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
### Docker Compose (HTTP)
|
|
||||||
|
|
||||||
```sh
|
```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
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The MCP endpoint is served at `http://localhost:8000/mcp`.
|
The MCP endpoint is served at `http://localhost:8000/mcp`.
|
||||||
|
|
||||||
### Docker (HTTP)
|
### Docker (without Compose)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t xmcp .
|
docker build -t xmcp .
|
||||||
docker run --rm -p 8000:8000 \
|
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
|
xmcp
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker (stdio)
|
For stdio, mount the config and run the container with an open stdin:
|
||||||
|
|
||||||
For stdio the container must run with an open stdin (`-i`); logs go to stderr,
|
|
||||||
so stdout stays protocol-clean:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -i --rm \
|
docker run -i --rm \
|
||||||
-e MCP_TRANSPORT=stdio \
|
-v "$PWD/xmcp.yml:/etc/xmcp/xmcp.yml:ro" \
|
||||||
-e OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json \
|
|
||||||
xmcp
|
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
|
### Local development with uv
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv sync
|
uv sync
|
||||||
OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json uv run xmcp
|
uv run xmcp --config xmcp.yml
|
||||||
# equivalent: uv run python -m xmcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Configuration
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
```yaml
|
||||||
| --- | --- | --- | --- |
|
server:
|
||||||
| `OPENAPI_SPEC_URL` | yes | – | `http(s)://` URL or local file path of the OpenAPI document (JSON or YAML) |
|
name: xmcp # optional; single-spec default is the spec title
|
||||||
| `OPENAPI_SPEC_TOKEN` | no | – | Bearer token used only when fetching a remote spec document |
|
transport: http # stdio | http
|
||||||
| `API_BASE_URL` | no | spec `servers[0].url` | Overrides the target API base URL |
|
host: 0.0.0.0
|
||||||
| `API_TOKEN` | no | – | Credential sent to the target API on every tool call |
|
port: 8000
|
||||||
| `API_AUTH_HEADER` | no | `Authorization` | Header used to carry the token (e.g. `X-API-Key`) |
|
log_level: INFO
|
||||||
| `API_AUTH_SCHEME` | no | `Bearer` | Scheme prefix; set to an empty value to send the raw token |
|
http_timeout: 30
|
||||||
| `MCP_API_KEY_HEADER` | no | `X-API-Key` | HTTP header treated as the per-request API key (empty disables the mapping) |
|
client_api_key_header: X-API-Key # "" disables per-request key forwarding
|
||||||
| `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 |
|
|
||||||
|
|
||||||
Authentication examples: for a bearer token leave the defaults; for an API key
|
api: # defaults applied to every OpenAPI document
|
||||||
header use `API_AUTH_HEADER=X-API-Key` with `API_AUTH_SCHEME` set to an empty
|
# base_url: https://api.example.com
|
||||||
string. At startup the generated tool list (name, tags and description) is
|
# token: ${API_TOKEN}
|
||||||
logged at INFO; set `LOG_LEVEL=WARNING` to suppress it.
|
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
|
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
|
instead of using the configured `api.token`. The value is treated as a raw
|
||||||
token and forwarded to the target API using `API_AUTH_HEADER` /
|
token and forwarded using each spec's `api.auth_header` / `api.auth_scheme`
|
||||||
`API_AUTH_SCHEME` (by default `Authorization: Bearer <key>`):
|
(by default `Authorization: Bearer <key>`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -X POST http://localhost:8000/mcp \
|
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",...}'
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",...}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Precedence: the request header wins, then `API_TOKEN`, then no authentication.
|
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
|
The header name is `server.client_api_key_header`; an empty value disables the
|
||||||
value to disable the mapping. When the key is mapped to a differently named
|
mapping. When mapped to a differently named target header, the source header is
|
||||||
target header, the source header is removed from the outgoing API request so
|
removed from the outgoing API request so the credential is not sent twice. In
|
||||||
the credential is not sent twice.
|
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
|
Note: FastMCP independently passes other incoming HTTP headers through to the
|
||||||
API request, except credential headers such as `Authorization` and `Cookie`.
|
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
|
## Security notes
|
||||||
|
|
||||||
- The API token is attached to every request the generated tools make to the
|
- The configuration file may contain API tokens. Keep it out of version
|
||||||
target API. It is never logged.
|
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
|
- The MCP HTTP endpoint (`/mcp`) itself is **not** authenticated. Run it on a
|
||||||
private network or behind a reverse proxy / gateway that enforces
|
private network or behind a reverse proxy / gateway that enforces
|
||||||
authentication before exposing it publicly.
|
authentication, and terminate TLS there so client keys are not sent in clear
|
||||||
- Per-request API keys (the `X-API-Key` header by default) are forwarded only to
|
text.
|
||||||
the target API and are never logged. Terminate TLS in front of the MCP
|
- Per-request API keys are forwarded only to the target API and are never
|
||||||
endpoint so client keys are not sent in clear text.
|
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
|
## 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:
|
Docker image and pushes it to the Gitea container registry:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git tag release/0.1.0
|
git tag release/0.2.0
|
||||||
git push origin release/0.1.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`.
|
`gitea.woggioni.net/woggioni/xmcp:latest`.
|
||||||
|
|
||||||
## Project layout
|
## 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/spec.py spec loading, base URL resolution, API client
|
||||||
src/xmcp/auth.py per-request API key forwarding (HTTP transport)
|
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)
|
Dockerfile multi-stage build (uv builder -> alpine runtime, non-root)
|
||||||
.gitea/workflows/build.yaml release pipeline (tag push -> docker build & push)
|
.gitea/workflows/build.yaml release pipeline (tag push -> docker build & push)
|
||||||
|
xmcp.example.yml annotated configuration example
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-11
@@ -3,16 +3,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
image: xmcp:latest
|
image: xmcp:latest
|
||||||
env_file:
|
volumes:
|
||||||
- .env
|
- ./xmcp.yml:/etc/xmcp/xmcp.yml:ro
|
||||||
environment:
|
# The container port must match server.port in xmcp.yml.
|
||||||
MCP_TRANSPORT: http
|
|
||||||
MCP_HOST: 0.0.0.0
|
|
||||||
MCP_PORT: "8000"
|
|
||||||
ports:
|
ports:
|
||||||
- "${MCP_PORT:-8000}:${MCP_PORT:-8000}"
|
- "8000:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# To serve a locally stored OpenAPI document, mount it and point
|
|
||||||
# OPENAPI_SPEC_URL at the in-container path:
|
|
||||||
# volumes:
|
|
||||||
# - ./openapi.json:/spec/openapi.json:ro
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "xmcp"
|
name = "xmcp"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "MCP server generated from an OpenAPI specification, built with FastMCP"
|
description = "MCP server generated from an OpenAPI specification, built with FastMCP"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""xmcp: an MCP server generated from an OpenAPI specification with FastMCP."""
|
"""xmcp: an MCP server generated from an OpenAPI specification with FastMCP."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
+402
-109
@@ -1,10 +1,20 @@
|
|||||||
"""Environment-driven configuration for xmcp."""
|
"""File-based configuration for xmcp."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_PATH = Path("/etc/xmcp/xmcp.yml")
|
||||||
|
CONFIG_PATH_ENV = "XMCP_CONFIG"
|
||||||
|
|
||||||
VALID_TRANSPORTS = ("stdio", "http")
|
VALID_TRANSPORTS = ("stdio", "http")
|
||||||
_TRANSPORT_ALIASES = {
|
_TRANSPORT_ALIASES = {
|
||||||
@@ -13,124 +23,407 @@ _TRANSPORT_ALIASES = {
|
|||||||
"streamablehttp": "http",
|
"streamablehttp": "http",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_NAMESPACE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
||||||
|
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
|
_SERVER_KEYS = {
|
||||||
|
"name",
|
||||||
|
"transport",
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
"log_level",
|
||||||
|
"http_timeout",
|
||||||
|
"client_api_key_header",
|
||||||
|
}
|
||||||
|
_API_KEYS = {"base_url", "token", "auth_header", "auth_scheme"}
|
||||||
|
_TOOLS_KEYS = {"include", "tags_include"}
|
||||||
|
_SPEC_KEYS = {"namespace", "spec_url", "spec_token", "server_name", "api"}
|
||||||
|
_TOP_LEVEL_KEYS = {"server", "api", "tools", "specs"}
|
||||||
|
|
||||||
|
LEGACY_ENV_VARS = (
|
||||||
|
"OPENAPI_SPEC_URL",
|
||||||
|
"OPENAPI_SPEC_TOKEN",
|
||||||
|
"API_BASE_URL",
|
||||||
|
"API_TOKEN",
|
||||||
|
"API_AUTH_HEADER",
|
||||||
|
"API_AUTH_SCHEME",
|
||||||
|
"MCP_API_KEY_HEADER",
|
||||||
|
"MCP_TRANSPORT",
|
||||||
|
"MCP_HOST",
|
||||||
|
"MCP_PORT",
|
||||||
|
"MCP_SERVER_NAME",
|
||||||
|
"MCP_TOOLS_INCLUDED",
|
||||||
|
"MCP_TOOLS_TAGS_INCLUDED",
|
||||||
|
"HTTP_TIMEOUT",
|
||||||
|
"LOG_LEVEL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(ValueError):
|
class ConfigError(ValueError):
|
||||||
"""Raised when the environment configuration is invalid."""
|
"""Raised when the configuration file is invalid."""
|
||||||
|
|
||||||
|
|
||||||
def _get(name: str) -> str | None:
|
@dataclass(frozen=True)
|
||||||
"""Read an environment variable, treating blank values as unset."""
|
class SpecSource:
|
||||||
value = os.environ.get(name, "").strip()
|
"""One OpenAPI document and its per-spec overrides."""
|
||||||
return value or None
|
|
||||||
|
|
||||||
|
spec_url: str
|
||||||
def _get_int(name: str, default: int, *, minimum: int, maximum: int | None = None) -> int:
|
namespace: str | None = None
|
||||||
raw = _get(name)
|
spec_token: str | None = None
|
||||||
if raw is None:
|
server_name: str | None = None
|
||||||
return default
|
api_base_url: str | None = None
|
||||||
try:
|
api_token: str | None = None
|
||||||
value = int(raw)
|
api_auth_header: str | None = None
|
||||||
except ValueError:
|
api_auth_scheme: str | None = None
|
||||||
raise ConfigError(f"{name} must be an integer, got {raw!r}") from None
|
|
||||||
if value < minimum or (maximum is not None and value > maximum):
|
|
||||||
bounds = f"at least {minimum}" if maximum is None else f"between {minimum} and {maximum}"
|
|
||||||
raise ConfigError(f"{name} must be {bounds}, got {value}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _get_float(name: str, default: float, *, minimum: float) -> float:
|
|
||||||
raw = _get(name)
|
|
||||||
if raw is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
value = float(raw)
|
|
||||||
except ValueError:
|
|
||||||
raise ConfigError(f"{name} must be a number, got {raw!r}") from None
|
|
||||||
if value < minimum:
|
|
||||||
raise ConfigError(f"{name} must be at least {minimum}, got {value}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_set(name: str) -> frozenset[str] | None:
|
|
||||||
"""Parse a comma-separated environment variable into a set of names."""
|
|
||||||
raw = _get(name)
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
values = frozenset(value.strip() for value in raw.split(",") if value.strip())
|
|
||||||
return values or None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Config:
|
class Config:
|
||||||
"""Resolved runtime configuration."""
|
"""Resolved runtime configuration."""
|
||||||
|
|
||||||
spec_url: str
|
path: Path
|
||||||
spec_token: str | None
|
specs: tuple[SpecSource, ...]
|
||||||
api_base_url: str | None
|
transport: str = "stdio"
|
||||||
api_token: str | None
|
host: str = "0.0.0.0"
|
||||||
api_auth_header: str
|
port: int = 8000
|
||||||
api_auth_scheme: str
|
server_name: str | None = None
|
||||||
mcp_api_key_header: str | None
|
timeout: float = 30.0
|
||||||
tools_included: frozenset[str] | None
|
log_level: str = "INFO"
|
||||||
tools_tags_included: frozenset[str] | None
|
client_api_key_header: str | None = "X-API-Key"
|
||||||
transport: str
|
tools_include: frozenset[str] | None = None
|
||||||
host: str
|
tools_tags_include: frozenset[str] | None = None
|
||||||
port: int
|
api_base_url: str | None = None
|
||||||
server_name: str | None
|
api_token: str | None = None
|
||||||
timeout: float
|
api_auth_header: str = "Authorization"
|
||||||
log_level: str
|
api_auth_scheme: str = "Bearer"
|
||||||
|
|
||||||
@classmethod
|
def api_for(self, spec: SpecSource) -> tuple[str | None, str | None, str, str]:
|
||||||
def from_env(cls) -> "Config":
|
"""Return (base_url, token, header, scheme) for a spec, applying defaults."""
|
||||||
"""Build a Config from environment variables, raising ConfigError."""
|
base_url = spec.api_base_url if spec.api_base_url is not None else self.api_base_url
|
||||||
spec_url = _get("OPENAPI_SPEC_URL")
|
token = spec.api_token if spec.api_token is not None else self.api_token
|
||||||
if spec_url is None:
|
header = (
|
||||||
raise ConfigError(
|
spec.api_auth_header
|
||||||
"OPENAPI_SPEC_URL is required: set it to an http(s) URL or a local "
|
if spec.api_auth_header is not None
|
||||||
"path of an OpenAPI document (JSON or YAML)"
|
else self.api_auth_header
|
||||||
)
|
|
||||||
|
|
||||||
transport = (_get("MCP_TRANSPORT") or "stdio").lower()
|
|
||||||
transport = _TRANSPORT_ALIASES.get(transport, transport)
|
|
||||||
if transport not in VALID_TRANSPORTS:
|
|
||||||
raise ConfigError(
|
|
||||||
f"MCP_TRANSPORT must be one of {'/'.join(VALID_TRANSPORTS)} "
|
|
||||||
f"(or 'streamable-http'), got {transport!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
log_level = (_get("LOG_LEVEL") or "INFO").upper()
|
|
||||||
if log_level not in logging.getLevelNamesMapping():
|
|
||||||
raise ConfigError(f"LOG_LEVEL is not a valid log level: {log_level!r}")
|
|
||||||
|
|
||||||
# Read without the blank-means-unset helper on purpose: an explicitly
|
|
||||||
# empty value means "send the raw token with no scheme prefix".
|
|
||||||
raw_scheme = os.environ.get("API_AUTH_SCHEME")
|
|
||||||
auth_scheme = "Bearer" if raw_scheme is None else raw_scheme.strip()
|
|
||||||
|
|
||||||
# Explicitly empty disables forwarding of per-request API keys.
|
|
||||||
raw_key_header = os.environ.get("MCP_API_KEY_HEADER")
|
|
||||||
key_header = "X-API-Key" if raw_key_header is None else raw_key_header.strip()
|
|
||||||
key_header = key_header or None
|
|
||||||
|
|
||||||
# Comma-separated allowlists of tool names / OpenAPI tags; unset or
|
|
||||||
# empty means no filtering on that dimension.
|
|
||||||
included = _parse_set("MCP_TOOLS_INCLUDED")
|
|
||||||
tags_included = _parse_set("MCP_TOOLS_TAGS_INCLUDED")
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
spec_url=spec_url,
|
|
||||||
spec_token=_get("OPENAPI_SPEC_TOKEN"),
|
|
||||||
api_base_url=_get("API_BASE_URL"),
|
|
||||||
api_token=_get("API_TOKEN"),
|
|
||||||
api_auth_header=_get("API_AUTH_HEADER") or "Authorization",
|
|
||||||
api_auth_scheme=auth_scheme,
|
|
||||||
mcp_api_key_header=key_header,
|
|
||||||
tools_included=included,
|
|
||||||
tools_tags_included=tags_included,
|
|
||||||
transport=transport,
|
|
||||||
host=_get("MCP_HOST") or "0.0.0.0",
|
|
||||||
port=_get_int("MCP_PORT", 8000, minimum=1, maximum=65535),
|
|
||||||
server_name=_get("MCP_SERVER_NAME"),
|
|
||||||
timeout=_get_float("HTTP_TIMEOUT", 30.0, minimum=0.1),
|
|
||||||
log_level=log_level,
|
|
||||||
)
|
)
|
||||||
|
scheme = (
|
||||||
|
spec.api_auth_scheme
|
||||||
|
if spec.api_auth_scheme is not None
|
||||||
|
else self.api_auth_scheme
|
||||||
|
)
|
||||||
|
return base_url, token, header, scheme
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config_path(cli_path: str | None = None) -> Path:
|
||||||
|
"""Resolve the configuration path: --config, then XMCP_CONFIG, then default."""
|
||||||
|
if cli_path:
|
||||||
|
return Path(cli_path).expanduser()
|
||||||
|
env_path = os.environ.get(CONFIG_PATH_ENV, "").strip()
|
||||||
|
if env_path:
|
||||||
|
return Path(env_path).expanduser()
|
||||||
|
return DEFAULT_CONFIG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_environment_variables() -> list[str]:
|
||||||
|
"""Return legacy env vars that are set but no longer used."""
|
||||||
|
return [name for name in LEGACY_ENV_VARS if os.environ.get(name, "").strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(cli_path: str | None = None) -> Config:
|
||||||
|
"""Load and validate the configuration file."""
|
||||||
|
path = resolve_config_path(cli_path)
|
||||||
|
document = _read_document(path)
|
||||||
|
return _parse_config(document, path)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# File loading and interpolation
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _read_document(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ConfigError(
|
||||||
|
f"configuration file not found: {path} "
|
||||||
|
f"(use --config, ${CONFIG_PATH_ENV}, or create the default file)"
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"could not read configuration file {path}: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = yaml.safe_load(text)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
raise ConfigError(f"invalid YAML/JSON in {path}: {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
raise ConfigError(f"configuration file {path} must contain a mapping at the top level")
|
||||||
|
|
||||||
|
return _interpolate(document, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _interpolate(value: Any, location: str) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: _interpolate(item, f"{location}.{key}" if location else str(key))
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [
|
||||||
|
_interpolate(item, f"{location}[{index}]")
|
||||||
|
for index, item in enumerate(value)
|
||||||
|
]
|
||||||
|
if isinstance(value, str):
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
name = match.group(1)
|
||||||
|
if name not in os.environ:
|
||||||
|
where = location or "<root>"
|
||||||
|
raise ConfigError(
|
||||||
|
f"environment variable {name!r} referenced at {where} is not set"
|
||||||
|
)
|
||||||
|
return os.environ[name]
|
||||||
|
|
||||||
|
return _ENV_PATTERN.sub(replace, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Document parsing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_config(document: Mapping[str, Any], path: Path) -> Config:
|
||||||
|
_reject_unknown(document, _TOP_LEVEL_KEYS, "the top level")
|
||||||
|
|
||||||
|
server = _mapping(document.get("server"), "server")
|
||||||
|
_reject_unknown(server, _SERVER_KEYS, "server")
|
||||||
|
|
||||||
|
transport = (_string(server, "transport", "server", "stdio") or "stdio").lower()
|
||||||
|
transport = _TRANSPORT_ALIASES.get(transport, transport)
|
||||||
|
if transport not in VALID_TRANSPORTS:
|
||||||
|
raise ConfigError(
|
||||||
|
f"server.transport must be one of {'/'.join(VALID_TRANSPORTS)} "
|
||||||
|
f"(or 'streamable-http'), got {transport!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
log_level = (_string(server, "log_level", "server", "INFO") or "INFO").upper()
|
||||||
|
if log_level not in logging.getLevelNamesMapping():
|
||||||
|
raise ConfigError(f"server.log_level is not a valid log level: {log_level!r}")
|
||||||
|
|
||||||
|
port = _int_value(server.get("port"), "server.port", 8000, 1, 65535)
|
||||||
|
timeout = _float_value(server.get("http_timeout"), "server.http_timeout", 30.0, 0.1)
|
||||||
|
host = _string(server, "host", "server", "0.0.0.0") or "0.0.0.0"
|
||||||
|
server_name = _blank_to_none(_string(server, "name", "server"))
|
||||||
|
|
||||||
|
if "client_api_key_header" in server:
|
||||||
|
raw_header = _string(server, "client_api_key_header", "server")
|
||||||
|
header = raw_header.strip() if raw_header is not None else ""
|
||||||
|
client_api_key_header = header or None
|
||||||
|
else:
|
||||||
|
client_api_key_header = "X-API-Key"
|
||||||
|
|
||||||
|
api = _mapping(document.get("api"), "api")
|
||||||
|
_reject_unknown(api, _API_KEYS, "api")
|
||||||
|
api_base_url = _blank_to_none(_string(api, "base_url", "api"))
|
||||||
|
api_token = _blank_to_none(_string(api, "token", "api"))
|
||||||
|
api_auth_header = _blank_to_none(_string(api, "auth_header", "api")) or "Authorization"
|
||||||
|
if "auth_scheme" in api:
|
||||||
|
raw_scheme = _string(api, "auth_scheme", "api")
|
||||||
|
api_auth_scheme = raw_scheme.strip() if raw_scheme is not None else ""
|
||||||
|
else:
|
||||||
|
api_auth_scheme = "Bearer"
|
||||||
|
|
||||||
|
tools = _mapping(document.get("tools"), "tools")
|
||||||
|
_reject_unknown(tools, _TOOLS_KEYS, "tools")
|
||||||
|
tools_include = _name_set(tools.get("include"), "tools.include")
|
||||||
|
tools_tags_include = _name_set(tools.get("tags_include"), "tools.tags_include")
|
||||||
|
|
||||||
|
specs_raw = document.get("specs")
|
||||||
|
if specs_raw is None:
|
||||||
|
raise ConfigError("specs is required: add at least one OpenAPI document")
|
||||||
|
if not isinstance(specs_raw, list) or not specs_raw:
|
||||||
|
raise ConfigError("specs must be a non-empty list")
|
||||||
|
|
||||||
|
base_dir = path.parent.resolve()
|
||||||
|
specs: list[SpecSource] = []
|
||||||
|
seen_namespaces: set[str] = set()
|
||||||
|
for index, raw_spec in enumerate(specs_raw):
|
||||||
|
location = f"specs[{index}]"
|
||||||
|
spec_map = _mapping(raw_spec, location)
|
||||||
|
_reject_unknown(spec_map, _SPEC_KEYS, location)
|
||||||
|
|
||||||
|
spec_url = _string(spec_map, "spec_url", location)
|
||||||
|
if not spec_url:
|
||||||
|
raise ConfigError(f"{location}.spec_url is required")
|
||||||
|
|
||||||
|
namespace = _blank_to_none(_string(spec_map, "namespace", location))
|
||||||
|
if namespace is not None:
|
||||||
|
if not _NAMESPACE_RE.match(namespace):
|
||||||
|
raise ConfigError(
|
||||||
|
f"{location}.namespace must match [A-Za-z0-9][A-Za-z0-9_-]*, "
|
||||||
|
f"got {namespace!r}"
|
||||||
|
)
|
||||||
|
if namespace in seen_namespaces:
|
||||||
|
raise ConfigError(f"duplicate namespace {namespace!r} in {location}")
|
||||||
|
seen_namespaces.add(namespace)
|
||||||
|
|
||||||
|
spec_api = _mapping(spec_map.get("api"), f"{location}.api")
|
||||||
|
_reject_unknown(spec_api, _API_KEYS, f"{location}.api")
|
||||||
|
spec_api_scheme: str | None = None
|
||||||
|
if "auth_scheme" in spec_api:
|
||||||
|
raw_spec_scheme = _string(spec_api, "auth_scheme", f"{location}.api")
|
||||||
|
spec_api_scheme = (
|
||||||
|
raw_spec_scheme.strip() if raw_spec_scheme is not None else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
specs.append(
|
||||||
|
SpecSource(
|
||||||
|
spec_url=_resolve_spec_location(spec_url, base_dir),
|
||||||
|
namespace=namespace,
|
||||||
|
spec_token=_blank_to_none(_string(spec_map, "spec_token", location)),
|
||||||
|
server_name=_blank_to_none(_string(spec_map, "server_name", location)),
|
||||||
|
api_base_url=_blank_to_none(
|
||||||
|
_string(spec_api, "base_url", f"{location}.api")
|
||||||
|
),
|
||||||
|
api_token=_blank_to_none(_string(spec_api, "token", f"{location}.api")),
|
||||||
|
api_auth_header=_blank_to_none(
|
||||||
|
_string(spec_api, "auth_header", f"{location}.api")
|
||||||
|
),
|
||||||
|
api_auth_scheme=spec_api_scheme,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(specs) > 1:
|
||||||
|
missing = [
|
||||||
|
f"specs[{index}]"
|
||||||
|
for index, spec in enumerate(specs)
|
||||||
|
if spec.namespace is None
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ConfigError(
|
||||||
|
"namespace is required for every entry when multiple specs are "
|
||||||
|
f"configured (missing in {', '.join(missing)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
path=path.resolve(),
|
||||||
|
specs=tuple(specs),
|
||||||
|
transport=transport,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
server_name=server_name,
|
||||||
|
timeout=timeout,
|
||||||
|
log_level=log_level,
|
||||||
|
client_api_key_header=client_api_key_header,
|
||||||
|
tools_include=tools_include,
|
||||||
|
tools_tags_include=tools_tags_include,
|
||||||
|
api_base_url=api_base_url,
|
||||||
|
api_token=api_token,
|
||||||
|
api_auth_header=api_auth_header,
|
||||||
|
api_auth_scheme=api_auth_scheme,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Small parsing helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any, location: str) -> Mapping[str, Any]:
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ConfigError(f"{location} must be a mapping")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_unknown(
|
||||||
|
mapping: Mapping[str, Any], allowed: set[str], location: str
|
||||||
|
) -> None:
|
||||||
|
unknown = sorted(str(key) for key in set(mapping) - allowed)
|
||||||
|
if unknown:
|
||||||
|
raise ConfigError(f"unknown key(s) in {location}: {', '.join(unknown)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _string(
|
||||||
|
mapping: Mapping[str, Any],
|
||||||
|
key: str,
|
||||||
|
location: str,
|
||||||
|
default: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
if key not in mapping or mapping[key] is None:
|
||||||
|
return default
|
||||||
|
value = mapping[key]
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ConfigError(f"{location}.{key} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _blank_to_none(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _int_value(
|
||||||
|
value: Any, location: str, default: int, minimum: int, maximum: int
|
||||||
|
) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ConfigError(f"{location} must be an integer")
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ConfigError(f"{location} must be an integer, got {value!r}") from None
|
||||||
|
if not minimum <= parsed <= maximum:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{location} must be between {minimum} and {maximum}, got {parsed}"
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _float_value(
|
||||||
|
value: Any, location: str, default: float, minimum: float
|
||||||
|
) -> float:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ConfigError(f"{location} must be a number")
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ConfigError(f"{location} must be a number, got {value!r}") from None
|
||||||
|
if parsed < minimum:
|
||||||
|
raise ConfigError(f"{location} must be at least {minimum}, got {parsed}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _name_set(value: Any, location: str) -> frozenset[str] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
items = value.split(",")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
if not all(isinstance(item, str) for item in value):
|
||||||
|
raise ConfigError(f"{location} must be a list of strings")
|
||||||
|
items = value
|
||||||
|
else:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{location} must be a list of strings or a comma-separated string"
|
||||||
|
)
|
||||||
|
names = frozenset(item.strip() for item in items if item.strip())
|
||||||
|
return names or None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_spec_location(raw: str, base_dir: Path) -> str:
|
||||||
|
"""Resolve local spec paths relative to the configuration file."""
|
||||||
|
scheme = urlparse(raw).scheme.lower()
|
||||||
|
if scheme in ("http", "https", "file"):
|
||||||
|
return raw
|
||||||
|
candidate = Path(raw).expanduser()
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = base_dir / candidate
|
||||||
|
return str(candidate)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Container healthcheck: probe the MCP HTTP port when that transport is configured."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .config import ConfigError, load_config
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(f"healthcheck: configuration error: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
|
||||||
|
if config.transport != "http":
|
||||||
|
return
|
||||||
|
|
||||||
|
host = "127.0.0.1" if config.host in ("0.0.0.0", "::") else config.host
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, config.port), timeout=3):
|
||||||
|
return
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"healthcheck: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+110
-40
@@ -1,59 +1,91 @@
|
|||||||
"""Build and run the FastMCP server generated from the OpenAPI document."""
|
"""Build and run the FastMCP server(s) generated from OpenAPI documents."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
from .config import Config, ConfigError
|
from .config import (
|
||||||
|
CONFIG_PATH_ENV,
|
||||||
|
DEFAULT_CONFIG_PATH,
|
||||||
|
Config,
|
||||||
|
ConfigError,
|
||||||
|
SpecSource,
|
||||||
|
legacy_environment_variables,
|
||||||
|
load_config,
|
||||||
|
)
|
||||||
from .spec import SpecError, build_api_client, load_spec, resolve_base_url
|
from .spec import SpecError, build_api_client, load_spec, resolve_base_url
|
||||||
|
|
||||||
logger = logging.getLogger("xmcp")
|
logger = logging.getLogger("xmcp")
|
||||||
|
|
||||||
|
|
||||||
def build_server(config: Config) -> FastMCP:
|
def _build_subserver(
|
||||||
"""Load the OpenAPI document and build the FastMCP server."""
|
config: Config, spec: SpecSource, default_name: str | None
|
||||||
spec = load_spec(config.spec_url, token=config.spec_token, timeout=config.timeout)
|
) -> FastMCP:
|
||||||
base_url = resolve_base_url(spec, override=config.api_base_url, spec_url=config.spec_url)
|
"""Build one FastMCP server from a single OpenAPI document."""
|
||||||
|
document = load_spec(spec.spec_url, token=spec.spec_token, timeout=config.timeout)
|
||||||
|
api_base_url, api_token, api_auth_header, api_auth_scheme = config.api_for(spec)
|
||||||
|
base_url = resolve_base_url(
|
||||||
|
document, override=api_base_url, spec_url=spec.spec_url
|
||||||
|
)
|
||||||
client = build_api_client(
|
client = build_api_client(
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
token=config.api_token,
|
token=api_token,
|
||||||
header=config.api_auth_header,
|
header=api_auth_header,
|
||||||
scheme=config.api_auth_scheme,
|
scheme=api_auth_scheme,
|
||||||
timeout=config.timeout,
|
timeout=config.timeout,
|
||||||
forwarded_header=config.mcp_api_key_header,
|
forwarded_header=config.client_api_key_header,
|
||||||
)
|
)
|
||||||
|
|
||||||
info = spec.get("info") if isinstance(spec.get("info"), dict) else {}
|
info = document.get("info") if isinstance(document.get("info"), dict) else {}
|
||||||
name = config.server_name or str(info.get("title") or "OpenAPI MCP Server")
|
name = (
|
||||||
|
spec.server_name
|
||||||
|
or default_name
|
||||||
|
or str(info.get("title") or "OpenAPI MCP Server")
|
||||||
|
)
|
||||||
|
|
||||||
server = FastMCP.from_openapi(openapi_spec=spec, client=client, name=name)
|
server = FastMCP.from_openapi(openapi_spec=document, client=client, name=name)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Generated MCP server %r from %s: %d path(s), target API %s",
|
"Generated MCP server %r from %s: %d path(s), target API %s",
|
||||||
name,
|
name,
|
||||||
config.spec_url,
|
spec.spec_url,
|
||||||
len(spec.get("paths") or {}),
|
len(document.get("paths") or {}),
|
||||||
base_url,
|
base_url,
|
||||||
)
|
)
|
||||||
if config.api_token:
|
if api_token is not None:
|
||||||
logger.debug(
|
logger.debug("API token configured for %r (%s header)", name, api_auth_header)
|
||||||
"Fallback API token configured for the %s header (%s)",
|
|
||||||
config.api_auth_header,
|
|
||||||
config.api_auth_scheme or "raw token",
|
|
||||||
)
|
|
||||||
if config.mcp_api_key_header:
|
|
||||||
logger.debug(
|
|
||||||
"Per-request API keys accepted from the %s header",
|
|
||||||
config.mcp_api_key_header,
|
|
||||||
)
|
|
||||||
elif not config.api_token:
|
|
||||||
logger.debug("No API token configured: requests are sent unauthenticated")
|
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def build_server(config: Config) -> FastMCP:
|
||||||
|
"""Build the MCP server from the configured OpenAPI documents."""
|
||||||
|
single = len(config.specs) == 1 and config.specs[0].namespace is None
|
||||||
|
default_name = config.server_name if single else None
|
||||||
|
|
||||||
|
subservers = [
|
||||||
|
(spec, _build_subserver(config, spec, default_name))
|
||||||
|
for spec in config.specs
|
||||||
|
]
|
||||||
|
|
||||||
|
if single:
|
||||||
|
return subservers[0][1]
|
||||||
|
|
||||||
|
parent = FastMCP(config.server_name or "xmcp")
|
||||||
|
for spec, subserver in subservers:
|
||||||
|
parent.mount(subserver, namespace=spec.namespace)
|
||||||
|
logger.info(
|
||||||
|
"Mounted %d OpenAPI server(s) under %r: %s",
|
||||||
|
len(subservers),
|
||||||
|
parent.name,
|
||||||
|
", ".join(f"{spec.namespace} <- {spec.spec_url}" for spec, _ in subservers),
|
||||||
|
)
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_tools(
|
async def _prepare_tools(
|
||||||
server: FastMCP,
|
server: FastMCP,
|
||||||
included: frozenset[str] | None,
|
included: frozenset[str] | None,
|
||||||
@@ -71,7 +103,7 @@ async def _prepare_tools(
|
|||||||
unknown = sorted(included - known_names)
|
unknown = sorted(included - known_names)
|
||||||
if unknown:
|
if unknown:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"MCP_TOOLS_INCLUDED lists unknown tool(s): %s",
|
"tools.include lists unknown tool(s): %s",
|
||||||
", ".join(unknown),
|
", ".join(unknown),
|
||||||
)
|
)
|
||||||
names = set(included & known_names)
|
names = set(included & known_names)
|
||||||
@@ -81,7 +113,7 @@ async def _prepare_tools(
|
|||||||
unknown_tags = sorted(tags_included - known_tags)
|
unknown_tags = sorted(tags_included - known_tags)
|
||||||
if unknown_tags:
|
if unknown_tags:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"MCP_TOOLS_TAGS_INCLUDED lists unknown tag(s): %s",
|
"tools.tags_include lists unknown tag(s): %s",
|
||||||
", ".join(unknown_tags),
|
", ".join(unknown_tags),
|
||||||
)
|
)
|
||||||
tags = set(tags_included & known_tags)
|
tags = set(tags_included & known_tags)
|
||||||
@@ -120,9 +152,27 @@ async def _prepare_tools(
|
|||||||
logger.info(" - %s%s%s", tool.name, tags, suffix)
|
logger.info(" - %s%s%s", tool.name, tags, suffix)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="xmcp",
|
||||||
|
description="Expose one or more OpenAPI documents as MCP servers.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--config",
|
||||||
|
metavar="PATH",
|
||||||
|
help=(
|
||||||
|
f"Configuration file (default: ${CONFIG_PATH_ENV} "
|
||||||
|
f"or {DEFAULT_CONFIG_PATH})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
args = _parse_args(argv)
|
||||||
try:
|
try:
|
||||||
config = Config.from_env()
|
config = load_config(args.config)
|
||||||
except ConfigError as exc:
|
except ConfigError as exc:
|
||||||
raise SystemExit(f"Configuration error: {exc}") from exc
|
raise SystemExit(f"Configuration error: {exc}") from exc
|
||||||
|
|
||||||
@@ -130,29 +180,49 @@ def main() -> None:
|
|||||||
level=config.log_level,
|
level=config.log_level,
|
||||||
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
# Keep FastMCP's own logging in sync with LOG_LEVEL unless set explicitly.
|
# Keep FastMCP's own logging in sync with the configured level.
|
||||||
os.environ.setdefault("FASTMCP_LOG_LEVEL", config.log_level)
|
os.environ.setdefault("FASTMCP_LOG_LEVEL", config.log_level)
|
||||||
|
|
||||||
|
legacy = legacy_environment_variables()
|
||||||
|
if legacy:
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring legacy environment variable(s): %s "
|
||||||
|
"(configuration is file-based: %s)",
|
||||||
|
", ".join(legacy),
|
||||||
|
config.path,
|
||||||
|
)
|
||||||
|
if config.client_api_key_header:
|
||||||
|
logger.debug(
|
||||||
|
"Per-request API keys accepted from the %s header",
|
||||||
|
config.client_api_key_header,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
server = build_server(config)
|
server = build_server(config)
|
||||||
except SpecError as exc:
|
except SpecError as exc:
|
||||||
raise SystemExit(f"Failed to load the OpenAPI document: {exc}") from exc
|
raise SystemExit(f"Failed to load an OpenAPI document: {exc}") from exc
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.tools_included is not None
|
config.tools_include is not None
|
||||||
or config.tools_tags_included is not None
|
or config.tools_tags_include is not None
|
||||||
or logger.isEnabledFor(logging.INFO)
|
or logger.isEnabledFor(logging.INFO)
|
||||||
):
|
):
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
_prepare_tools(
|
_prepare_tools(server, config.tools_include, config.tools_tags_include)
|
||||||
server, config.tools_included, config.tools_tags_included
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Logs go to stderr so the stdio transport's stdout stays protocol-clean.
|
# Logs go to stderr so the stdio transport's stdout stays protocol-clean.
|
||||||
if config.transport == "http":
|
if config.transport == "http":
|
||||||
logger.info("Serving MCP over streamable HTTP at http://%s:%d/mcp", config.host, config.port)
|
logger.info(
|
||||||
|
"Serving MCP over streamable HTTP at http://%s:%d/mcp",
|
||||||
|
config.host,
|
||||||
|
config.port,
|
||||||
|
)
|
||||||
server.run(transport="http", host=config.host, port=config.port)
|
server.run(transport="http", host=config.host, port=config.port)
|
||||||
else:
|
else:
|
||||||
logger.info("Serving MCP over stdio")
|
logger.info("Serving MCP over stdio")
|
||||||
server.run(transport="stdio")
|
server.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -1231,7 +1231,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xmcp"
|
name = "xmcp"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# xmcp configuration. Copy to xmcp.yml and adjust.
|
||||||
|
# Settings are read from this file only; ${VAR} placeholders are filled from
|
||||||
|
# the environment (a missing variable is a startup error).
|
||||||
|
|
||||||
|
server:
|
||||||
|
name: xmcp
|
||||||
|
transport: http # stdio | http
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8000
|
||||||
|
log_level: INFO
|
||||||
|
http_timeout: 30
|
||||||
|
# Header from which MCP clients may supply a per-request API key.
|
||||||
|
# Empty string disables forwarding.
|
||||||
|
client_api_key_header: X-API-Key
|
||||||
|
|
||||||
|
# Defaults applied to every OpenAPI document (per-spec `api` keys override).
|
||||||
|
api:
|
||||||
|
# base_url: https://api.example.com
|
||||||
|
# token: ${API_TOKEN}
|
||||||
|
auth_header: Authorization
|
||||||
|
auth_scheme: Bearer # "" sends the raw token
|
||||||
|
|
||||||
|
# Optional global tool filters.
|
||||||
|
# tools:
|
||||||
|
# include: [getPetById, findPetsByStatus]
|
||||||
|
# tags_include: [pet]
|
||||||
|
|
||||||
|
specs:
|
||||||
|
# A single document needs no namespace; tool names stay unprefixed.
|
||||||
|
- spec_url: https://petstore3.swagger.io/api/v3/openapi.json
|
||||||
|
api:
|
||||||
|
base_url: https://petstore3.swagger.io/api/v3
|
||||||
|
|
||||||
|
# Multiple documents: give every entry a unique namespace. Tools are then
|
||||||
|
# exposed as "<namespace>_<tool>".
|
||||||
|
# specs:
|
||||||
|
# - namespace: pet
|
||||||
|
# spec_url: https://petstore3.swagger.io/api/v3/openapi.json
|
||||||
|
# api:
|
||||||
|
# base_url: https://petstore3.swagger.io/api/v3
|
||||||
|
# - namespace: billing
|
||||||
|
# spec_url: ./specs/billing.yaml
|
||||||
|
# api:
|
||||||
|
# base_url: https://billing.example.com
|
||||||
|
# token: ${BILLING_TOKEN}
|
||||||
|
# auth_header: X-API-Key
|
||||||
|
# auth_scheme: ""
|
||||||
Reference in New Issue
Block a user