Skip to main content
Glama

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

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

Related MCP server: mcp-server

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). Every value an agent sends is validated against the schema it was shown before any app code runs.

Functions

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

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

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:

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:

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 @watchers 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.

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's embedded chat widget, in-process, through LLMAdapter:

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

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Exposes FastAPI endpoints as Model Context Protocol (MCP) tools while preserving existing authentication, schemas, and documentation. It enables seamless integration of FastAPI services into MCP ecosystems using a native ASGI transport layer.
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables serving a remote Model Context Protocol endpoint by exposing tools, prompts, and resources with customizable authorization and hooks, allowing AI models to interact with your application through a standardized JSON-RPC interface.
    673 npm
    Business Source 1.1