Skip to content

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 _handlers and then re-imports affected modules from scratch so that every @capability decorator fires again. Capabilities in unmodified modules are unaffected — they stay in _handlers because 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)

trails dev

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)

trails serve

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

trails serve --watch

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:

[mcp]
transport = "sse"
port = 8080

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_type class). This is what produces the inputSchema. 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 test after making changes to confirm the server starts cleanly and tools/list returns the manifest you expect before connecting a real client.


See also