Skip to main content
Glama
README.md
# trame-mcp

Add an MCP (Model Context Protocol) server to any trame application, so LLM
clients (Claude Code, Claude Desktop, ...) can inspect and drive the app while
human users watch it update live in the browser.

The MCP endpoint is served on the same port as the application itself — no
second server, no second process.

## Quick start

```python
from trame.app import get_server

from trame_mcp import get_mcp

server = get_server()
mcp = get_mcp(server)  # the MCP server attached to this trame server

server.state.resolution = 6


@mcp.tool
def set_resolution(resolution: int) -> str:
    """Set the number of facets of the cone."""
    with server.state as state:
        state.resolution = resolution
    return f"Resolution set to {resolution}"


mcp.enabled = True  # nothing is served until agent access is on
server.start()
```

At startup the console shows how to connect:

```
MCP server available at http://localhost:8080/mcp
  claude mcp add --transport http trame http://localhost:8080/mcp
```

## Declaring what the agent can use

The decorators follow trame's own split between plain functions and methods:

|                                                        | trame                                     | trame-mcp                                        |
| ------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------ |
| Plain functions: exposed on that server right away     | `@state.change("a")`, `@ctrl.set("name")`  | `@mcp.tool`, `@mcp.resource(uri)`, `@mcp.prompt` |
| Methods: inert markers, bound when the object is built | `@change("a")`, `@controller.set("name")` | `@tool`, `@resource(uri)`, `@prompt`             |

`mcp` is always the object `get_mcp(server)` returns: the MCP server of that
trame server, one per server like `get_server()`. The markers (`tool`,
`resource`, `prompt`, plus `mcp_field` for trame-dataclass fields) are
imported from `trame_mcp`; like `trame.decorators`, they need no server, so
libraries can use them freely. So do the base classes, which pair with
trame's: `MCPApp` / `TrameApp`, `MCPComponent` / `TrameComponent` and
`MCPStateDataModel` / `StateDataModel`.

| What                      | Declared with                                                         | The agent gets                  |
| ------------------------- | --------------------------------------------------------------------- | ------------------------------- |
| Functions                 | `@mcp.tool`, `@mcp.resource(uri)`, `@mcp.prompt`                      | a tool, resource or prompt each |
| App and component methods | `@tool`, `@resource(uri)`, `@prompt` in an `MCPApp` / `MCPComponent`  | the same, optionally prefixed   |
| Plain trame state         | `mcp.state.expose(name, ...)`                                         | `state_get` / `state_update`    |
| trame-dataclass models    | `MCPStateDataModel` with `mcp_field` and `@tool` for models you own; `mcp.dataclass.expose_model(cls)` for others; `mcp.dataclass.expose_instance(obj, name)` for entry points | `dataclass_schemas` / `dataclass_get` / `dataclass_update` / `dataclass_call` |

Declaring serves nothing: agent access stays off until `mcp.enabled = True`
([Declare, then go live](#declare-then-go-live)). Every value an agent sends
is validated against the schema it was shown before any app code runs.

## Functions

```python
from typing import Annotated

from trame.app import get_server

from trame_mcp import get_mcp

server = get_server()
mcp = get_mcp(server)

server.state.resolution = 6
server.state.opacity = 1.0


@mcp.tool
def set_opacity(
    opacity: Annotated[float, "0 = transparent, 1 = opaque", {"minimum": 0, "maximum": 1}],
) -> str:
    """Set the opacity of the cone."""
    with server.state as state:
        state.opacity = opacity
    return f"Opacity set to {opacity}"


@mcp.tool(read_only=True)  # advertised as safe: clients may skip confirmation
def get_settings() -> dict:
    """Current resolution and opacity."""
    return {"resolution": server.state.resolution, "opacity": server.state.opacity}


@mcp.resource("app://help", mime_type="text/markdown")
def help_page() -> str:
    """How to use this application."""
    return "# Cone viewer\nChange the resolution and the opacity of the cone."


@mcp.prompt
def smooth_cone(level: str = "very") -> str:
    """Ask the agent to smooth the cone."""
    return f"Make the cone {level} smooth without making it transparent."
```

- The name comes from the function and the description from its docstring
  (override with `name=` / `description=`).
- The input schema comes from the type hints; `Annotated` adds a description
  (a string) and JSON Schema constraints (a dict).
- `str`, `dict` and `list` results become text (dicts also as structured
  content); return `trame_mcp.Image(png_bytes)` to send an image.
- Functions may be sync or `async`; all run on trame's event loop, inside
  `with state:`, so what they assign reaches browsers (and `@state.change`
  reactions run) when they return.
- A module that has no server at hand can mark its functions with `@tool`
  instead; the app then exposes them with `mcp.expose(fn, ...)`.

## Methods of app classes and components

```python
from typing import Literal

from trame_mcp import MCPApp, MCPComponent, resource, tool


class Viewer(MCPComponent):  # may ship in a library: it never turns MCP on
    @tool
    def set_background(self, color: Literal["black", "white", "gray"]) -> str:
        """Set the background color of the 3D view."""
        with self.state:
            self.state.background = color
        return f"Background set to {color}"


class ConeApp(MCPApp):
    def __init__(self, server=None):
        super().__init__(server)
        self.state.resolution = 6
        self.viewer = Viewer(self.server, mcp_prefix="viewer_")
        self.mcp.configure(name="cone-app", enabled=True)

    @tool
    def set_resolution(self, resolution: int) -> str:
        """Set the number of facets of the cone."""
        with self.state:
            self.state.resolution = resolution
        return f"Resolution set to {resolution}"

    @resource("app://settings", mime_type="application/json")
    def settings(self) -> dict:
        """Current view settings."""
        return {"resolution": self.state.resolution}


if __name__ == "__main__":
    ConeApp().server.start()
```

`MCPApp` and `MCPComponent` are trame's `TrameApp` and `TrameComponent`,
plus one thing: when built, they expose their own marked methods, the way
trame binds `@change` and `@trigger`. The agent sees `set_resolution`,
`viewer_set_background` and the `app://settings` resource. `self.mcp` is the
object `get_mcp(self.server)` returns; use it once `super().__init__()` has
run.

- `MCPMixin` does the same for an app that already derives from another
  `TrameApp` subclass: `class App(MCPMixin, TheirApp)`.
- Any other object with marked methods is exposed explicitly:
  `mcp.expose(obj, prefix="obj_")`.

## Plain trame state

```python
from trame.app import get_server

from trame_mcp import get_mcp

server = get_server()
server.state.resolution = 6
server.state.color = "red"

mcp = get_mcp(server)
mcp.state.expose(
    "resolution",
    description="Number of facets of the cone",
    type=int,
    schema={"minimum": 3, "maximum": 60},
)
mcp.state.expose("color", read_only=True, description="Cone color, a CSS color name")
```

The agent reads exposed variables with `state_get` and assigns the writable
ones with `state_update`. Updates are all-or-nothing, checked against `type`
and `schema`, and applied inside `with state:`, so `@state.change` reactions
fire and browsers update. `mcp.state.unexpose("color")` withdraws a variable
at any time.

## trame-dataclass models

Two declarations work together: **models** say which classes the agent may
see, and which of their fields and methods; **instances** say where it
starts. Everything reachable from an exposed instance through exposed fields
is then addressable, including instances created later.

Models you own derive from `MCPStateDataModel`, the agent-visible
`StateDataModel`, and mark their fields and methods. This needs no server:

```python
from trame_dataclass.v2 import ServerOnly, Sync

from trame_mcp import MCPStateDataModel, mcp_field, tool


class Person(MCPStateDataModel):
    name = mcp_field(Sync(str, ""), "Full name", schema={"minLength": 1})
    age = mcp_field(Sync(int, 0), "Age in years", schema={"minimum": 0})
    notes = ServerOnly(str)  # not marked: invisible to the agent

    @tool
    def celebrate_birthday(self) -> str:
        """Increment this person's age by one year."""
        self.age += 1
        return f"{self.name} is now {self.age}"


class AddressBook(
    MCPStateDataModel, all_fields=True, all_tools=True, tools={"export_csv": {"hidden": True}}
):
    title = Sync(str, "My contacts")
    contacts = Sync(list[Person], list, has_dataclass=True)

    def add_contact(self, name: str, age: int = 0) -> dict:
        """Create a contact and add it to the book."""
        person = Person(self.server, name=name, age=age)
        self.contacts = [*self.contacts, person]
        return {"id": person._id}

    def export_csv(self, path: str) -> None:  # public, but hidden from the agent
        ...
```

- `mcp_field(descriptor, description, read_only=, hidden=, schema=)` opts a
  field in. Whether the agent may write it follows the field's mode (`Sync`
  writable, `ClientOnly` read-only; a `ServerOnly` field needs an explicit
  `read_only=`), and `schema` constraints are enforced on every write.
- `@tool` opts a method in; `@tool(read_only=True)` keeps it callable on
  read-only instances.
- Class keywords set the options: `all_fields=True` / `all_tools=True` opt
  in every field by its mode and every public method. `fields=` / `tools=`
  list names, or map them to the same options as `mcp_field` / `@tool`;
  `{"hidden": True}` excludes one.
- Subclasses inherit the options. Keywords on a subclass replace
  `all_fields` / `all_tools` and add `fields` / `tools` entries.

The application then exposes, on its server, the models it doesn't own and
the instances the agent starts from:

```python
from trame.app import get_server
from trame_dataclass.v2 import StateDataModel, Sync

from trame_mcp import get_mcp


class Camera(StateDataModel):  # stands in for a model from a library
    position = Sync(list[float], lambda: [0.0, 0.0, 1.0])
    focal_point = Sync(list[float], lambda: [0.0, 0.0, 0.0])
    cache_key = Sync(str, "")

    def reset(self) -> None:
        """Look at the origin from the default position."""
        self.position = [0.0, 0.0, 1.0]
        self.focal_point = [0.0, 0.0, 0.0]


server = get_server()
mcp = get_mcp(server)

mcp.dataclass.expose_model(
    Camera,
    fields={
        "position": {"description": "[x, y, z]", "schema": {"minItems": 3, "maxItems": 3}},
        "focal_point": {"read_only": True},
    },
    tools=["reset"],
)

# Person and AddressBook as defined above
book = AddressBook(server, contacts=[Person(server, name="Ada", age=36)])
camera = Camera(server)
presets = {"front": Camera(server), "top": Camera(server, position=[0.0, 1.0, 0.0])}

mcp.dataclass.expose_instance(book, name="address_book")
mcp.dataclass.expose_instance(lambda: camera, name="camera")  # looked up on every call
mcp.dataclass.expose_instance(presets, name="presets", read_only=True)
```

- `expose_model()` takes the same options as the `MCPStateDataModel` class
  keywords, once per class; a plain list works too
  (`fields=["position", "focal_point"]`). It also overrides the options of an
  `MCPStateDataModel` subclass, so the app has the final word.
- An instance source is an instance, a list or dict of instances, or a
  function evaluated on every call, which keeps following a variable or
  attribute that gets reassigned. `read_only=True` allows reading only, plus
  `@tool(read_only=True)` methods. `mcp.dataclass.unexpose_instance(name)`
  withdraws an entry point.

The agent then works with four generic tools:

```
dataclass_schemas()                                      # models, fields, methods, entry points
dataclass_get("address_book", depth=2)                   # values, nested instances inlined
dataclass_call("address_book", "add_contact", {"name": "Grace", "age": 36})
dataclass_update("<person id>", {"age": 37})             # validated, all-or-nothing
```

`dataclass_update` and `dataclass_call` return once the instance's
`@watch`ers have run, so what they derive is in the agent's next read.

## What the agent reads

Everything the agent knows about the app comes from what you declare:

- **On connection**, the server sends instructions: your `mcp.instructions`
  text, if any, then a generated overview. It says the app is live and shared
  with the people using it, and where to start (data entry points, state).
- **Tools, resources and prompts** are described by their docstrings.
  Parameters, prompt arguments included, take descriptions and constraints
  from `Annotated[T, "description", {...}]`.
- **trame-dataclass models** are described by `dataclass_schemas()`: class
  docstrings, `mcp_field` descriptions and schemas, method docstrings and
  parameters. A subclass lists only what it adds or changes, under
  `"extends": "BaseModel"`.

Docstrings reach the agent verbatim. When one is written for developers, give
the agent its own text with `description=`: on `@mcp.tool` / `@tool`, as an
`MCPStateDataModel` class keyword or an `expose_model()` option, and in
`fields=` / `tools=` entries. A model's description, like its docstring, is
not inherited by its subclasses.

## Declare, then go live

Everything above only *declares* what an agent could use. None of it is
reachable until agent access is turned on, so an app, or a library it uses,
can carry its whole MCP surface and still ship with it off.

- **One switch goes live**: `mcp.enabled` (default `False`) covers every
  agent channel, the HTTP endpoint and the in-process chat (`LLMAdapter`)
  alike. Set it in code, flip it at runtime, or let the end user decide with
  `--mcp` / `--no-mcp`.
- **The command line has the last word at launch**: before the server
  starts, values set in code are defaults that `--mcp` / `--no-mcp` (and
  `--mcp-token`) override. Once it is running, every assignment applies.

```python
from trame.app import get_server

from trame_mcp import get_mcp

server = get_server()
mcp = get_mcp(server)
mcp.enabled = True  # a default: launching with --no-mcp still keeps it off


@server.state.change("agent_access")  # e.g. bound to a checkbox in the UI
def on_agent_access(agent_access, **_):
    mcp.enabled = agent_access
```

|                         | `enabled=False` (default)       | `enabled=True` |
| ----------------------- | ------------------------------- | -------------- |
| HTTP endpoint           | answers 503                     | served         |
| HTTP with `http=False`  | no route                        | no route       |
| In-process `LLMAdapter` | calls raise `MCPDisabledError`  | served         |

`http=False` is the structural choice, fixed once the server starts: no route
is ever mounted, for an app whose embedded chat is the only agent. Otherwise
the route is mounted even while access is off, so it can be switched on later;
the connection banner prints when access first goes live.

## Options

`get_mcp(server, **options)` and `mcp.configure(**options)` set several
options at once; each is also an attribute (`mcp.name = "cone-app"`).

| Option            | Default               | Meaning                                                              |
| ----------------- | --------------------- | -------------------------------------------------------------------- |
| `enabled`         | `False`               | agent access switch; `--mcp` / `--no-mcp` at launch                  |
| `name`            | the trame server name | server name reported to MCP clients                                  |
| `instructions`    | `None`                | your guidance for MCP clients, sent before the generated overview    |
| `token`           | `None`                | bearer token required on the endpoint; `--mcp-token`, `TRAME_MCP_TOKEN` |
| `allowed_origins` | `()`                  | extra browser origins allowed to call the endpoint                   |
| `http`            | `True`                | mount the HTTP endpoint; `False` for in-process use only             |
| `path`            | `"/mcp"`              | endpoint path on the trame web server                                |
| `serialize_tools` | `True`                | run tool calls one at a time                                         |

`http` and `path` are fixed once the server starts; the others can change at
any time.

## Endpoint security

- Browser pages from other sites cannot reach the endpoint (Origin, Host and
  Content-Type checks), so the default tokenless setup is safe on localhost.
- `token=` / `--mcp-token` / `TRAME_MCP_TOKEN` require a bearer token; when the
  server listens beyond localhost without one, a token is generated and
  printed with the connection banner.

## Using with trame-llm

The same `mcp` can feed [trame-llm](https://github.com/Kitware/trame-llm)'s
embedded chat widget, in-process, through `LLMAdapter`:

```python
from trame.app import get_server
from trame_llm.core import LLMClient

from trame_mcp import LLMAdapter, get_mcp

mcp = get_mcp(get_server())
llm_client = LLMClient()
llm_client.register_mcp(LLMAdapter(mcp))  # after everything is declared
```

`mcp.enabled` applies to the chat too. While it is off, the chat still lists
the tools (trame-llm reads them once at startup), but every call fails with
"MCP access is currently disabled by the application". For a chat-only
application that should not accept outside MCP connections, pass
`http=False` to `get_mcp()` (or `self.mcp.configure(http=False)`).

## Examples

- `examples/00_quickstart.py` — a function tool and plain state.
- `examples/01_class_based.py` — `MCPApp`; starts with agent access off and
  toggles it from the UI.
- `examples/02_address_book.py` — trame-dataclass models and instances.
- `examples/03_wasm_quadrants.py` — nested models, inheritance,
  `all_fields` / `all_tools`, VTK scenes.

See `DESIGN.md` for the full design document.

## License

This library is OpenSource and follows the Apache Software License.

Maintenance

ActivityMaintained
ResponsivenessNo issues