feat!: file-based configuration and multiple OpenAPI specs

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

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

The Docker image no longer sets MCP_* defaults and expects the config
mounted at /etc/xmcp/xmcp.yml; the healthcheck reads the config.
This commit is contained in:
2026-10-22 01:56:10 +00:00
parent 72bdbdb3b2
commit 9185b5fa05
13 changed files with 757 additions and 340 deletions
+2 -1
View File
@@ -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
-46
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+150 -121
View File
@@ -10,104 +10,148 @@
[![Docker](https://img.shields.io/badge/Docker-image-2496ED?logo=docker&logoColor=white&style=flat-square)](https://www.docker.com/) [![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/) [![uv](https://img.shields.io/badge/uv-managed-DE5FE9?style=flat-square)](https://docs.astral.sh/uv/)
Turns any [OpenAPI](https://spec.openapis.org/oas/latest.html) document Turns one or more [OpenAPI](https://spec.openapis.org/oas/latest.html) documents
(JSON or YAML) into a ready-to-run [MCP](https://modelcontextprotocol.io) (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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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"
+389 -96
View File
@@ -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
) )
scheme = (
spec.api_auth_scheme
if spec.api_auth_scheme is not None
else self.api_auth_scheme
)
return base_url, token, header, scheme
transport = (_get("MCP_TRANSPORT") or "stdio").lower()
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) transport = _TRANSPORT_ALIASES.get(transport, transport)
if transport not in VALID_TRANSPORTS: if transport not in VALID_TRANSPORTS:
raise ConfigError( raise ConfigError(
f"MCP_TRANSPORT must be one of {'/'.join(VALID_TRANSPORTS)} " f"server.transport must be one of {'/'.join(VALID_TRANSPORTS)} "
f"(or 'streamable-http'), got {transport!r}" f"(or 'streamable-http'), got {transport!r}"
) )
log_level = (_get("LOG_LEVEL") or "INFO").upper() log_level = (_string(server, "log_level", "server", "INFO") or "INFO").upper()
if log_level not in logging.getLevelNamesMapping(): if log_level not in logging.getLevelNamesMapping():
raise ConfigError(f"LOG_LEVEL is not a valid log level: {log_level!r}") raise ConfigError(f"server.log_level is not a valid log level: {log_level!r}")
# Read without the blank-means-unset helper on purpose: an explicitly port = _int_value(server.get("port"), "server.port", 8000, 1, 65535)
# empty value means "send the raw token with no scheme prefix". timeout = _float_value(server.get("http_timeout"), "server.http_timeout", 30.0, 0.1)
raw_scheme = os.environ.get("API_AUTH_SCHEME") host = _string(server, "host", "server", "0.0.0.0") or "0.0.0.0"
auth_scheme = "Bearer" if raw_scheme is None else raw_scheme.strip() server_name = _blank_to_none(_string(server, "name", "server"))
# Explicitly empty disables forwarding of per-request API keys. if "client_api_key_header" in server:
raw_key_header = os.environ.get("MCP_API_KEY_HEADER") raw_header = _string(server, "client_api_key_header", "server")
key_header = "X-API-Key" if raw_key_header is None else raw_key_header.strip() header = raw_header.strip() if raw_header is not None else ""
key_header = key_header or None client_api_key_header = header or None
else:
client_api_key_header = "X-API-Key"
# Comma-separated allowlists of tool names / OpenAPI tags; unset or api = _mapping(document.get("api"), "api")
# empty means no filtering on that dimension. _reject_unknown(api, _API_KEYS, "api")
included = _parse_set("MCP_TOOLS_INCLUDED") api_base_url = _blank_to_none(_string(api, "base_url", "api"))
tags_included = _parse_set("MCP_TOOLS_TAGS_INCLUDED") 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"
return cls( tools = _mapping(document.get("tools"), "tools")
spec_url=spec_url, _reject_unknown(tools, _TOOLS_KEYS, "tools")
spec_token=_get("OPENAPI_SPEC_TOKEN"), tools_include = _name_set(tools.get("include"), "tools.include")
api_base_url=_get("API_BASE_URL"), tools_tags_include = _name_set(tools.get("tags_include"), "tools.tags_include")
api_token=_get("API_TOKEN"),
api_auth_header=_get("API_AUTH_HEADER") or "Authorization", specs_raw = document.get("specs")
api_auth_scheme=auth_scheme, if specs_raw is None:
mcp_api_key_header=key_header, raise ConfigError("specs is required: add at least one OpenAPI document")
tools_included=included, if not isinstance(specs_raw, list) or not specs_raw:
tools_tags_included=tags_included, raise ConfigError("specs must be a non-empty list")
transport=transport,
host=_get("MCP_HOST") or "0.0.0.0", base_dir = path.parent.resolve()
port=_get_int("MCP_PORT", 8000, minimum=1, maximum=65535), specs: list[SpecSource] = []
server_name=_get("MCP_SERVER_NAME"), seen_namespaces: set[str] = set()
timeout=_get_float("HTTP_TIMEOUT", 30.0, minimum=0.1), for index, raw_spec in enumerate(specs_raw):
log_level=log_level, 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)
+31
View File
@@ -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
View File
@@ -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()
Generated
+1 -1
View File
@@ -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" },
+47
View File
@@ -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: ""