feat: filter tools by OpenAPI tag via MCP_TOOLS_TAGS_INCLUDED
CI / Build and push docker image (push) Successful in 1m21s
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:
+4
-2
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user