FastMCP Tool Surface (31 tools, HTTP-deployed)
traceo intermediate 6 min read
ELI5
FastMCP is a vending machine of labelled buttons (tools) an AI client can press. In production the machine sits behind the same front door as the web API: an HTTP endpoint with token checking. Some buttons are deliberately disconnected. Every “write a baseline” button answers “not here, that happens upstream”, whoever presses it.
Technical Deep Dive
Source: traceo-cat/traceo_mcp_server/traceo_mcp_server/server.py:261-1700 (tool definitions), :1785-1906 (main, transport wrap), ariel_integration/mcp_tools.py:36-340, ariel_integration/policy.py, services/relationships.py:1-40, auth/rbac.py:394-445; decisions 0104, 0108; traceo-cat origin/main 7d647ab, read 2026-10-25
Transport
Railway runs --transport http, so remote MCP clients reach FastMCP’s streamable-HTTP endpoint through CORSMiddleware(AuthMiddleware(app, allow_anonymous=True)). The initialize handshake works without a token. Tenant-scoped tools then refuse when there is no workspace context. stdio remains the CLI default for local use (MCP_TRANSPORT), but it is not what production serves. A local stdio session has no HTTP layer and therefore no AuthMiddleware.
Catalogue: server.py (21 tools)
| Family | Tools | Code guard beyond RLS |
|---|---|---|
| Spaces | list_spaces, get_space, create_space | authenticated user via SpaceService |
| Requirements | list_requirements, get_requirement, create_requirement, update_requirement, delete_requirement | @require_permission on create/update/delete, a no-op while ENABLE_RBAC is off (traceo-005) |
| Relationships | create_relationship, delete_relationship | principal role editor/admin/owner, own-workspace endpoints, DB-enum type (0108) |
| Query / trace | search_requirements, trace_requirement, analyze_impact, get_traceability_matrix | none |
| AI | search_requirements_semantic (falls back to keyword search), analyze_requirements, ask_documentation | none; optional imports |
| Versions | get_version_history, compare_requirement_versions | none |
| Import / export | import_requirements (@require_permission, no-op), export_requirements | import_requirements writes via _scoped_write_services(space_id) |
Catalogue: ariel_integration/mcp_tools.py (10 tools)
| Tool | Behaviour (decision 0104, accepted 2026-10-10) |
|---|---|
ariel_baseline_create, ariel_baseline_add_ci, ariel_baseline_freeze, ariel_build | refused for every caller with BASELINE_WRITE_DISABLED. Baselines are authored upstream in ariel (I-1) |
ariel_baseline_verify, ariel_baseline_diff, ariel_baseline_list, ariel_baseline_trace, ariel_validate | reads: anonymous callers refused, and the caller’s tenant must be on BASELINE_READ_TENANT_IDS (unset means nobody reads) |
ariel_sync | admin/owner only, checked from the principal; never anonymous, never a machine identity |
The v1.0 table’s REQUIREMENTS_CREATE / REQUIREMENTS_UPDATE floors on the ariel tools no longer exist. Those tools return a refusal before any service call.
classDiagram class FastMCP { +tool() decorator +http_app() +run(transport) } class ServerTools { 21 tools in server.py } class ArielTools { 10 tools in mcp_tools.py 4 write tools always refused } class Policy { baseline_write_refusal() read_refusal() sync_refusal() } class RelationshipWriteService { require_relationship_writer() create() delete() } FastMCP <|-- ServerTools : registers FastMCP <|-- ArielTools : register_ariel_tools ArielTools --> Policy : every call ServerTools --> RelationshipWriteService : create and delete relationshipWhat actually decides a call
flowchart LR CL["MCP client"] -->|"HTTPS /mcp"| CORS["CORS"] --> AU["AuthMiddleware allow_anonymous"] AU --> T{"tool"} T -->|"ariel write"| REF["BASELINE_WRITE_DISABLED"] T -->|"relationship write"| PR["principal role check"] --> DB T -->|"requirement write"| RB["require_permission (no-op, RBAC off)"] --> DB T -->|"read"| DB[("Postgres as traceo_app, RLS")]Key Terms
@mcp.tool()→ the FastMCP decorator that registers an async function as a tool.allow_anonymous→ lets the MCP handshake run without a token. Tools that need a workspace refuse on their own.- Upstream-only baselines → invariant I-1, enforced by refusing every baseline write in code (0104).
_scoped_write_services(space_id)→ builds services bound to a validated workspace override for one call.
Q&A
Q: Which decorator registers a Traceo MCP tool?
A: @mcp.tool() from fastmcp. server.py registers 21 tools and register_ariel_tools(mcp) adds 10.
Q: Over which transport do remote clients reach the deployed tools?
A: Streamable HTTP (--transport http), wrapped in CORS and AuthMiddleware. stdio is only for local runs.
Q: What does ariel_baseline_create do when an owner calls it?
A: It returns BASELINE_WRITE_DISABLED. Every baseline write (create, add-CI, freeze, build) is refused for every caller, owner included, by decision 0104.
Q: Which tools write relationships, and what authorizes them?
A: create_relationship and delete_relationship. services/relationships.py checks the principal’s role (editor/admin/owner) and keeps both endpoints in the caller’s own workspace, and RLS as traceo_app has the final word. ENABLE_RBAC is not consulted (0108).
Examples
An agent calls create_requirement with a viewer token. With ENABLE_RBAC off, the decorator passes it through. The INSERT then runs as traceo_app with the viewer’s GUCs, and members_create_requirements refuses it, because the "user" row’s role is not owner, admin or editor. The tool returns an error, and the database made the decision.
neighbors on the map
- Requirement & Relationship Schema (Drizzle base + 007–023) adding a new requirement field to migrations
- MCP Bridge — 4 Tools (Tier 2) integrating IRIS with Claude or other MCP clients
- CAIRNET MCP Tools for Agent Interaction implementing a new MCP tool for CAIRNET