Skip to content

core: a protocol can be registered per client, and a client owns what it creates - #108

Merged
h3xxit merged 4 commits into
devfrom
feat/per-client-protocol-factories
Sep 16, 2026
Merged

h3xxit merged 4 commits into
devfrom
feat/per-client-protocol-factories

Conversation

@h3xxit

@h3xxit h3xxit commented Sep 15, 2026

Copy link
Copy Markdown
Member

Reference-implementation counterpart of typescript-utcp #52 and #54. The spec is generated from this repo's REQUIRED docstrings, so every new piece of surface carries one (checked with scripts/extract_required_docs.py).

The gap

A protocol registered in communication_protocols is one instance shared by every UtcpClient in the process, and so is any state it keeps. Right for a credential cache; wrong for connections. A caller creating a client per tenant, per user or per pooled connection was not actually isolating them — every client dialled into the same MCP session cache — and no client could tear its own connections down: the Python client had no close() at all.

The mechanism

  • New registry communication_protocol_factories, filled through register_communication_protocol_factory(type, factory). UtcpClient.create calls a factory once per client and the client records the instance as owned. A factory wins over a shared instance of the same type, so a plugin migrates by moving its registration and callers change nothing. Shared instances are still looked up live, so registering one after a client exists keeps working (existing tests rely on it).
  • Teardown scoped to what the client owns. New UtcpClient.close() (interface + implementation) closes the owned instances and leaves shared ones to the process. Every instance is closed even when one fails; the failures are then raised together as UtcpProtocolCloseError.
  • create() adopts factories inside a cleanup guard. Nothing that needs closing is created in the constructor (it can't await). A factory that raises part-way leaves the earlier instances closable; any initialization failure closes them before re-raising; a failing cleanup is logged with the original error kept as the one the caller sees.
  • CommunicationProtocol.close() joins the interface, a no-op by default, so the client can close any protocol uniformly.
  • utcp-mcp registers as a factory — sessions and stdio child processes belong to the client that opened them. utcp-http stays shared: its OAuth cache is meant to be reused.

Versions

Core → 1.2.0 (new registry + close() on the client interface), utcp-mcp1.2.0 requiring utcp>=1.2.0.

Tests

Ten, all through the public surface (core/tests/client/test_client_protocol_ownership.py): per-client routing, factory over shared, late shared registration, unknown type names both registries, close() scoped to the own instance, close() waits for every instance even when one fails, failed create() closes what it created, a raising factory leaves earlier instances closed, a shared instance survives a failed create(), a failing cleanup is reported. Each guard mutation-checked — every mutation fails exactly its tests. Core 51/51, MCP 48/48, http 238, cli 62, text 11.

🤖 Generated with Claude Code


Summary by cubic

Protocols can now be registered per client, so each UtcpClient owns its protocol instances and can close them. Previously one protocol instance was shared by every client, which broke per-tenant isolation and left connections unclosed.

Refactors

  • Added communication_protocol_factories; UtcpClient.create instantiates each registered factory per client, and factories registered later are adopted and owned on first use.
  • Added UtcpClient.close() to close owned protocol instances; shared instances remain process-wide.
  • Added CommunicationProtocol.close() as an interface method with a no-op default.
  • A failed create() closes any owned instances before re-raising; close failures are collected in UtcpProtocolCloseError.
  • register_manuals waits for every sibling registration to settle before raising, so a failed create() never closes a protocol another registration is still using.
  • utcp-mcp and utcp-websocket now register as factories; utcp-http stays shared.

Migration

  • Protocols whose state must not be shared between clients should switch from register_communication_protocol to register_communication_protocol_factory.
  • utcp, utcp-mcp, and utcp-websocket become 1.2.0, with the plugins requiring utcp>=1.2.0.

Written for commit f4e87bd. Summary will update on new commits.

Review in cubic

h3xxit and others added 2 commits September 15, 2026 22:20
… it creates

A communication protocol registered in communication_protocols is one
instance shared by every UtcpClient in the process, and so is any state
it keeps. Right for a credential cache; wrong for connections: a caller
creating a client per tenant, per user or per pooled connection was not
actually isolating them, and no client could tear its own down — the
client had no close() at all.

New registry: communication_protocol_factories, filled through
register_communication_protocol_factory. UtcpClient.create calls a
factory once per client, and the client records the instance as OWNED.
A factory wins over a shared instance of the same type, so a plugin
migrates by moving its registration and callers change nothing. Shared
instances are still looked up live, so late registration keeps working.

Teardown is scoped to what the client owns. UtcpClient.close() (new,
on the interface and the implementation) closes the owned instances and
leaves shared ones to the process — every instance is closed even when
one fails, then the failures are raised together as
UtcpProtocolCloseError. create() adopts the factory instances inside a
cleanup guard: nothing that needs closing is created in the constructor
(it cannot await), a factory that raises part-way leaves the earlier
instances closable, and any initialization failure closes them before
re-raising, a failing cleanup being logged with the original error kept
as the one the caller sees.

CommunicationProtocol.close() is now part of the interface, a no-op by
default, so the client can close any protocol uniformly. All of it
carries REQUIRED docstrings, since the spec is generated from them.

Tests (10, through the public surface): per-client routing, factory over
shared, late shared registration, unknown type names both registries,
close() scoped to own instance, close() waits for every instance,
failed create closes what it created, a raising factory leaves earlier
instances closed, shared survives a failed create, failing cleanup is
reported. Each guard mutation-checked. Core 51/51, http 238, cli 62,
text 11.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The MCP protocol holds live sessions and, for stdio, child processes.
Registered as one shared instance, every client in the process dialled
into one session cache and one client's close() drained everyone's.
It now registers through register_communication_protocol_factory, so
each UtcpClient gets its own instance, its own connections and its own
teardown.

The factory registry is a new core feature, so core goes to 1.2.0 and
utcp-mcp to 1.2.0 requiring utcp>=1.2.0. MCP suite 48/48.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 13 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread core/src/utcp/implementations/utcp_client_implementation.py
Comment thread core/src/utcp/implementations/utcp_client_implementation.py Outdated
…te factory is adopted on first use

Two findings from cubic on #108.

register_manuals gathered its registrations first-failure-wins: when one
raised UtcpVariableNotFound the caller got the failure while the sibling
registrations were still running underneath it — and create(), which
closes the client's protocols right after, would close them under a
registration still in flight. Every registration now settles before the
first failure is raised, so a caller holding the error holds a quiet
client.

The resolver consulted the factory registry only at creation, so a
factory registered after a client existed was invisible to it while a
shared instance registered late was not. Both registries are now
consulted live: a late factory is adopted on first use, owned and closed
like the rest. A type resolves the same way whenever it was registered.

Tests: register_manuals raises only once every sibling has finished; a
failed create closes its protocols only after every registration has
finished; a late-registered factory is adopted on first use and owned.
Each mutation-checked. Ownership 13/13, core 54/54, MCP 48/48, http 238.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread README.md Outdated
The guidance said a protocol that holds connections registers a
factory, while the WebSocket plugin — one live WebSocket per manual name
and URL — still registered a shared instance: two clients registering
the same manual used, and on deregistration closed, each other's
connection, and one client's close() dropped everyone's. It now
registers through register_communication_protocol_factory, so each
client owns its connections. utcp-websocket goes to 1.2.0 requiring
utcp>=1.2.0. Suite 38/38.

The criterion itself was too blunt. 'Holds connections' would also
sweep in the HTTP plugin's pooled aiohttp session, which is meant to be
shared. The README, the interface docstring and the registry docstring
now say what actually decides it: state that must not be shared between
clients — sessions or connections keyed per manual, child processes,
anything one client's use or close() would take away from another —
goes in a factory; a credential cache or a pooled session stays an
instance. Every shipped plugin is now on the side the criterion puts it:
mcp and websocket are factories; http, sse, streamable_http, gql, cli,
file, text, tcp and udp keep nothing per client and stay shared.

Raised by cubic on #108.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@h3xxit
h3xxit merged commit 2d4aa02 into dev Sep 16, 2026
10 checks passed
@h3xxit
h3xxit deleted the feat/per-client-protocol-factories branch September 16, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant