|
| 1 | +--- |
| 2 | +name: mcp-fastmcp-structured-output |
| 3 | +description: "Migrate a low-level MCP server to FastMCP with structured output, preserve curated input schemas, and evaluate the tool surface. Trigger: FastMCP migration, adding outputSchema/structuredContent, 'None is not of type' output-validation errors, building MCP tool-selection evals." |
| 4 | +author: KINTSUGI Team |
| 5 | +date: 2026-06-22 |
| 6 | +--- |
| 7 | + |
| 8 | +# MCP Server: FastMCP Migration + Structured Output + Evaluation |
| 9 | + |
| 10 | +## Experiment Overview |
| 11 | +| Item | Details | |
| 12 | +|------|---------| |
| 13 | +| **Date** | 2026-06-22 | |
| 14 | +| **Goal** | Migrate `kintsugi.mcp` from low-level `mcp.server.Server` to FastMCP, deliver structured tool output, and stand up an evaluation/improvement workflow | |
| 15 | +| **Environment** | `mcp==1.27.2` (floor `>=1.10`), Python 3.11/3.12, FastMCP (`mcp.server.fastmcp`) | |
| 16 | +| **Status** | Success | |
| 17 | + |
| 18 | +## Context |
| 19 | +The MCP server hand-wrote a `Tool(...)` list + an `elif` dispatch in `call_tool`, |
| 20 | +returned only `TextContent` (a JSON string), and exposed no output schemas. Goal: |
| 21 | +move to FastMCP so each tool returns machine-readable `structuredContent`, on a |
| 22 | +foundation that makes later resources/prompts/elicitation cheap. |
| 23 | + |
| 24 | +## What Worked (verified approach) |
| 25 | + |
| 26 | +1. **Floor `mcp>=1.10.0,<2`.** Structured output (`Tool.outputSchema`, |
| 27 | + `CallToolResult.structuredContent`, FastMCP `structured_output`) and |
| 28 | + elicitation **do not exist below 1.10** (the SDK release after spec 2025-06-18). |
| 29 | + |
| 30 | +2. **Register existing handlers; keep them FastMCP-agnostic.** A declarative |
| 31 | + registry (`tool_specs.py`: name → module/attr + description + input schema) |
| 32 | + plus, in `server.py`: |
| 33 | + ```python |
| 34 | + server = FastMCP("kintsugi") |
| 35 | + handler = getattr(module, spec["attr"]) # plain async dict-returning fn |
| 36 | + server.add_tool(handler, name=spec["name"], |
| 37 | + description=spec["description"], structured_output=True) |
| 38 | + ``` |
| 39 | + Handlers stay plain `async` functions returning `dict[str, Any]`, so the unit |
| 40 | + tests keep calling them directly. |
| 41 | + |
| 42 | +3. **Returns: `dict[str, Any]` + `structured_output=True`.** FastMCP then emits |
| 43 | + the dict as `structuredContent` **and** a JSON text block, for both the |
| 44 | + success and the in-band `{"error": ...}` branches — no per-tool models needed. |
| 45 | + |
| 46 | +4. **Preserve curated input schemas (descriptions + enums) by overriding** the |
| 47 | + advertised schema after registration; validation still runs from the handler |
| 48 | + signature: |
| 49 | + ```python |
| 50 | + # FastMCP's signature-derived schema drops per-param descriptions + enums. |
| 51 | + server._tool_manager.get_tool(name).parameters = curated_input_schema # wrap in try/except |
| 52 | + ``` |
| 53 | + |
| 54 | +5. **Evaluation harness** (`evals/mcp/`): a structural pass (tool-definition token |
| 55 | + cost, schema-quality checks, confusable-group flags, task coverage) + an |
| 56 | + agentic pass (`tool_choice="auto"`, score the model's first `tool_use`). |
| 57 | + Tool **selection** needs only the schemas — **no image data or handler |
| 58 | + execution** — so it runs with just an API key. Wire agentic into CI behind an |
| 59 | + `ANTHROPIC_API_KEY` secret (manual + scheduled); gate PRs with the structural |
| 60 | + `--strict` pass. The harness immediately found `analyze_weighted_subtraction` |
| 61 | + documented + implemented but **never registered**. |
| 62 | + |
| 63 | +## What Failed (do not repeat) |
| 64 | + |
| 65 | +| Attempt | Result | |
| 66 | +|---------|--------| |
| 67 | +| Strict `TypedDict` returns (`total=False`) to get rich per-field outputSchema | **FAILED.** FastMCP materializes absent keys as `None`, then validates against the non-nullable field type → `Output validation error: None is not of type 'string'`, `structuredContent=None`, `isError=True` on **both** success and error returns | |
| 68 | +| `Optional`-field `TypedDict`s (to dodge the above) | Validates, but **pollutes every result** with `null` keys for absent fields (noisy/misleading) → use plain `dict[str, Any]` instead | |
| 69 | +| Rely on docstrings for per-parameter descriptions under FastMCP | **FAILED.** FastMCP 1.27 does **not** parse docstring args; descriptions are dropped. Use `Annotated[T, Field(description=...)]` or override `.parameters` | |
| 70 | +| Raise the `mcp` floor only in `pyproject.toml` extras | **Incomplete.** `src/kintsugi/deps.py` `OPTIONAL_GROUPS` is a parallel source of truth used by `kintsugi install`/`check` — update both (`claude` *and* `dev`) | |
| 71 | + |
| 72 | +## Verification |
| 73 | +- `pytest tests/test_mcp_tools.py tests/test_mcp_path_safety.py` — direct-call |
| 74 | + tests stay green (handlers unchanged); add an in-memory client test |
| 75 | + (`mcp.shared.memory.create_connected_server_and_client_session`) asserting |
| 76 | + `outputSchema` present + `structuredContent` populated + text block + error |
| 77 | + branch clean. |
| 78 | +- `python evals/mcp/harness.py --strict` — structural gate (fails on coverage |
| 79 | + gaps / missing descriptions). |
| 80 | +- Preserve the `create_server()` `ImportError` contract by simulating |
| 81 | + `MCP_AVAILABLE = False` (don't swallow the import in a bare `except`). |
| 82 | + |
| 83 | +## Key Files (reference implementation) |
| 84 | +- `src/kintsugi/mcp/server.py` — FastMCP wiring, `_advertise_schema` override |
| 85 | +- `src/kintsugi/mcp/tool_specs.py` — declarative registry (mcp-free, also feeds `kintsugi mcp tools`) |
| 86 | +- `evals/mcp/{tasks.py,harness.py,README.md}` — evaluation harness |
| 87 | +- `.github/workflows/mcp-eval.yml` — keyed agentic eval + structural PR gate |
0 commit comments