From e9225e680d8f337ab3c9ffa1597ee8200576d29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gemma=20Torell=C3=B3?= Date: Mon, 27 Jul 2026 13:23:56 +0200 Subject: [PATCH] fix(mcp): ads discoverability, legacy ads_* aliases, lenient call_tool args Three fixes for the MCP server surfaced by a real support case: - Pin the ads read tools (tree, campaigns, ads, analytics) and document the search_tools/call_tool pattern in the server instructions. All ads tools were hidden behind search with no mention anywhere, so chat clients concluded Zernio has no ads capability without ever searching. - Alias pre-re-tag ads_* tool names to their split-resource equivalents (ad_campaigns_*, ad_insights_*, ...) at the tool-lookup layer. The 2026-07 OpenAPI re-tag renamed them with no alias; clients holding old names got "Unknown tool". MCP counterpart of the client.ads.* SDK shim. - Accept JSON-stringified arguments in the call_tool proxy. Claude Desktop/Cowork currently serializes object params as JSON strings (anthropics/claude-code#26094), which failed schema validation on every proxied call. Co-Authored-By: Claude Fable 5 --- src/late/mcp/server.py | 43 +++++++++--- src/late/mcp/transforms.py | 127 +++++++++++++++++++++++++++++++++++ tests/test_mcp_transforms.py | 113 +++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 src/late/mcp/transforms.py create mode 100644 tests/test_mcp_transforms.py diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index f619b3a..5694ada 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -32,13 +32,13 @@ import httpx from fastmcp import FastMCP from fastmcp.server.dependencies import get_access_token -from fastmcp.server.transforms.search import BM25SearchTransform from mcp.types import ToolAnnotations from late import Late, MediaType, PostStatus from .auth import build_auth_provider from .tool_definitions import TOOL_DEFINITIONS +from .transforms import LegacyAdsAliasTransform, LenientCallToolSearchTransform # Context variable to store the Zernio API key for the current connection _zernio_api_key: ContextVar[str | None] = ContextVar("zernio_api_key", default=None) @@ -51,12 +51,12 @@ # Always-visible core: the 20 hand-written ergonomic tools (guardrails + # LLM-shaped args, preferred over their raw generated twins) plus the generated # tools central to working on a profile — posting, the scheduling queue, -# pre-publish validation, account health, cross-platform analytics basics, and -# engagement on published posts. The remaining ~430 generated tools (messaging -# suite, ads, connect, platform-specific analytics, ...) sit behind the BM25 -# tool-search transform (search_tools / call_tool), so clients see ~51 tools up -# front instead of 481 — small enough that clients which cap or truncate large -# tool lists show them all. +# pre-publish validation, account health, cross-platform analytics basics, +# engagement on published posts, and the ads read surface. The remaining ~420 +# generated tools (ads writes, messaging suite, connect, platform-specific +# analytics, ...) sit behind the BM25 tool-search transform (search_tools / +# call_tool), so clients see ~55 tools up front instead of 481 — small enough +# that clients which cap or truncate large tool lists show them all. _PINNED_TOOLS = [ # Hand-written ergonomic tools "accounts_list", "accounts_get", @@ -86,6 +86,12 @@ # Campaign tags & usage "tracking_tags_list_tracking_tags", "tracking_tags_get_tracking_tag_stats", "usage_get_usage", + # Ads read surface. Without these pinned, the whole ads capability is + # invisible to clients that never think to call search_tools (observed: + # chat concluding "Zernio doesn't support Google Ads"). Writes stay + # behind search. + "ad_campaigns_get_ad_tree", "ad_campaigns_list_ad_campaigns", + "ad_campaigns_list_ads", "ad_insights_get_campaign_analytics", ] # Initialize MCP server @@ -100,6 +106,18 @@ - posts_* : Create, list, update, delete posts - media_* : Upload images and videos - docs_* : Search Zernio API documentation +- ad_campaigns_* / ad_insights_* : Ads — campaign tree, ads, analytics + +ONLY A CORE SUBSET OF TOOLS IS LISTED UP FRONT. Hundreds more are +available on demand — full ads management (all platforms: Meta, Google, +LinkedIn, TikTok), messaging/inbox, account connect flows, +platform-specific analytics, and more: + + 1. search_tools("") returns matching tool definitions. + 2. call_tool(name, arguments) executes any tool found that way. + +If a capability seems missing from the visible tool list, ALWAYS call +search_tools before concluding it does not exist. MULTI-ACCOUNT WORKFLOW (agencies, multi-client setups): When a user has more than one account on the same platform, you MUST @@ -118,9 +136,14 @@ # Resource-server auth: validates the bearer against the Zernio API and # auto-serves RFC 9728 protected-resource discovery (see late.mcp.auth). auth=build_auth_provider(), - # Collapse the ~383 generated tools behind search_tools/call_tool; the - # pinned ergonomic tools remain always-visible. - transforms=[BM25SearchTransform(always_visible=_PINNED_TOOLS, max_results=8)], + # Collapse the generated long tail behind search_tools/call_tool; the + # pinned tools remain always-visible. The lenient variant accepts + # JSON-stringified `arguments` (broken-client defense) and the alias + # transform keeps pre-re-tag `ads_*` tool names callable. + transforms=[ + LenientCallToolSearchTransform(always_visible=_PINNED_TOOLS, max_results=8), + LegacyAdsAliasTransform(), + ], ) diff --git a/src/late/mcp/transforms.py b/src/late/mcp/transforms.py new file mode 100644 index 0000000..ea10e87 --- /dev/null +++ b/src/late/mcp/transforms.py @@ -0,0 +1,127 @@ +"""Hardened variants of FastMCP's search transform for the Zernio server.""" + +from __future__ import annotations + +import json +from typing import Annotated, Any + +from fastmcp.server.context import Context +from fastmcp.server.transforms import GetToolNext, Transform +from fastmcp.server.transforms.search import BM25SearchTransform +from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.versions import VersionSpec + + +class LenientCallToolSearchTransform(BM25SearchTransform): + """BM25 search transform whose call_tool also accepts stringified arguments. + + Some MCP clients (Claude Desktop/Cowork regressions, e.g. + anthropics/claude-code#26094) serialize object-typed tool parameters as + JSON strings. The stock call_tool proxy types `arguments` as dict-only, + so every proxied call from those clients fails schema validation. This + override widens `arguments` to accept a JSON-encoded string and parses + it server-side. + """ + + def _make_call_tool(self) -> Tool: + transform = self + + async def call_tool( + name: Annotated[str, "The name of the tool to call"], + arguments: Annotated[ + dict[str, Any] | str | None, + "Arguments to pass to the tool, as a JSON object. A " + "JSON-encoded string of that object is also accepted.", + ] = None, + ctx: Context = None, # type: ignore[assignment] + ) -> ToolResult: + """Call a tool by name with the given arguments. + + Use this to execute tools discovered via search_tools. + """ + if name in {transform._call_tool_name, transform._search_tool_name}: + raise ValueError( + f"'{name}' is a synthetic search tool and cannot be " + "called via the call_tool proxy" + ) + return await ctx.fastmcp.call_tool(name, coerce_arguments(arguments)) + + return Tool.from_function(fn=call_tool, name=self._call_tool_name) + + +def coerce_arguments( + arguments: dict[str, Any] | str | None, +) -> dict[str, Any] | None: + """Normalize call_tool arguments; raises ValueError on a non-object string.""" + if not isinstance(arguments, str): + return arguments + stripped = arguments.strip() + if not stripped: + return None + try: + parsed = json.loads(stripped) + except json.JSONDecodeError as e: + raise ValueError( + "call_tool received `arguments` as a string that is not valid " + f'JSON: {e}. Pass a JSON object, e.g. {{"limit": 10}}.' + ) from e + if not isinstance(parsed, dict): + raise ValueError( + "call_tool received `arguments` as a JSON " + f"{type(parsed).__name__}; it must be a JSON object mapping " + "parameter names to values." + ) + return parsed + + +# Resources the 2026-07 OpenAPI re-tag (API repo #1777) scattered the old +# [Ads] tag into. Old generated MCP tool names were `ads_`; new ones are +# `_`, with the generator collapsing `__` +# to `_` (see scripts/generate_mcp_tools.py). +ADS_SPLIT_RESOURCES = ( + "ad_campaigns", + "ad_accounts", + "ad_creatives", + "ad_insights", + "ad_targeting", + "conversions", + "lead_gen", + "messaging_ads", + "reach_and_frequency", +) + + +class LegacyAdsAliasTransform(Transform): + """Resolve pre-split `ads_*` tool names to their post-re-tag equivalents. + + The re-tag renamed every generated `ads_*` MCP tool with no alias, so + clients holding pre-split names (cached tool lists, older conversations) + get `Unknown tool`. Mirrors the deprecated `client.ads.*` forwarding shim + in late/resources/ads.py, but at the MCP tool-lookup layer. + """ + + async def get_tool( + self, + name: str, + call_next: GetToolNext, + *, + version: VersionSpec | None = None, + ) -> Tool | None: + tool = await call_next(name, version=version) + if tool is not None or not name.startswith("ads_"): + return tool + for candidate in legacy_ads_candidates(name.removeprefix("ads_")): + tool = await call_next(candidate, version=version) + if tool is not None: + return tool + return None + + +def legacy_ads_candidates(suffix: str) -> list[str]: + """Post-split tool names a legacy `ads_` call may map to.""" + candidates = [] + for resource in ADS_SPLIT_RESOURCES: + candidates.append(f"{resource}_{suffix}") + if suffix.startswith(f"{resource}_"): + candidates.append(suffix) + return candidates diff --git a/tests/test_mcp_transforms.py b/tests/test_mcp_transforms.py new file mode 100644 index 0000000..3030d87 --- /dev/null +++ b/tests/test_mcp_transforms.py @@ -0,0 +1,113 @@ +"""Tests for the MCP search-transform hardening (lenient call_tool + ads aliases).""" + +import pytest + +fastmcp = pytest.importorskip("fastmcp") + +from late.mcp.transforms import ( # noqa: E402 + LegacyAdsAliasTransform, + coerce_arguments, + legacy_ads_candidates, +) + + +class TestCoerceArguments: + def test_dict_passes_through(self) -> None: + assert coerce_arguments({"limit": 5}) == {"limit": 5} + + def test_none_passes_through(self) -> None: + assert coerce_arguments(None) is None + + def test_json_string_is_parsed(self) -> None: + assert coerce_arguments('{"limit": 5}') == {"limit": 5} + + def test_empty_string_becomes_none(self) -> None: + assert coerce_arguments("") is None + assert coerce_arguments(" ") is None + + def test_invalid_json_string_raises(self) -> None: + with pytest.raises(ValueError, match="not valid JSON"): + coerce_arguments("{limit: 5}") + + def test_non_object_json_string_raises(self) -> None: + with pytest.raises(ValueError, match="must be a JSON object"): + coerce_arguments("[1, 2]") + + +class TestLegacyAdsCandidates: + def test_plain_suffix_maps_to_every_split_resource(self) -> None: + candidates = legacy_ads_candidates("list_ads") + assert "ad_campaigns_list_ads" in candidates + assert "ad_insights_list_ads" in candidates + + def test_resource_prefixed_suffix_adds_collapsed_candidate(self) -> None: + # Generator collapses `conversions_conversions_*` to `conversions_*`, + # so old `ads_conversions_send_x` must also try `conversions_send_x`. + candidates = legacy_ads_candidates("conversions_send_x") + assert "conversions_send_x" in candidates + + +class TestLegacyAdsAliasTransform: + @staticmethod + def _call_next_returning(known: dict[str, object]): + async def call_next(name: str, *, version=None): + return known.get(name) + + return call_next + + async def test_known_name_untouched(self) -> None: + transform = LegacyAdsAliasTransform() + sentinel = object() + call_next = self._call_next_returning({"posts_list": sentinel}) + assert await transform.get_tool("posts_list", call_next) is sentinel + + async def test_legacy_ads_name_resolves_to_split_resource(self) -> None: + transform = LegacyAdsAliasTransform() + sentinel = object() + call_next = self._call_next_returning({"ad_campaigns_list_ads": sentinel}) + assert await transform.get_tool("ads_list_ads", call_next) is sentinel + + async def test_unknown_legacy_name_returns_none(self) -> None: + transform = LegacyAdsAliasTransform() + call_next = self._call_next_returning({}) + assert await transform.get_tool("ads_nonexistent", call_next) is None + + async def test_non_ads_unknown_name_not_aliased(self) -> None: + transform = LegacyAdsAliasTransform() + sentinel = object() + call_next = self._call_next_returning({"ad_campaigns_list_bogus": sentinel}) + assert await transform.get_tool("list_bogus", call_next) is None + + +class TestServerWiring: + async def test_call_tool_schema_accepts_string_arguments(self) -> None: + """The proxied call_tool must validate stringified arguments (broken + clients like Cowork send `{"limit": 5}` as a string) instead of + rejecting them with a dict-vs-string schema error.""" + from fastmcp import Client + + from late.mcp.server import mcp + + async with Client(mcp) as client: + # A synthetic-tool target trips the guard AFTER schema validation, + # proving the string form got past the schema without executing + # any real (network-touching) tool. + with pytest.raises(Exception, match="synthetic"): + await client.call_tool( + "call_tool", + {"name": "search_tools", "arguments": '{"query": "x"}'}, + ) + + async def test_ads_read_tools_are_pinned(self) -> None: + from fastmcp import Client + + from late.mcp.server import mcp + + async with Client(mcp) as client: + visible = {t.name for t in await client.list_tools()} + assert { + "ad_campaigns_get_ad_tree", + "ad_campaigns_list_ad_campaigns", + "ad_campaigns_list_ads", + "ad_insights_get_campaign_analytics", + } <= visible