tf-mcp — exposing factory tools to the chat agent¶
tf.add_mcp_server(description='Description of what these tools do')
def my_handler(params):
# params is a dict matching the inputSchema
return {"result": "data"} # must be JSON-serializable
tf.add_mcp_tool('tool_name', 'Description for the LLM') \
.with_input({
"type": "object",
"properties": {
"param_name": {
"type": "string",
"description": "What this param does",
"enum": ["option1", "option2"] # optional
}
},
"required": ["param_name"]
}) \
.with_annotations({"readOnlyHint": True, "openWorldHint": False}) \
.do(my_handler)
# Tool NAME must match ^[a-z0-9_]{1,35}$ (lowercase letters, digits, underscore;
# 1-35 chars) — it becomes the `_mcp_<name>` collection (whose CHECK constraint is
# exactly this shape) + a Postgres NOTIFY channel, so uppercase/hyphens/spaces/
# dots would corrupt it or fail the CHECK at invoke time. A bad name is REJECTED
# at .do(): the tool is skipped and an ERROR row is written to factory_logs (the
# agent's other valid tools keep working). Use snake_case, verb-first
# (query_spend, search_claims).
# Calling add_mcp_server() is the SWITCH that publishes this agent's tools — with
# no server declared, NOTHING is published even if add_mcp_tool() ran. Its args are
# KEYWORD-ONLY: use add_mcp_server(description='…'); a bare positional
# add_mcp_server('…') raises TypeError. `description` (a one-line summary shown to
# the Foreman chat) is OPTIONAL — add_mcp_server() alone is a valid publish switch.
# (The old `name=` arg is deprecated and ignored — the agent slug + factory.yml
# title are the identity; passing name= logs a one-line deprecation notice.)
#
# Order of add_mcp_server() vs add_mcp_tool() doesn't matter.
# On the first tf.run_pending() tick, the core:
# 1. Writes a row to `_mcp_tool_catalog` (key = AGENT_SLUG, the stable
# factory.yml agent key; falls back to AGENT_NAME only if slug wasn't injected).
# - data = {server, tools} if MCP is configured
# - data = {} if no tools were registered (explicit signal)
# 2. Subscribes via tf.on_state('_mcp_{tool_name}', 'request')
# to each tool's dedicated inbox collection.
Read-me-first gate (tf.add_mcp_readme)¶
Declare a mandatory briefing for an agent's tools:
tf.add_mcp_readme("""
Filing conventions for this agent's tools:
- ALWAYS search before you create.
- Write anchored diff-edits, never wholesale overwrites.
""")
This registers a read_me_first tool and flags the agent's catalog with readme_gate:
true. The tool's description is fixed ("Read this FIRST…") — you supply only the body.
An agent that only calls add_mcp_readme (no add_mcp_server) still publishes.
Per-audience bodies (.audience([...])). Chain .audience() (same surface tokens as
tool scoping — external / foreman / agent_loop, alias internal) to serve different
briefing text to different surfaces — e.g. an external product how-to vs. an internal
agent-loop guide:
tf.add_mcp_readme(EXTERNAL_HOWTO).audience(['external']) # Claude-in-PowerPoint / research
tf.add_mcp_readme(INTERNAL_GUIDE).audience(['internal']) # foreman + agent loops
Omit .audience() for one body shared by every surface. A later call with the same
audience replaces that body. The bodies ride on the single read_me_first catalog entry as
a readme_bodies list; each surface serves its own body straight from the catalog (the
briefing is static text — it is never round-tripped to the agent, so it works even when the
agent is stopped). A surface an audience explicitly names wins over the all-surfaces default.
Enforcement differs by consumer:
| Consumer | Behaviour |
|---|---|
| Foreman chat | Gated — the agent's other tools are blocked (per conversation) until read_me_first is called; the blocked call returns "Call the _read_me_first() tool first — …". Orchestrator-side, keyed on the conversation session. The gate is set only if a body targets foreman — an external-only readme does NOT gate the foreman (it couldn't list read_me_first to ack it, which would deadlock the agent's other tools). |
External MCP clients (/api/mcp) |
Not gated — the fixed description instructs them to read it first (soft). Served the external body. |
Cross-agent LLM loop (add_tools_from_agent) |
Not gated — read_me_first is simply available in the bound set; served the agent_loop body. |
Convention: the readme is the CANONICAL "how to use these tools" text. An agent's own system prompt should defer to it, not restate it (avoids two drifting sources of truth).
Constraint — one gated agent per factory (for now). The foreman chat flattens every
agent's tools into one namespace keyed by bare tool name (last-wins). read_me_first is a
fixed name, so if two agents in the same factory both call add_mcp_readme, one agent's
read_me_first collides away and that agent's tools can never be unlocked in chat. Keep the
readme on a single agent per factory until per-agent namespacing lands (e.g. the intelligence
factory keeps read_me_first on the librarian, not on each ops agent).
Tool annotations (.with_annotations)¶
.with_annotations({...}) attaches the standard MCP ToolAnnotations object to the tool. It is optional — but declare it on every externally exposed tool: external clients (claude.ai) use annotations to bucket tools, and an unannotated tool lands in an "Other tools" bucket. tf forwards the dict to MCP clients verbatim (no validation; use spec keys only).
| Key | Type | MCP default if absent | Meaning |
|---|---|---|---|
readOnlyHint |
bool | false |
Tool does not modify its environment |
destructiveHint |
bool | true |
Tool may perform destructive updates (only meaningful when not read-only) |
idempotentHint |
bool | false |
Repeated identical calls have no additional effect |
openWorldHint |
bool | true |
Tool interacts with external entities (web etc.) — factory tools that only touch the factory DB should set false |
title |
str | — | Human-readable display title |
# Read-only query tool
.with_annotations({"readOnlyHint": True, "openWorldHint": False})
# Write tool (non-destructive insert, not idempotent)
.with_annotations({"readOnlyHint": False, "destructiveHint": False,
"idempotentHint": False, "openWorldHint": False})
The annotations land in the tool's _mcp_tool_catalog entry as an optional annotations field (absent when not declared); the orchestrator's external MCP endpoint passes the object through to clients verbatim.
Tool audience — publish scoping (.audience([...]))¶
A tool can be published to three surfaces: external MCP clients (/api/mcp), the foreman
chat, and agent LLM loops (add_tools_from_self / add_tools_from_agent). .audience(list)
is an author-side whitelist naming the surfaces a tool is published to. Omit it and the
tool is visible on every surface (fail-open — a sensitive tool must opt IN to a
restriction; forgetting .audience() never silently narrows).
| Surface token | Publishes to |
|---|---|
'external' |
External MCP clients (/api/mcp) |
'foreman' |
The in-built foreman chat |
'agent_loop' |
The LLM-loop bulk binders (add_tools_from_self / add_tools_from_agent) |
'internal' |
Alias for foreman + agent_loop (every non-external surface) |
# internal only — foreman + loops, NOT the public API
tf.add_mcp_tool('run_fraud_sweep', 'Sweep the whole book for fraud rings') \
.with_input({...}) \
.audience(['internal']) \
.do(run_sweep)
# pipeline-only — driven ONLY by direct _mcp_<name> state-writes, no LLM/client surface
tf.add_mcp_tool('recompute_index', 'Rebuild the vector index') \
.audience([]) \
.do(recompute)
- An empty audience (
.audience([])) leaves a tool reachable ONLY via its own_mcp_<name>request→response pipeline (any code writing that row still drives it — see "How tool calls flow" below). - Omitting
'agent_loop'from the audience affects the bulk binders only. An explicitadd_tool('name')is a deliberate single pick and still binds — the author's own override. - Unknown surface tokens are logged and ignored (the valid tokens are whitelisted).
- Compiles at registration to a per-tool
hidden_from: [...]denylist in the catalog (the complement of the audience; absent ⇒ visible everywhere).external/foremanare enforced orchestrator-side (dropped from that surface'stools/listANDtools/call);agent_loopis enforced core-side in_gather_tools. - This is author-side scoping, distinct from a credential's caller-side
tool_selectionallow-list.
How tool calls flow¶
Each tool has its own collection _mcp_{tool_name}. A call is a single row whose state transitions:
| Step | Actor | What |
|---|---|---|
| 1 | orchestrator backend | INSERT row (collection=_mcp_{tool}, key=correlation_id, state='request', data={agent, params}) |
| 2 | agent's on_state('_mcp_{tool}', 'request') handler |
Sees the row. Checks data.agent == AGENT_SLUG (fallback AGENT_NAME); if not, silently skips. |
| 3 | agent | Executes handler; UPDATEs the same row to state='response' with data.result or data.error. |
| 4 | orchestrator backend | Polls the row by key until state = 'response'; returns data.result to the LLM. |
The tool inbox is an ordinary (collection, state) pipeline: collection _mcp_{tool_name}, the agent subscribes via tf.on_state('_mcp_{tool_name}', 'request'), and consumes each call by transitioning the row to state='response'. No special channel — same poll-based dispatch as every other subscription, and the same failure contract: a request row the handler never transitions to response is retried and eventually parked under the 5-strike-park rule (see tf-common).
Audit trail: every tool call is one row you can inspect:
SELECT key, state, value, created_at, updated_at
FROM factory_data
WHERE factory_name='my_factory'
AND collection='_mcp_search_docs'
ORDER BY updated_at DESC LIMIT 5;
value; the Python surface exposes it as data.)