From 321996f436949f0e61175e0b354361aee279ba5a Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Mon, 19 Oct 2026 09:13:00 +0000 Subject: [PATCH] feat: filter tools by OpenAPI tag via MCP_TOOLS_TAGS_INCLUDED 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. --- .env.example | 6 ++-- README.md | 18 +++++++---- src/xmcp/config.py | 23 +++++++++----- src/xmcp/server.py | 76 +++++++++++++++++++++++++++++++++++----------- 4 files changed, 89 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 57343b5..43e2fe1 100644 --- a/.env.example +++ b/.env.example @@ -33,9 +33,11 @@ MCP_HOST=0.0.0.0 MCP_PORT=8000 #MCP_SERVER_NAME=My API -# Comma-separated allowlist of tool names to expose. Unset or empty exposes -# every generated tool; unknown names are ignored with a startup warning. +# 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 diff --git a/README.md b/README.md index 22c9659..0edfd99 100644 --- a/README.md +++ b/README.md @@ -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_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 | @@ -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 request (at least on `tools/call`). -### Tool allowlist +### Tool allowlists 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 -only those: +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 ``` -Unset or empty exposes all tools. Names that do not match a generated tool are -ignored with a startup warning, and the active selection is reported at startup -(`Tools allowlist active: exposing N of M tool(s): ...`). +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 diff --git a/src/xmcp/config.py b/src/xmcp/config.py index c4c2957..4f4496c 100644 --- a/src/xmcp/config.py +++ b/src/xmcp/config.py @@ -51,6 +51,15 @@ def _get_float(name: str, default: float, *, minimum: float) -> float: 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) class Config: """Resolved runtime configuration.""" @@ -63,6 +72,7 @@ class Config: api_auth_scheme: str mcp_api_key_header: str | None tools_included: frozenset[str] | None + tools_tags_included: frozenset[str] | None transport: str host: str 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 = key_header or None - # Comma-separated allowlist of tool names; unset or empty exposes all. - raw_included = _get("MCP_TOOLS_INCLUDED") - included: frozenset[str] | None = None - if raw_included is not None: - names = frozenset( - name.strip() for name in raw_included.split(",") if name.strip() - ) - included = names 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, @@ -120,6 +126,7 @@ class Config: 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), diff --git a/src/xmcp/server.py b/src/xmcp/server.py index 17d6bce..9ccbd5f 100644 --- a/src/xmcp/server.py +++ b/src/xmcp/server.py @@ -54,26 +54,58 @@ def build_server(config: Config) -> FastMCP: return server -async def _prepare_tools(server: FastMCP, included: frozenset[str] | None) -> None: - """Apply the optional tools allowlist and log the exposed tools.""" +async def _prepare_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() - if included is not None: - known = {tool.name for tool in tools} - unknown = sorted(included - known) - if unknown: - logger.warning( - "MCP_TOOLS_INCLUDED lists unknown tool(s): %s", - ", ".join(unknown), - ) - exposed = included & known - server.enable(names=set(exposed), only=True, components={"tool"}) + if included is not None or tags_included is not None: + known_names = {tool.name for tool in tools} + known_tags = {tag for tool in tools for tag in tool.tags} + + names: set[str] | None = None + if included is not None: + unknown = sorted(included - known_names) + if unknown: + logger.warning( + "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() + + 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( - "Tools allowlist active: exposing %d of %d tool(s)%s", - len(exposed), - len(known), - f": {', '.join(sorted(exposed))}" if exposed else "", + "Tool filters active (%s): exposing %d of %d tool(s)%s", + "; ".join(filters), + len(tools), + len(known_names), + f": {', '.join(sorted(tool.name for tool in tools))}" if tools else "", ) if logger.isEnabledFor(logging.DEBUG): @@ -105,8 +137,16 @@ def main() -> None: except SpecError as 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): - asyncio.run(_prepare_tools(server, config.tools_included)) + if ( + 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. if config.transport == "http":