Skip to content

MCP Integration

Trails speaks the Model Context Protocol (revision 2024-11-05) as its primary transport — see ADR-0008. Every @capability, @resource, and @prompt registered in your app is reachable from any MCP-compatible client (Claude Desktop, Cursor, Continue, custom agents) without writing integration code.

Two transports are shipped, both speaking the same JSON-RPC 2.0 dialect:

  • stdio — single-client, subprocess-style, zero non-stdlib deps.
  • SSE — HTTP + long-lived event streams, multi-client, per-session subscription state.

Note: Trails exposes every @capability as an MCP tool automatically — no configuration needed. See Automatic MCP exposure for the full explanation of how the manifest is derived from your Python type annotations and how hot-reload keeps connected clients in sync.

Quickstart

Embed Trails in a local MCP host (Claude Desktop, Cursor):

trails server --transport stdio

Run a shared HTTP endpoint for remote or multi-client use:

trails server --transport sse --port 8080

trails serve is preserved as a back-compat alias. When --transport is omitted, Trails picks stdio if stdin is piped, otherwise HTTP. Configure defaults in trails.toml:

[mcp]
transport = "sse"
host = "0.0.0.0"
port = 8080

Before the server binds, Trails autoloads the project so that @capability, @resource, @prompt, and @node_type decorators register against the live kernel. One broken module does not abort startup — its traceback is surfaced on stderr.

Primitives overview

Trails exposes three MCP primitives, each backed by a decorator in the Python surface. They are additive and independent — register any combination.

Primitive Decorator Purpose Methods
Tools @capability Callable functions invoked by the client tools/list, tools/call
Resources @resource Read-only data sources addressed by URI resources/list, resources/read, resources/subscribe, resources/unsubscribe
Prompts @prompt Reusable prompt templates prompts/list, prompts/get

The initialize response advertises every primitive the server can serve:

{
  "protocolVersion": "2024-11-05",
  "serverInfo": {"name": "trails", "version": "0.0.1a0"},
  "capabilities": {
    "tools":     {"listChanged": false},
    "resources": {"listChanged": true, "subscribe": true},
    "prompts":   {"listChanged": true}
  }
}

Tools via @capability

Every function decorated with @capability is auto-mounted as an MCP tool. Trails reads the handler's signature and type hints to derive a JSON-Schema inputSchema:

from trails import capability

@capability("classify", description="Classify a document")
def classify(text: str) -> dict:
    ...

tools/list returns:

{
  "name": "classify",
  "description": "Classify a document",
  "inputSchema": {
    "type": "object",
    "properties": {"text": {"type": "string"}},
    "required": ["text"]
  }
}

str, int, float, and bool annotations project to their JSON-Schema equivalents; anything else falls back to string. Parameters with no default become required.

tools/call dispatches through trails.runtime.invoke, so tools pick up provenance recording, middleware, and policy enforcement for free. Handler exceptions become MCP tool errors (isError: true with the message as text content) rather than JSON-RPC errors — this matches Anthropic's reference servers and lets the model see the failure.

Resources via @resource

Resources are read-only data sources identified by a URI. They are ideal for surfacing docs, configuration, reference data, or anything the model needs to consult rather than call.

from trails import resource, notify_resource_updated

@resource(uri="trails://readme", mime="text/markdown")
def readme() -> str:
    """Project README."""
    return "# Hello"

@resource(uri="trails://logo.png", mime="image/png")
def logo() -> bytes:
    return LOGO_PNG_BYTES

The decorator requires uri= (it cannot be inferred from the function name — one subtle but deliberate asymmetry with @prompt). mime defaults to text/plain. name defaults to fn.__name__; description defaults to the first line of the docstring. URIs with whitespace are rejected at decoration time.

Providers return str (delivered as text) or bytes (delivered as base64-encoded blob). Any other return type raises TrailsError.

List and read

// resources/list
{"resources": [
  {"uri": "trails://readme", "name": "readme",
   "mimeType": "text/markdown", "description": "Project README."}
]}

// resources/read -> {"uri": "trails://readme"}
{"contents": [
  {"uri": "trails://readme", "mimeType": "text/markdown",
   "text": "# Hello"}
]}

Subscriptions

Clients track a specific URI via resources/subscribe and resources/unsubscribe. When the content changes, call the public helper notify_resource_updated(uri) — the server fans notifications/resources/updated out to every subscriber of that URI (and only that URI):

from trails import notify_resource_updated

cache["readme"] = new_markdown
notify_resource_updated("trails://readme")

Registering a new @resource automatically emits notifications/resources/list_changed to every connected client — no explicit call needed. On SSE the update is scoped to exactly the sessions that subscribed; on stdio the single client receives it.

Subscribing to a URI that is not yet registered is legal; the subscription becomes live when the decorator eventually runs.

Prompts via @prompt

Prompts are reusable templates that render to MCP-shaped message lists. Unlike tools they are not invoked — the client pulls them, fills the declared arguments, and uses the result in its own conversation.

from trails import prompt

@prompt(name="summarize", description="Summarize a document")
def summarize_prompt(document: str, length: str = "short") -> list[dict]:
    return [
        {"role": "user",
         "content": {"type": "text",
                     "text": f"Summarize ({length}):\n{document}"}},
    ]

