Skip to content
Closed
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
43 changes: 33 additions & 10 deletions src/late/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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("<what you need>") 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
Expand All @@ -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(),
],
)


Expand Down
127 changes: 127 additions & 0 deletions src/late/mcp/transforms.py
Original file line number Diff line number Diff line change
@@ -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_<op>`; new ones are
# `<resource>_<op>`, with the generator collapsing `<resource>_<resource>_`
# to `<resource>_` (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_<suffix>` 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
113 changes: 113 additions & 0 deletions tests/test_mcp_transforms.py
Original file line number Diff line number Diff line change
@@ -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
Loading