From d3a37e59eec74c82d0de1c116ec9ad91c3e2d5ee Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Thu, 5 Nov 2026 14:28:03 -0500 Subject: [PATCH] feat(mcp): let agents (MCP) use trame apps Re-export agent access from trame-common and trame-server, and document it. - trame.decorators: mcp, the decorators module (@mcp.method, @mcp.property, @mcp.tool, @mcp.resource, @mcp.prompt, @mcp.constructor) - trame.app.mcp: MCP, HTTPTransport, MCPClient, MCPExposable, Progress, Image, the error types, and claude_command / codex_command - docs: a guide page (Agents > MCP), and the API reference of trame.app.mcp and of the decorators - examples/10_mcp: a quick start, a class-based app, an address book of models, permissions and naming objects, plus examples/validation/mcp/wasm_quadrants.py Requires trame-server 4.2 and trame-common 1.3.0. --- docs/api/README.md | 4 +- docs/api/requirements.txt | 4 +- docs/api/source/decorators.mcp.rst | 6 + docs/api/source/index.rst | 1 + docs/vitepress/.vitepress/config.mjs | 4 + docs/vitepress/guide/mcp/index.md | 909 ++++++++++++++++++++++ examples/10_mcp/00_quickstart.py | 49 ++ examples/10_mcp/01_class_based.py | 96 +++ examples/10_mcp/02_address_book.py | 100 +++ examples/10_mcp/03_permissions.py | 161 ++++ examples/10_mcp/04_exposure.py | 160 ++++ examples/validation/mcp/wasm_quadrants.py | 799 +++++++++++++++++++ pyproject.toml | 4 +- src/trame/app/mcp.py | 31 + src/trame/decorators.py | 2 + tests/test_mcp.py | 33 + 16 files changed, 2357 insertions(+), 6 deletions(-) create mode 100644 docs/api/source/decorators.mcp.rst create mode 100644 docs/vitepress/guide/mcp/index.md create mode 100644 examples/10_mcp/00_quickstart.py create mode 100644 examples/10_mcp/01_class_based.py create mode 100644 examples/10_mcp/02_address_book.py create mode 100644 examples/10_mcp/03_permissions.py create mode 100644 examples/10_mcp/04_exposure.py create mode 100644 examples/validation/mcp/wasm_quadrants.py create mode 100644 src/trame/app/mcp.py create mode 100644 tests/test_mcp.py diff --git a/docs/api/README.md b/docs/api/README.md index 72818b604..b75d81a5c 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,11 +1,11 @@ -To build the docs, first install the requirements in the `docs/` +To build the docs, first install the requirements in the `docs/api/` directory via: ``` pip install -r requirements.txt ``` -Then, run the bash script in the `docs/` directory: +Then, run the bash script in the `docs/api/` directory: ``` ./local_build.sh diff --git a/docs/api/requirements.txt b/docs/api/requirements.txt index 8f63f65b8..e8bd6e9d6 100644 --- a/docs/api/requirements.txt +++ b/docs/api/requirements.txt @@ -8,7 +8,7 @@ sphinx_rtd_theme # core trame-client>=3.13.2 -trame-server>=3.12.5 +trame-server>=4.2 # Vue 2 trame-grid-layout @@ -32,7 +32,7 @@ trame-simput trame-components trame-matplotlib trame-flow -trame-dataclass +trame-dataclass>=2.3 # Vue 3 trame-formkit diff --git a/docs/api/source/decorators.mcp.rst b/docs/api/source/decorators.mcp.rst new file mode 100644 index 000000000..16b086d6d --- /dev/null +++ b/docs/api/source/decorators.mcp.rst @@ -0,0 +1,6 @@ +decorators.mcp +============== + +.. automodule:: trame_common.decorators.mcp + :members: + :show-inheritance: diff --git a/docs/api/source/index.rst b/docs/api/source/index.rst index 97fcd2649..1e967f44f 100644 --- a/docs/api/source/index.rst +++ b/docs/api/source/index.rst @@ -16,6 +16,7 @@ Welcome to Trame's documentation! trame.widgets trame.ui trame.decorators + decorators.mcp trame.assets trame.env diff --git a/docs/vitepress/.vitepress/config.mjs b/docs/vitepress/.vitepress/config.mjs index bed840258..dab83195b 100644 --- a/docs/vitepress/.vitepress/config.mjs +++ b/docs/vitepress/.vitepress/config.mjs @@ -146,6 +146,10 @@ export default defineConfig({ { text: "ParaView", link: "/guide/tutorial/paraview" }, ], }, + { + text: "Agents", + items: [{ text: "MCP", link: "/guide/mcp/" }], + }, { text: "Jupyter", items: [ diff --git a/docs/vitepress/guide/mcp/index.md b/docs/vitepress/guide/mcp/index.md new file mode 100644 index 000000000..3825e1922 --- /dev/null +++ b/docs/vitepress/guide/mcp/index.md @@ -0,0 +1,909 @@ +# Agents (MCP) + +AI agents such as Claude Code or Codex can drive a Trame application through +the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). While an +agent works, users see its changes appear in their browsers in real time. + +As the developer, you decide what an agent is allowed to do in the +application. Trame serves these capabilities from the application's existing +web server, under the path `/mcp`, so no extra process is needed. An agent +running inside the application's own process can use the same capabilities. +Declaring them doesn't turn agent access on: agents can use the application +only after you enable access, in code or with the `--mcp` flag at launch. + +## Quick start + +Below is a minimal Trame application, written with the modern `TrameApp` and +`TrameComponent` classes. A few extra lines of code define precisely what an +agent can do. + +```python +from typing import Annotated + +from trame.app import TrameApp, TrameComponent +from trame.decorators import mcp # [!code highlight] +from trame.ui.html import DivLayout +from trame.widgets import html + + +class Cone(TrameComponent): + """A cone in the scene.""" + + def __init__(self, server, name): + super().__init__(server) + self.resolution_key = f"{name}_resolution" # the state key the page shows + self.state[self.resolution_key] = 6 + + @mcp.property # [!code highlight] + def resolution(self) -> int: + """Number of facets.""" + return self.state[self.resolution_key] + + @mcp.method # [!code highlight] + def set_resolution( + self, resolution: Annotated[int, {"minimum": 3, "maximum": 60}] + ) -> str: + """Set the number of facets of this cone.""" + self.state[self.resolution_key] = resolution + return f"Resolution set to {resolution}" + + +class ConeApp(TrameApp): + def __init__(self, server=None): + super().__init__(server) + self.left_cone = Cone(self.server, "left_cone") + self.right_cone = Cone(self.server, "right_cone") + # The names agents use, and which cone each one is + self.left_cone.mcp_expose("left_cone", description="The cone on the left.") # [!code highlight] + self.right_cone.mcp_expose("right_cone", description="The cone on the right.") # [!code highlight] + with DivLayout(self.server): + html.H1("{{ left_cone_resolution }} and {{ right_cone_resolution }} facets") + + @mcp.tool # [!code highlight] + def reset_resolution(self) -> str: + """Put both cones back to 6 facets.""" + self.left_cone.set_resolution(6) + self.right_cone.set_resolution(6) + return "Both cones reset" + + +if __name__ == "__main__": + app = ConeApp() + app.mcp.enabled = True # serve agents (or launch with --mcp) [!code highlight] + app.server.start() +``` + +An agent interacts with an application by calling tools. The three decorators +in this example give it access in two different ways. + +**`@mcp.tool` turns a method into a top-level tool.** The agent finds +`reset_resolution` in its list of tools, next to the other tools it has, and +can call it directly: + +``` +reset_resolution() +``` + +A tool has a single, fixed name, so it cannot tell two instances of a class +apart. For that reason, `@mcp.tool` should only decorate methods of classes +that the application creates once, such as the application class itself. + +**`@mcp.method` and `@mcp.property`, on the other hand, belong to each +instance.** The example above has two `Cone` components, and each one has its +own `set_resolution` method and `resolution` property. The agent needs a way to +tell the two cones apart, which is what `mcp_expose()` is for: it gives the +agent access to each cone under its own name. + +`@mcp.method` and `@mcp.property` don't add top-level tools to the MCP server. +Instead, the agent reaches the properties and methods of each instance through +a few tools built into Trame. Their first argument is the name the object was +given with `mcp_expose`: + +``` +object_call("left_cone", "set_resolution", {"resolution": 12}) # calls left_cone.set_resolution(12) +object_get("right_cone") # reads the properties of right_cone +``` + +The agent learns what each tool, method and property does from your +docstrings and type hints. The `Annotated[int, {"minimum": 3, "maximum": 60}]` +hint also limits the values it accepts, so a call with `{"resolution": 2}` is +rejected before `set_resolution` runs. Any change the agent makes appears in +the browser immediately. + +Declaring what agents may use doesn't serve anything to them yet: agent access +must also be turned on, which this example does in its `__main__` block. You +can also turn it on at launch, with the `--mcp` command-line flag. + +### Plain functions and state + +Simpler Trame applications may not use `TrameApp` and `TrameComponent` at all. +In such applications, you can still give agents access to state variables and +plain functions: + +```python +from trame.app import get_server + +server = get_server() + +server.state.resolution = 6 +server.state.mcp_expose( # [!code highlight] + "resolution", type=int, schema={"minimum": 3, "maximum": 60}, + description="Number of facets of the cone", +) + + +@server.mcp.tool # [!code highlight] +def reset_resolution() -> str: + """Reset the resolution to its default value.""" + server.state.resolution = 6 + return "Resolution reset to 6" + + +server.mcp.enabled = True # [!code highlight] +server.start() +``` + +Here the agent calls `reset_resolution` as a tool. The `resolution` state +variable appears to the agent as a property of a built-in object named +`"state"`. The agent reads it with `object_get("state")` and changes it with +`object_update("state", {"resolution": 12})`, and values outside the schema +are rejected. + +## Connecting an agent + +When agent access is on, agents in other processes connect over HTTP, the +default transport. The endpoint is the application's own address followed by +`/mcp`, which by default is `http://localhost:8080/mcp`. This command starts a +Claude Code session connected to it: + +```bash +claude --mcp-config '{"mcpServers":{"trame":{"type":"http","url":"http://localhost:8080/mcp"}}}' +``` + +The equivalent command for Codex is: + +```bash +codex -c 'mcp_servers.trame.url="http://localhost:8080/mcp"' +``` + +Both commands add the application to that session only, alongside any MCP +servers you have already configured. You don't need to remember them, +because the application prints both, with the correct port and name, as soon +as agents can connect: when access is on and HTTP is among the transports. + +If the application listens on addresses beyond this machine, for example with +`--host 0.0.0.0`, the endpoint requires a bearer token. The application then +generates a token and includes it in the commands it prints, unless you set +your own with `--mcp-http-token`, `TRAME_MCP_HTTP_TOKEN` or `server.mcp.http.token`. A token +you set appears in the commands as ``. + +### From inside the application + +An agent can also run inside the application's own process. An example is an +assistant panel that calls a language model with the user's own API key. Such +an agent needs no endpoint. `server.mcp.client()` gives it the same view and the same +rules as an agent connected over HTTP: access still follows `enabled`, and +permissions still apply. + +```python +client = server.mcp.client("assistant") # the name shown in the call log + +client.instructions # what agents receive when they connect +tools = await client.list_tools() # each with name, description, inputSchema +result = await client.call_tool("object_list") +result["content"] # what to hand back to the model +result.get("isError") # True when the tool failed + +def show(value, total, message): ... # e.g. update a progress bar +await client.call_tool("object_call", {...}, on_progress=show) +``` + +A tool that fails still returns a result, with `isError` set, and the agent +hands it back to its model like any other result. A request the application +can't serve at all, such as a call to an unknown tool, raises `MCPError`. +Cancelling the task that awaits a call cancels the call. With `on_progress`, +the call receives the progress its method reports, as described in +[Reporting progress](#reporting-progress). + +To allow only agents inside the process, turn off the transports for agents +in other processes with `server.mcp.transports = []`. See +[Options, launch flags and security](#options-launch-flags-and-security) for +details. + +## What agents see + +This section looks at the quick start's cone application from the agent's +side. + +When it connects, the agent receives a short overview of the application: + +``` +A live trame app, shared with its users: they see your changes at once. +Objects: left_cone (Cone: The cone on the left.), right_cone (Cone: The cone on the right.); object_list() lists them, object_types() describes their types. +``` + +It also receives these tools: + +| Tool | Provided by | Purpose | +| --- | --- | --- | +| `reset_resolution()` | Your `@mcp.tool` | Puts both cones back to 6 facets | +| `object_list(offset?)` | Trame (built in) | Lists the objects your application named, with their types and descriptions | +| `object_types(type_names?, include_inherited?)` | Trame (built in) | Gives an overview of the objects' types. With `type_names`, it describes types such as `"Cone"`, or single members such as `"Cone.set_resolution"`, in detail | +| `object_get(target, properties?, depth?)` | Trame (built in) | Reads the properties of an object, and those of the objects it refers to, up to `depth` levels away | +| `object_update(target, values)` | Trame (built in) | Sets properties of an object. Every value is checked before any of them is applied | +| `object_call(target, method, args?)` | Trame (built in) | Calls a method of an object | +| `object_batch(steps, results?)` | Trame (built in) | Batches multiple object operations into a single tool call | + +The six `object_*` tools are built into Trame, and they are +the same in every application. + +Their `target` argument tells them which object to use. It can be the name +you gave the object with `mcp_expose`, such as `"left_cone"`, or the object's +id. Every object has an id, a short hex string such as `"0x1f"` that +identifies it uniquely. Replies include it, so in the session below +`left_cone` appears with the id `"0x1"`. Ids are also how agents refer to the +objects they find through the properties of other objects, which have no name. + +A typical session looks like this: + +```json +→ object_list() +← {"objects": {"left_cone": {"type": "Cone", "description": "The cone on the left."}, + "right_cone": {"type": "Cone", "description": "The cone on the right."}}} + +→ object_types() +← {"types": {"Cone": {"about": "A cone in the scene.", + "properties": ["resolution"], "methods": ["set_resolution"]}}} + +→ object_types(["Cone"]) +← {"types": {"Cone": { + "description": "A cone in the scene.", + "properties": {"resolution": {"type": "integer", "description": "Number of facets.", + "read_only": true}}, + "methods": {"set_resolution": { + "description": "Set the number of facets of this cone.", + "parameters": {"type": "object", + "properties": {"resolution": {"type": "integer", "minimum": 3, "maximum": 60}}, + "required": ["resolution"], "additionalProperties": false}, + "returns": {"type": "string"}}}}}} + +→ object_get("left_cone") +← {"result": {"id": "0x1"}, + "objects": {"0x1": {"type": "Cone", "names": ["left_cone"], "values": {"resolution": 6}}}} + +→ object_call("left_cone", "set_resolution", {"resolution": 12}) +← Resolution set to 12 + +→ object_call("right_cone", "set_resolution", {"resolution": 2}) +← (error) Invalid arguments for Cone.set_resolution: args.resolution: must be at least 3, got 2 + +→ object_update("left_cone", {"resolution": 20}) +← (error) Nothing was applied. Rejected on Cone 0x1: resolution (read-only). Writable: none. + Methods: set_resolution; object_types(["Cone"]) says what they do. + +→ reset_resolution() +← Both cones reset +``` + +A few things are worth noticing in this exchange. + +- **The descriptions and schemas come from your code.** They are built from + your docstrings and type hints, and from any + `Annotated[T, "description", {json-schema}]` hints you add. Trame checks + every value an agent sends against them before your code runs, which is why + the call with a resolution of 2 never reached the cone. +- **`object_get` returns two parts.** `result` identifies what was read, and + `objects` contains each object that was read, keyed by its id. When an + object refers to other objects, it shows each reference as `{"id"}` if the + other object is included in `objects`, and as `{"id", "type"}` if it is not. + The `depth` argument controls how far the reading goes. +- **Methods return their values as they are.** A string reaches the agent as + text, and other values as JSON, so `None` arrives as `null`. When the value + contains objects, it is formatted like an `object_get` reply. Your own tools + behave the same way. A method that changes something is more useful to the + agent when it returns a short description of the change, as + `set_resolution` does. +- **Errors tell the agent how to proceed.** For example, the rejected update + of the read-only `resolution` property points the agent to + `set_resolution`. +- **Users see every change immediately.** Trame runs each agent call inside a + `with state:` block, so your `@state.change` callbacks run and the connected + browsers update. +- **Replies are kept short.** The built-in tools shorten long lists and + strings unless the agent asks for them specifically, and keep each reply + under `server.mcp.max_response_tokens`. Anything left out is listed under `more`, + along with the call that retrieves it when there is one. + +## Marking methods and properties + +This section describes the decorators and their options in more detail. + +```python +from typing import Annotated + +from trame.app import TrameComponent +from trame.decorators import mcp + + +class Dataset(TrameComponent): + @mcp.method(annotations={"destructiveHint": True}) + def clear(self) -> None: + """Unload the data.""" + + @mcp.method(read_only=True, concurrent=True) + async def center(self) -> Annotated[list[float], "[x, y, z]"]: ... + + @mcp.property(requires="files") + def data_path(self) -> Annotated[str, "File the data is read from"]: + return self._path + + @data_path.setter(requires="scripts") + def data_path(self, value: str): + self.load(value) +``` + +`@mcp.method` accepts these options: + +- `name` sets the name agents call the method by. It defaults to the method's + own name. +- `description` sets the text agents read. It defaults to the docstring, with + its wrapped lines joined. +- `read_only`: with `True`, the method declares that it changes nothing. + Agents can call read-only methods even on objects they are only allowed to + read (see [Naming objects](#naming-objects-and-read-only-access)). +- `annotations` takes MCP tool annotations, such as + `{"destructiveHint": True}`. Agents see them when they describe the type. +- `concurrent`: with `True`, the method may run while other agent calls are in + progress. By default, agent calls run one at a time. +- `requires` lists the permissions the method needs (see + [Permissions](#permissions)). + +Methods can be async. Their parameters are described by their type hints. When +a parameter's type is a component or model class, the agent passes the name or +id of such an object, and your method receives the object itself. + +`@mcp.property` accepts these options: + +- `description` sets the text agents read. It defaults to the text in the + getter's return type, as in the example, or else to the getter's docstring. +- `requires` lists the permissions the property needs, both to read and to + write it. + +Its setter accepts one option: + +- `requires` lists the permissions that only writing needs. In the example, + agents need `files` to read `data_path`, and both `files` and `scripts` to + change it. + +Without a setter, agents can read the property but cannot assign it a new +value. That doesn't make the value itself read-only: when the property returns +an object, agents can still call that object's methods and set its +properties. What they can do with it depends on the name through which they +reached it (see [Naming objects](#naming-objects-and-read-only-access)). + +These decorators describe what agents may do with the instances of a class, +but they don't give agents access to any instance. Agents reach an instance +once your application names it with `mcp_expose`, or through a property of an +object they can already reach (see +[Naming objects](#naming-objects-and-read-only-access)). Models from +trame-dataclass follow the same rule. + +### Class options + +Instead of decorating each member, a class can list what agents see in its +`mcp=` keyword. A subclass can use it to change what it inherits: + +```python +class Viewport(TrameComponent, mcp={"description": "A 3D view, as the user sees it.", + "methods": ["reset_camera", "set_view_up"]}): + def reset_camera(self): ... + def set_view_up(self, axis: str): ... + + +class LockedViewport(Viewport, mcp={"methods": {"set_view_up": False}}): ... # one less +class OffscreenViewport(Viewport, mcp=False): ... # never exposed +``` + +The keyword takes a dictionary of options, or `False`, which hides the class. +`True` makes the class visible to agents without other options, and on a +model it also shows all of its fields. The options are: + +- `description` sets the text agents read about the class. It defaults to the + class docstring. +- `methods` and `properties` accept a list of names, or a dictionary that maps + each name to `True`, `False`, a description, or a dictionary of options such + as `requires`. `False` hides a member, even one that a base class exposes. +- `"all_methods": True` exposes every public method, except Trame's own API + and the callbacks registered with `@change`, `@trigger`, `@controller` or + `@watch`. +- `constructor` lets agents create instances of the class (see + [Creating objects](#creating-objects)). +- Models also accept `all_fields` (see + [trame-dataclass models](#trame-dataclass-models)). + +A class with options exposes only the members its options list, plus those +marked with decorators. Options are combined from the base classes down, so +subclasses inherit them, except `description`, which each class sets for +itself. + +Agents see a subclass in terms of its base class. `object_types` names the +base under `"extends"`, lists what the subclass adds, and marks with `false` +what it removes. + +### Tools, resources and prompts + +Besides tools, MCP lets an application offer resources, which are documents +the agent can read, and prompts, which are predefined instructions that the +agent's user can invoke. A class that your application creates once, such as +the application itself or a panel of tools, can provide all three: + +```python +from trame.app.mcp import Image +from trame.decorators import mcp + + +class AgentTools(TrameComponent): + @mcp.tool(read_only=True) # same options as @mcp.method + def screenshot(self) -> Image: ... + + @mcp.resource("app://guide", mime_type="text/markdown") + def guide(self) -> str: ... + + @mcp.prompt + def review_session(self) -> str: ... +``` + +Trame registers them when the instance is created and removes them when it is +unbound. Creating a second instance while the first one is still bound raises +an error, because the names would collide. The instance needs no name for +them to work, and they don't give agents access to the instance itself. + +Without a class, the `@server.mcp.tool`, `@server.mcp.resource(uri)` and +`@server.mcp.prompt` decorators do the same for plain functions. + +### Reporting progress + +A method or tool that takes a while can tell the agent how far it has got. +Declare a parameter of type `Progress`, with `None` as its default, on an +`async` method: + +```python +import asyncio + +from trame.app.mcp import Progress + + +class Simulation(TrameComponent): + @mcp.method + async def run(self, steps: int, progress: Progress | None = None) -> str: + """Run the simulation.""" + job = self.solver.start(steps) # the long operation, started elsewhere + while not job.done: + if progress: + await progress(job.step, total=steps, message=f"Step {job.step}") + await asyncio.sleep(0.5) + return f"Ran {steps} steps" +``` + +The agent doesn't see the parameter. When it calls the method, Trame passes a +reporter in that parameter. If the agent asked for no progress, the reporter +does nothing. When your application calls the method itself, the parameter keeps its +default, `None`. + +Pass `progress` a number that increases from one call to the next, and +optionally a `total` and a `message`. When reports arrive faster than the +client can use them, Trame sends only the latest. + +In a call to `object_batch`, the agent receives a decimal progress value, and +the total is the number of steps. The integer part of the value is the current +step, counted from 0. The decimal part comes from the reports of that step's +method: + +- with a `total`, it is the fraction of the step done: 30 of 100 in the third + step gives 2.3; +- without a `total`, it is an arbitrary value that increases with each report, + such as 2.00000001, then 2.00000002. + +The parameter requires an `async` function, because reports are awaited; a +plain function declaring it is rejected. To report on blocking work, such as a +long VTK update, run that work with `asyncio.to_thread` and report while you +await it. + +## Naming objects and read-only access + +```python +class ConeApp(TrameApp): + def __init__(self, server=None): + super().__init__(server) + self.left_cone = Cone(self.server, "left_cone") + self.right_cone = Cone(self.server, "right_cone") + self.active = self.left_cone # the cone the user selected + self.left_cone.mcp_expose("left_cone", read_only=True) + self.right_cone.mcp_expose("right_cone", read_only=True) + self.mcp.expose_object( + lambda: self.active, + "active_cone", + description="The cone the user is working on.", + ) +``` + +`obj.mcp_expose(name)` gives an object a name, and an object can have several +names. Each name refers to a single object. Exposing a name that already +refers to a different object raises an error, so that two components cannot +take the same name by accident. To reuse a name, withdraw it first with +`self.mcp.unexpose_object(name)`. `obj.mcp_unexpose()` withdraws all of an +object's names at once, and unbinding a component does this automatically. +Even then, agents can still reach the object through a property of another +object they reach, if one refers to it. + +Names go through the server's translator, as state keys do. A component that +your application builds on several child servers can therefore use the same +name in each one. On a child server created with +`server.create_child_server(prefix="left_")`, `mcp_expose("cone")` gives the +name `left_cone`, and that is the name agents see. + +The `description` option tells agents what the object is for in your +application. The class docstring already says what a `Cone` is. The name's +description says which cone this one is, and why an agent would use it. +Agents read it next to the name, in the list of objects that +`object_list()` returns. The instructions they receive when they connect +include its first sentence. + +`self.mcp.expose_object(source, name)` takes the same options. Its source can +be an object, or a function that returns an object, or `None` when there is +nothing to expose. Trame calls the function again on every agent request, so +`lambda: self.active` always refers to the cone that is currently active, +and you never have to expose it again when it changes. To let agents reach a +list of objects, give each one its own name, as above, or return the list from +an `@mcp.property` of an object they can already reach. + +With `read_only=True`, agents can read the object and call its read-only +methods, but they cannot call its other methods or change its properties. +This holds whether the agent targets the object by that name or by its id, +and it also covers the objects agents reach only through it. An object that +agents can also reach another way, under a writable name or through a +property of a writable object, stays writable. A name whose function returns +the object counts as one of those ways. In the example above, both cones are +read-only under their own names, but the active one is also exposed as +`active_cone`, so it is the only one agents can change. + +## trame-dataclass models + +Models from trame-dataclass work like components: you mark their methods with +`@mcp.method`, name them with `mcp_expose`, and agents reach them by the same +rules. The difference is that fields take the place of properties. + +```python +from trame.decorators import mcp +from trame_dataclass.v2 import ServerOnly, StateDataModel, Sync + + +class Label(StateDataModel, mcp={"all_fields": True, "constructor": "default"}): + text = Sync(str, "") + position = Sync(list[float], lambda: [0.0, 0.0, 0.0], mcp="[x, y, z]") + + +class Scene(StateDataModel): # no keyword: only what is marked + labels = Sync(list[Label], list, has_dataclass=True, mcp=True) + path = ServerOnly(str, mcp={"read_only": True}) + + @mcp.method + def add_label(self, label: Label) -> None: + self.labels = [*self.labels, label] + + @mcp.constructor(description="An empty scene") + @classmethod + def create(cls, server) -> "Scene": + return cls(server) + + +scene = Scene(server) +scene.mcp_expose("scene") +``` + +### Fields + +The class decides which fields agents see: + +- With `mcp=True` on the class, or `"all_fields": True` in its + [class options](#class-options), agents see all of its fields except the + `ServerOnly` ones. `Label` does this. +- Otherwise, agents see only the fields you mark, as in `Scene`. + +A field's descriptor (`Sync`, `ClientOnly` or `ServerOnly`) decides what +agents can do with it: + +| Descriptor | Agents can | +| --- | --- | +| `Sync` | Read and write it | +| `ClientOnly` | Read it | +| `ServerOnly` | Nothing, unless you mark it. A marked `ServerOnly` field must state `read_only`, as `path` does | + +You mark a field with its `mcp=` argument, which accepts: + +- `True`, to show the field; +- `False`, to hide it, even when the class shows all of its fields; +- a string, to show it with that description, as `position` does; +- a dictionary with `description`, `read_only` and `schema`. `read_only` + overrides what the field's descriptor allows, and `schema` adds JSON Schema + keywords, such as `minimum`, that the values agents write must satisfy. + +### Reaching models + +Agents reach a model the way they reach a component: by its name, or by its id +when a visible property of an object they reach refers to it. In the example, +exposing `scene` is enough for agents to reach every label in `scene.labels`. +If `scene` is exposed with `read_only=True`, those labels are read-only too. + +To set a field that holds models, agents pass the ids of those models. + +## Creating objects + +Agents can create a component or a model only if its class gives them a +constructor, and only inside `object_batch`. A class can give agents a +constructor in one of two ways: + +- **The class's own constructor.** With `"constructor": "default"` in the class + options, as `Label` has, agents pass values for the fields they can write, + and Trame calls `Label(server, **values)`. A component has no fields, so + Trame calls `Cls(server)`, and agents can set its properties in a later + step. If `__init__` can't be called that way, Trame logs a warning and + agents get no constructor. +- **A classmethod.** Mark it with `@mcp.constructor`, as `Scene.create` is. It + receives the server as its first argument, and agents pass its other + parameters. For a class you can't decorate, name the classmethod in the + class options instead: `"constructor": "create"`. + +Subclasses inherit the constructor. To turn it off in a subclass, set +`"constructor": False`. + +Agents can only create objects of the types they already know about: the +types of the objects they can reach, and of the values those objects' +properties can hold. + +In a batch, a `new` step creates an object. Its `as` key names the result, and +later steps refer to it by that name with an `@` in front. This batch creates +a label and adds it to the scene, in a single call: + +``` +→ object_batch([{"new": "Label", "args": {"text": "Skull"}, "as": "skull"}, + {"call": "scene", "method": "add_label", "args": {"label": "@skull"}}]) +← {"results": [{"id": "0x2"}, null], "objects": {"0x2": {"type": "Label"}}} +``` + +The reply has one result per step, in order: the id of the new label, then +`null`, because `add_label` returns nothing. By default (`results="links"`), +each object in the reply shows only its references to other objects. With +`results="full"`, it shows all of its values. + +An object that the batch creates but never attaches to anything is listed +under `unattached` in the reply, and then discarded. + +## Plain state + +You can give agents access to state variables without writing a class: + +```python +server.state.mcp_expose("resolution", type=int, schema={"minimum": 3}, + description="Facets of the cone", read_only=False) +server.state.mcp_unexpose("resolution") +``` + +Agents see the exposed variables as properties of a built-in object named +`state`. A variable exposed through a child server appears under its full, +prefixed name. + +## Permissions + +The people running your application may not want an agent to have some +capabilities, such as reading files or running code. You can declare these +capabilities as permissions, which can then be turned off while the +application runs or at launch. + +First, declare each permission with a name and a description: + +```python +server.mcp.permissions.declare("files", "Read files on this computer") +server.mcp.permissions.declare("scripts", "Run Python code the agent writes", + requires="files", allowed=False) +``` + +The description says what the permission lets an agent do. Agents read it +when a call needs the permission while it is off. A permission starts +allowed, unless you declare it with `allowed=False`, as `scripts` is. Its +`requires` option is described [below](#permissions-that-require-others). + +Then, give each tool, method, property, property setter or constructor the +permissions it needs, with its own `requires=` option: + +```python +class Editor(TrameComponent): + @mcp.method(requires="files") + def open(self, path: str) -> str: ... +``` + +Sometimes only some calls need a permission, depending on their arguments. +In that case, leave `requires=` out and call `server.mcp.permissions.require()` +inside the method instead. While the permission is allowed, it does nothing. +While the permission is off, it raises an error that stops the call: + +```python +SAMPLES = {"skull", "engine"} + + +class Dataset(TrameComponent): + @mcp.method + def load(self, name: str) -> str: + """Load a bundled sample by name, or a file by its path.""" + if name not in SAMPLES: # a file on this computer + self.mcp.permissions.require("files", what=f"Loading {name}") + ... +``` + +With `files` off, agents can still load the samples, but loading a file fails. +The agent receives the error's message, which starts with the `what` option +(by default, "This call"): + +``` +Loading /data/head.vti: permission "files" (Read files on this computer) is off; only the people running the app can turn it on. +``` + +You can turn permissions on and off in code at any time, for example from a +switch in your UI. `server.mcp.permissions` works like a dictionary that maps each +permission's name to whether it is allowed: + +```python +server.mcp.permissions["scripts"] = True # allow it +server.mcp.permissions["scripts"] = False # deny it +server.mcp.permissions.update(files=True, scripts=True) # several at once +server.mcp.permissions["files"] # True while it is allowed +``` + +While a permission is off, anything that requires it disappears from the tool +list and from `object_types`, and calls to it are rejected. A property +whose setter requires it becomes read-only. An agent that listed its tools +before the permission was turned off keeps them in its list until it lists +them again, but its calls are rejected. The overview that agents receive when +they connect lists the permissions that are off. + +### Permissions that require others + +A permission can itself require other permissions, with `requires=` in +`declare()`. It is then allowed only while they are. Use this when one +permission grants another anyway. In the example, a script can read files, so +`scripts` requires `files`: + +- Denying `files` also denies `scripts`. +- Allowing `scripts` while `files` is denied raises an error. Allow both in a + single call instead, with `update(files=True, scripts=True)`. + +In your UI, disable the `scripts` switch while `files` is off. + +### Denying permissions at launch + +The `--mcp-deny` flag and the `TRAME_MCP_DENY` environment variable deny +permissions for the whole run, and neither code nor UI can turn them back on. +`server.mcp.permissions.locked` lists them, along with the permissions that require +them, so that your UI can disable the corresponding switches. + +## Classes you don't own + +Decorators and the `mcp=` keyword only work in classes you write. For a +component or model class from another library, call its `mcp_describe()` +method instead. It takes the same options as the +[`mcp=` keyword](#class-options): + +```python +from some_library import Camera + +Camera.mcp_describe(description="The camera of the 3D view.", + properties={"position": "[x, y, z]"}, methods=["reset"]) +``` + +- The options also apply to the class's subclasses. `Camera.mcp_describe(False)` + hides the class. +- These settings apply to the whole process, and only one `mcp_describe` call + per class may change them. +- Call `mcp_describe` before you expose an instance of the class. Exposing an + instance of a class that declares nothing for agents raises an error. An + instance created before the call can still be exposed after it. +- `Cls.mcp_describe()` without arguments returns the class's current options, + and `server.mcp.describe(Cls)` returns what agents see of it, which is + useful in tests. + +## Options, launch flags and security + +### Options + +`server.mcp.configure(...)` sets several options at once, and each option is also an +attribute of `server.mcp`. A `TrameApp` can turn agent access on when it's built, with `super().__init__(server, mcp_enabled=True)`, which +does the same as setting `enabled`. + +| Option | Default | Description | +| --- | --- | --- | +| `enabled` | `False` | Turns agent access on or off, whichever way agents connect. It can change at any time | +| `transports` | `("http",)` | How agents outside the application's process connect. It can change at any time | +| `name` | the server's name | The name reported to agents and used in the connection commands | +| `instructions` | `None` | Text sent to agents before the generated overview | +| `concurrent_calls` | `False` | Lets every agent call run while others are in progress, instead of one at a time | +| `structured_content` | `False` | Also sends dictionary results as `structuredContent` | +| `max_response_tokens` | `8000` | The maximum size, in tokens, of a reply from the built-in tools | + +A *transport* is the way an agent running in another process reaches the +application. The only transport today is HTTP: an endpoint on the same port as +the application, which is the default. With `transports=[]`, no transport is +served, and only agents running inside the application's own process can use +the application, through [`server.mcp.client()`](#from-inside-the-application). `enabled` turns +their access on and off as well. Like `enabled`, `transports` can change while +the application runs, so a switch in its interface can let agents in other +processes in and out. While HTTP is left out, its endpoint answers that it is +off. + +The HTTP transport has its own options on `server.mcp.http`, set the same way: +`server.mcp.http.configure(...)` sets several at once, and each one is also an +attribute. + +| Option | Default | Description | +| --- | --- | --- | +| `token` | `None` | The bearer token agents must send. One is generated when the application listens beyond this machine | +| `allowed_origins` | `()` | Additional browser origins allowed to call the endpoint | +| `path` | `"/mcp"` | The path of the endpoint. It cannot change once the server has started | +| `url` | | The endpoint's URL, as agents on this machine reach it (read-only) | + +### Launch flags + +The people who run your application can set some of these options when they +launch it, without changing its code. Some flags can also be given as +environment variables: + +```bash +python app.py --mcp --mcp-deny scripts +``` + +| Flag | Environment variable | Description | +| --- | --- | --- | +| `--mcp`, `--no-mcp` | | Turns agent access on or off. `--no-mcp` holds for the whole run: no transport is served, and agents inside the process are refused too | +| `--mcp-transport T[,T]` | | Chooses the transports to start with, overriding the application's choice: `http`, or `none` to start with agents inside the process only. It doesn't turn access on | +| `--no-mcp-transport` | | Serves no transport for the whole run: only agents inside the process can use the application | +| `--mcp-http-token TOKEN` | `TRAME_MCP_HTTP_TOKEN` | Sets the HTTP transport's bearer token | +| `--mcp-log` | `TRAME_MCP_LOG` | Logs one line per agent call, at INFO on the `trame_server.mcp` loggers | +| `--mcp-deny P[,P]` | `TRAME_MCP_DENY` | Denies permissions for the whole run; the application can't allow them again | + +Until the server starts, launch flags take precedence over the values set in +code. After that, the application's own changes apply, except that what +`--no-mcp`, `--no-mcp-transport` and `--mcp-deny` withhold stays withheld for +the whole run. For example, `--mcp --mcp-transport none` turns access on for +an assistant inside the application and keeps agents in other processes out +until the application lets them in, while `--mcp --no-mcp-transport` keeps +them out for good. `server.mcp.enabled_locked` and `server.mcp.transports_locked` tell the +application's interface when its switches have no effect. + +As elsewhere in Trame, `--mcp-log` only sets a logging level, so the lines +appear once your application configures logging, for example with +`logging.basicConfig(level=logging.INFO)`. Each line names the agent's +session, what it called with its arguments shortened, how the call ended and +how long it took. + +### Security + +The HTTP endpoint checks the `Origin` header and rejects requests coming from web +pages on other sites. Without a token, it also rejects requests addressed to +any host name other than a loopback address (`localhost`, `127.0.0.1` or +`::1`). It is served by aiohttp, Trame's default web server. + +## Examples + +The [`examples/10_mcp`](https://github.com/Kitware/trame/tree/master/examples/10_mcp) +directory contains complete applications: + +| Example | What it shows | +| --- | --- | +| `00_quickstart.py` | The quick start without classes: one state variable and one tool | +| `01_class_based.py` | The quick start's two cones, with sliders and a switch that turns agent access on and off | +| `02_address_book.py` | trame-dataclass models that agents create and attach in a batch | +| `03_permissions.py` | Permissions on a tool, methods and a property setter, switched from the UI or with `--mcp-deny`, and a method marked destructive | +| `04_exposure.py` | A class from another library, a subclass that hides a method, read-only names, a name that follows the selection and has a description, a resource and a prompt | + +[`examples/validation/mcp/wasm_quadrants.py`](https://github.com/Kitware/trame/tree/master/examples/validation/mcp) +is a larger stress test, with a grid of VTK WASM scenes and models nested +three levels deep. diff --git a/examples/10_mcp/00_quickstart.py b/examples/10_mcp/00_quickstart.py new file mode 100644 index 000000000..fb636b855 --- /dev/null +++ b/examples/10_mcp/00_quickstart.py @@ -0,0 +1,49 @@ +"""Quick start: let agents drive a trame app through MCP. + +Run: python examples/10_mcp/00_quickstart.py --server +Then use one of the commands the console prints to start a Claude Code or +Codex session connected to the app. + +The app exposes one state key and one tool, then turns agent access on. +Launch with --no-mcp to keep it off. +""" + +from trame.app import get_server +from trame.ui.html import DivLayout +from trame.widgets import html + +server = get_server("quickstart") +state = server.state + +state.resolution = 6 + +# Agents read and write it as a property of the "state" object +state.mcp_expose( + "resolution", + type=int, + schema={"minimum": 3, "maximum": 60}, # checked on every agent write + description="Number of facets of the cone", +) + + +@server.mcp.tool +def reset_resolution() -> str: + """Reset the resolution to its default value (6).""" + state.resolution = 6 + return "Resolution reset to 6" + + +server.mcp.enabled = True # serve what is declared above (--no-mcp overrides) + +with DivLayout(server): + html.H1("Resolution: {{ resolution }}") + html.Input( + type="range", + min=3, + max=60, + step=1, + v_model_number=("resolution",), + ) + +if __name__ == "__main__": + server.start() diff --git a/examples/10_mcp/01_class_based.py b/examples/10_mcp/01_class_based.py new file mode 100644 index 000000000..2413788bb --- /dev/null +++ b/examples/10_mcp/01_class_based.py @@ -0,0 +1,96 @@ +"""Class-based app: a tool of the app's own, and methods called on one instance. + +Run: python examples/10_mcp/01_class_based.py --server # agent access off + python examples/10_mcp/01_class_based.py --server --mcp # agent access on + +The app shows two cones. It marks reset_resolution() with @mcp.tool: the app +is built once, so it is a tool of its own, which agents call directly. Each Cone +marks set_resolution() with @mcp.method and resolution with @mcp.property: +there are two cones, so agents say which one, by the name the app gave it. + +An agent can then: + reset_resolution() + object_get("left_cone") + object_call("left_cone", "set_resolution", {"resolution": 12}) + +Everything is declared either way; nothing is served until agent access is +on. The "Allow agent access" checkbox toggles `server.mcp.enabled` at runtime. +""" + +from typing import Annotated + +from trame.app import TrameApp, TrameComponent +from trame.decorators import change, mcp +from trame.ui.html import DivLayout +from trame.widgets import html + + +class Cone(TrameComponent): + """A cone in the scene.""" + + def __init__(self, server, name): + super().__init__(server) + self.resolution_key = f"{name}_resolution" # the state key the page shows + self.state[self.resolution_key] = 6 + + @mcp.property + def resolution(self) -> int: + """Number of facets.""" + return self.state[self.resolution_key] + + @mcp.method + def set_resolution( + self, resolution: Annotated[int, {"minimum": 3, "maximum": 60}] + ) -> str: + """Set the number of facets of this cone.""" + self.state[self.resolution_key] = resolution + return f"Resolution set to {resolution}" + + +class ConeApp(TrameApp): + def __init__(self, server=None): + super().__init__(server) + self.left_cone = Cone(self.server, "left_cone") + self.right_cone = Cone(self.server, "right_cone") + # The names agents use, and which cone each one is + self.left_cone.mcp_expose("left_cone", description="The cone on the left.") + self.right_cone.mcp_expose("right_cone", description="The cone on the right.") + + self.state.agent_access = self.mcp.enabled + self._build_ui() + + @mcp.tool + def reset_resolution(self) -> str: + """Put both cones back to 6 facets.""" + self.left_cone.set_resolution(6) + self.right_cone.set_resolution(6) + return "Both cones reset" + + @change("agent_access") + def _on_agent_access(self, agent_access, **_): + self.mcp.enabled = agent_access + + def _build_ui(self): + with DivLayout(self.server): + with html.Label(): + html.Input(type="checkbox", v_model=("agent_access",)) + html.Span("Allow agent access") + for cone in (self.left_cone, self.right_cone): + with html.Div(): + html.Input( + type="range", + min=3, + max=60, + step=1, + v_model_number=(cone.resolution_key,), + ) + html.Span(f"{{{{ {cone.resolution_key} }}}} facets") + + +def main(): + app = ConeApp() + app.server.start() + + +if __name__ == "__main__": + main() diff --git a/examples/10_mcp/02_address_book.py b/examples/10_mcp/02_address_book.py new file mode 100644 index 000000000..1644999c6 --- /dev/null +++ b/examples/10_mcp/02_address_book.py @@ -0,0 +1,100 @@ +"""An address book of trame-dataclass models, driven by agents. + +Run: python examples/10_mcp/02_address_book.py --server +Deps: pip install trame-dataclass + +An agent can then: + object_types() + object_get("address_book") + object_call("address_book", "add_contact", {"name": "Grace", "age": 36}) + object_update("", {"age": 37}) + object_batch([ + {"new": "Person", "args": {"name": "Alan", "age": 41}, "as": "alan"}, + {"call": "address_book", "method": "add", "args": {"person": "@alan"}}, + ]) +""" + +from trame.app import TrameApp +from trame.decorators import mcp +from trame.ui.html import DivLayout +from trame.widgets import html +from trame_dataclass.v2 import ServerOnly, StateDataModel, Sync + + +class Person(StateDataModel): + """A contact in the address book.""" + + name = Sync(str, "", mcp={"description": "Full name", "schema": {"minLength": 1}}) + age = Sync( + int, + 0, + mcp={"description": "Age in years", "schema": {"minimum": 0, "maximum": 150}}, + ) + notes = ServerOnly(str) # not marked: agents never see it + + @mcp.constructor(description="A new person, in no address book yet") + @classmethod + def new(cls, server, name: str, age: int = 0) -> "Person": + return cls(server, name=name, age=age) + + @mcp.method + def celebrate_birthday(self) -> str: + """Increment this person's age by one year.""" + self.age += 1 + return f"{self.name} is now {self.age}" + + @mcp.method(read_only=True) + def summary(self) -> str: + """One-line description of this contact.""" + return f"{self.name}, {self.age}" + + +class AddressBook(StateDataModel, mcp=True): # every field, by mode + title = Sync(str, "My contacts", mcp="Title shown above the list") + contacts = Sync(list[Person], list, has_dataclass=True) + + @mcp.method + def add_contact(self, name: str, age: int = 0) -> Person: + """Create a new contact and add it to the book.""" + person = Person(self.server, name=name, age=age) + self.contacts = [*self.contacts, person] + return person + + @mcp.method + def add(self, person: Person) -> None: + """Add an existing person to the book.""" + self.contacts = [*self.contacts, person] + + @mcp.method + def remove(self, person: Person) -> None: + """Remove a contact from the book.""" + self.contacts = [p for p in self.contacts if p is not person] + + +class ContactsApp(TrameApp): + def __init__(self, server=None): + super().__init__(server) + self.mcp.configure(name="address-book", enabled=True) + self.book = AddressBook(self.server) + self.book.contacts = [Person(self.server, name="Ada", age=36)] + self.book.mcp_expose("address_book") + self._build_ui() + + def _build_ui(self): + with DivLayout(self.server): + with self.book.provide_as("book"): + html.H1("{{ book.title }}") + html.Div( + "{{ contact.name }} ({{ contact.age }})", + v_for="contact in book.contacts", + key="contact._id", + ) + + +def main(): + app = ContactsApp() + app.server.start() + + +if __name__ == "__main__": + main() diff --git a/examples/10_mcp/03_permissions.py b/examples/10_mcp/03_permissions.py new file mode 100644 index 000000000..2d3686dcf --- /dev/null +++ b/examples/10_mcp/03_permissions.py @@ -0,0 +1,161 @@ +"""Permissions: the people running the app decide what agents may do. + +Run: python examples/10_mcp/03_permissions.py --server + python examples/10_mcp/03_permissions.py --server --mcp-deny files + +The app edits notes kept in a temporary folder. It declares two permissions, +"files" (read the notes) and "save" (write them, which requires reading), and +each tool, method or property setter that needs one says so with requires=. +While a permission is off, what needs it is left out of what agents are +offered and calls to it are refused. The checkboxes switch permissions while +the app runs; --mcp-deny (or TRAME_MCP_DENY) withholds them for the whole run. + +An agent can then: + list_notes() # needs "files" + object_call("editor", "open", {"name": "todo.txt"}) # needs "files" + object_update("editor", {"text": "Buy milk"}) + object_update("editor", {"note": "shopping.txt"}) # needs "save" + object_call("editor", "save") # needs "save" + object_call("editor", "clear") # marked destructive +""" + +import tempfile +from pathlib import Path + +from trame.app import TrameApp +from trame.decorators import change, mcp +from trame.ui.html import DivLayout +from trame.widgets import html + + +class NotesApp(TrameApp): + """A text editor over a folder of notes.""" + + def __init__(self, server=None): + super().__init__(server) + self.folder = Path(tempfile.mkdtemp(prefix="trame-notes-")) + (self.folder / "todo.txt").write_text("Write the docs\nTry the examples\n") + (self.folder / "ideas.txt").write_text("An agent that tidies notes\n") + + permissions = self.mcp.permissions + permissions.declare("files", f"Read the notes in {self.folder}") + permissions.declare( + "save", f"Write notes in {self.folder}", requires="files", allowed=False + ) + self.mcp.configure(name="notes", enabled=True) + self.mcp_expose("editor") + + self.state.note = "untitled.txt" + self.state.text = "" + self.state.locked = [] + self._show_permissions() + self.ctrl.on_server_ready.add(self._on_ready) + self._build_ui() + + # ------------------------------------------------------------------------- + # What agents may use + # ------------------------------------------------------------------------- + + @mcp.tool(read_only=True, requires="files") + def list_notes(self) -> list[str]: + """The notes in the folder, by file name.""" + return sorted(path.name for path in self.folder.glob("*.txt")) + + @mcp.method(requires="files") + def open(self, name: str) -> str: + """Show a note in the editor.""" + path = self._path(name) + if not path.exists(): + msg = f"No note {name!r}; list_notes() lists them" + raise ValueError(msg) + self.state.update({"note": path.name, "text": path.read_text()}) + return f"Opened {path.name}" + + @mcp.method(requires="save") + def save(self) -> str: + """Write the editor's text to its note.""" + self._path(self.state.note).write_text(self.state.text) + return f"Saved {self.state.note}" + + @mcp.method(annotations={"destructiveHint": True}) + def clear(self) -> None: + """Empty the editor. The note keeps its text until it is saved.""" + self.state.text = "" + + @mcp.property + def text(self) -> str: + """The text in the editor.""" + return self.state.text + + @text.setter + def text(self, value: str): + self.state.text = value + + @mcp.property + def note(self) -> str: + """The file name the editor saves to. Changing it needs "save".""" + return self.state.note + + @note.setter(requires="save") + def note(self, value: str): + self.state.note = self._path(value).name + + # ------------------------------------------------------------------------- + # The app + # ------------------------------------------------------------------------- + + def _path(self, name): + path = (self.folder / name).resolve() + if path.parent != self.folder.resolve() or path.suffix != ".txt": + msg = f"A note is a .txt file name, without a folder: got {name!r}" + raise ValueError(msg) + return path + + def _show_permissions(self): + permissions = self.mcp.permissions + self.state.update( + {"allow_files": permissions["files"], "allow_save": permissions["save"]} + ) + + def _on_ready(self, **_): + # --mcp-deny is known once the server starts: those switches stay off + self.state.locked = list(self.mcp.permissions.locked) + self._show_permissions() + + # "save" requires "files": switching files off switches save off too + @change("allow_files") + def _on_allow_files(self, allow_files, **_): + self.mcp.permissions["files"] = allow_files + self._show_permissions() + + @change("allow_save") + def _on_allow_save(self, allow_save, **_): + self.mcp.permissions["save"] = allow_save + self._show_permissions() + + def _build_ui(self): + with DivLayout(self.server): + html.P(f"Notes in {self.folder}") + # "save" can only be switched on while "files" is + for name, label, disabled in ( + ("files", "read notes", "locked.includes('files')"), + ("save", "save notes", "locked.includes('save') || !allow_files"), + ): + with html.Label(style="margin-right: 1rem"): + html.Input( + type="checkbox", + v_model=(f"allow_{name}",), + disabled=(disabled,), + ) + html.Span(f"Agents may {label}") + html.H3("{{ note }}") + html.Textarea(v_model=("text",), rows=10, style="width: 100%") + + +def main(): + app = NotesApp() + app.server.start() + + +if __name__ == "__main__": + main() diff --git a/examples/10_mcp/04_exposure.py b/examples/10_mcp/04_exposure.py new file mode 100644 index 000000000..3c04b2b6d --- /dev/null +++ b/examples/10_mcp/04_exposure.py @@ -0,0 +1,160 @@ +"""Exposure: classes you don't own, read-only names, getters, resources, prompts. + +Run: python examples/10_mcp/04_exposure.py --server + +Panel stands for a component class from another library: it knows nothing of +agents, so the app describes it with Panel.mcp_describe(). A subclass hides a +method of its base. The app then exposes each panel under a read-only name, and +the panel selected in the UI under a getter, "selected", which follows the +selection on every call and tells agents what it is for. Only the selected +panel can change, because no other panel has a writable name: agents reach +an object only through names, or through the properties of objects they +reach, and read-only access follows the way they reach it. + +The app adds a resource and a prompt of its own. It marks nothing else, so it +is not itself an object agents see. + +An agent can then: + object_list() + object_get("drafts") + object_call("selected", "set_color", {"color": "teal"}) + object_update("pinned", {"title": "Hi"}) # refused unless it is selected + resources/read app://help, prompts/get recolor +""" + +from trame.app import TrameApp, TrameComponent +from trame.decorators import mcp +from trame.ui.html import DivLayout +from trame.widgets import html + +# ----------------------------------------------------------------------------- +# A component class from a library: it declares nothing for agents +# ----------------------------------------------------------------------------- + + +class Panel(TrameComponent): + """A colored panel with a title.""" + + def __init__(self, server, title, color="lightgray", on_change=None): + super().__init__(server) + self._title = title + self._color = color + self._on_change = on_change + + @property + def title(self) -> str: + return self._title + + @title.setter + def title(self, value: str): + self._title = value + self._changed() + + @property + def color(self) -> str: + return self._color + + @property + def cache_key(self) -> str: + return f"{self._title}:{self._color}" + + def set_color(self, color: str) -> None: + self._color = color + self._changed() + + def reset(self) -> None: + self._color = "lightgray" + self._changed() + + def _changed(self): + if self._on_change: + self._on_change() + + +# ----------------------------------------------------------------------------- +# The app: what agents see of those classes, and how they reach them +# ----------------------------------------------------------------------------- + +# Before any panel is built: a class is described once, for the whole process. +# Listed members only: cache_key stays hidden. +Panel.mcp_describe( + description="A colored panel the user sees.", + properties={"title": "Shown at the top of the panel", "color": "A CSS color"}, + methods={ + "set_color": "Change the panel's background color", + "reset": "Back to the default gray", + }, +) + + +class PinnedPanel(Panel, mcp={"methods": {"reset": False}}): + """A panel whose color can change but not be reset.""" + + +HELP = """\ +# Panels + +The user selects a panel by clicking it. Only the selected panel can change; +the others are read-only. A pinned panel can't be reset. +""" + + +class PanelsApp(TrameApp): + def __init__(self, server=None): + super().__init__(server) + self.mcp.configure(name="panels", enabled=True) + + refresh = self._refresh + self.panels = [ + Panel(self.server, "Inbox", on_change=refresh), + Panel(self.server, "Drafts", "khaki", on_change=refresh), + PinnedPanel(self.server, "Pinned", "lightblue", on_change=refresh), + ] + self.state.selected = 0 + for name, panel in zip(("inbox", "drafts", "pinned"), self.panels): + panel.mcp_expose(name, read_only=True) + self.mcp.expose_object( + lambda: self.panels[self.state.selected], + "selected", + description="The panel the user selected, the only one agents can change.", + ) + + self._refresh() + self._build_ui() + + @mcp.resource("app://help", mime_type="text/markdown") + def help(self) -> str: + """How the panels work.""" + return HELP + + @mcp.prompt + def recolor(self, mood: str = "calm") -> str: + """Recolor the selected panel to match a mood.""" + return f"Pick a color for a {mood} mood and apply it to the selected panel." + + def _refresh(self): + self.state.panels = [ + {"title": panel.title, "color": panel.color} for panel in self.panels + ] + + def _build_ui(self): + with DivLayout(self.server, style="display: flex; gap: 1rem"): + html.Div( + "{{ panel.title }}", + v_for="(panel, i) in panels", + key="i", + click="selected = i", + style=( + "{ background: panel.color, padding: '2rem', cursor: 'pointer'," + " outline: i === selected ? '3px solid black' : 'none' }", + ), + ) + + +def main(): + app = PanelsApp() + app.server.start() + + +if __name__ == "__main__": + main() diff --git a/examples/validation/mcp/wasm_quadrants.py b/examples/validation/mcp/wasm_quadrants.py new file mode 100644 index 000000000..e166f6ffd --- /dev/null +++ b/examples/validation/mcp/wasm_quadrants.py @@ -0,0 +1,799 @@ +"""Stress example: dynamic quadrant grid of VTK WASM scenes, all trame-dataclass. + +Run: python examples/validation/mcp/wasm_quadrants.py --server +Deps: pip install trame-dataclass vtk trame-vtklocal + +Then use one of the commands the console prints to start a Claude Code or +Codex session connected to the app. The MCP root is `workspace`: + object_types() + object_call("workspace", "set_grid", {"rows": 2, "cols": 2}) + object_call("", "add_object", {"type": "cone"}) + object_update("", {"color": [1, 0, 0], "resolution": 42}) + +Fully client-reactive UI: the main layout template is written ONCE. Quadrants +and object rows are rendered by Vue from the synced dataclass state (`v-for` + +per-class `generate_gui` templates). Each scene's WASM view is a LocalView in +its own named server template (created once per scene, referenced reactively +via `trame-template`), so object add/remove never remounts a view. + +Stress points exercised: +- three-level reachability (workspace -> scenes -> objects) +- structural changes via model methods (grid reconciliation, add/remove + object); the reference properties holding scenes and objects are read-only +- `object_update` on plain properties (rows/cols) triggering structural watchers +- ServerOnly fields hiding live VTK objects from MCP +- ClientOnly `type` field: readable over MCP, never writable +- the three ways to expose a model: SceneObject opts in every field by mode + (`all_fields`), Scene every public method minus its lifecycle hooks + (`all_methods` + `methods` set to False), Workspace only what it marks (no + class keyword) +- `mcp=` field options: descriptions and constraints (ranges, enums, vector + lengths) advertised by object_types and enforced on every MCP write; + unmarked per-browser UI state stays invisible +- agent-facing descriptions next to developer docstrings (`description` on + SceneObject and Scene, and on Scene's clear_objects method) +- a live entry point (`lambda: self.workspace`) +- model inheritance: SceneObject base, per-type subclasses each with their own + MCP schema (only resolution-capable objects expose `resolution`) +- no agent-built models (no `@mcp.constructor`): scenes and objects only come + from set_grid and add_object, which build their VTK pipelines +- a first-class prompt marked on the app (`@mcp.prompt`), built once +- human edits (sliders/checkboxes) and MCP edits converging on the same models +""" + +import contextlib +from typing import Annotated, Literal + +# Required for rendering/interactor factories +import vtkmodules.vtkInteractionStyle +import vtkmodules.vtkRenderingOpenGL2 # noqa: F401 +from trame.app import TrameApp +from trame.decorators import mcp +from trame.ui.html import DivLayout +from trame.widgets import client, html, vtklocal +from trame.widgets import dataclass as dc +from trame_dataclass.v2 import ( + ClientOnly, + ServerOnly, + StateDataModel, + Sync, + get_instance, + watch, +) +from vtkmodules.vtkFiltersSources import ( + vtkConeSource, + vtkCubeSource, + vtkCylinderSource, + vtkSphereSource, + vtkSuperquadricSource, +) +from vtkmodules.vtkRenderingCore import ( + vtkActor, + vtkPolyDataMapper, + vtkRenderer, + vtkRenderWindow, + vtkRenderWindowInteractor, +) +from vtkmodules.vtkRenderingFreeType import vtkVectorText + +OBJECT_TYPES = ("cone", "sphere", "cylinder", "cube", "torus", "text") +MAX_QUADRANTS = 25 +ObjectType = Literal["cone", "sphere", "cylinder", "cube", "torus", "text"] +REPRESENTATIONS = ("surface", "wireframe", "points") + +VEC3 = {"minItems": 3, "maxItems": 3} +RGB = {**VEC3, "items": {"type": "number", "minimum": 0, "maximum": 1}} + +# Multiple LocalViews need the async wasm runtime, otherwise only one view's +# event loop runs (trame-vtklocal issue #76). Async requires JSPI +# (WebAssembly.Suspending): available by default on recent Chromium, not on +# Firefox. Detect it client-side and degrade to sync (renders fine, but only +# one quadrant's camera is interactive) instead of crashing. +WASM_CONFIG_EXPR = ( + "(utils.get('WebAssembly') || {}).Suspending " + "? {rendering: 'webgl', exec: 'async'} " + ": {rendering: 'webgl', exec: 'sync'}" +) + +CELL_STYLE = ( + "border: 1px solid #445; border-radius: 6px; padding: 4px; margin: 0; " + "flex: 1; display: flex; flex-direction: column; min-height: 0; " + "font-family: sans-serif; font-size: 14px;" +) +ROW_STYLE = "display: flex; gap: 6px; align-items: center;" + +# VTK Set/Get pairs that are internals, not user-facing geometry properties +VTK_PROPERTY_EXCLUDE = { + "AbortExecute", + "AbortOutput", + "Debug", + "GlobalWarningDisplay", + "ObjectName", + "OutputPointsPrecision", + "Progress", + "ProgressShiftScale", + "ProgressText", + "ReferenceCount", + "ReleaseDataFlag", +} + + +def vtk_scalar_properties(obj): + """Introspect a VTK object for its scalar/vector Set/Get property pairs. + + Returns {name: {"value": ..., "min": ..., "max": ...}} — min/max only for + properties that advertise bounds (VTK's GetMinValue convention). + """ + result = {} + for attr in dir(obj): + if not attr.startswith("Set"): + continue + name = attr[3:] + if ( + not name + or name in VTK_PROPERTY_EXCLUDE + or name.endswith(("MinValue", "MaxValue")) + ): + continue + getter = getattr(obj, f"Get{name}", None) + if getter is None: + continue + try: + value = getter() + except TypeError: # getter requires arguments -> not a plain property + continue + is_scalar = isinstance(value, (bool, int, float, str)) + is_vector = ( + isinstance(value, tuple) + and bool(value) + and all(isinstance(v, (int, float)) for v in value) + ) + if not (is_scalar or is_vector): + continue + entry = {"value": list(value) if is_vector else value} + for bound, suffix in (("min", "MinValue"), ("max", "MaxValue")): + bound_getter = getattr(obj, f"Get{name}{suffix}", None) + if bound_getter is not None: + with contextlib.suppress(TypeError): + entry[bound] = bound_getter() + result[name] = entry + return result + + +# ----------------------------------------------------------------------------- +# Data models +# ----------------------------------------------------------------------------- + + +class SceneObject( # options inherited by every object type + StateDataModel, + mcp={"description": "An object drawn in a scene.", "all_fields": True}, +): + """Base class for anything that can live in a Scene.""" + + # ClientOnly: read-only over MCP by field mode; subclasses redeclare the + # default and inherit the description + type = ClientOnly(str, "", mcp="Object type") + color = Sync( + list[float], + lambda: [0.9, 0.9, 0.9], + mcp={"description": "RGB, 0-1 each", "schema": RGB}, + ) + position = Sync( + list[float], + lambda: [0.0, 0.0, 0.0], + mcp={"description": "[x, y, z]", "schema": VEC3}, + ) + scale = Sync( + list[float], + lambda: [1.0, 1.0, 1.0], + mcp={"description": "[sx, sy, sz]", "schema": VEC3}, + ) + rotation = Sync( + list[float], + lambda: [0.0, 0.0, 0.0], + mcp={"description": "Degrees around x, y, z", "schema": VEC3}, + ) + opacity = Sync( + float, + 1.0, + mcp={ + "description": "0 = transparent, 1 = opaque", + "schema": {"minimum": 0, "maximum": 1}, + }, + ) + representation = Sync( + str, "surface", mcp={"schema": {"enum": list(REPRESENTATIONS)}} + ) + visible = Sync(bool, True) + + source = ServerOnly(object) + actor = ServerOnly(object) + scene = ServerOnly(object) # backref, not a dataclass field -> no MCP cycle + + SOURCE_CLASS = None # set by concrete subclasses + + @classmethod + def generate_gui(cls, trame_server=None) -> str: + with html.Div( + trame_server=trame_server, + v_if="self_available && self._id", + style=ROW_STYLE, + ) as root: + cls._gui_row(root) + return root.html + + @classmethod + def _gui_row(cls, _root): + html.Span("{{ self.type }} #{{ self._id }}", style="min-width: 5.5em;") + html.Span("visible") + html.Input(type="checkbox", v_model="self.visible") + html.Button("remove", click=(cls.ui_remove, "[self._id]")) + + @classmethod + def ui_remove(cls, object_id): + obj = get_instance(object_id) + if obj is not None and obj.scene is not None: + obj.scene.remove_object(object_id) + + @mcp.method(read_only=True) + def vtk_source_properties(self) -> dict: + """Advanced: list every scalar/vector property of this object's + underlying VTK source, beyond the declared properties. + + Returns {name: {value, min?, max?}}; set them with + set_vtk_source_property. Prefer the declared properties when one + covers the same thing (e.g. resolution). + """ + if self.source is None: + msg = "Object has no VTK source yet" + raise RuntimeError(msg) + return vtk_scalar_properties(self.source) + + @mcp.method + def set_vtk_source_property( + self, name: str, value: float | int | bool | str | list[float] + ) -> str: + """Advanced: set a VTK source property by name, as listed by + vtk_source_properties (e.g. Height or Radius on a cone). + + Vector-valued properties take a list. Changes render immediately but + are not tracked as model properties. + """ + properties = vtk_scalar_properties(self.source) + if name not in properties: + msg = ( + f"Unknown VTK property {name!r} on {type(self.source).__name__}. " + f"Available: {sorted(properties)}" + ) + raise ValueError(msg) + setter = getattr(self.source, f"Set{name}") + if isinstance(value, list): + setter(*value) + else: + setter(value) + self.scene.request_render() + return f"{type(self.source).__name__}.{name} = {value}" + + def build(self, scene): + self.scene = scene + self.source = self.SOURCE_CLASS() + self._setup_source() + mapper = vtkPolyDataMapper() + mapper.SetInputConnection(self.source.GetOutputPort()) + self.actor = vtkActor() + self.actor.SetMapper(mapper) + self._apply_resolution() + self._apply_appearance() + return self.actor + + def _setup_source(self): + """Subclass hook to configure the freshly created source.""" + + def _apply_resolution(self): + """No resolution on the base class (see ResolutionObject).""" + + def _apply_appearance(self): + prop = self.actor.GetProperty() + prop.SetColor(*self.color[:3]) + prop.SetOpacity(max(0.0, min(float(self.opacity), 1.0))) + representation = str(self.representation).lower() + if representation == "wireframe": + prop.SetRepresentationToWireframe() + elif representation == "points": + prop.SetRepresentationToPoints() + prop.SetPointSize(4) + else: + prop.SetRepresentationToSurface() + self.actor.SetPosition(*self.position[:3]) + self.actor.SetScale(*self.scale[:3]) + self.actor.SetOrientation(*self.rotation[:3]) + self.actor.SetVisibility(bool(self.visible)) + + @watch( + "color", + "position", + "scale", + "rotation", + "opacity", + "representation", + "visible", + sync=True, + ) + def _on_appearance(self, *_): + if self.actor is None: + return + self._apply_appearance() + self.scene.request_render() + + +class ResolutionObject(SceneObject): + """Scene object with a tessellation resolution (cone, cylinder, sphere, torus).""" + + resolution = Sync( + int, + 16, + mcp={ + "description": "Tessellation resolution", + "schema": {"minimum": 3, "maximum": 128}, + }, + ) + + @classmethod + def _gui_row(cls, _root): + html.Span("{{ self.type }} #{{ self._id }}", style="min-width: 5.5em;") + html.Span("visible") + html.Input(type="checkbox", v_model="self.visible") + html.Span("resolution") + html.Input( + type="range", + min=3, + max=64, + v_model_number="self.resolution", + style="flex: 1;", + ) + html.Button("remove", click=(cls.ui_remove, "[self._id]")) + + def _apply_resolution(self): + self.source.SetResolution(max(3, min(int(self.resolution), 128))) + + @watch("resolution", sync=True) + def _on_resolution(self, _resolution): + if self.source is None: + return + self._apply_resolution() + self.scene.request_render() + + +class ConeObject(ResolutionObject): + type = ClientOnly(str, "cone") + SOURCE_CLASS = vtkConeSource + + +class CylinderObject(ResolutionObject): + type = ClientOnly(str, "cylinder") + SOURCE_CLASS = vtkCylinderSource + + +class SphereObject(ResolutionObject): + type = ClientOnly(str, "sphere") + SOURCE_CLASS = vtkSphereSource + + def _apply_resolution(self): + resolution = max(3, min(int(self.resolution), 128)) + self.source.SetThetaResolution(resolution) + self.source.SetPhiResolution(resolution) + + +class CubeObject(SceneObject): + type = ClientOnly(str, "cube") + SOURCE_CLASS = vtkCubeSource + + +class TorusObject(ResolutionObject): + type = ClientOnly(str, "torus") + thickness = Sync( + float, + 0.3, + mcp={ + "description": "Ring thickness", + "schema": {"minimum": 0.01, "maximum": 1}, + }, + ) + SOURCE_CLASS = vtkSuperquadricSource + + def _setup_source(self): + self.source.ToroidalOn() + self.source.SetThickness(max(0.01, min(float(self.thickness), 1.0))) + + def _apply_resolution(self): + resolution = max(3, min(int(self.resolution), 128)) + self.source.SetThetaResolution(resolution) + self.source.SetPhiResolution(resolution) + + @watch("thickness", sync=True) + def _on_thickness(self, thickness): + if self.source is None: + return + self.source.SetThickness(max(0.01, min(float(thickness), 1.0))) + self.scene.request_render() + + +class TextObject(SceneObject): + type = ClientOnly(str, "text") + text = Sync(str, "hello", mcp="Displayed text") + SOURCE_CLASS = vtkVectorText + + def _setup_source(self): + self.source.SetText(self.text or " ") + + @classmethod + def _gui_row(cls, _root): + html.Span("{{ self.type }} #{{ self._id }}", style="min-width: 5.5em;") + html.Span("visible") + html.Input(type="checkbox", v_model="self.visible") + html.Input(v_model="self.text", style="flex: 1;") + html.Button("remove", click=(cls.ui_remove, "[self._id]")) + + @watch("text", sync=True) + def _on_text(self, text): + if self.source is None: + return + self.source.SetText(str(text) or " ") + self.scene.request_render() + + +TYPE_TO_CLASS = { + "cone": ConeObject, + "sphere": SphereObject, + "cylinder": CylinderObject, + "cube": CubeObject, + "torus": TorusObject, + "text": TextObject, +} + + +class Scene( + StateDataModel, + mcp={ + "description": "One quadrant of the grid: a 3D view and the objects in it.", + "all_methods": True, + "methods": { + "build_pipeline": False, + "request_render": False, + # the docstring is written for developers + "clear_objects": "Remove every object from this scene.", + }, + }, +): + name = Sync(str, "Scene", mcp="Label shown in the quadrant header") + background = Sync( + list[float], + lambda: [0.12, 0.12, 0.18], + mcp={"description": "Background RGB, 0-1 each", "schema": RGB}, + ) + # structural changes go through add_object / remove_object, which keep + # the renderer in step: read-only for agents + objects = Sync(list[SceneObject], list, has_dataclass=True, mcp={"read_only": True}) + # per-browser UI state: not marked, not exposed + overlay_open = ClientOnly(bool, True) + + renderer = ServerOnly(object) + render_window = ServerOnly(object) + interactor = ServerOnly(object) + view = ServerOnly(object) + + @classmethod + def generate_gui(cls, trame_server=None) -> str: + with html.Div( + trame_server=trame_server, + v_if="self_available && self._id", + style=CELL_STYLE, + ) as root: + # header: editable name + add-object buttons + camera reset + with html.Div(style=ROW_STYLE): + html.Input(v_model="self.name", style="width: 6em;") + for object_type in OBJECT_TYPES: + html.Button( + f"+{object_type}", + click=(cls.ui_add_object, f"[self._id, '{object_type}']"), + ) + html.Button("reset view", click=(cls.ui_reset_camera, "[self._id]")) + + # the WASM view lives in a per-scene server template, created once + # in build_pipeline() and referenced reactively by name here + with html.Div(style="flex: 1; min-height: 100px; position: relative;"): + client.ServerTemplate(name=("`scene_view_${self._id}`",)) + + # object rows float over the top of the 3D view; minimizable, + # capped to the quadrant height, scrollable when crowded + with html.Div( + style=( + "position: absolute; top: 4px; left: 4px; z-index: 1; " + "max-height: calc(100% - 8px); " + "max-width: calc(100% - 8px); " + "display: flex; flex-direction: column; " + "background: rgba(255, 255, 255, 0.65); " + "backdrop-filter: blur(2px); " + "border-radius: 4px; padding: 2px 6px;" + ), + v_if="self.objects.length", + ): + with html.Div(style=ROW_STYLE): + html.Span( + "objects ({{ self.objects.length }})", + style="font-weight: bold;", + ) + html.Button( + "{{ self.overlay_open ? '-' : '+' }}", + click="self.overlay_open = !self.overlay_open", + style="margin-left: auto;", + ) + with html.Div( + v_show="self.overlay_open", + style=( + "overflow-y: auto; min-height: 0; " + "display: flex; flex-direction: column; gap: 2px;" + ), + ): + with html.Div(v_for="obj in self.objects", key=("obj._id",)): + dc.Gui(instance=("obj._id",)) + return root.html + + @classmethod + def ui_add_object(cls, scene_id, object_type): + scene = get_instance(scene_id) + if scene is not None: + scene.add_object(object_type) + + @classmethod + def ui_reset_camera(cls, scene_id): + scene = get_instance(scene_id) + if scene is not None: + scene.reset_camera() + + def _next_free_position(self): + # First default slot along X not occupied by an existing object, so a + # new object never overlaps survivors of earlier removals + taken = {round(o.position[0] / 1.2) for o in self.objects if o is not None} + slot = 0 + while slot in taken: + slot += 1 + return [slot * 1.2, 0.0, 0.0] + + def build_pipeline(self): + self.renderer = vtkRenderer() + self.renderer.SetBackground(*self.background[:3]) + self.render_window = vtkRenderWindow() + self.render_window.OffScreenRenderingOn() + self.render_window.AddRenderer(self.renderer) + # The WASM remote session requires an interactor on the mirrored window + self.interactor = vtkRenderWindowInteractor() + self.interactor.SetRenderWindow(self.render_window) + self.interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + + # One LocalView per scene, in its own named template, created exactly + # once - object add/remove never remounts it + with DivLayout( + self.server, + template_name=f"scene_view_{self._id}", + style="position: absolute; inset: 0;", + ): + self.view = vtklocal.LocalView( + self.render_window, + throttle_rate=20, + config=(WASM_CONFIG_EXPR,), + ) + + def request_render(self, push_camera=False): + if self.render_window is None: + return + self.render_window.Render() + if self.view is not None: + if push_camera: + self.view.update(push_camera=True) + else: + self.view.update_throttle() + + @watch("background", sync=True) + def _on_background(self, background): + if self.renderer is None: + return + self.renderer.SetBackground(*background[:3]) + self.request_render() + + def add_object(self, type: ObjectType) -> dict: + """Add a new object to this scene and return its id. + + Change its appearance with object_update on that id; the properties + of each object type are listed by object_types. + """ + if type not in TYPE_TO_CLASS: + msg = f"Unknown type {type!r}. Available types: {list(TYPE_TO_CLASS)}" + raise ValueError(msg) + + obj = TYPE_TO_CLASS[type](self.server) + obj.position = self._next_free_position() + self.renderer.AddActor(obj.build(self)) + self.objects = [*self.objects, obj] + + self.renderer.ResetCamera() + self.request_render(push_camera=True) + return {"id": obj._id, "type": type} + + def remove_object(self, object_id: str) -> str: + """Remove an object from this scene by its id.""" + live = [o for o in self.objects if o is not None] + match = [o for o in live if o._id == object_id] + if not match: + msg = ( + f"No object {object_id!r} in scene {self.name!r}. " + f"Object ids: {[o._id for o in live]}" + ) + raise ValueError(msg) + self.renderer.RemoveActor(match[0].actor) + self.objects = [o for o in live if o._id != object_id] + self.request_render() + return f"Removed {object_id}" + + def clear_objects(self) -> str: + """Remove every object from this scene. + + Also recovers from any corrupted entries by removing all actors from + the renderer, including strays with no backing object. + """ + self.renderer.RemoveAllViewProps() + self.objects = [] + self.request_render() + return "Scene cleared" + + def reset_camera(self) -> str: + """Reset this scene's camera to fit all visible objects.""" + self.renderer.ResetCamera() + self.request_render(push_camera=True) + return "Camera reset" + + +class Workspace(StateDataModel): # no keyword: only what it marks + """The quadrant grid, one scene per quadrant.""" + + rows = Sync( + int, + 1, + mcp={ + "description": "Grid rows", + "schema": {"minimum": 1, "maximum": MAX_QUADRANTS}, + }, + ) + cols = Sync( + int, + 1, + mcp={ + "description": "Grid columns", + "schema": {"minimum": 1, "maximum": MAX_QUADRANTS}, + }, + ) + # grid size drives the scenes (set_grid, rows/cols): read-only for agents + scenes = Sync(list[Scene], list, has_dataclass=True, mcp={"read_only": True}) + + @mcp.method + def set_grid( + self, rows: Annotated[int, {"minimum": 1}], cols: Annotated[int, {"minimum": 1}] + ) -> str: + """Set the quadrant grid to rows x cols (max 25 quadrants total). + + Each quadrant holds one scene; shrinking the grid destroys trailing + scenes and their objects. + """ + if rows < 1 or cols < 1 or rows * cols > MAX_QUADRANTS: + msg = f"Grid must be at least 1x1 and at most {MAX_QUADRANTS} quadrants (got {rows}x{cols})" + raise ValueError(msg) + self.rows = rows + self.cols = cols + return f"Grid set to {rows}x{cols} ({len(self.scenes)} scenes)" + + @watch("rows", "cols", sync=True) + def _on_grid(self, rows, cols): + # Clamp direct property writes (object_update / UI) + rows, cols = max(1, int(rows)), max(1, int(cols)) + while rows * cols > MAX_QUADRANTS: + if cols > rows: + cols -= 1 + else: + rows -= 1 + self.rows, self.cols = rows, cols # no-op when already equal + self.reconcile() + + def reconcile(self): + desired = self.rows * self.cols + scenes = list(self.scenes) + + while len(scenes) > desired: + scenes.pop() + while len(scenes) < desired: + scene = Scene(self.server, name=f"Scene {len(scenes) + 1}") + scene.build_pipeline() + scenes.append(scene) + + if [s._id for s in scenes] != [s._id for s in self.scenes]: + self.scenes = scenes + + +# ----------------------------------------------------------------------------- +# Application +# ----------------------------------------------------------------------------- + + +class WasmQuadrantsApp(TrameApp): + def __init__(self, server=None): + super().__init__(server) + self.mcp.configure( + name="wasm-quadrants", + enabled=True, + instructions=( + "This app shows a grid of 3D scenes rendered in the browser. " + "Use set_grid to size the quadrant grid, add_object on a scene, " + "and object_update on objects to change color/position/resolution." + ), + ) + + self.workspace = Workspace(self.server) + self.mcp.expose_object(lambda: self.workspace, "workspace") + self.workspace.reconcile() # initial 1x1 grid + self._build_ui() # built once - everything after this is reactive + + @mcp.prompt + def demo_script(self) -> str: + """Suggested walkthrough of the quadrants app.""" + return ( + "Create a 2x2 grid of scenes. Put a red cone in the first scene, " + "a blue sphere in the second, a green cylinder in the third and a " + "yellow cube in the last one, then describe the workspace." + ) + + def _ui_adjust_grid(self, d_rows, d_cols): + ws = self.workspace + rows = max(1, ws.rows + d_rows) + cols = max(1, ws.cols + d_cols) + if rows * cols <= MAX_QUADRANTS: + ws.rows, ws.cols = rows, cols # sync watcher reconciles + + def _build_ui(self): + with DivLayout( + self.server, + style="height: 100vh; display: flex; flex-direction: column; font-family: sans-serif;", + ): + with self.workspace.provide_as("w"): + with html.Div(style=ROW_STYLE + "padding: 6px;"): + html.B("Grid {{ w.rows }} x {{ w.cols }}") + html.Span("rows:") + html.Button("-", click=(self._ui_adjust_grid, "[-1, 0]")) + html.Button("+", click=(self._ui_adjust_grid, "[1, 0]")) + html.Span("cols:") + html.Button("-", click=(self._ui_adjust_grid, "[0, -1]")) + html.Button("+", click=(self._ui_adjust_grid, "[0, 1]")) + html.Span( + "({{ w.scenes.length }} scenes, max " + str(MAX_QUADRANTS) + ")" + ) + + grid_style = ( + "`flex: 1; display: grid; gap: 6px; padding: 6px; min-height: 0; " + "grid-template-columns: repeat(${w.cols}, 1fr); " + "grid-template-rows: repeat(${w.rows}, 1fr);`" + ) + with html.Div(style=(grid_style,)): + with html.Div( + v_for="scene in w.scenes", + key=("scene._id",), + style="min-height: 0; display: flex;", + ): + dc.Gui(instance=("scene._id",), style="flex: 1; display: flex;") + + +def main(): + app = WasmQuadrantsApp() + app.server.start() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 6bfc9fa93..4bc1dbe0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ version = "4.0.0" description = "Trame, a framework to build applications in plain Python" authors = [{ name = "Kitware Inc." }] dependencies = [ - "trame-server>=4,<5", + "trame-server>=4.2,<5", "trame-client>=4,<5", - "trame-common>=1.2.7,<2", + "trame-common>=1.3.0,<2", "wslink>=2.5.7", "pyyaml", # for trame.tools.widgets ] diff --git a/src/trame/app/mcp.py b/src/trame/app/mcp.py new file mode 100644 index 000000000..43d861a31 --- /dev/null +++ b/src/trame/app/mcp.py @@ -0,0 +1,31 @@ +from trame_common.obj.mcp import MCPExposable +from trame_server.mcp import ( + MCP, + HTTPTransport, + Image, + MCPClient, + MCPDisabledError, + MCPError, + PermissionDeniedError, + Progress, + ToolError, + ValidationError, + claude_command, + codex_command, +) + +__all__ = [ + "MCP", + "HTTPTransport", + "Image", + "MCPClient", + "MCPDisabledError", + "MCPError", + "MCPExposable", + "PermissionDeniedError", + "Progress", + "ToolError", + "ValidationError", + "claude_command", + "codex_command", +] diff --git a/src/trame/decorators.py b/src/trame/decorators.py index 3c4980907..2597e4433 100644 --- a/src/trame/decorators.py +++ b/src/trame/decorators.py @@ -1,3 +1,4 @@ +from trame_common.decorators import mcp from trame_common.decorators.hot_reload import hot_reload from trame_common.decorators.klass import ( TrameApp, @@ -16,4 +17,5 @@ "life_cycle", "hot_reload", "task", + "mcp", ] diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 000000000..afd697519 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,33 @@ +def test_mcp_exports(): + from trame.app.mcp import ( # noqa: F401 + MCP, + Image, + MCPDisabledError, + MCPExposable, + PermissionDeniedError, + ToolError, + ValidationError, + claude_command, + codex_command, + ) + from trame.decorators import mcp + + for name in ("method", "property", "tool", "resource", "prompt", "constructor"): + assert callable(getattr(mcp, name)) + + +def test_app_methods_reach_agents(): + import asyncio + + from trame.app import TrameApp, get_server + from trame.decorators import mcp + + class AgentApp(TrameApp): + @mcp.method + def greet(self, name: str) -> str: + return f"Hello {name}" + + app = AgentApp(get_server("test_app_methods_reach_agents")) + app.mcp_expose("app") + result = asyncio.run(app.mcp._objects.call("app", "greet", {"name": "trame"})) + assert result == "Hello trame"