Tool Authoring Guide
Tool Authoring Guide
Section titled “Tool Authoring Guide”This is the vertical-facing guide for adding a tool to the Rewards Assistant. A tool is the seam the orchestrator LLM reaches through to act: search offers, look up purchase history, fetch a webpage. The guide covers the three authoring paths, the naming rules that keep the tool registry collision-free, the conformance gate every new tool must pass, and how to write a description the model can route on.
The framing that runs through all of it: a tool in this repo is a thin adapter.
The real work (search, enrichment, ranking) lives in a server you do not own:
rover-mcp or the consumer-context-service (CCS). Your wrapper carries the
LLM-facing name, description, and args_schema, plus typed errors and
observability. It never re-implements server logic. If you find yourself porting
business rules into the wrapper, stop: that logic belongs in the server, and the
wrapper should call it.
The three authoring paths
Section titled “The three authoring paths”Pick the path by where the data lives and who owns the backend.
| Path | Use when | Base class | Example |
|---|---|---|---|
| CCS canonical (Path-1) | The data comes from CCS behind the enrichment envelope | CCSTool | SearchOffersCCSTool |
| Direct | You call a single backend endpoint yourself, no shared MCP server, no envelope | BaseTool (LangChain) | ProductSearchTool |
| Vertical-owned MCP | Your vertical runs its own MCP server exposing server-owned tools | MCPTool | SearchOffersTool |
All three live in src/consumer_agent/utils/tools.py (or, for the direct path,
under src/consumer_agent/tools/). All three are LangChain tools the registry
builds and the orchestrator binds. The difference is the unwrap and error
contract each base gives you for free.
Path A: CCS canonical (Path-1)
Section titled “Path A: CCS canonical (Path-1)”This is the preferred path for catalog and user data. CCS returns data wrapped in
an enrichment envelope (status plus an optional principal). CCSTool owns the
unwrap so you do not: it parses the envelope, checks the principal as a security
gate, and decides on status. An ok or partial status forwards the payload
to the orchestrator; an error status, or a present-and-mismatched principal,
raises a typed ToolException instead of leaking the raw envelope or an error
string that reads like a successful result.
You override exactly two hooks: _ccs_call (the server call) and, optionally,
_transform (reshape the unwrapped payload). You do not touch the call/error
path. Here is the real offer-search twin:
class SearchOffersCCSTool(CCSTool): name: str = "search_offers" description: str = ( "Search for available offers based on a query. " "Returns relevant offers matching the search criteria. " "Can be personalized with user_id for better results." ) args_schema: type[BaseModel] = SearchOffersInput
async def _ccs_call( self, invoking_user: str = "", query: str = "", user_id: str | None = None, limit: int = 200, **_: Any, ) -> dict: return await self.ccs_client.search_offers_enriched( query=query, user_id=user_id, limit=limit )When you need to reshape the unwrapped payload, override _transform rather than
the call path. The product twin filters to Button partners this way:
class SearchProductsCCSTool(CCSTool): name: str = "search_products" args_schema: type[BaseModel] = SearchProductsInput
async def _ccs_call(self, invoking_user: str = "", descriptions=None, **_: Any) -> dict: query = " ".join(descriptions or []) return await self.ccs_client.search_products_enriched(query=query)
def _transform(self, payload: dict[str, Any]) -> str: # CCS returns {query, results, result_count}; reshape to the MCP twin's # {query: [products]} mapping that _filter_products_dict iterates over. query = payload.get("query", "") results = payload.get("results", []) if not isinstance(results, list): results = [] return json.dumps(_filter_products_dict({query: results}), ensure_ascii=False)The principal gate is load-bearing for per-user data. GetUserPurchaseHistoryCCSTool
relies on it: a mismatched principal suppresses the payload before it ever reaches
the sub-agent’s context. You get that for free by subclassing CCSTool; do not
re-implement it.
Register your twin in ccs_path1_tools, the enumerated builder that the feature
flag flip targets when a CCS tool replaces its legacy counterpart.
Path B: direct
Section titled “Path B: direct”Use the direct path when your tool calls one backend endpoint itself, with no
shared MCP server and no enrichment envelope to unwrap. You subclass LangChain’s
BaseTool directly and own the call and error handling yourself. ProductSearchTool
is the worked example: it calls a CCS search-and-enrich endpoint over httpx,
unwraps the envelope inline, and returns a sub-agent-facing payload.
class ProductSearchTool(BaseTool): name: str = "product_search" description: str = ( "Search for products available through Fetch Rewards partner retailers. " "Returns product details with prices, images, retailer info, and Fetch " "points-per-dollar (PPD). Use for product discovery, comparisons, price " "checks, availability, and recommendations. Do NOT use for product " "reviews, safety recalls, or non-grocery items." ) args_schema: type[BaseModel] = ProductSearchInput
def __init__(self, ccs_client: CCSClient) -> None: super().__init__() self._ccs_client = ccs_clientThe direct path gives you the most control and the least scaffolding. The cost is that you are responsible for typed errors and observability yourself. Prefer Path A when the data is behind the CCS envelope; reach for direct only when there is no shared base that fits.
Path C: vertical-owned MCP
Section titled “Path C: vertical-owned MCP”If your vertical runs its own MCP server, your tools subclass MCPTool. The base
owns the call, the result-text extraction, the typed error, and the observability
span and metrics. You declare name, description, args_schema, and the
server-side mcp_tool_name, and optionally override _transform to reshape the
returned text. The legacy offer-search tool is the minimal shape:
class SearchOffersTool(MCPTool): name: str = "search_offers" description: str = ( "Search for available offers based on a query. " "Returns relevant offers matching the search criteria. " "Can be personalized with user_id for better results." ) args_schema: type[BaseModel] = SearchOffersInput mcp_tool_name: str = "search_offers"A tool that reshapes output overrides _transform only. SearchProductsTool
keeps the whole call/error contract from the base and adds just the partner
filter:
class SearchProductsTool(MCPTool): name: str = "search_products" args_schema: type[BaseModel] = SearchProductsInput mcp_tool_name: str = "search_products"
def _transform(self, raw: str) -> str: return _filter_search_products(raw)The registry connects to the MCP server (transport is per-server) and, when the agent card names individual tools instead of the whole group, filters the returned set down to the named tools. A named tool the server does not expose fails loud at build time.
Naming and registry collisions
Section titled “Naming and registry collisions”A tool’s name is its identity in the registry and the string the LLM emits to
call it. Two rules:
-
Use a vertical-prefixed name to avoid colliding with another vertical’s tool.
search_offersis an unprefixed legacy name; a new restaurant-network tool should berestaurant_search_offers, not a baresearch_offers. The registry binds tools into one flat name space, so an unprefixed name is a landmine for the next team. -
One name maps to one tool within a single build. The registry builds a frozen index from the final tool list and fails loud on a real collision rather than silently binding whichever tool won by build order.
The two error types you will meet, both in
src/consumer_agent/composition/resolution/tool_registry.py:
RegistryCollisionErroris raised at build time when two tools are registered under the same name simultaneously. It names both offending classes. This is a developer error: fix it by renaming one tool.ToolResolutionErroris raised when a tool id cannot be resolved, for example when an agent card names a tool the connected server does not provide. It names the unknown tool and lists what is available, before the orchestrator LLM is ever called.
Both subclass ValueError, so existing broad handlers keep working. The
intentional exception to the one-name rule is the search_products A/B: two
classes share the name search_products, but a feature flag selects exactly one
per build, so only one is ever in the index at a time. That is selection, not
collision.
The conformance gate
Section titled “The conformance gate”Every tool must pass the conformance gate before it ships. Run it locally:
make lint-tool-conformanceIt also runs in the make lint composite and as a pre-commit hook, so CI
enforces it. The checker is static (it parses utils/tools.py and builds the
registry against a stub client, so no network call is made). Legacy tools are
grandfathered: their debt is reported but does not block; a new tool’s violation
does block.
What each check requires of your tool:
- Declare both
nameandargs_schema. The checker reads them straight off the class. The framework bases (MCPTool,CCSTool) and the evalStubToolare excluded; every concrete tool must declare both. - Resolve through the registry index. The gate builds the tool set across the
product_cardflag combinations withproduct_searchheld false, and confirms every built tool resolves back to its own instance with no collision. It does not exercise theproduct_searchtrue branch (that branch builds provider dicts and CCS tools that carry noname, so they add no resolution signal), so this is a partial flag sweep, not the full feature-flag matrix. You get this for free if your name is unique per build. - Have a contract or availability test. Add a test under
tests/unit/tools/that references your tool class by name. This repo tests the framework contract and tool availability, not per-tool backend logic (that logic lives in the server repo). A contract test checks the wrapper shape: name and schema, that a failed call raisesToolException, that the observability span carries the right keys. The existing twin tests are the template to copy. - List a user-facing tool in the eval manifest. If the LLM can select your tool
on a user’s behalf, add its
nameto the tool allowlist inconfigs/evaluation_manifest.yaml. Internal tools (the ones the model uses to log a signal, not to serve a user request) are exempt. This check is driven by a hardcoded allowlist (USER_FACING_TOOL_NAMESin the checker), so it does not automatically catch a brand-new user-facing tool name: add your tool’s name to that frozenset as well, or the manifest requirement will not fire for it.
Observability: what you inherit, what you must not break
Section titled “Observability: what you inherit, what you must not break”Subclass a framework base and the span and the connector.invocation.total,
connector.invocation.errors_total, and connector.invocation.latency_ms
metrics are emitted for you, keyed on the connector_id dimension (your tool’s
name). The build-time collision tripwire is connector.registration.collision_total.
These metric names and the connector_id dimension key are fixed: dashboards key
on those exact strings, so do not rename them even though the rest of the code and
this guide say “tool”.
Every per-traffic metric and span attribute carries the five mandatory platform
slicing dimensions (vertical, sub_agent_id, dm_type, experiment_arm,
agent_definition_version), imported from consumer_agent.platform.slicing_dimensions.
The bases emit them as the empty string at the tool boundary (present-but-unset,
which the telemetry SDK keeps, unlike a null which it drops); downstream layers
fill in the real values. If you write a direct-path tool that emits its own
metrics, you must carry these dimensions too, or annotate a non-traffic emission
as a convention exception on the same line. The metric-dimension lint enforces
this.
Writing a routing-grade description
Section titled “Writing a routing-grade description”The description is not documentation: it is the only signal the orchestrator
LLM has when it decides whether to route a request to your tool. A vague
description gets your tool skipped or misfired. Treat it as load-bearing.
Make it capability-anchored. State what the tool does, what it returns, and the
boundary of when not to use it. ProductSearchTool is the model to follow: it
says what it returns (prices, images, retailer, points-per-dollar) and draws the
line (“Do NOT use for product reviews, safety recalls, or non-grocery items”).
That negative boundary is what stops the model from reaching for the wrong tool.
Guidance:
- Lead with the capability in plain terms, not the backend name. The model routes on what the tool does, not which service answers.
- Name the shape of what comes back, so the model knows whether the result will answer the question.
- Add an explicit do-not-use boundary when a neighbouring tool could be confused for yours.
- Keep argument semantics in the
args_schemafield descriptions, not crammed into the tool description.
Checklist before you open a PR
Section titled “Checklist before you open a PR”- Path chosen deliberately (CCS canonical, direct, or vertical-owned MCP).
-
nameis vertical-prefixed and unique in the registry. -
nameandargs_schemadeclared on the concrete class. - Description is capability-anchored with a do-not-use boundary.
- Contract or availability test added under
tests/unit/tools/. - User-facing tool listed in
configs/evaluation_manifest.yaml. -
make lint-tool-conformancepasses.
CCS-side authoring
Section titled “CCS-side authoring”CCS-owned tools have a second half: the server-side definition in the CCS repo, owned by the CCS team. That guide is coordinated separately and is not covered here. When your tool is backed by a new CCS endpoint, coordinate the server-side contract with the CCS team before wiring the wrapper.