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.
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¶
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.