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.