trame-mcp
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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/mcpRelated MCP server: fastapi-mcp-inspect
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 |
|
|
Methods: inert markers, bound when the object is built |
|
|
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 |
| a tool, resource or prompt each |
App and component methods |
| the same, optionally prefixed |
Plain trame state |
|
|
trame-dataclass models |
|
|
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;
Annotatedadds a description (a string) and JSON Schema constraints (a dict).str,dictandlistresults become text (dicts also as structured content); returntrame_mcp.Image(png_bytes)to send an image.Functions may be sync or
async; all run on trame's event loop, insidewith state:, so what they assign reaches browsers (and@state.changereactions run) when they return.Calls run one at a time (
serialize_tools). A tool that only reads or waits (a status poll, a wait for a job) should say@mcp.tool(serialize=False): it then runs alongside the other calls instead of holding them back, a cancel included, for as long as it waits.A module that has no server at hand can mark its functions with
@toolinstead; the app then exposes them withmcp.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.
MCPMixindoes the same for an app that already derives from anotherTrameAppsubclass: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 (Syncwritable,ClientOnlyread-only; aServerOnlyfield needs an explicitread_only=), andschemaconstraints are enforced on every write.@toolopts a method in;@tool(read_only=True)keeps it callable on read-only instances, and@tool(serialize=False)letsdataclass_callrun it while other calls are in progress.A parameter annotated with a model class (
person: Person, alsoPerson | Noneandlist[Person]) takes the instance's id; trame-mcp passes the instance, after checking it is reachable and of that class. The same holds for@mcp.toolfunctions. A returned instance reaches the agent asdataclass_getshows it.@constructormarks the classmethod agents create instances with (see Several steps in one call).Class keywords set the options:
all_fields=True/all_tools=Trueopt in every field by its mode and every public method.fields=/tools=list names, or map them to the same options asmcp_field/@tool;{"hidden": True}excludes one.Subclasses inherit the options. Keywords on a subclass replace
all_fields/all_toolsand addfields/toolsentries.
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 theMCPStateDataModelclass keywords, once per class; a plain list works too (fields=["position", "focal_point"]). It also overrides the options of anMCPStateDataModelsubclass, 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=Trueallows reading only, plus@tool(read_only=True)methods.mcp.dataclass.unexpose_instance(name)withdraws an entry point.
The agent then works with five 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_batch([...]) # several steps in one calldataclass_update and dataclass_call return once the instance's
@watchers have run, so what they derive is in the agent's next read.
Several steps in one call
dataclass_batch(steps) runs a list of steps in order, in one call: one turn
of the tool lock and one state flush for the whole list. A step creates an
instance (new), calls a method (call) or updates fields (update), and a
later step refers to an earlier one's result by the name it gave with as:
class Person(MCPStateDataModel):
name = mcp_field(Sync(str, ""))
@constructor(description="A new person, in no address book yet")
@classmethod
def new(cls, server, name: str) -> "Person":
return cls(server, name=name)
class AddressBook(MCPStateDataModel):
contacts = mcp_field(Sync(list[Person], list, has_dataclass=True))
@tool
def add(self, person: Person) -> None: # takes the person's id
"""Add a person to the book."""
self.contacts = [*self.contacts, person]dataclass_batch([
{"new": "Person", "args": {"name": "Grace"}, "as": "grace"},
{"update": "@grace", "values": {"name": "Grace Hopper"}},
{"call": "address_book", "method": "add", "args": {"person": "@grace"}},
])
→ {"results": [{"id": "31", "type": "Person", "values": {}}, {"id": "31", "updated": ["name"]}, null],
"unattached": []}Compact results. An instance in the results shows its id and its references to other instances (as ids, what later calls link with), not its other fields, and only its id once an earlier step showed it: a batch that builds and adds a dozen models stays small.
results: "full"shows each instance asdataclass_getdoes.Constructors.
@constructormarks one classmethod per class (above or below@classmethod); trame-mcp calls it with the server first, which is not part of its schema, and subclasses inherit it.expose_model(cls, constructor="create")names one for a class you don't own,Falsehides one.dataclass_schemaslists each model's constructor.Built instances live for the batch. What a
newstep builds is reachable by the following steps only, until one attaches it somewhere an entry point reaches. One never attached is dropped and listed inunattached: nothing is added implicitly, and trame-mcp keeps no reference.References.
"@grace"is the result namedgrace;"@grace.ports.0"follows a field of an instance, a key of an object or an index of a list. Instances inargstravel by id."@@"writes a literal leading@.Checked first, no rollback. Step shapes, names and references are checked before any step runs. The batch stops at the first failing step, and the error names it, gives the reason and lists the results of the steps before it, which stay applied.
What the agent reads
Everything the agent knows about the app comes from what you declare:
On connection, the server sends instructions: your
mcp.instructionstext, 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_fielddescriptions 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(defaultFalse) 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(and--mcp-token,--mcp-log) override. Once it is running, every assignment applies.What the command line withholds stays withheld:
--no-mcpand--mcp-denyhold for the whole run, whatever the code or a UI switch assigns later.mcp.enabled_lockedandmcp.permissions.lockedtell a UI which switches to disable.
from trame.app import get_server
from trame_mcp import get_mcp
server = get_server()
mcp = get_mcp(server)
mcp.enabled = True # a default: --no-mcp keeps it off for the whole run
@server.state.change("agent_access") # e.g. bound to a checkbox in the UI
def on_agent_access(agent_access, **_):
mcp.enabled = agent_access
|
| |
HTTP endpoint | answers 503 | served |
HTTP with | no route | no route |
In-process | calls raise | 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.
Letting users withhold powers
Some tools hand an agent more than the app itself: a path anywhere on disk, code to run. Declare each such power as a permission and name it where it is needed; the people running the app can then withhold it, at launch or from your UI.
mcp.permissions.declare("files", "Read files and folders on this computer")
mcp.permissions.declare("scripts", "Run Python code the agent writes", implies="files")
class Viewer(MCPApp):
@tool(requires="files", read_only=True)
def list_files(self, path: str) -> list[str]: ...
class Node(MCPStateDataModel):
@constructor(requires="scripts")
@classmethod
def from_script(cls, server, script: str) -> "Node": ...
@tool
def set_executor(self, env_path: str = ""):
if env_path: # a need that depends on the arguments
get_mcp(self.server).permissions.require("files", what="set_executor(env_path)")requires=takes a name or a list, on@tool,@mcp.tooland@constructor, and inexpose_model()'stools=andconstructor=options.While a permission is denied, what needs it is left out of
tools/listanddataclass_schemas, and the instructions sent on connection say it is turned off. A client that listed the tools earlier keeps its list (the server cannot tell it the list changed), so calls are refused too, withPermissionDeniedError: the agent reads which permission is off and that only the people running the app can turn it on.A
dataclass_batchchecks its steps before running any, so a refused step leaves nothing half-done. Only a step whose target is known once earlier steps ran ("@w.nodes.0") is checked when it runs.impliessays a permission grants another anyway (a script can read any file): allowing it allows the ones it implies, and denying one denies the ones implying it. A UI reads the permissions back after a change.mcp.permissionsmaps each name to whether it is allowed:permissions["scripts"] = False,permissions.update(files=True),permissions.denied.--mcp-deny files,scripts(repeatable) orTRAME_MCP_DENYdeny permissions for the whole run, with those implying them:permissions.lockedlists them, and allowing one is ignored. A name the app does not declare refuses every agent call once the server runs: a typo must not leave a power allowed.
The call log shows a refused call as denied.
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 |
|
| agent access switch; |
| the trame server name | server name reported to MCP clients |
|
| your guidance for MCP clients, sent before the generated overview |
|
| bearer token required on the endpoint; |
|
| extra browser origins allowed to call the endpoint |
|
| mount the HTTP endpoint; |
|
| endpoint path on the trame web server |
|
| run tool calls one at a time (but |
|
| log what agents call: |
http and path are fixed once the server starts; the others can change at
any time.
Long calls and cancellation
A client that gives up on a call (its user interrupted it, or its own
timeout ran out) stops it: on notifications/cancelled, or when its HTTP
connection closes, the tool is cancelled at its next await and the tool
lock is released. Long waits are best kept short anyway, returning a status
the agent can poll, and marked serialize=False: clients cut off a call
that sends nothing back for a few minutes (Claude Code's default idle
timeout for HTTP servers is 5 minutes), and the server cannot send progress
over its plain JSON responses. A cancelled dataclass_batch stops between
steps; the ones that ran stay applied.
Seeing what agents call
--mcp-log (or mcp.log = "calls", any time) logs every tool call,
resource read and prompt when it ends, whether it came over HTTP or
in-process through LLMAdapter:
14:02:11 mcp[3f2aQx] dataclass_batch new TransformNodeModel as t; call pipeline.add_node -> ok 0.21s (2.1 KB)
14:02:41 mcp[3f2aQx] pipeline_status {wait: true, since: 12} -> ok 30.02s (1.4 KB, queued 0.3s)
14:02:42 mcp[3f2aQx] dataclass_call 6x.set_parameters {values: {n: 256}} -> error 0.01s: 'n' must be at most 128, got 256
14:02:43 mcp[in-process] screenshot -> ok 0.40s (image, 84.2 KB)The caller is the start of the client's
Mcp-Session-Id(-without one), orin-process.Arguments are shortened (long strings become
<str 2134 chars>); the genericdataclass_*tools summarize themselves (a batch lists its steps, a call readstarget.method).queuedis the time spent waiting for the tool lock behind other calls.--mcp-log all(mcp.log = "all") also logs each call as it starts, with its full arguments and the request's_meta, and every other protocol message (initialize, the lists, notifications).
The lines go to the trame_mcp.calls logger. Turning the log on gives it a
stderr handler unless the application has given it one of its own, so it
prints without any logging setup; add a handler to send the lines elsewhere.
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_TOKENrequire 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 declaredmcp.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.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
Enable secure connectivity between Sentry issues and debugging data, and LLM clients, using a Model Context Protocol (MCP) server.
Model Context Protocol server for the Apideck Unified API. Connect any MCP-compatible agent framework to 100+ accounting systems, HRIS platforms, file storage providers, and more through one integration. More information https://www.apideck.com/mcp-server
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
MCP server for secureFlows: token-free URL builders and integration-linting tools for AI agents.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceSets up a Model Context Protocol (MCP) server with FastMCP, allowing LLMs to use prompts, tools, and resources.-
- AlicenseNot gradedqualityBmaintenanceMounts an MCP server onto FastAPI to let AI agents inspect routes, endpoints, and schemas at runtime.47 PyPIMIT

@absolutejs/mcpofficial
AlicenseNot gradedqualityBmaintenanceEnables 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.772 npmBusiness Source 1.1- AlicenseNot gradedqualityCmaintenanceExposes a DeepSeek Harness instance — either the whole runtime or one session chosen in the UI — as an MCP Streamable HTTP endpoint on the Harness Web server's origin. Lets other MCP-speaking agents such as Codex or Claude Code delegate tasks, list and read session transcripts, and cancel or inspect running turns using the Harness's own model, tools, workspace and sandbox policy.MIT