Port the MCP server to mcp 2.x, and say what each tool is allowed to do

mcp 2.0.0 removed mcp.server.fastmcp; MCPServer is the same object under a
new name, so @mcp.tool() and mcp.run() are untouched and every tool keeps its
name, signature and returned shape. Verified against a clean 2.0.0 venv: all
four tools list, and the suite passes at 100% coverage, 609 tests.

Two things the port buys, neither of which existed in 1.x:

* Tool annotations, which matter more here than in the sibling repos because
  this server has a tool that writes. The three readers are marked read-only,
  idempotent and closed-world; log_meal is marked not-read-only but
  explicitly NOT destructive -- it appends and never rewrites or deletes --
  not idempotent, since a second confirmed call logs a second meal, and
  open-world, because it reaches Open Food Facts when macros are not given.
  A client can now tell the difference without reading the docstring.
* Structured output: 2.x derives an output schema from the return types and
  sends structured content alongside the text. No code change -- it falls out
  of the type hints that were already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 20:02:00 +02:00
parent a737843503
commit 58e6731705
4 changed files with 29 additions and 9 deletions

View File

@ -103,7 +103,7 @@ repos:
- requests
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync
- mcp>=1.9,<2
- mcp>=2,<3
- pydantic
# Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate.

View File

@ -37,7 +37,8 @@ import logging
import sys
from typing import Any
from mcp.server.fastmcp import FastMCP
from mcp.server import MCPServer
from mcp.types import ToolAnnotations
from pydantic import BaseModel
from diet_guard._budget import BudgetError
@ -65,7 +66,26 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
mcp = FastMCP("diet-guard")
mcp = MCPServer("diet-guard")
# Machine-readable versions of what the docstrings already say, so a client
# can decide what to run unattended. The day a "read" tool starts writing,
# these annotations are what has to change with it.
_READS_ONLY = ToolAnnotations(
read_only_hint=True,
idempotent_hint=True,
open_world_hint=False,
)
# log_meal is the one tool that writes. It appends and never rewrites or
# deletes, so it is not destructive; it is not idempotent either, because a
# second confirmed call logs a second meal. It reaches Open Food Facts when
# macros are not supplied, which is exactly what open_world means.
_APPENDS = ToolAnnotations(
read_only_hint=False,
destructive_hint=False,
idempotent_hint=False,
open_world_hint=True,
)
def _entry_view(entry: dict[str, object]) -> dict[str, Any]:
@ -98,7 +118,7 @@ def _entry_view(entry: dict[str, object]) -> dict[str, Any]:
# ──────────────────────────────────────────────────────────────
@mcp.tool()
@mcp.tool(title="Today's intake status", annotations=_READS_ONLY)
def get_status() -> dict[str, Any]:
"""Return today's intake status without ever revealing the daily budget.
@ -126,7 +146,7 @@ def get_status() -> dict[str, Any]:
}
@mcp.tool()
@mcp.tool(title="Meals logged today", annotations=_READS_ONLY)
def list_today() -> dict[str, Any]:
"""List today's logged meals (valid entries only), newest last.
@ -141,7 +161,7 @@ def list_today() -> dict[str, Any]:
}
@mcp.tool()
@mcp.tool(title="Meal slots for the day", annotations=_READS_ONLY)
def get_slots() -> dict[str, Any]:
"""Return the day's fixed meal slots and which one is current.
@ -177,7 +197,7 @@ class Macros(BaseModel):
fat: float = 0.0
@mcp.tool()
@mcp.tool(title="Log a meal (gated write)", annotations=_APPENDS)
def log_meal(
description: str,
grams: float | None = None,

View File

@ -15,7 +15,7 @@ dependencies = [
# so the systemd/CLI system-python path stays dependency-light and never has
# to import the MCP SDK.
[project.optional-dependencies]
mcp = ["mcp>=1.9,<2"]
mcp = ["mcp>=2,<3"]
# Without this, setuptools' flat-layout auto-discovery treats the Flutter
# app/ directory as a second top-level package and refuses to build.

View File

@ -5,7 +5,7 @@ codespell>=2.2.0
coverage>=7.4.0
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
mcp>=1.9,<2
mcp>=2,<3
mypy>=1.8.0
pre-commit>=3.6.0
pydantic>=2