Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .claude/rules/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,18 @@ Optional caller arguments translate to the generated client's `UNSET` sentinel v

## List tools

A paginated list tool follows a fixed shape: default `page=1, per_page=50`, call `require_per_page(per_page)` first (the shared ceiling is 100), forward optional `order_by` through `to_unset` and `order_direction` through `parse_order_direction`, and return the raw pagination envelope via `expect_dict`. See `list_shopping_lists` in `households_shopping_lists.py`.
A paginated list tool follows a fixed shape: default `page=1, per_page=50`, call `require_pagination(page, per_page)` first, forward optional `order_by` through `to_unset` and `order_direction` through `parse_order_direction`, and return the raw pagination envelope via `expect_dict`. See `list_shopping_lists` in `households_shopping_lists.py`.

The pagination bound is two-sided, and its job is bounding tool output size, not server politeness. `per_page` is held to `1..100`: the ceiling caps how much a single call returns, and the floor rejects the two low values Mealie mishandles, `-1` (an unbounded "all rows" fetch that defeats the ceiling) and `0` (an empty page). `page` is held to `>= 1`, since Mealie silently coerces `0` to page 1 and reads a negative page as the last page, so an out-of-range value returns a surprising result rather than an error.

## Scoping a list

Do not expose Mealie's generic `queryFilter` expression as a list-scoping input. It is an untyped filter string, error prone for an assistant to build and a poor fit for typed tool inputs. Scope a list with explicit typed parameters instead, and build the `queryFilter` internally if the endpoint needs one. The recipe timeline list, for example, takes a typed `recipe_id` and builds the filter from it. When such a parameter is an opaque key, a slug or an id rather than a display name, the docstring says so, since a display name silently returns no matches.

The rule is scoped to list-scoping inputs. When the filter DSL is the resource's own persisted field rather than a parameter that narrows a result set, exposing it verbatim is correct: `create_cookbook` and `update_cookbook` in `households_cookbooks.py` take `query_filter_string` directly, because a cookbook stores that string as its own definition.

When a tool interpolates a value into a `queryFilter` it builds internally, that value must first be validated to a shape that cannot alter the expression: UUID-parse ids, never interpolate free text. A value carrying a quote or a DSL operator would otherwise change the parsed filter. `list_recipe_timeline_events` UUID-parses `recipe_id` before interpolating it.

## Building a body from caller input

When a tool builds a generated-client body from caller-supplied data with `Model.from_dict(...)`, wrap the call in `try/except (AttributeError, KeyError, TypeError, ValueError)` and re-raise as `ToolError`, so malformed input surfaces as a clean tool error rather than a stack trace. See `update_recipe` in `recipe_crud.py`.
Expand All @@ -71,7 +75,7 @@ Some write endpoints return no useful body, for example setting a rating or addi

Tool modules are grouped by Mealie OpenAPI tag, one module per group, mirroring `mealie_mcp.client.api`. Tool names follow `mealie_<verb>_<noun>`. A new tool group is a single new file with a `register(mcp, get_client)` callable; `register_all` auto-discovers it.

A non-underscore module that defines no `register` callable is rejected: `_iter_tool_modules` raises rather than skipping it, so a group whose tools would never be exposed fails boot instead of vanishing behind a green merge gate. The per-group `call_tool` round-trip (see the live-test rubric) doubles as the registration check for a wired group: it fails if the group's tools are not registered. Together they mean a missing or misnamed `register` cannot ship silently, so a group needs no separate name-presence assertion against `mcp.list_tools()`.
A non-underscore module that defines no `register` callable is rejected: `_require_register` raises rather than skipping it, so a group whose tools would never be exposed fails boot instead of vanishing behind a green merge gate. The per-group `call_tool` round-trip (see the live-test rubric) doubles as the registration check for a wired group: it fails if the group's tools are not registered. Together they mean a missing or misnamed `register` cannot ship silently, so a group needs no separate name-presence assertion against `mcp.list_tools()`.

## Update bodies

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Review is CI's job. Do not spawn a review agent locally before pushing. Running

- Each MCP tool is a typed module-level function plus a thin `@mcp.tool()` wrapper inside a `register(mcp, get_client)` function. The typed function is the testable unit; the wrapper calls `get_client()` and forwards.
- Tool modules are grouped by Mealie OpenAPI tag, one module per group, mirroring `mealie_mcp.client.api`. Tool names follow `mealie_<verb>_<noun>`. Test files mirror the same grouping under `tests/unit/` and `tests/live/`. `register_all` auto-discovers a new module.
- Shared helpers in `src/mealie_mcp/tools/_common.py` cover validation (`require_non_empty`, `require_per_page`), optional-argument translation (`to_unset`, `parse_order_direction`), response decoding (`expect_dict`, `expect_list`, `expect_str`, plus the lower-level `decode` and `raise_api_error`), and the delete contract (`ack_delete`). Do not re-implement these inline.
- Shared helpers in `src/mealie_mcp/tools/_common.py` cover validation (`require_non_empty`, `require_pagination`, `parse_recipe_uuid`), optional-argument translation (`to_unset`, `parse_order_direction`), response decoding (`expect_dict`, `expect_list`, `expect_str`, plus the lower-level `decode` and `raise_api_error`), and the delete contract (`ack_delete`). Do not re-implement these inline.

