Skip to content
Merged

Dev #111

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,31 @@ client = await UtcpClient.create(config={

# Call a tool
result = await client.call_tool("my_api.get_data", {"id": "123"})

# Release the connections this client opened
await client.close()
```

### Protocol Lifetime: Shared vs. Per-Client Instances

A protocol plugin registers itself in one of two registries, and the choice decides who its state belongs to.

An instance registered with `register_communication_protocol` is **shared by every `UtcpClient` in the process**, and so is any state it keeps. That is right for state that is *meant* to be shared — a credential cache, a registry a decorator writes into — and wrong for state that belongs to one client. A shared instance lives as long as the process; no client closes it.

A protocol whose state **must not be shared between clients** registers a **factory** instead: live sessions or connections keyed per manual, child processes — anything one client's use or `close()` would take away from another. Each `UtcpClient` calls the factory once — at creation, or on first use if it was registered later — so each client gets its own instance, its own connections, and its own teardown on `close()`:

```python
from utcp.plugins.discovery import register_communication_protocol_factory

register_communication_protocol_factory("custom_type", CustomCommunicationProtocol)
```

This is what makes "a client per tenant / per user / per pooled connection" actually isolate them, rather than every client reaching into one shared instance. A type registered as a factory wins over the same type registered as an instance, so a plugin migrates by moving its registration from one call to the other and callers change nothing.

`utcp-mcp` and `utcp-websocket` register this way — MCP sessions (and, for stdio, child processes) and per-manual WebSocket connections belong to the client that opened them. `utcp-http` (HTTP, SSE, streamable HTTP) and `utcp-gql` stay shared instances: the only state they keep is an OAuth token cache, which is meant to be reused across clients; every request opens its own session.

`client.close()` closes the instances the client created — every one of them, even if one fails, after which the failures are raised together as `UtcpProtocolCloseError`. If `UtcpClient.create` fails after creating them, they are closed the same way before the error is re-raised.

## Protocol Plugins

UTCP supports multiple communication protocols through dedicated plugins:
Expand Down
23 changes: 23 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,31 @@ client = await UtcpClient.create(config={

# Call a tool
result = await client.call_tool("my_api.get_data", {"id": "123"})

# Release the connections this client opened
await client.close()
```

### Protocol Lifetime: Shared vs. Per-Client Instances

A protocol plugin registers itself in one of two registries, and the choice decides who its state belongs to.

An instance registered with `register_communication_protocol` is **shared by every `UtcpClient` in the process**, and so is any state it keeps. That is right for state that is *meant* to be shared — a credential cache, a registry a decorator writes into — and wrong for state that belongs to one client. A shared instance lives as long as the process; no client closes it.

A protocol whose state **must not be shared between clients** registers a **factory** instead: live sessions or connections keyed per manual, child processes — anything one client's use or `close()` would take away from another. Each `UtcpClient` calls the factory once — at creation, or on first use if it was registered later — so each client gets its own instance, its own connections, and its own teardown on `close()`:

```python
from utcp.plugins.discovery import register_communication_protocol_factory

register_communication_protocol_factory("custom_type", CustomCommunicationProtocol)
```

This is what makes "a client per tenant / per user / per pooled connection" actually isolate them, rather than every client reaching into one shared instance. A type registered as a factory wins over the same type registered as an instance, so a plugin migrates by moving its registration from one call to the other and callers change nothing.

`utcp-mcp` and `utcp-websocket` register this way — MCP sessions (and, for stdio, child processes) and per-manual WebSocket connections belong to the client that opened them. `utcp-http` (HTTP, SSE, streamable HTTP) and `utcp-gql` stay shared instances: the only state they keep is an OAuth token cache, which is meant to be reused across clients; every request opens its own session.

`client.close()` closes the instances the client created — every one of them, even if one fails, after which the failures are raised together as `UtcpProtocolCloseError`. If `UtcpClient.create` fails after creating them, they are closed the same way before the error is re-raised.

## Protocol Plugins

UTCP supports multiple communication protocols through dedicated plugins:
Expand Down
2 changes: 1 addition & 1 deletion core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "utcp"
version = "1.1.4"
version = "1.2.0"
authors = [
{ name = "UTCP Contributors" },
]
Expand Down
4 changes: 3 additions & 1 deletion core/src/utcp/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from utcp.exceptions.utcp_variable_not_found_exception import UtcpVariableNotFound
from utcp.exceptions.utcp_serializer_validation_error import UtcpSerializerValidationError
from utcp.exceptions.utcp_protocol_close_error import UtcpProtocolCloseError

__all__ = [
"UtcpVariableNotFound",
"UtcpSerializerValidationError"
"UtcpSerializerValidationError",
"UtcpProtocolCloseError",
]
16 changes: 16 additions & 0 deletions core/src/utcp/exceptions/utcp_protocol_close_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import List


class UtcpProtocolCloseError(Exception):
"""REQUIRED
Raised when one or more of a client's own protocol instances failed to close.

Every instance is still asked to close before this is raised, so nothing is
left half-torn-down behind it; `failures` carries what each failing close raised.
"""

def __init__(self, failures: List[BaseException], total: int):
self.failures = failures
self.total = total
super().__init__(f"{len(failures)} of {total} owned communication protocol(s) failed to close: "
+ "; ".join(f"{type(f).__name__}: {f}" for f in failures))
Loading
Loading