Skip to content

The agent context, generated from the library

An agent — an LLM with tools, or a script written against the session — needs to know what this build can make, what each class holds, what values are legal, and what can be done to an object. All of that is already declared, in the property declarations the serializer walks. None of it needs to be written out a second time, and this is the machinery that makes sure it isn't.

serialize() / the property model
        │
 Schema / SchemaTable ──┬──▶ .fbs               (uprops/FlatBuffersSchema.h)
                        └──▶ MCP context        (uprops/McpContext.h)
                                   │
                        mcp_context command     (Core/ObjectCatalog.h)
                                   │
                      ┌────────────┴────────────┐
                      ▼                         ▼
              uLib.pyagent               the MCP server
        tools, proxies, checking      (tools/list, tools/call)

Nothing in that chain contains a hand-written description of a uLib class. Add a property tomorrow and it is in the agent's vocabulary tomorrow, with its range and its units; rename one and the old name is gone rather than lingering in a tool description that still claims it exists.


1. What gets generated

uprops::DescribeContainerAsMcp(object, uri, options) turns one class into:

A resource — ulib://type/ContainerBox — the description document: every property with its JSON-Schema type, description, units, range, enum labels, default, whether it is read-only, and whether it is a port; plus the list of actions the class offers.

A configure tool — ulib.ContainerBox.configure — whose inputSchema is the JSON Schema of the writable properties. A read-only or computed property is described in the resource and deliberately absent here: a tool that accepts a value it will then refuse teaches an agent a rule by failing.

A tool per action — ulib.Recipe.Run — because an ActionProperty is a call and not a value, and is the one thing a schema of properties cannot express.

Three kinds of property are kept apart, because they are three different things:

Kind In the description In the configure tool
Stored (Property<T>) yes yes, writable
Computed (FunctionalProperty<T>) yes, readOnly no
Action (ActionProperty) listed under actions a tool of its own

Each generated context carries the same type identity the class's .fbs carries (typeShape, typeId, schemaId — see uprops/TypeSystem.h), so a context and a schema can be recognised as two renderings of one type.


2. Asking a session for it

Any session with a remote-control port answers mcp_context:

from uLib import remote

client = remote.connect(port=50051)
context = client.mcp_context()               # the classes this scene holds
context = client.mcp_context(registry=True)  # every registered class
context = client.mcp_context("ContainerBox") # one of them

The default — the classes the scene holds — is the vocabulary of the document in front of the agent, and is cheap. registry=True constructs one of every registered class in order to read it, which is why it is not the default.

In C++ the same call is uLib::McpContextJson(className, scene, registry, out, error), and the command is installed alongside catalog and scene_tree by InstallIntrospectionCommands.


3. uLib.pyagent

The backend that turns that context into something callable.

from uLib.pyagent import connect

agent = connect(port=50051)

As MCP tools

for tool in agent.tools():
    print(tool["name"], "-", tool["description"])

result = agent.call("ulib.ContainerBox.configure",
                    {"object": "crate", "Size": "2 2 2"})
print(result.ok, result.content)

call never raises for a refusal. ToolResult.ok is False with a sentence saying what was wrong, because "Size is at most 50; got 900" is something an agent can correct and an exception is not. result.to_mcp() gives the MCP tools/call result shape.

Arguments are checked against the schema the agent was given, before anything is sent: an unknown argument (answered with the list of real ones), a missing object, a wrong type, a value outside a declared range, a value not in a declared enum, a write to a read-only property. Only what passes goes to the session.

One thing the schema cannot say precisely. A value uprops carries as text — a three-component vector is the common case — is declared "type": "string", because text is what crosses the wire. Both forms are accepted:

agent.call("ulib.ContainerBox.configure", {"object": "crate", "Size": "2 2 2"})
agent.call("ulib.ContainerBox.configure", {"object": "crate", "Size": [2, 2, 2]})

A list of numbers is joined for you; a list of anything else is the wrong type and is refused. Reading such a property back gives the text, since that is what the session holds.

Two tools are added by this backend rather than generated, because they are about the session and not about any class — ulib.scene.list and ulib.scene.create. They are marked "kind": "session" in their _ulib block so a reader can tell them from the rest.

As Python objects

The same context, read as classes:

crate = agent.scene["crate"]
crate.Size = "2 2 2"          # checked, then written
print(crate.Size)             # read back from the session
crate.Run()                   # an action is a method

print(agent.scene.generated("ContainerBox").help())

help() is generated too — it prints the properties with their types, ranges, defaults and descriptions, and the actions, as this build declares them.

Reading resources

agent.classes()                              # what this context describes
agent.describe("ContainerBox")               # the description document
agent.read_resource("ulib://object/crate")   # what that object holds now

An object's values are read live rather than from the cached context: values are stale the moment anything runs.

Refreshing

agent.refresh()

A class added to the scene brings its tools with it. ulib.scene.create refreshes for you — an agent that creates an object and is then told there is no tool for it has been misled.


4. Through the MCP server

python -m uLib.mcp advertises both halves. The static tools (session_start, scene_tree, object_create, eval_python, run_tests and the rest — see the MCP server doc) are what an agent uses to get a session at all. Once one is attached, tools/list also carries everything that session generates for its own classes, and tools/call dispatches a generated name through the backend that generated it.

With no session attached there is nothing to describe, and the list is simply the static one — which includes the tool for starting a session.


5. Why this shape

Generated, not written. A hand-written tool list is a second copy of the library. It starts being wrong at the first rename and nothing compiles the prose, so nothing catches it. There is no second copy here.

Precise, not generic. "Set a property by name" is a tool an agent has to guess with. ulib.DetectorChamber.configure with resolution declared as a number in millimetres between 0 and 50 is a tool it can use correctly the first time.

Refusals that teach. Everything needed to say which rule was broken is in the schema the agent already holds, so the check happens here and the answer names the rule.

One identity everywhere. The context, the .fbs and the wire all derive the same uuids from the same walk, so a peer, a schema registry and an agent are demonstrably talking about the same type.


6. Reference

C++ (src/Core/uprops/McpContext.h)

McpOptions toolPrefix, uriScheme, typeName, includeComputed, includeActions, includeConfigureTool, includeValues, addressByObject, stampIdentity
EmitMcpContext(schema, uri, opts) structure only, no instance
DescribeContainerAsMcp(object, uri, opts) the complete one
McpContext tools, resources, typeName/typeId/typeShape/schemaId, Merge, ToJson
JsonSchemaForFields(fields, description, opts) the reusable half

C++ (src/Core/ObjectCatalog.h): McpContextJson(className, scene, wholeRegistry, out, error), and the mcp_context command.

Python (uLib.remote): client.mcp_context(class_name="", registry=False).

Python (uLib.pyagent): connect(host, port, registry) → AgentBackend, with tools(), tool(name), resources(), read_resource(uri), classes(), describe(name), call(name, args), call_all([ToolCall...]), refresh(), scene; and SceneFacade, GeneratedClass, ObjectFacade in uLib.pyagent.proxy.

Tests: src/Core/uprops/testing/McpContextTest.cpp (the emitter, with every document parsed back), src/Core/testing/McpCatalogTest.cpp (uLib's real classes), src/Python/testing/pyagent_test.py (a live session, end to end), and the generated-tool case in src/Python/testing/mcp_server_test.py.