The detailed implementation rubric lives in `.claude/rules/tools.md` and loads on demand when files under `src/mealie_mcp/tools/` are read.

Expand Down
27 changes: 23 additions & 4 deletions src/mealie_mcp/tools/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
from http import HTTPStatus
from typing import Any
from uuid import UUID

from fastmcp.exceptions import ToolError

Expand Down Expand Up @@ -44,10 +45,20 @@ def require_non_empty(name: str, value: str) -> None:
MAX_PER_PAGE = 100


def require_per_page(per_page: int) -> None:
"""Raise a `ToolError` if `per_page` exceeds the shared list-tool ceiling."""
if per_page > MAX_PER_PAGE:
raise ToolError(f"per_page must be <= {MAX_PER_PAGE} (got {per_page})")
def require_pagination(page: int, per_page: int) -> None:
"""Raise a `ToolError` if the pagination window is outside the supported range.

`per_page` is bounded to `1..MAX_PER_PAGE` on both sides. The ceiling caps
tool output size; the floor stops the two broken low values Mealie accepts,
`-1` (an unbounded "all rows" fetch that defeats the ceiling) and `0` (an
empty page). `page` is bounded to `>= 1` for the same reason: Mealie
silently coerces `0` to page 1 and reads a negative page as the last page,
so an out-of-range value returns a surprising result instead of an error.
"""
if page < 1:
raise ToolError(f"page must be >= 1 (got {page})")
if per_page < 1 or per_page > MAX_PER_PAGE:
raise ToolError(f"per_page must be between 1 and {MAX_PER_PAGE} (got {per_page})")


