Tools¶
Tool system for AI function calling. See the Tool Calling & Policies guide and MCP Tool Provider guide for usage examples.
ToolPolicy ¶
Bases: BaseModel
Per-agent allow/deny rules for tool access.
Rules use :func:fnmatch.fnmatch glob patterns (e.g. "mcp_*",
"search_*").
Resolution order:
- Empty allow and empty deny → permit all (backward compatible).
- If the tool name matches any deny pattern → blocked.
- If allow is non-empty and the tool name matches no allow pattern → blocked.
- Otherwise → permitted.
In short: deny always wins, and a non-empty allow list is a whitelist.
Role overrides ~~~~~~~~~~~~~~
role_overrides maps :class:~roomkit.models.enums.ParticipantRole string
values (e.g. "observer", "member") to :class:RoleOverride instances.
Call :meth:resolve with a role to obtain an effective ToolPolicy that
merges the base rules with the role-specific override.
resolve ¶
Return an effective ToolPolicy for the given role.
If role is None or has no override entry, returns self
unchanged (backward compatible).
RoleOverride ¶
Bases: BaseModel
Per-role tool policy override.
mode controls how the override combines with the base policy:
"restrict"(default): deny lists are unioned, allow lists are intersected (a tool must pass both the base and override allow lists)."replace": the override completely replaces the base policy.
MCPToolProvider ¶
MCPToolProvider(url=None, *, transport='streamable_http', tool_filter=None, headers=None, command=None, args=(), env=None, cwd=None, discover=True)
Discover and invoke tools from an MCP server.
Three transports: streamable_http (default) and sse for a server
reached by URL, and stdio for a server RoomKit starts as a subprocess
and talks to over its stdin/stdout — the way most MCP servers are run.
Usage::
async with MCPToolProvider.from_url("http://localhost:8000/mcp") as mcp:
tools = mcp.get_tools() # list[AITool]
handler = mcp.as_tool_handler() # ToolHandler for AIChannel
async with MCPToolProvider.from_command("uvx", ["mcp-server-time"]) as mcp:
...
The tools are listed once, on entry; no call lists them again (RFC §21.2).
discover=False connects without listing, for a host that only reads
the server's resources.
Enter and exit it in the same task (async with): the MCP SDK's
transports hold anyio cancel scopes that must close where they opened.
connected
property
¶
Whether the provider holds a live connection: entered, not yet exited.
server_extensions
property
¶
The extensions the server declared when it connected, by identifier,
with their settings (io.modelcontextprotocol/skills among them);
empty for a server that declared none (RFC §21.2).
instructions
property
¶
What the server published, when it connected, on how to use its
tools (InitializeResult.instructions); None when it published
nothing (RFC §21.2).
The server's text, as it sent it: hand it to a channel's
server_instructions, which sets it apart and bounds it, rather than
into a prompt directly.
server_info
property
¶
What the server said of itself when it connected (serverInfo:
name, version...), as it sent it (RFC §21.2).
The server's own words, not a name for it: a host names a server by a
label of its own (server_instructions, add_mcp_server).
from_url
classmethod
¶
Create an MCPToolProvider for the given URL.
The provider is not connected until used as an async context manager.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
MCP server URL. |
required |
transport
|
str
|
|
'streamable_http'
|
tool_filter
|
Callable[[str], bool] | None
|
Optional predicate to include only matching tool names. |
None
|
headers
|
dict[str, str] | None
|
Optional HTTP headers sent with every request. |
None
|
discover
|
bool
|
List the server's tools on entry (the default). |
True
|
Returns:
| Type | Description |
|---|---|
MCPToolProvider
|
An MCPToolProvider instance (not yet connected). |
from_command
classmethod
¶
Create an MCPToolProvider for a server started as a subprocess (stdio).
Entering the provider starts command with args; exiting stops it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
command
|
str
|
Executable of the MCP server ( |
required |
args
|
Sequence[str]
|
Its arguments, passed as a list: no shell is involved. |
()
|
env
|
dict[str, str] | None
|
Extra environment variables for the server. The MCP SDK starts
it with a minimal environment ( |
None
|
cwd
|
str | None
|
Working directory of the server. |
None
|
tool_filter
|
Callable[[str], bool] | None
|
Optional predicate to include only matching tool names. |
None
|
discover
|
bool
|
List the server's tools on entry (the default); see
:meth: |
True
|
Returns:
| Type | Description |
|---|---|
MCPToolProvider
|
An MCPToolProvider instance (not yet connected). |
get_tools_as_dicts ¶
Return discovered tools as plain dicts (for binding metadata).
tool_meta ¶
The _meta each discovered tool was listed with, by tool name.
Read from the listing made at connection, so no second tools/list:
an MCP App's ui (resourceUri, csp) among the rest. A tool
listed without one, or not discovered (tool_filter, a name no
provider accepts, discover=False), is absent.
read_resource
async
¶
The server's ReadResourceResult for uri (an MCP App's HTML, say).
Raises:
| Type | Description |
|---|---|
McpError
|
The server refused the read (an unknown resource). |
TimeoutError
|
No answer within timeout seconds. |
request
async
¶
Send method, a request the SDK does not model (skills/list,
say), and return its result as the server sent it (RFC §21.2).
Raises:
| Type | Description |
|---|---|
McpError
|
The server answered with an error. |
TimeoutError
|
No answer within timeout seconds. |
call_tool_result
async
¶
Call the tool and return the server's CallToolResult as it is.
A failure is the result's isError, not an exception: :meth:call_tool
renders it into its error envelope, the tool handler raises it
(:class:~roomkit.core.exceptions.ToolFailedError). For a
host relaying the raw result (an MCP App's frame calling its server).
A successful result's structuredContent is also published to the
tool call in progress, when there is one (the model's own calls).
Like :meth:call_tool, it calls any tool the server has: tool_filter
shapes what discovery offers a model, not what the host may call (an
app-only tool the frame calls, say). The host authorizes the call.
A successful result of a tool listed with an output schema is checked
against it, and one that breaks it raises RuntimeError, as the MCP
SDK does. A tool the listing did not name is called as it is: no call
lists the server's tools (RFC §21.2).
call_tool
async
¶
Call a tool on the MCP server and return the result as a string.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Tool name. |
required |
arguments
|
dict[str, Any]
|
Tool arguments dict. |
required |
timeout
|
float
|
Maximum seconds to wait for a response. |
_DEFAULT_CALL_TIMEOUT
|
Returns:
| Type | Description |
|---|---|
str
|
Result string. Single TextContent → plain text; multi-part → JSON array; |
str
|
error results → |
The error envelope is this method's contract and does not change. A tool
loop reads :meth:as_tool_handler instead, which raises
:class:~roomkit.core.exceptions.ToolFailedError so the outcome does
not have to be recognised in the body.
as_tool_handler ¶
Return a ToolHandler suitable for AIChannel(tool_handler=...).
A tool that is not from this MCP server raises
:class:~roomkit.core.exceptions.UnservedToolCallError, which hands
the call to the next handler of a compose_tool_handlers chain and
reads as served by nothing on a channel (RFC §21.4).
gate_discovery=False forwards every name to the server instead. A
gateway that routes by name prefix and authenticates the caller per
call serves tools this connection never listed — a server whose
tools/list answers only behind the caller's own credential, say —
and a host with its own allow-list in front has already decided what
the model may call. Such a handler never raises
UnservedToolCallError, so it sits last in a
compose_tool_handlers chain: nothing after it would be reached.
A provider connected with discover=False discovered nothing, so
its gated handler serves no name: every call raises
UnservedToolCallError.
A tool whose result says isError raises
:class:~roomkit.core.exceptions.ToolFailedError either way: the tool
ran and failed, so the tool loop marks the call failed (not refused)
and hands the server's message to the model unchanged.
A result that carries an image (PNG, JPEG, GIF or WebP, with a payload
that decodes) comes back as content parts
(:class:~roomkit.providers.ai.base.AITextPart and
:class:~roomkit.providers.ai.base.AIImagePart), so the model sees the
image; any other result is the string :meth:call_tool returns. Binary
content the model cannot take (another image format, audio, a blob
resource) becomes a one-line note rather than its base64.
compose_tool_handlers ¶
Chain multiple ToolHandlers so the first one that handles a tool wins.
Each handler is tried in order. A handler that declines the call, by
raising :class:~roomkit.core.exceptions.UnservedToolCallError or by
answering the {"error": "Unknown tool: ..."} envelope (RFC §21.4),
hands it to the next one. The last handler's answer is the
composition's, a decline included: the channel then reads the call as
served by nothing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*handlers
|
ToolHandler
|
Two or more ToolHandler callables. |
()
|
Returns:
| Type | Description |
|---|---|
ToolHandler
|
A single ToolHandler that dispatches to the first matching handler. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If fewer than two handlers are provided. |
Per-Call Context¶
A tool handler receives only (name, arguments). These accessors read the turn
it is running under from a contextvar — see
What a handler knows about the call.
current_tool_room_id ¶
Room id of the tool loop the caller is executing under.
Returns None when called outside a tool loop.
current_tool_room ¶
The :class:~roomkit.models.room.Room of the turn the caller is executing under.
The room as the store loaded it when the turn began: the same object
RoomContext.room holds for that turn's hooks, memory provider and
config provider, so a handler deciding whom a call acts for reads the
room's organization_id, metadata or status here instead of
re-reading the room by :func:current_tool_room_id on every call. On a
realtime tool call, which runs no turn, it is the room as the store
loaded it for that call, shared with the BEFORE_TOOL_USE gate's
context when a hook made the channel build one. A
patch written to the store during the turn, by this handler or another,
is not in it; re-read the room when the turn's own writes matter. Do not
mutate it: the object is shared with the whole turn, a room changes
through the store, and a write on this object would be read by the rest
of the turn (the agent-response policy, the delivery plan) as if the
room had changed.
Like :func:current_tool_actor_id, it names the turn and authenticates
nothing. Which organization the room belongs to is a fact of the room;
whether the caller may act for it is the host's rule, applied by the
host.
None outside a tool loop, and None for a loop started without a
turn above it.
current_tool_actor_id ¶
Participant id of whoever's turn the caller is executing under.
The author of the event that woke the channel this round. Read it rather than the identity a handler captured when it was built — one channel object serves every room and every speaker, so a captured identity is whoever happened to attach it.
It names the turn; it does not authenticate it. The value is a room
Participant.id, and the inbound pipeline only substitutes the resolved
Identity.id for it once identification succeeds — a turn still pending,
ambiguous or unknown carries whatever the channel supplied, or a synthetic
pending-…, and reads back just as non-None. A handler that reaches
a person's data with it resolves it first: load the participant, require
Participant.identification to be IDENTIFIED, and take
Participant.identity_id as the principal.
The author need not be human, either. In a multi-agent room the waking
event may be another agent's, whose participant id reads back the same
way — compare the participant's role against ParticipantRole.AGENT
when that distinction matters.
None outside a tool loop, and None when the turn has no
participant behind it (a system injection, a webhook, a scheduled run).
A caller that needs a person then decides for itself — refuse, or fall
back to a principal it configured on purpose — rather than borrow whoever
spoke last.
current_tool_allowed_names ¶
Names of every tool in the current turn's resolved toolset that its tool policy admits, with what a round declared beyond it (the re-read of a stored result, from the round that declared it), nothing withdrawn.
_build_context stamps the turn's full toolset (config-provider
result plus channel-injected tools) into the loop context; a host's
tool handler can validate an incoming call against it instead of an
attach-time snapshot that goes stale on shared channels. A tool the
policy denies the turn's actor is left out, on every channel: the gate
refuses it before any handler. Includes skill-gated tools whose
visibility is filtered per round — gating is presentation, not an
execution boundary.
On a realtime tool call (a voice session, a conference) it is every
tool the session declares that its policy admits: its catalogue, what
orchestration set up, the channel's own (RFC §21.4); None when the
session declares no catalogue: it names no list, and its gate, policy
included, still judges each call.
Returns None outside a tool loop or before context build, so
hosts can fall back to their own allowlist.
current_tool_call ¶
The per-call context of the tool call the caller is executing under.
What _run_one set before invoking the handler — the call's id, its
room, its channel — and the reverse channel the handler may fill:
structured_content, the MCP structured result the tool-call events
carry for UI surfaces, as it is but for binary payloads past the event's
bound. A host that rewrites a result before the
model reads it (a provider's private address turned into its own relay
link, say) reaches the structured copy here, so the persisted event does
not keep what the text no longer says.
None outside a tool call.
ToolCallContext
dataclass
¶
Contextvar payload carrying tool-call metadata.
The ToolHandler protocol is (name, arguments) → result — it does not
receive room_id, tool_call_id or channel_id. This payload
bridges the gap: _ai_tools._run_one() sets it before calling the
handler, and a handler that needs the call's origin reads it. Safe
with :func:asyncio.gather, which creates Tasks with copied contexts.
structured_content is the reverse channel: the ToolHandler contract
returns only text or content parts, but MCP tools can produce a structured result
(CallToolResult.structuredContent) that UI surfaces need as it is —
the LLM-facing string may be truncated/evicted when large. A handler
that has one sets it here; _run_one() reads it back after the call
and carries it on the tool-call events untouched by eviction, its binary
payloads bounded like the result's.
current_response_metadata ¶
The response-metadata record of the turn the caller is executing under.
The one mapping RoomKit merges into every MESSAGE event the turn produces
(see :mod:roomkit.models.response_metadata): a memory provider writing it
during context build, a BEFORE_AI_GENERATION hook writing
event.ai_context.response_metadata, and a tool handler or a
BEFORE_TOOL_USE hook writing here (the hook runs under the call's turn)
all reach the same object, the one InboundResult.response_metadata
hands the caller, the turn answered or failed. A tool handler is the case this exists for — the
ToolHandler protocol hands it nothing but (name, arguments), and a
document it read is a fact about the turn, not about the tool's string
result.
Returns None when no loop context is set (a direct call) and on a
realtime tool call, where no turn merges the record: the guard
if record is not None then skips a write nothing would carry. A loop
started without a turn — no handle_event above it — carries a record
of its own that no MESSAGE event is built from; writes to it are harmless
and go nowhere.
tool_turn_context ¶
tool_turn_context(*, room_id=None, room=None, actor_id=None, tools=None, chain_depth=0, call=None, requester=None)
Run the enclosed code as a tool call of a turn described by the arguments.
What a test calling a tool handler directly needs: inside the block the
accessors of this module answer for the turn described here, as they do
for a call the tool loop makes, and on the way out (an exception
included) they answer what they answered before. A handler called
outside a tool loop otherwise reads None everywhere.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
room_id
|
str | None
|
The turn's room id, read by :func: |
None
|
room
|
Room | None
|
The turn's :class: |
None
|
actor_id
|
str | None
|
Whose turn it is, read by :func: |
None
|
tools
|
Iterable[AITool] | None
|
The turn's resolved toolset, read by
:func: |
None
|
chain_depth
|
int
|
The chain depth of the response the turn produces (RFC §8.3), which a result delivered later on its behalf inherits. 0, the default, is what outside a turn reads (RFC §21.4); an AI channel's turn answering a human's message runs at 1. |
0
|
requester
|
str | None
|
Who asked, read by :func: |
None
|
call
|
ToolCallContext | None
|
The per-call record :func: |
None
|
The turn carries a fresh response-metadata record, which
:func:current_response_metadata answers inside the block.
Raises:
| Type | Description |
|---|---|
ValueError
|
The arguments name more than one room: room and
room_id, or the turn's room and call's |
Turn Response Metadata¶
The record every writer of a turn shares — see What a handler can tell the turn.
ResponseMetadata ¶
Bases: MutableMapping[str, Any]
Turn-level metadata merged into every MESSAGE event the turn produces.
Behaves as a dict for every reader and writer ([...], .get,
.update, **, dict(...), ==); the only thing it adds is that
Pydantic keeps the instance instead of copying it, so one turn has one
record. A bare mapping passed where this type is expected is wrapped — the
caller's dict is then a snapshot, which is what passing a literal means.
Each MESSAGE event carries the record as it stands when the event is created: a streamed segment persisted before a tool round shows what was known then, the final answer shows everything the turn learned; the non-streaming path builds all its events at the end, so they read alike.
coerce
classmethod
¶
The instance itself, or a bare mapping wrapped — anything else is refused.
Human-in-the-Loop¶
HumanInputHandler ¶
Manages pending human input requests.
Core lifecycle::
pending = await handler.create("AskUser", args, room_id="r1", ...)
# → request is answerable from here on; the
# ON_USER_INPUT_REQUIRED notification runs alongside
result = await handler.wait(pending.pending_id, timeout=300)
# → blocks until resolve() / reject() / timeout
Two invariants the caller can rely on:
- The notification never gates the answer.
create()arms the request and returns; the_on_input_requiredcallback runs in a background task. A human who answers while that callback is still running — a slow WebSocket broadcast, a hook burning its 30 s budget — is answering a request that is already listening. A denial coming back from the callback rejects the request, andwait()reports it. - A recorded outcome stays readable. A request settling — answered,
rejected, timed out — is kept in a bounded retention (retention
entries, newest kept), and
wait()replays it once the request has left the active set. Only a genuinely unknown id raisesValueError, so neither a second read nor a host that keeps its own bookkeeping can turn an answer that arrived into a hard failure. - A channel scope belongs to a channel object, not to its id. A host
that rebuilds the channel serving an id — the same agent re-attached to
a second room — hands the same shared handler a succession of owners.
Registering re-opens the scope, and the departing owner's
close()is a no-op once a newer one has taken over, so a predecessor being torn down cannot silence its live successor.
The _on_input_required callback is injected by the framework
(via register_channel hook builder) or set by the application
directly.
close
async
¶
Stop notifications and settle requests owned by one channel.
When channel_id is omitted, all work owned by this handler is
stopped. Channel-scoped closing lets a handler be shared safely by
multiple :class:~roomkit.channels.ai.AIChannel instances.
registration is the token :meth:_set_on_input_required handed
the closing channel. Passing it makes the close belong to that channel
object rather than to the id it used: a channel displaced from the
registry and torn down later closes nothing, because the id is already
serving its replacement. Omitting it closes the scope unconditionally,
which is what a lone owner and a manual host call both want.
create
async
¶
create(tool_name, arguments, *, room_id='', tool_call_id='', channel_id='', channel_type=AI, actor_id=None)
Register a new pending input request and schedule the callback.
Returns as soon as the request is answerable — the
_on_input_required callback runs in a background task and a
denial from it rejects the request wherever wait() has got to.
wait() owns this request's cleanup; for a request no one will
wait on, use :meth:create_detached.
actor_id names whose turn raised the request, so a notification
layer can ask that person rather than the whole room. The native
:class:HumanInputToolHandler fills it from the tool loop; a caller
driving its own loop passes what it knows.
create_detached
async
¶
create_detached(tool_name, arguments, *, room_id='', tool_call_id='', channel_id='', channel_type=AI, actor_id=None)
Register a pending request that no one will :meth:wait on.
For runtimes that own their own tool loop — a Claude Code sandbox,
say — where create() exists to raise the request and the answer
travels back another way. Nothing here retires the request, so its
creator MUST call :meth:release when done with it; otherwise the
entry lives as long as the handler.
wait
async
¶
Block until the request is resolved, rejected, or times out.
An outcome already reached and consumed is replayed from the retention, so waiting twice — or waiting after someone else dropped their own record of the request — reports what happened rather than an error.
Returns:
| Type | Description |
|---|---|
str
|
The result string on resolution. |
Raises:
| Type | Description |
|---|---|
TimeoutError
|
If the timeout expires, or if a retained request had timed out. |
HumanInputRejectedError
|
If the request was rejected, by a human
or an |
RuntimeError
|
If the handler gave the request up before an answer (its close, a release), or is closed. |
ValueError
|
If pending_id is unknown — never seen, or retired long enough ago to have been evicted from the retention. |
release ¶
Drop a request whose cleanup the caller owns.
The counterpart of :meth:create_detached. A request still
unanswered is rejected on the way out, so a stray waiter unblocks
instead of hanging; the outcome goes to the retention either way and
stays readable by :meth:wait.
Returns True if an active request was dropped.
resolve ¶
Resolve a pending request with a result.
Returns True if the request was found and resolved.
reject ¶
Reject a pending request.
Returns True if the request was found and rejected.
HumanInputToolHandler ¶
ToolHandler wrapper that blocks on human input for specified tools.
Composes with other handlers via
:func:~roomkit.tools.compose.compose_tool_handlers. Declines a
non-matching tool name by raising
:class:~roomkit.core.exceptions.UnservedToolCallError, so the compose
chain continues to the next handler (RFC §21.4).
Pass this as human_input_handler= to an
:class:~roomkit.channels.ai.AIChannel, a
:class:~roomkit.channels.realtime_voice.RealtimeVoiceChannel or a
:class:~roomkit.conference.models.ConferenceRealtimeConfig: the channel
declares :attr:tools and serves :attr:tool_names before the host's
handler, on every door, under :attr:timeout rather than its default
call bound, and the framework injects the ON_USER_INPUT_REQUIRED
hook callback at registration time (RFC §9.3, §21.6). Given as a plain
tool_handler it is served as any other handler, without those rules.
ask
async
¶
Block on a person's answer to the call name; decline any other.
channel_type is the door the call came through, which the request
and its ON_USER_INPUT_REQUIRED event name. Raises
:class:~roomkit.core.exceptions.UnservedToolCallError for a name
outside :attr:tool_names, so a compose chain goes on to the next
handler (RFC §21.4).
Raises :class:~roomkit.core.exceptions.ToolRefusedError when the
request is rejected (a human, an ON_USER_INPUT_REQUIRED hook), the
model reading why, and
:class:~roomkit.core.exceptions.ToolFailedError when nobody answers
within :attr:timeout: the tool ran and got no answer. Any other
error, the handler giving the request up included, takes the generic
failure path, its message withheld from the model (RFC §9.3).
PendingInput
dataclass
¶
PendingInput(pending_id, tool_name, arguments, room_id, tool_call_id, channel_id, status=PENDING, result=None, reject_reason=None, detached=False, created_at=_utcnow(), actor_id=None, _event=Event(), _withdrawn=False)
A pending human input request.
Mutable — transitions from PENDING to RESOLVED/REJECTED/TIMED_OUT
when the application calls :meth:HumanInputHandler.resolve or
:meth:HumanInputHandler.reject.
detached
class-attribute
instance-attribute
¶
No one will call wait() on this request — its creator frees it with
:meth:HumanInputHandler.release. wait() owns the cleanup of every
other request.
actor_id
class-attribute
instance-attribute
¶
Participant whose turn raised this request, when the tool loop knew one.
A request that names nobody is a request a notification layer has to
broadcast, and an answer it cannot attribute. None when the turn had no
author (a system injection, a webhook, a scheduled run) or when the creator
runs its own tool loop and did not supply one. It names the turn without
authenticating it — resolve it against the room's roster before treating it
as a principal, as current_tool_actor_id() documents.
PendingInputEvent
dataclass
¶
PendingInputEvent(pending_id, tool_name, arguments, room_id, tool_call_id, channel_id, channel_type, timestamp=_utcnow(), actor_id=None)
Event fired through ON_USER_INPUT_REQUIRED hooks.
Carries the pending request details so notification layers (WebSocket, REST, etc.) can inform the user.
timestamp
class-attribute
instance-attribute
¶
When the pending request was created.
actor_id
class-attribute
instance-attribute
¶
Participant whose turn raised the request, when the tool loop knew one.
What lets a notification layer ask the person who asked rather than
everyone in the room. Appended rather than grouped with the origin fields
above so existing positional construction keeps working. None when the
turn had no author.
PendingInputStatus ¶
Bases: StrEnum
Status of a pending human input request.