The decorator also accepts the bare form @prompt (no parens); the prompt name then defaults to fn.__name__. @resource deliberately does not support this shape because a URI cannot be inferred. When parameters are given, @prompt(name=..., description=...) wins.

Trails introspects the signature: every positional-or-keyword / keyword-only parameter becomes an argument in prompts/list; *args and **kwargs are ignored. A parameter with no default is required: true.

// prompts/list
{"prompts": [
  {"name": "summarize",
   "description": "Summarize a document",
   "arguments": [
     {"name": "document", "description": "", "required": true},
     {"name": "length",   "description": "", "required": false}
   ]}
]}

// prompts/get -> {"name": "summarize", "arguments": {"document": "..."}}
{"description": "Summarize a document",
 "messages": [{"role": "user", "content": {...}}]}

Unknown prompt names, missing required arguments, or undeclared arguments all surface as -32602 Invalid params. Registering a new @prompt emits notifications/prompts/list_changed.

Transports

stdio

StdioTransport exchanges line-delimited JSON-RPC 2.0 messages over stdin/stdout; logs and banners go to stderr so the protocol channel stays clean. It is the canonical way to embed Trails in Claude Desktop, Cursor, Continue, or any host that spawns MCP servers as subprocesses. The implementation is pure stdlib — no aiohttp, no asyncio, no upstream mcp SDK — which keeps subprocess cold-start fast.

EOF on stdin shuts the loop down cleanly; SIGINT closes stdin and drops the loop. Malformed JSON produces -32700 Parse error with id: null and the loop continues; the server never dies over a bad line.

Connect to Claude Desktop

trails mcp install --claude

That's it. Restart Claude Desktop and your app's capabilities appear as tools. The command finds the correct config file for your OS, adds an mcpServers entry for your project, and preserves any existing entries.

To connect to Cursor instead:

trails mcp install --cursor

Check registration status across all known clients:

trails mcp status

Remove the registration:

trails mcp uninstall --claude

Run a quick smoke test to verify the server starts and responds:

trails mcp test

See ADR-0065 for the design rationale.

Manual configuration

If you prefer to edit the config file yourself, the Claude Desktop entry looks like this:

{
  "mcpServers": {
    "my-trails-app": {
      "command": "trails",
      "args": ["server", "--transport", "stdio"],
      "cwd": "/path/to/your/trails/app"
    }
  }
}

Or use the trails-mcp shortcut entry point (less typing):

{
  "mcpServers": {
    "my-trails-app": {
      "command": "trails-mcp",
      "cwd": "/path/to/your/trails/app"
    }
  }
}

SSE

MCPSSETransport (aiohttp) serves two endpoints:

  • GET /sse — long-lived Server-Sent Events stream for server-to-client messages.
  • POST /messages — client-to-server JSON-RPC 2.0 requests.

SSE is multi-client by construction. Each client gets its own session keyed by the Mcp-Session-Id HTTP header, which partitions subscription state and notification fan-out so one client's resources/subscribe never leaks to another.

Session management

The first POST without an Mcp-Session-Id mints a fresh session and echoes the id back in the response header; every subsequent request carries it. The GET /sse handler also returns the id in its headers and pushes a event: endpoint frame containing /messages?session=<id> so clients that missed the header can still learn their id.

Sessions expire after session_ttl_seconds (default 3600s / 60 min) of inactivity — POST or GET activity resets the timer. On expiry the subscription set is dropped and the SSE queue is closed. A GET /sse with an unknown or expired session id returns 404 rather than silently minting a new session — that would break the reconnect contract.

If a client's only SSE stream disconnects, its session is purged immediately. Everything else is handled by the background reaper.

Client examples

Raw JSON-RPC over either transport:

// tools/list
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}

// tools/call
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
 "params": {"name": "classify",
            "arguments": {"text": "Recent advances in..."}}}

// resources/read
{"jsonrpc": "2.0", "id": 3, "method": "resources/read",
 "params": {"uri": "trails://readme"}}

// resources/subscribe
{"jsonrpc": "2.0", "id": 4, "method": "resources/subscribe",
 "params": {"uri": "trails://readme"}}

// prompts/get
{"jsonrpc": "2.0", "id": 5, "method": "prompts/get",
 "params": {"name": "summarize",
            "arguments": {"document": "..."}}}

For SSE clients, POST these to /messages with the Mcp-Session-Id header echoed from the first response and subscribe to /sse for notifications and response echoes.

Reference

Symbol Role
trails.capability Decorator → tools/list + tools/call
trails.resource Decorator → resources/list + resources/read
trails.prompt Decorator → prompts/list + prompts/get
trails.notify_resource_updated(uri) Fire notifications/resources/updated for subscribers
trails.mcp_server.create_mcp_server() Build a TrailsMCPServer programmatically
trails.mcp_server.StdioTransport(server).serve() Run the stdio loop in-process
trails.mcp_transport.MCPSSETransport(server) aiohttp-backed SSE transport with per-client sessions
trails.mcp_transport.SESSION_HEADER "Mcp-Session-Id" — the session-partition HTTP header

See also: Capabilities & Dispatch, Agent Runtime, Testing.