def to_unset[T](value: T | None) -> T | Unset:
Expand All @@ -73,6 +84,14 @@ def ack_delete(action: str, response: Response[Any], ack_id: str) -> dict[str, A
return {"id": ack_id, "deleted": True}


def parse_recipe_uuid(value: str) -> UUID:
"""Parse a recipe id into a UUID or raise `ToolError`."""
try:
return UUID(value)
except ValueError as exc:
raise ToolError(f"recipe_id must be a recipe UUID: {exc}") from exc


def parse_order_direction(value: str | None) -> OrderDirection | Unset:
"""Coerce a caller-supplied 'asc'/'desc' into the typed enum."""
if value is None:
Expand Down
6 changes: 3 additions & 3 deletions src/mealie_mcp/tools/households_cookbooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
expect_dict,
parse_order_direction,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand All @@ -45,7 +45,7 @@ def list_cookbooks(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List the household's cookbooks, paginated. Returns the pagination envelope."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_households_cookbooks_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -147,7 +147,7 @@ def _list_cookbooks(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
order_by: Optional column name to sort on (e.g. ``"name"``).
order_direction: ``"asc"`` or ``"desc"``.

Expand Down
12 changes: 5 additions & 7 deletions src/mealie_mcp/tools/households_mealplans.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
ack_delete,
expect_dict,
parse_order_direction,
require_per_page,
parse_recipe_uuid,
require_pagination,
to_unset,
)

Expand Down Expand Up @@ -68,10 +69,7 @@ def _parse_recipe_id(value: str | None) -> UUID | Unset:
"""Parse an optional recipe UUID string into a UUID or UNSET."""
if value is None:
return UNSET
try:
return UUID(value)
except ValueError as exc:
raise ToolError(f"recipe_id must be a recipe UUID: {exc}") from exc
return parse_recipe_uuid(value)


def list_mealplans(
Expand All @@ -84,7 +82,7 @@ def list_mealplans(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List meal plan entries, paginated and optionally date-range filtered."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_households_mealplans_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -190,7 +188,7 @@ def _list_mealplans(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
start_date: Optional inclusive lower bound as ``YYYY-MM-DD``.
end_date: Optional inclusive upper bound as ``YYYY-MM-DD``.
order_by: Optional column name to sort on (e.g. ``"date"``).
Expand Down
6 changes: 3 additions & 3 deletions src/mealie_mcp/tools/households_shopping_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
expect_dict,
parse_order_direction,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand All @@ -60,7 +60,7 @@ def list_shopping_lists(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List shopping lists for the household, paginated."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_households_shopping_lists_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -208,7 +208,7 @@ def _list_shopping_lists(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
order_by: Optional column name to sort on (e.g. ``"name"``).
order_direction: ``"asc"`` or ``"desc"``.

Expand Down
6 changes: 3 additions & 3 deletions src/mealie_mcp/tools/organizer_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
expect_dict,
parse_order_direction,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand All @@ -41,7 +41,7 @@ def list_categories(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List recipe categories, paginated. Returns the pagination envelope."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_organizers_categories_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -121,7 +121,7 @@ def _list_categories(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
search: Optional free-text search.
order_by: Optional column name to sort on.
order_direction: ``"asc"`` or ``"desc"``.
Expand Down
6 changes: 3 additions & 3 deletions src/mealie_mcp/tools/organizer_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
expect_dict,
parse_order_direction,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand All @@ -41,7 +41,7 @@ def list_tags(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List recipe tags, paginated. Returns the pagination envelope."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_organizers_tags_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -121,7 +121,7 @@ def _list_tags(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
search: Optional free-text search.
order_by: Optional column name to sort on.
order_direction: ``"asc"`` or ``"desc"``.
Expand Down
6 changes: 3 additions & 3 deletions src/mealie_mcp/tools/organizer_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
expect_dict,
parse_order_direction,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand All @@ -41,7 +41,7 @@ def list_tools(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List recipe tools, paginated. Returns the pagination envelope."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_organizers_tools_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -132,7 +132,7 @@ def _list_tools(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
search: Optional free-text search.
order_by: Optional column name to sort on.
order_direction: ``"asc"`` or ``"desc"``.
Expand Down
6 changes: 3 additions & 3 deletions src/mealie_mcp/tools/recipe_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
expect_list,
parse_order_direction,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand Down Expand Up @@ -61,7 +61,7 @@ def list_comments(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List all comments across recipes, paginated. Returns the page payload."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_comments_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -140,7 +140,7 @@ def _list_comments(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
order_by: Optional column name to sort on (e.g. ``"createdAt"``).
order_direction: ``"asc"`` or ``"desc"``.

Expand Down
6 changes: 3 additions & 3 deletions src/mealie_mcp/tools/recipe_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
expect_str,
parse_order_direction,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand Down Expand Up @@ -91,7 +91,7 @@ def list_recipes(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List recipes, paginated. Returns the pagination envelope."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_recipes_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -342,7 +342,7 @@ def _list_recipes(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
search: Optional free-text search.
categories: Optional list of category slugs to filter by.
tags: Optional list of tag slugs to filter by.
Expand Down
12 changes: 8 additions & 4 deletions src/mealie_mcp/tools/recipe_timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
ack_delete,
expect_dict,
parse_order_direction,
parse_recipe_uuid,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand Down Expand Up @@ -71,11 +72,14 @@ def list_recipe_timeline_events(
) -> dict[str, Any]:
"""List a recipe's timeline events, paginated. Returns the pagination envelope."""
require_non_empty("recipe_id", recipe_id)
require_per_page(per_page)
# UUID-parse before interpolating so a caller value carrying a quote or a
# DSL operator cannot alter the parsed queryFilter expression.
recipe_uuid = parse_recipe_uuid(recipe_id)
require_pagination(page, per_page)

response = get_all_api_recipes_timeline_events_get.sync_detailed(
client=client,
query_filter=f'recipe_id="{recipe_id}"',
query_filter=f'recipe_id="{recipe_uuid}"',
page=page,
per_page=per_page,
order_by=to_unset(order_by),
Expand Down Expand Up @@ -184,7 +188,7 @@ def _list_recipe_timeline_events(
Args:
recipe_id: UUID of the recipe whose events to list.
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
order_by: Optional column name to sort on (e.g. ``"timestamp"``).
order_direction: ``"asc"`` or ``"desc"``.

Expand Down
6 changes: 3 additions & 3 deletions src/mealie_mcp/tools/recipes_foods.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
expect_dict,
parse_order_direction,
require_non_empty,
require_per_page,
require_pagination,
to_unset,
)

Expand All @@ -40,7 +40,7 @@ def list_foods(
order_direction: Literal["asc", "desc"] | None = None,
) -> dict[str, Any]:
"""List ingredient foods, paginated. Returns the pagination envelope."""
require_per_page(per_page)
require_pagination(page, per_page)
response = get_all_api_foods_get.sync_detailed(
client=client,
page=page,
Expand Down Expand Up @@ -115,7 +115,7 @@ def _list_foods(

Args:
page: 1-indexed page number. Defaults to 1.
per_page: Page size. Defaults to 50. Capped at 100.
per_page: Page size, 1 to 100. Defaults to 50.
search: Optional free-text search.
order_by: Optional column name to sort on.
order_direction: ``"asc"`` or ``"desc"``.
Expand Down
Loading