Automatic MCP Exposure¶
Zero configuration. Write a capability, get MCP.
Every function decorated with @capability is automatically exposed as an
MCP tool, an HTTP endpoint, and a GraphQL query. You write Python. Trails
handles the protocol surface. There is no MCP SDK to import, no schema to
declare separately, no server file to edit.
How it works — static path¶
At serve time, Trails builds the MCP tool manifest entirely from what is already in your Python code.
flowchart TD
A["@capability decorator\n(decorators.py)"] -->|"registers fn into"| B["_handlers dict\n(module-level registry)"]
B -->|"discover_and_import()\nauto-discovers all capabilities"| C["Live _handlers at startup"]
C -->|"read on every request"| D["list_tools()\n(mcp_server.py:282)"]
D -->|"derives inputSchema from"| E["Python type annotations\n+ _param_schema_for_hint()"]
E -->|"@app.model fields via"| F["node_type_to_json_schema()"]
D --> G["MCP tools/list response"]
F --> G
The key insight is that list_tools() reads _handlers live on every
tools/list request — it never caches a frozen snapshot. The manifest is
always consistent with whatever capabilities are currently registered.
Registration¶
When the interpreter loads your module, @capability("id", description="...")
executes immediately and inserts the decorated function into the
module-level _handlers dict:
from trails import capability
@capability("summarise", description="Summarise a document to a target length")
def summarise(text: str, max_words: int = 150) -> dict:
...
No further code is needed. The entry in _handlers contains the function
reference, its name, its description, and its original signature.
Discovery¶
Before the server binds its socket, discover_and_import() walks the
project tree, imports every Python module under app.py and app/, and
lets the decorators fire. By the time the first client connects, every
capability in your project is in _handlers.
Schema derivation¶
_param_schema_for_hint() (mcp_server.py lines 100–180) translates Python
type annotations into JSON Schema fragments, which are assembled into the
inputSchema object:
| Python annotation | JSON Schema type |
|---|---|
str |
"string" |
int |
"integer" |
float |
"number" |
bool |
"boolean" |
list[str] |
{"type": "array", "items": {"type": "string"}} |
list[int] |
{"type": "array", "items": {"type": "integer"}} |
dict |
{"type": "object"} |
@app.model type |
full object schema via node_type_to_json_schema() |
| anything else | "string" (safe fallback) |
Parameters without a default value are placed in the required array.
Parameters with a default are optional and the default is not included in
the schema (MCP clients infer optionality from presence in required).
When a parameter's type annotation is a class decorated with
@app.model (i.e. a @node_type), Trails calls
node_type_to_json_schema() to expand the model's field definitions into
a nested {"type": "object", "properties": {...}} block. This means your
data models flow into the MCP manifest automatically — change a field,
restart (or hot-reload), and the schema updates.
How it works — hot reload¶
In trails dev (and trails serve --watch) a FileWatcher monitors your
source files. When you save a change, the registry is rebuilt in-place and
connected clients are notified — no reconnect, no server restart.
sequenceDiagram
participant Dev as Developer
participant FW as FileWatcher
participant Reg as _handlers registry
participant Srv as TrailsMCPServer
participant Client as SSE Client
Dev->>FW: saves app.py (or app/*.py)
FW->>Reg: _reload_registries()<br/>clears _handlers, re-imports modules
Reg-->>FW: registry repopulated
FW->>Srv: server._emit_notification(<br/>"notifications/tools/list_changed")
Srv-->>Client: SSE event: notifications/tools/list_changed
Client->>Srv: tools/list
Srv-->>Client: new manifest (updated tools)
Note over Client: No reconnect needed.<br/>list_tools() already reads<br/>the live _handlers dict.
The notification is cheap: it carries no payload. Clients that care
(Claude Desktop, Cursor, any compliant MCP host) respond by re-issuing
tools/list, which hits the already-updated _handlers dict and returns
the new manifest.
Note:
_reload_registries()clears_handlersand then re-imports affected modules from scratch so that every@capabilitydecorator fires again. Capabilities in unmodified modules are unaffected — they stay in_handlersbecause they were registered at original import time and the clear only removes entries for the reloaded module.
What gets auto-generated¶
For every @capability in your project, Trails produces three artefacts
simultaneously. You write the capability once; the framework derives the
rest.
| Your code | MCP tool field | Source |
|---|---|---|
First arg to @capability |
name |
@capability("my_name", ...) |
description= kwarg |
description |
@capability(..., description="...") |
| Parameter names + types | inputSchema.properties |
_param_schema_for_hint() |
| Parameters without defaults | inputSchema.required |
signature inspection |
| Return type annotation | outputSchema (if present) |
_param_schema_for_hint() |
Example — given:
@capability("classify", description="Classify a document by topic")
def classify(text: str, top_k: int = 3) -> dict:
...
tools/list returns:
{
"name": "classify",
"description": "Classify a document by topic",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string"},
"top_k": {"type": "integer"}
},
"required": ["text"]
}
}
top_k is absent from required because it has a default value of 3.
Schema derivation — full reference¶
Primitive types¶
@capability("example", description="Type mapping demo")
def example(
name: str, # -> {"type": "string"}
count: int, # -> {"type": "integer"}
ratio: float, # -> {"type": "number"}
enabled: bool, # -> {"type": "boolean"}
) -> dict: ...
Collection types¶
@capability("batch", description="Batch operation")
def batch(
tags: list[str], # -> {"type": "array", "items": {"type": "string"}}
scores: list[float], # -> {"type": "array", "items": {"type": "number"}}
meta: dict, # -> {"type": "object"}
) -> dict: ...
Node type (model) parameters¶
When a parameter's type is a class decorated with @app.model, Trails
expands the full field schema:
from trails import node_type
@node_type("Patient", fields={"name": str, "age": int})
class Patient: ...
@capability("admit", description="Admit a patient")
def admit(patient: Patient) -> dict:
...
tools/list for admit produces:
{
"name": "admit",
"description": "Admit a patient",
"inputSchema": {
"type": "object",
"properties": {
"patient": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"]
}
},
"required": ["patient"]
}
}
Optional parameters¶
Any parameter with a default value is excluded from required:
@capability("search", description="Full-text search")
def search(
query: str, # required
limit: int = 10, # optional
offset: int = 0, # optional
include: bool = True, # optional
) -> dict: ...
Running the server¶
Development (always hot-reload)¶
Starts the MCP server in hot-reload mode. Every .py change under app.py
and app/ triggers _reload_registries() and emits
notifications/tools/list_changed. Use this during active development.
Production (no hot-reload)¶
Starts the MCP + HTTP server. The manifest is built once at startup and does not change unless the process is restarted.
Production with hot-reload¶
Identical to trails dev in reload behaviour but uses production server
settings (no debug output, no development middleware).
Transport selection¶
trails server --transport stdio # single-client subprocess mode
trails server --transport sse # HTTP + SSE, multi-client
When --transport is omitted, Trails picks stdio if stdin is a pipe,
otherwise SSE. Configure a permanent default in trails.toml:
Zero-thought checklist¶
You do not need to do most things MCP guides tell you to do. Here is the complete list of things you actually need:
- Annotate every parameter with a Python type (
str,int,float,bool,list[str],dict, or a@node_typeclass). This is what produces theinputSchema. Un-annotated parameters fall back to"string"but you lose precision. - Write a
description=string on@capability. This is what the model reads to decide whether to call your tool. A vague or missing description produces bad tool selection. - That is it. No MCP imports. No schema files. No server code.
Tip: Run
trails mcp testafter making changes to confirm the server starts cleanly andtools/listreturns the manifest you expect before connecting a real client.
See also¶
- MCP Integration — transports, resources, prompts, session management
- Capabilities guide — full
@capabilitydecorator API - ADR-0008 — why MCP is the primary transport