feat: filter tools by OpenAPI tag via MCP_TOOLS_TAGS_INCLUDED
CI / Build and push docker image (push) Successful in 1m21s

Adds a comma-separated tag allowlist alongside the existing name
allowlist. When both are configured a tool must match both (AND).
Unknown names/tags are ignored with a startup warning, and a filter
that resolves to nothing exposes no tools rather than falling back to
all of them. Startup logs the active filters and the exposed tools.
This commit is contained in:
2026-10-19 09:13:00 +00:00
parent 3ec12439b4
commit 321996f436
4 changed files with 89 additions and 34 deletions
+4 -2
View File
@@ -33,9 +33,11 @@ MCP_HOST=0.0.0.0
MCP_PORT=8000 MCP_PORT=8000
#MCP_SERVER_NAME=My API #MCP_SERVER_NAME=My API
# Comma-separated allowlist of tool names to expose. Unset or empty exposes # Comma-separated allowlists of tool names and OpenAPI tags to expose. Unset or
# every generated tool; unknown names are ignored with a startup warning. # 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_INCLUDED=getPetById,findPetsByStatus
#MCP_TOOLS_TAGS_INCLUDED=pet
# Timeout in seconds for fetching the spec and calling the API (default: 30). # Timeout in seconds for fetching the spec and calling the API (default: 30).
#HTTP_TIMEOUT=30 #HTTP_TIMEOUT=30
+12 -6
View File
@@ -83,6 +83,7 @@ OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json uv run xmcp
| `MCP_PORT` | no | `8000` | Listen port 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_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_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 | | `HTTP_TIMEOUT` | no | `30` | Timeout in seconds for spec fetch and API calls |
| `LOG_LEVEL` | no | `INFO` | Python log level | | `LOG_LEVEL` | no | `INFO` | Python log level |
@@ -117,19 +118,24 @@ API request, except credential headers such as `Authorization` and `Cookie`.
MCP clients that support custom transport headers should send the key on every MCP clients that support custom transport headers should send the key on every
request (at least on `tools/call`). request (at least on `tools/call`).
### Tool allowlist ### Tool allowlists
By default every operation in the OpenAPI document is exposed as an MCP tool. By default every operation in the OpenAPI document is exposed as an MCP tool.
Set `MCP_TOOLS_INCLUDED` to a comma-separated list of tool names to expose Two environment variables can restrict the exposed set; each accepts a
only those: comma-separated list, and unset or empty disables that filter:
```sh ```sh
# Only these operations
MCP_TOOLS_INCLUDED=getPetById,findPetsByStatus MCP_TOOLS_INCLUDED=getPetById,findPetsByStatus
# Only operations tagged "pet" in the OpenAPI document
MCP_TOOLS_TAGS_INCLUDED=pet
``` ```
Unset or empty exposes all tools. Names that do not match a generated tool are When both are set, a tool must match both filters (name **and** tag). Entries
ignored with a startup warning, and the active selection is reported at startup that do not match anything are ignored with a startup warning, and the active
(`Tools allowlist active: exposing N of M tool(s): ...`). selection is reported at startup
(`Tool filters active (names: ...; tags: ...): exposing N of M tool(s): ...`).
### Serving a local spec file ### Serving a local spec file
+15 -8
View File
@@ -51,6 +51,15 @@ def _get_float(name: str, default: float, *, minimum: float) -> float:
return 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."""
@@ -63,6 +72,7 @@ class Config:
api_auth_scheme: str api_auth_scheme: str
mcp_api_key_header: str | None mcp_api_key_header: str | None
tools_included: frozenset[str] | None tools_included: frozenset[str] | None
tools_tags_included: frozenset[str] | None
transport: str transport: str
host: str host: str
port: int port: int
@@ -102,14 +112,10 @@ class Config:
key_header = "X-API-Key" if raw_key_header is None else raw_key_header.strip() key_header = "X-API-Key" if raw_key_header is None else raw_key_header.strip()
key_header = key_header or None key_header = key_header or None
# Comma-separated allowlist of tool names; unset or empty exposes all. # Comma-separated allowlists of tool names / OpenAPI tags; unset or
raw_included = _get("MCP_TOOLS_INCLUDED") # empty means no filtering on that dimension.
included: frozenset[str] | None = None included = _parse_set("MCP_TOOLS_INCLUDED")
if raw_included is not None: tags_included = _parse_set("MCP_TOOLS_TAGS_INCLUDED")
names = frozenset(
name.strip() for name in raw_included.split(",") if name.strip()
)
included = names or None
return cls( return cls(
spec_url=spec_url, spec_url=spec_url,
@@ -120,6 +126,7 @@ class Config:
api_auth_scheme=auth_scheme, api_auth_scheme=auth_scheme,
mcp_api_key_header=key_header, mcp_api_key_header=key_header,
tools_included=included, tools_included=included,
tools_tags_included=tags_included,
transport=transport, transport=transport,
host=_get("MCP_HOST") or "0.0.0.0", host=_get("MCP_HOST") or "0.0.0.0",
port=_get_int("MCP_PORT", 8000, minimum=1, maximum=65535), port=_get_int("MCP_PORT", 8000, minimum=1, maximum=65535),
+58 -18
View File
@@ -54,26 +54,58 @@ def build_server(config: Config) -> FastMCP:
return server return server
async def _prepare_tools(server: FastMCP, included: frozenset[str] | None) -> None: async def _prepare_tools(
"""Apply the optional tools allowlist and log the exposed tools.""" server: FastMCP,
included: frozenset[str] | None,
tags_included: frozenset[str] | None,
) -> None:
"""Apply the optional name/tag filters and log the exposed tools."""
tools = await server.list_tools() tools = await server.list_tools()
if included is not None: if included is not None or tags_included is not None:
known = {tool.name for tool in tools} known_names = {tool.name for tool in tools}
unknown = sorted(included - known) known_tags = {tag for tool in tools for tag in tool.tags}
if unknown:
logger.warning( names: set[str] | None = None
"MCP_TOOLS_INCLUDED lists unknown tool(s): %s", if included is not None:
", ".join(unknown), unknown = sorted(included - known_names)
) if unknown:
exposed = included & known logger.warning(
server.enable(names=set(exposed), only=True, components={"tool"}) "MCP_TOOLS_INCLUDED lists unknown tool(s): %s",
", ".join(unknown),
)
names = set(included & known_names)
tags: set[str] | None = None
if tags_included is not None:
unknown_tags = sorted(tags_included - known_tags)
if unknown_tags:
logger.warning(
"MCP_TOOLS_TAGS_INCLUDED lists unknown tag(s): %s",
", ".join(unknown_tags),
)
tags = set(tags_included & known_tags)
# When both filters are set, a tool must match both (AND semantics).
# FastMCP treats an empty tag set as "match everything", so a configured
# filter that resolved to nothing must disable everything explicitly.
if (names is not None and not names) or (tags is not None and not tags):
server.disable(components={"tool"})
else:
server.enable(names=names, tags=tags, only=True, components={"tool"})
tools = await server.list_tools() tools = await server.list_tools()
filters = []
if included is not None:
filters.append(f"names: {', '.join(sorted(included)) or '-'}")
if tags_included is not None:
filters.append(f"tags: {', '.join(sorted(tags_included)) or '-'}")
logger.info( logger.info(
"Tools allowlist active: exposing %d of %d tool(s)%s", "Tool filters active (%s): exposing %d of %d tool(s)%s",
len(exposed), "; ".join(filters),
len(known), len(tools),
f": {', '.join(sorted(exposed))}" if exposed else "", len(known_names),
f": {', '.join(sorted(tool.name for tool in tools))}" if tools else "",
) )
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
@@ -105,8 +137,16 @@ def main() -> None:
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 the OpenAPI document: {exc}") from exc
if config.tools_included is not None or logger.isEnabledFor(logging.DEBUG): if (
asyncio.run(_prepare_tools(server, config.tools_included)) config.tools_included is not None
or config.tools_tags_included is not None
or logger.isEnabledFor(logging.DEBUG)
):
asyncio.run(
_prepare_tools(
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":