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
@capabilityas 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):
Run a shared HTTP endpoint for remote or multi-client use:
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:
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¶
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:
Check registration status across all known clients:
Remove the registration:
Run a quick smoke test to verify the server starts and responds:
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.