feat(mcp): give every MCP tool route an explicit operation_id

fastapi-mcp uses each route's OpenAPI operationId as the MCP tool name
exposed to LLM clients (fastapi_mcp/server.py:619-620). When the
operation_id is omitted, FastAPI auto-generates a name like
`prepare_submit_job_prepare_submit_job_post` (function name + path +
method), which is verbose and ugly for the agent.

This change adds an explicit `operation_id=` to all 17 MCP tool
routes in spark_executor/server.py, named after the handler function
(stripping the leading underscore). Concretely, the LLM client now sees
tool names like `prepare_submit_job`, `confirm_submit_job`,
`get_job_status`, etc. — same as the route paths.

`/health` (the only non-MCP GET route) is left untouched; its
auto-generated name (`health_check_health_get`) is never exposed.

Also adds a regression test
`test_every_mcp_route_has_explicit_clean_operation_id` in
tests/integration/test_mcp_routes.py that:
  * fetches the live OpenAPI spec,
  * asserts every MCP tool route has an operationId,
  * rejects FastAPI's auto-generated format (e.g. operationId ending
    in `_post` / `_get`),
  * asserts no two routes share an operationId (fastapi-mcp keys
    its tool registry by name, so duplicates would shadow silently).

All 226 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-29 17:48:45 +08:00
co-authored by Claude Fable 5
parent ce868a7717
commit 565f6a9c9d
2 changed files with 68 additions and 0 deletions
+51
View File
@@ -64,6 +64,57 @@ def test_seventeen_tool_routes_registered():
assert "/update_pending_job" in paths
# --- operation_id: pin clean MCP tool names (no auto-generated suffixes) ---
#
# fastapi-mcp uses each route's OpenAPI `operationId` as the MCP tool name
# exposed to LLM clients (fastapi_mcp/server.py:619-620). If we omit
# `operation_id=`, FastAPI auto-generates a name like
# `prepare_submit_job_prepare_submit_job_post` (function_name + path +
# method), which is ugly for the agent. This test asserts every MCP tool
# route has an explicit, clean operation_id matching its handler.
# Set of routes that are NOT MCP tools (no operation_id expected).
_NON_MCP_PATHS = {"/health", "/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
def test_every_mcp_route_has_explicit_clean_operation_id():
c = TestClient(app)
openapi = c.get("/openapi.json").json()
paths = openapi["paths"]
seen: list[tuple[str, str, str]] = [] # (path, method, operation_id)
for path, methods in paths.items():
if path in _NON_MCP_PATHS:
continue
for method, op in methods.items():
if method.upper() not in {"GET", "POST", "PUT", "DELETE", "PATCH"}:
continue
op_id = op.get("operationId")
assert op_id, (
f"route {method.upper()} {path} has no operationId in OpenAPI — "
f"fastapi-mcp will fall back to an auto-generated name like "
f"`{path.strip('/').replace('/', '_')}_{method}_...` and expose "
f"that to LLM clients. Add an explicit `operation_id=` to the decorator."
)
# Reject FastAPI's auto-generated format: it always embeds the
# HTTP method as a `_post`/`_get` suffix, and repeats the path.
assert not op_id.endswith(f"_{method}"), (
f"route {method.upper()} {path} has auto-generated operationId "
f"{op_id!r} (ends with `_{method}`). Add an explicit "
f"`operation_id=` to the decorator."
)
seen.append((path, method.upper(), op_id))
# Sanity: we expect at least the 16 MCP tool routes from the project.
assert len(seen) >= 16, f"only found {len(seen)} MCP tool routes, expected >=16: {seen}"
# Every operation_id must be unique — fastapi-mcp's tool registry keys
# by name, so duplicates would silently shadow one another.
ids = [op_id for _, _, op_id in seen]
dupes = [x for x in set(ids) if ids.count(x) > 1]
assert not dupes, f"duplicate operation_ids: {dupes}"
# --- End-to-end body-based calls (the gap the route-registration test missed) ---
def test_save_connection_accepts_dict_spark_conf_in_body():