Skip to content

The MCP server: driving a live session from an agent

python -m uLib.mcp speaks the Model Context Protocol on stdio and drives a running uLib application over the remote-control port gcompose and ucontext-server already open with --port. An agent gets the same reach a person has through the GUI: launch a session, ask what the library can build, edit the scene, script it, write and run recipes, look at a viewport, run the tests.

Nothing here is a second API. Every tool is a command on the existing line protocol, which is why a session an agent drives and a session a person drives are the same session — attach to the port someone already has open and the two work side by side.

Layout

agent ──stdio / MCP──► python -m uLib.mcp        src/Python/uLib/mcp/
                            │                      server.py   protocol
                            │ uLib.remote.Client   tools.py    the 26 tools
                            ▼ TCP                  session.py  the app process
                    gcompose --port N  /  ucontext-server --port N
                            │
              RemoteBridge / ContextServer  ──►  the live ObjectsContext

Wiring it up

The repository ships a client configuration in .mcp.json:

{
  "mcpServers": {
    "ulib": {
      "command": "python",
      "args": ["-m", "uLib.mcp"],
      "env": { "PYTHONPATH": "${workspaceFolder}/src/Python" }
    }
  }
}

Run it inside the uLib micromamba environment, the same one the project builds in. python -m uLib.mcp --list-tools prints the tool names and exits, which is the quickest check that the path is right; --attach-port N connects to an application already listening before the first tool call.

Over HTTP: Antigravity and other URL-configured clients

stdio fits a client that launches the server. To reach an application that is already running, a client connects to a URL instead. python -m uLib.mcp --http PORT --attach-port N serves the same tools over HTTP, on two bindings (src/Python/uLib/mcp/http_transport.py):

Path Transport Notes
/mcp Streamable HTTP (2025-03-26 .. 2025-11-25) initialize mints Mcp-Session-Id; requests answered as JSON; GET is 405; DELETE ends the session
/sse, /messages HTTP+SSE (2024-11-05) still the first try of some clients

A client on the 2026-07-28 revision, which has no initialize handshake, gets a 400 without a modern error code. That is the cue the spec gives it to fall back to initialize. gRPC is not a standard MCP transport and is not served.

Every request must carry Authorization: Bearer <token>. The token is read from stdin (--token-stdin) or ULIB_MCP_TOKEN and never from the command line. A request whose Origin is not local is refused. The server binds 127.0.0.1 unless --host says otherwise, and refuses a non-local host without a token. --app-session leaves out session_start/attach/stop, for a server attached to an application someone else started. If the application is not listening yet, the server attaches on the first request.

gcompose starts it for you. In Preferences → AI Assist → External MCP clients, switch the endpoint on and pick a port. Copy Antigravity config then puts the entry for ~/.gemini/config/mcp_config.json on the clipboard, token included:

{
  "mcpServers": {
    "gcompose": {
      "serverUrl": "http://127.0.0.1:8765/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}

The same URL and header work in any client that takes a server URL. The endpoint runs while gcompose does, attached to gcompose's own remote-control port (opened on 127.0.0.1 if --port was not given), and follows the preferences when they are applied.

The tools

Session — session_start (launch gcompose or ucontext-server and connect), session_attach (join one already running), session_stop, session_status (port, pid, and the tail of the app's own console output, which is where Qt, VTK and Geant4 report trouble).

Scene — scene_tree, object_describe, object_create, object_remove, object_duplicate, object_reparent, object_rename, object_set_property, object_set_reference (draw a wire), object_invoke (run / start / stop / pause / resume / update).

Catalog — catalog_classes is every class this build can create; catalog_class adds every property with its type, group, units, default, port direction and access. This is what "advertising uLib's structures to an agent" means in practice: a recipe or a script can be written against what the build actually has rather than against a guess. recipe_api returns the InputPort / OutputPort / Property declaration reference.

Recipes — recipe_list, recipe_get (source plus the ports it currently declares), recipe_set (replace the source; the ports are re-read at once), recipe_run.

The live session — document_open, document_save, eval_python (a script in the session's own interpreter, where ctx is the live scene), screenshot, run_tests (ctest in the build tree).

Generated, once a session is attached — everything above is written here; the rest is not. tools/list also carries one tool per class the session holds (ulib.ContainerBox.configure, whose schema is that class's writable properties with their real ranges, enums and defaults) and one per action it offers (ulib.Recipe.Run). They are produced by the application from its own property declarations — uprops::McpContext — so they describe the build in front of the agent rather than the build someone documented. tools/call dispatches them through the same backend that generated them. With no session attached there is nothing to describe and the list is the static one, which includes the tool for starting a session.

The Python side of that, and how to use it without an MCP client at all, is Agent context (pyagent).

What the applications had to grow

Three commands, registered through uprops::PropertyServer::SetCommandHandler — the extension point for anything the property protocol has no field for. They run through the same dispatcher as every built-in command, which in gcompose is the GUI thread.

command served by answers
catalog Core (ObjectCatalog.cpp), so both servers registered classes; one class with its properties
mcp_context Core (ObjectCatalog.cpp), so both servers the same introspection as agent tools and resources, generated
scene_tree Core, likewise {id, name, class, parent} per object, parents first
eval_python gcompose (RemoteBridge) {ok, stdout, error} from PythonEngine::Evaluate
screenshot gcompose a PNG of a pane or the whole window

catalog for a named class needs an instance to read properties off, since the factory holds constructors rather than prototypes. It prefers one already in the scene; failing that it constructs a throwaway, describes it, and caches the result — which is why the whole-catalog detail form exists but is never the default.

Recipes reach Python through four new ctx methods (ctx.recipes, ctx.recipe_source, ctx.set_recipe_source, ctx.recipe_ports, ctx.run_recipe). A recipe's source is a plain member, not a property: it is the object's code rather than one of its values, so no property command can reach it.

Screenshots and OpenGL

A VTK pane is captured from its render window; anything else is grabbed from Qt. Under QT_QPA_PLATFORM=offscreen there is no OpenGL context at all, so a 3D capture is empty by construction and the command says so rather than returning a blank image — start the session with display="xvfb" (or a real display) when the 3D view is what matters. The widget grab keeps working either way, which covers the node graph, the ROOT canvas and whole-window shots.

Scope and safety

eval_python runs arbitrary code in the application, and the catalog describes the whole build. This is a development tool on a development machine, exactly like the --port flag it rides on: the port has never been something to expose to a network you do not control.

Tests

src/Python/testing/mcp_http_test.py (ctest: UlibMcpHttpTest) covers the HTTP transports on the wire against a real gcompose: the Streamable HTTP session lifecycle, the SSE flow, the token and Origin guards, the fallback answer for 2026-07-28 clients, and the session_* exclusion.

src/Python/testing/mcp_server_test.py (ctest: UlibMcpServerTest) covers the protocol on its own — initialize, tools/list, notifications going unanswered, a tool called with no session — and then every tool against a real gcompose process the server starts for itself.