Tools subsystem
Tools are the agent’s interface to the external world. Every side-effecting action — web search, file operation, API call, script execution, MCP invocation — flows through the tool system. The design keeps the agent process free of direct dependencies on external services; it invokes tools by name, and the infrastructure routes and executes them.
Tool execution model
LLM produces a tool call (name + JSON arguments)
│
▼
UserMessageHandler (in agent process)
│ RegistryToolFunction wraps each registered tool as an AIFunction
│
▼
IToolRegistry.GetExecutor(name)
│
▼
IToolExecutor.ExecuteAsync(ToolInvokeRequest)
│
├── Local executor (web, scheduling, in-process MCP)
│ Returns ToolInvokeResponse directly
│
└── Remote executor (MCP proxy, script runner)
Publishes ToolInvokeRequest to message bus topic
Awaits correlated ToolInvokeResponse (or ToolError) via ReplyTo
All tools are registered in IToolRegistry at startup by hosted service registrars. The agent
never hard-codes tool names — it discovers them at runtime via the registry and exposes them to
the LLM as AIFunction instances.
Core abstractions (RockBot.Tools.Abstractions)
IToolRegistry
Central directory of registered tools and their executors:
public interface IToolRegistry
{
IReadOnlyList<ToolRegistration> GetTools();
IToolExecutor? GetExecutor(string name);
void Register(ToolRegistration registration, IToolExecutor executor);
void Unregister(string name);
}
ToolRegistry is a thread-safe ConcurrentDictionary-backed implementation. Attempting to
register a duplicate tool name throws — enforcing uniqueness across all tool providers.
ToolRegistration
Metadata the LLM receives for each tool:
public sealed record ToolRegistration(
string Name, // e.g. "web_search", "mcp_invoke_tool"
string Description, // Natural-language description for the LLM
string ParametersSchema, // JSON Schema string (OpenAI function-calling format)
string Source // Backend type, e.g. "brave", "mcp:weather-server"
);
ToLlmToolDefinition() converts a ToolRegistration to the AITool type expected by
Microsoft.Extensions.AI.
IToolExecutor
public interface IToolExecutor
{
Task<ToolInvokeResponse> ExecuteAsync(
ToolInvokeRequest request,
CancellationToken ct = default);
}
ToolInvokeRequest / ToolInvokeResponse / ToolError
public sealed record ToolInvokeRequest(
string ToolCallId, // Correlates LLM tool call to response
string ToolName,
string Arguments, // JSON string of the tool's arguments
string SessionId
);
public sealed record ToolInvokeResponse(
string ToolCallId,
string ToolName,
string Content, // Tool output (plain text or JSON)
bool IsError
);
public sealed record ToolError(
string ToolCallId,
string ToolName,
string Code, // ToolError.Codes.* constant
string Message,
bool IsRetryable
);
ToolError.Codes constants: ToolNotFound, ExecutionFailed, Timeout, InvalidArguments.
Tool guide system
Each tool subsystem can register an IToolSkillProvider that publishes a usage guide —
a markdown document explaining how to use the tools effectively.
public interface IToolSkillProvider
{
string Name { get; } // e.g. "web-tools", "mcp"
string Summary { get; } // One-line description
string GetDocument(); // Full markdown guide
}
ToolGuideTools exposes two LLM-callable tools built from all registered providers:
| Tool | Purpose |
|---|---|
list_tool_guides() |
Lists available guides by name + summary |
get_tool_guide(name) |
Returns the full markdown guide for a named provider |
The typical agent workflow:
- Encounter a new capability (e.g. MCP server)
- Call
list_tool_guidesto discover available guides - Call
get_tool_guide("mcp")to read the full procedure - Follow the guide; call
save_skillto cache the pattern for future sessions
Tool invoke handler
ToolInvokeHandler is an IMessageHandler<ToolInvokeRequest> that receives tool invocation
requests from the message bus (topic tool.invoke), dispatches to the registered executor,
and publishes the result to the ReplyTo topic (default tool.result).
Error classification:
TimeoutException→ToolErrorwith codeTimeout,IsRetryable = trueArgumentException→ToolErrorwith codeInvalidArguments,IsRetryable = false- Other exceptions →
ToolErrorwith codeExecutionFailed,IsRetryable = false - Unregistered tool name →
ToolErrorwith codeToolNotFound
Instrumentation: every invocation records an Activity (kind Producer) and updates the
rockbot.tool.invoke.duration histogram and rockbot.tool.invocations counter, tagged by
tool name and result status.
MCP bridge (RockBot.Tools.Mcp)
The MCP (Model Context Protocol) bridge connects the agent to external MCP servers — services that expose tools in a standard protocol over HTTP (Streamable HTTP or legacy SSE).
It has two halves that talk over the message bus:
- Agent side (
RockBot.Tools.Mcp, registered withagent.AddMcpToolProxy(...)): themcp_*management tools, the typed{server}__{tool}tools,McpToolProxy, and the server index the agent reads. - Bridge (
McpBridgeServiceinsrc/RockBot.Agent/McpBridge/): a hosted service inside the RockBot.Agent process that owns the MCP client connections. There is no separate bridge process or deployable; the bus topics (tool.invoke.mcp,mcp.manage,tool.meta.mcp.*) are the boundary between the halves.
Discovery and registration
McpBridgeService connects to each configured MCP server at startup:
- Loads
mcp.json(McpBridge:ConfigPath), seeds anyMcpBridge:DefaultServersentries, and assigns each entry a stableid - Connects to each server, reads its tools and prompts, and applies the entry’s
allowedTools/deniedToolsfilters - Writes a one-sentence LLM summary of each server (
McpBridge:GenerateLlmSummaries, default on) - Publishes
McpServersIndexedontool.meta.mcp.{agentName}
On the agent side McpServersIndexedHandler applies the message to McpServerIndex, registers
the management tools on the first message, and has McpWrapperCatalog reconcile the typed
tools. Downstream tools are not registered under their bare names: the model reaches them
through typed tools or mcp_invoke_tool.
McpStartupProbeService sends a McpMetadataRefreshRequest after the agent is fully started,
closing the race condition where the bridge publishes the inventory before the agent has
subscribed.
Server configuration
Servers are declared in mcp.json (Helm sets McpBridge__ConfigPath to
/data/agent/mcp.json on the agent PVC):
{
"mcpServers": {
"weather": {
"type": "sse",
"url": "http://mcp-weather:8080/",
"deniedTools": ["delete_station"],
"toolTimeoutMs": 120000
}
}
}
typesse,httpandstreamable-httpall mean an HTTP server;transportMode(defaultauto) picks Streamable HTTP or legacy SSE. Entries withcommand/args(stdio) are parsed but skipped with a warning — the bridge runs inside the agent and does not launch server processes.- Other per-server fields:
allowedTools,deniedTools,headers,auth,toolTimeoutMs,attachments,argGuards,elicitation,tasksandtaskTimeoutMinutes(the last five are described below).idandoriginare written by the bridge. McpBridge:DefaultServers:{name}(Helm configmap) maps a name to a URL; each is seeded intomcp.jsonas ansseentry unless an entry already points at that URL.- The bridge watches
mcp.json(file watcher plus aConfigPollIntervalSecondspoll) and picks up edits without a restart.
See design/mcp-bridge.md for the full field list and the ownership rules.
Management tools
When the first McpServersIndexed message arrives, McpServersIndexedHandler registers eight
management tools that give the agent runtime control over MCP servers:
| Tool | Purpose |
|---|---|
mcp_list_services |
Lists all connected MCP servers from the local index (no bridge call) |
mcp_get_service_details(server_name, tool_name?) |
Returns the server’s identity and capped instructions, plus tool and prompt schemas (or a single tool’s). In lazy/pinned mode it also makes those tools callable by typed name |
mcp_invoke_tool(server_name, tool_name, arguments) |
Invokes a downstream tool by the server’s own tool name — the escape hatch when there is no typed tool |
mcp_register_server(name, type, url) |
Connects a new HTTP MCP server at runtime (type is sse). New names only; it can’t change an existing server |
mcp_unregister_server(server_name) |
Disconnects and removes a server added with mcp_register_server. Servers the operator configured can’t be removed |
mcp_get_prompt(server_name, prompt_name, arguments?) |
Fills in a server’s prompt template and returns its messages |
mcp_list_resources(server_name) |
Lists a server’s resources (uri) and resource templates (uriTemplate), with name, description and MIME type |
mcp_read_resource(server_name, uri) |
Reads one resource by the server’s own URI, any template expanded. Text comes back inline; binary or long text is saved to the shared volume and returned as a path |
A ninth, mcp_find_tools(query, limit?), is registered alongside them when some tier’s
wrapper mode is Lazy or Pinned (see below). Runs in an Off or Eager tier drop it from
their tool list. Two more follow the connected servers: mcp_answer while a server can hand
questions back (see Elicitation), and mcp_cancel_task while a server runs
background tasks (see Background tasks).
Critical: mcp_invoke_tool requires the exact server_name from mcp_list_services. The
rb-mcp-server header carries the server name through the message bus so the bridge routes
to the correct server. Case-insensitive matching is used throughout.
Typed tools ({server}__{tool})
Each downstream tool can also be offered as a typed tool named {server}__{tool}, with the
server’s own input schema as its parameters (McpWrapperCatalog, McpTypedToolSurface). Typed
calls and mcp_invoke_tool share one invoke path (McpManagementExecutor.InvokeDownstreamAsync),
so guards, attachments, elicitation, timeouts and recovery apply to both.
McpBridge:WrapperMode (Helm agent.mcpWrapperMode) picks how they are offered, and
McpBridge:WrapperModeByTier:{Low|Balanced|High} (Helm agent.mcpWrapperModeByTier) overrides
it per model tier:
| Mode | Typed tools in a run |
|---|---|
Off |
None — management tools only |
Eager |
Every typed tool, in every run |
Lazy |
Only those the session activated: found with mcp_find_tools, opened with mcp_get_service_details, or called by typed name |
Pinned (default) |
As Lazy, plus every typed tool of the servers the session has called |
In Lazy and Pinned runs, each downstream prompt also has a typed tool,
{server}__{prompt}-prompt, whose parameters are the prompt’s arguments (all strings). It is
activated only by mcp_find_tools (which returns matching prompts in a separate prompts list)
or by calling its name; mcp_get_service_details lists it as the prompt’s typedName, and a pinned
server never brings its prompts in. Calling one returns the prompt’s messages, the same as
mcp_get_prompt, which stays available in every mode.
Naming, activation limits and the per-tier measurements are in design/mcp-bridge.md.
Resources
Downstream MCP resources (readable data addressed by URI) are reached through
mcp_list_resources and mcp_read_resource only: they have no typed tools and
mcp_find_tools doesn’t search them. mcp_list_services shows a resourceCount for a server
that has any. Text up to McpBridge:ResourceInlineTextLimit (default 32,000 characters) is
returned inline; blobs and longer text are saved to the shared attachments volume and come back
as a path, never as base64. In a tool result, a resource_link block becomes a
[resource link] line naming the server and URI to read, and an embedded resource becomes
its text (or, for a blob, a saved file). See design/mcp-bridge.md.
Orientation and server instructions
Every run whose tool list has an MCP tool carries a short MCP orientation: a system message
(McpOrientation, under 2,000 characters — a test holds it there) that AgentLoopRunner
inserts right after the system prompt. It covers what the servers are for, the typed-tool
naming, the workflow for the run’s wrapper mode, the mcp/{server} skill to read first, and the
mcp_invoke_tool escape hatch. The full reference is the mcp tool guide
(McpToolSkillProvider), fetched with get_tool_guide.
A downstream server’s own instructions are capped by McpInstructionsCap: 2,000 characters in
mcp_get_service_details output, and 8,000 in the prompt the bridge uses to write the server’s
summary. Longer text is cut on a line boundary and ends with an explicit
[Server instructions truncated: N of M characters shown. ...] marker, which names the server’s
guide-like tools (guide, help, docs, …) when it has any.
Tool invocation flow
Agent: calendar__get_events(args) or mcp_invoke_tool(server_name, tool_name, args)
│
▼
McpManagementExecutor.InvokeDownstreamAsync → McpToolProxy
│ Publishes ToolInvokeRequest to "tool.invoke.mcp"
│ rb-mcp-server: {server_name}
│
▼
McpBridgeService (hosted in the agent process)
│ guards → attachment rewrite → tools/call over HTTP
│
▼
ToolInvokeResponse on "tool.result.{agentName}"
│ Correlated by the envelope's CorrelationId
│
▼
McpRecoveryExecutor → tool result
McpToolProxy uses lazy subscription initialization (semaphore-protected) so the
result-listener topic is subscribed only on the first actual invocation.
In-process registration (non-production)
AddMcpTools is an older in-process path that RockBot.Agent does not use:
agent.AddMcpTools(opts => builder.Configuration.GetSection("Mcp").Bind(opts));
It registers McpToolRegistrar, which launches each McpOptions.Servers entry as a stdio
process (Name, Command, Arguments, EnvironmentVariables) and registers every downstream
tool in IToolRegistry under its bare name with source mcp:{serverName}. There is no bridge,
no management tools, no typed tools, and none of the guards, attachment, elicitation or
recovery handling described here.
Attachment passthrough
Some MCP servers (calendar, email, drive) take or return file attachments. Passing those bytes through the LLM as base64 wastes context and confuses the model — and most servers also accept a “stash + handle” REST flow that the model cannot easily orchestrate by itself. The bridge’s attachment passthrough hides both shapes behind a single convention: the model speaks paths, and the bridge translates them into whatever the server expects.
Storage is the shared volume mounted into the agent and script pods at $ROCKBOT_SHARED_PATH
(defaults to /rockbot/shared). Attachments live under ${ROCKBOT_SHARED_PATH}/attachments/,
so a script can write a file there and the agent can hand it to an MCP tool without ever
loading the bytes into context.
Opt in per server with an attachments block in mcp.json:
{
"mcpServers": {
"calendar": {
"type": "sse",
"url": "https://calendar-mcp.example/",
"attachments": {
"thresholdBytes": 262144,
"uploadFieldName": "file",
"endpointPath": "/attachments",
"outbound": {
"paramPaths": ["attachments[*]"]
},
"inbound": {
"tools": ["get_email_attachment"]
}
}
}
}
}
| Field | Default | Purpose |
|---|---|---|
thresholdBytes |
262144 (256 KB) | Below: outbound files are inlined as {name, base64Content}. At or above: uploaded via POST /attachments and replaced with {attachmentId}. |
uploadFieldName |
file |
Multipart form-field name used for the upload. |
endpointPath |
/attachments |
Path appended to the server URL for POST (upload), GET /{id} (download), and DELETE /{id} (cleanup). |
outbound.paramPaths |
none | JSON paths that contain attachment objects to rewrite. First version supports arrayKey[*] — a top-level array whose items have a path field. |
inbound.tools |
none | Tool names that accept the gateway-only mode: "save" argument. |
Outbound (model attaches a file)
When the model calls e.g. send_email({ attachments: [{ path: "/rockbot/shared/attachments/report.xlsx" }] }):
- Bridge reads bytes from the path via
IAttachmentStorage. - Below threshold → replaces the entry with
{ name, base64Content }. - At or above threshold →
POST /attachments(multipart), accepts 200 or 201, replaces the entry with{ attachmentId }.
The underlying tool sees a fully populated payload and never knows the gateway was involved.
Inbound (model wants a file written to disk)
For tools listed in inbound.tools, the model can pass a gateway-only argument
mode: "save". The bridge translates this into mode: "stash" (default) or
mode: "inline" (when an optional sizeHint is below threshold), invokes the tool, and
then materializes the bytes:
- Stash path →
GET /attachments/{id}to fetch the body, write to${ROCKBOT_SHARED_PATH}/attachments/<name>, fire-and-forgetDELETE /attachments/{id}. A 404 on DELETE is tolerated. - Inline path → base64-decode the response and write to disk.
Either way the agent receives { path, name, size, mime } — a tiny JSON payload it can act on
without seeing the bytes. Filename collisions are resolved with -2, -3, … suffixes.
Attachment passthrough is a no-op when a server has no attachments block. The agent skill
guide instructs the model to pass { path: "/rockbot/shared/attachments/<file>" } rather than
base64 whenever a tool’s parameter takes attachments — operators don’t need to do anything more
than enable the manifest.
Binary capture (response side, no manifest required)
Passthrough needs a cooperating server. Capture handles the ones that don’t: every MCP response
passes through BinaryResponseCapture on its way back to the agent.
Typed image/audio content blocks are captured with no configuration — MCP already
labels them, so the bytes are written to the attachments directory and the block is replaced
with {path, name, size, mime, note}. Other blocks in the response are untouched.
Base64 inside an ordinary JSON response needs a declared rule, because sniffing for “a field that looks like base64” is the fragile heuristic the manifest design avoids:
{
"attachments": {
"capture": {
"rules": [
{
"tools": ["get_file_contents"],
"contentField": "content",
"nameField": "name",
"encodingField": "encoding"
}
]
}
}
}
That is the shape of the official Gitea server’s file tool, and it needs no change on the server side — only a description of the response it already sends.
A rule only fires when the payload really is binary: the name or MIME field decides when there
is one (image/*, audio/*, video/*, PDF, zip, Office — but not SVG, which is text the model
can read), and otherwise the bytes do, via a NUL byte or a strict UTF-8 decode failure. A README
returned through the same base64 field as a PNG stays in the response as content.
Some servers don’t base64 their binary at all — they decode the bytes as UTF-8 first, which
destroys them at the source. Under a declared rule, a content field that fails base64 decoding is
checked for that signature (eight or more U+FFFD in at least a kilobyte) and dropped with a note
explaining that the bytes are unrecoverable and that retrying returns the same corrupted text.
The response’s name, sha, size and html_url survive. On the reference deployment this
took one such call from 1,366,356 characters and 22 working-memory chunks to 588 characters and
none.
Set capture.enabled: false to disable it for a server. Capture never fails a tool call — any
error logs and passes the server’s original response through untouched.
Argument guards
Some third-party MCP servers resolve path arguments inside their own pod: a
download_file call with save_directory: "/tmp" succeeds — but the file lands in the
server pod’s local filesystem, invisible to the agent and script pods, while the tool
reports success. Argument guards let an operator declare per-server validation that the
bridge enforces before forwarding a tool call:
{
"mcpServers": {
"onedrive-personal": {
"type": "sse",
"url": "http://onedrive-personal:3001/",
"argGuards": [
{ "handler": "path-prefix",
"tools": ["download_file"],
"options": {
"args": ["save_directory"],
"allowedPrefixes": ["/rockbot/shared"],
"requireArgs": true } }
]
}
}
}
| Field | Meaning |
|---|---|
handler |
Registry name of the IMcpArgGuard implementation. Built-in: path-prefix. |
tools |
Tool names the rule applies to (case-insensitive). Empty/omitted = all tools. |
options |
Handler-specific; for path-prefix: args (argument names to validate), allowedPrefixes (absolute paths), requireArgs (reject when a listed argument is missing; default false). |
Behavior:
- Guards run on the model’s original arguments, before attachment passthrough rewrites them, and apply to every caller (primary agent, subagents, workers, wisps, A2A) — all MCP invocations flow through the bridge’s single invoke handler.
- A rejection returns
ToolError(invalid_arguments, not retryable); the message names the offending argument, the allowed prefixes, and the reason, so the model can self-correct in one turn. - Fail closed: an unknown
handlername or invalidoptionsrefuses the server connection entirely (logged as an error). A declared policy that cannot be enforced never silently degrades. path-prefixrejects relative paths and traversal that escapes the prefixes (/rockbot/shared/../../tmp); comparison isOrdinal(Linux semantics) and boundary-aware (/rockbot/shareddoes not match/rockbot/shared-evil). Missing arguments pass unlessrequireArgsis set. EmptyallowedPrefixesis a config error, not allow-all.- Handlers are resolved from a DI registry by name — mcp.json never names CLR types
(
mcp_register_serveris model-callable, so config-driven type loading would be a code execution channel). The model can’t replace or remove a server that has guards, so it can’t strip them (see “Operator entries and model registrations” indesign/mcp-bridge.md). - Guards are excluded from the canonical-identity dedup, like
attachments: they describe how the server is invoked, not which server it is.
See design/mcp-arg-guards.md for the security rationale.
Elicitation (servers that ask questions mid-call)
MCP servers may interrupt a tools/call to ask the client a question — “which mailbox?”,
“this overwrites 12 rows, confirm?” — and block the tool call until they get an answer. The
bridge always answers, always within the tool-call budget, with one of the protocol’s three
actions (accept, decline, cancel), so such a call finishes on the server’s terms instead
of hanging until the timeout.
Policy is per server, with a bridge-wide McpBridge:DefaultElicitation fallback:
{
"mcpServers": {
"mail": {
"type": "streamable-http",
"url": "http://mcp-mail:8080/mcp",
"elicitation": {
"mode": "auto",
"maxPerCall": 3,
"responderTimeoutMs": 20000,
"responder": "llm",
"defaults": { "mailbox": "work" },
"deniedFields": ["accountId"]
}
}
}
}
| Field | Default | Meaning |
|---|---|---|
mode |
auto |
auto answers; decline refuses every question; off never advertises the capability, so a compliant server does not ask; handback hands the question to the calling agent (see below; server policy only). An unrecognized value reads as decline. |
handbackTtlMinutes |
30 | handback only: how long a handed-back question stays open before its call is cancelled. |
maxPerCall |
3 | Rounds answered per tool call. 0 answers none. |
defaults |
{} |
Deterministic answers by field name (case-insensitive), applied before — and overriding — the responder. |
deniedFields |
[] |
Extra field names to refuse, on top of the built-in credential heuristics. |
responderTimeoutMs |
20000 | Budget for working out an answer, inside the tool-call timeout. |
responder |
(host default) | Which responder answers for this server, by keyed-service name: llm (the default — answers from the call’s own arguments) or conversation (answers choice fields from the calling conversation; server policy only, see below). A name that is not registered answers from defaults only — it never falls back. |
Behavior:
- Only
formmode is answered.urlmode expects the client to walk a person through a browser flow; a headless agent has neither, so it is declined with that reason. - Answers are schema-validated. Values that do not fit the schema the server sent are never forwarded — no coercion, so a string where a number was asked for is a decline, not a parse. Invented fields are dropped; omitted optional fields are left out so the server’s own schema defaults still apply. An answer that supplies none of the requested fields is a decline, and a field of a type the bridge cannot read (anything outside MCP’s primitive subset) declines the whole request.
- Yes/no decisions are never made by a model. A boolean field, or a choice between
yes/no/confirm/cancel-style options, is a checkpoint the server put there for a person. A
required one declines the form unless
defaultspre-answers it ({"confirm": true}); an optional one is left out so the server applies its own default. - Credential-shaped fields decline the whole request, before anything tries to answer it.
Field name, title and description are matched against credential vocabulary (
password,apiKey,token,ssn, card numbers, one-time codes, …). This is code, not config, and cannot be switched off from mcp.json. - The agent is told. Whatever was asked and how it was answered is appended to the tool result as a text block naming the missing fields, so the next attempt can supply them as ordinary tool arguments — or the agent can ask the user. Timeout and failure errors carry the same note, and a call that fails after a declined question is not transparently retried. Retrying is only suggested for fields that are parameters of the tool; for anything else the note says a retry would just be asked the same question, and to ask the user instead.
- Answers come from the in-flight call. The default responder is an LLM call in the bridge
with no conversation and no tools, prompted to answer only from the arguments the agent
already passed and to decline rather than invent.
IMcpElicitationResponderis the seam for a deployment that can put the question to a person instead; register it as a keyed service and name it in a server’sresponderto use it for that server only. "responder": "conversation"answers from the calling conversation’s recent turns — for questions like “which of these did you mean?” that the call’s arguments can’t settle. It sends values drawn from the user’s conversation to the server, so it is deliberately narrow:- it must be named in the server’s own
elicitationblock — it is refused inMcpBridge:DefaultElicitation, and the model can’t re-point the server it was granted to; - it answers choices only — any free-text or number field is declined before the conversation is read;
- it sees only the recent conversation, with secret-shaped text (pasted keys, tokens, passwords, card numbers) redacted — no memory, working memory, rules or tools;
- it answers only calls from a single user conversation, and cannot ask the user (an unsettled question is declined and handed back to the agent).
It runs a short model call through the agent loop, so give the server
"responderTimeoutMs": 30000or more and atoolTimeoutMsabove that.- it must be named in the server’s own
"mode": "handback"lets the agent, or the user, answer. The tool call returns at once with the server’s question and aquestion_id. The agent answers from what it knows, or asks the user and answers on a later turn, with themcp_answertool. The paused call then resumes andmcp_answerreturns its result.- It applies only to servers on the 2026-07-28 protocol, and only to calls from a run that has
mcp_answer: the primary agent’s conversation, and subagents.- Scheduled tasks, workers and wisps get the server’s
responderinstead, as do older servers. - A subagent answers from its task’s context. It can’t ask the user, so it declines decisions and reports them in its result.
- When a subagent’s run ends, any question it left open is released.
- Scheduled tasks, workers and wisps get the server’s
- It must be in the server’s own
elicitationblock.McpBridge:DefaultElicitationtreats it asauto. - Everything above still runs first: credential and denied fields, url mode,
defaultsthat settle the whole form, andmaxPerCall. - A yes/no decision is accepted only after the user has said something since the question was handed back.
- A question is answered once, only from the conversation that caused it, and expires after
handbackTtlMinutes. - Limits:
McpBridge:MaxPendingQuestionsPerSession(5) andMcpBridge:MaxPendingQuestions(50). Past them a question is declined in-band. - The waiting time doesn’t count against
toolTimeoutMs, which bounds the call’s active stretches only. - Each handed-back question is recorded in a ledger,
McpBridge:PendingLedgerPath(defaultmcp/pending-questions.jsonbesidemcp.json). The ledger holds the question and the call’s context, never the answers. After a restart, each conversation with an interrupted call is told what that call was doing, both in its next turn and in the chat. The call itself can’t be resumed. mcp_answeris offered only while a connected server can hand questions back.- See
design/mcp-elicitation-handback.md.
- It applies only to servers on the 2026-07-28 protocol, and only to calls from a run that has
- Servers registered at runtime via
mcp_register_serveralways getDefaultElicitation— a model-registered server cannot ship its owndefaultsor relaxdeniedFields. The model can’t replace or remove a server you configured, so it can’t shed that server’selicitationblock. McpBridge:DefaultElicitation:Defaultsworks from appsettings, Helm values and environment variables (e.g.McpBridge__DefaultElicitation__Defaults__mailbox=work). Those values arrive as strings; a string default for a boolean or number field is read as the literal it spells.sampling/createMessage(a server asking the client to run an LLM turn) is not implemented; the capability is not advertised.
See design/mcp-elicitation.md for the design rationale.
Background tasks (long-running tools)
A research run or an advisor council can take minutes. A synchronous tool call would hold the
agent’s turn, and the user, for that long. A server that supports the MCP Tasks extension
(SEP-2663; .WithTasks(store) in the C# SDK) can instead run such a call as a background task,
and the bridge delivers its result later, the way an A2A result arrives.
Turn it on in the server’s own mcp.json entry:
{
"mcpServers": {
"research": {
"type": "http",
"url": "http://research-agent:8080/mcp",
"tasks": true,
"taskTimeoutMinutes": 30,
"elicitation": { "mode": "handback", "responder": "conversation" }
}
}
}
What happens:
- Start. The call goes out with
CallToolAsTaskAsync. When the server creates a task, the tool result comes back at once: it says the tool “started as a background task” and gives atask_id(t_…, the bridge’s own id). A tool the server runs synchronously just answers. - Poll. The bridge polls
tasks/geton its own lifetime, at the interval the server suggests (clamped toMcpBridge:TaskPollMinMs..TaskPollMaxMs, 500 ms to 30 s). Polling leases the server by name each time, so reconnects don’t interrupt it. - Deliver. The result goes through the same mapping a synchronous result gets (binary
capture, content mapping, the elicitation note, error hints). It is stored in the session’s
working memory at
{session}/mcp-task/{server}/{task_id}/result, then published tomcp.task.result.{agent}.McpTaskResultHandlertakes it from there:- a user conversation gets a turn naming the result (inline when short) and the agent runs, so the user gets a reply;
- a subagent still running keeps the result among its outputs.
SubagentRunnerwaits for its tasks before it reports, as it does for A2A tasks; - a subagent that has exited has it folded back into its primary conversation, as a late A2A reply is.
- Questions mid-task. A task’s
elicitation/creategoes through the server’s elicitation policy exactly as a synchronous call’s does: credential fields declined,defaults, the responder,maxPerCall. Inhandbackmode, for a conversation whose run hasmcp_answer, the question arrives as its own turn (and as a form in a chat that can show one). The agent answers withmcp_answeras usual; the reply only confirms the answer was sent, and the task’s result still arrives later. Subagents’ tasks get the server’s responder instead. - Who gets a task. Only a run whose session a later result can reach: a user conversation
(
session/{id}) or a subagent (subagent/{id}). The agent side marks those calls with therb-mcp-tasksheader. Wisps, workers, scheduled tasks and patrols make an ordinary synchronous call, even to a server withtaskson. - Restarts. A task lives on its server, so it survives a restart of the agent. Each task is
written to a ledger,
McpBridge:TaskLedgerPath(defaultmcp/tasks.jsonbesidemcp.json), before the agent hears of it. On startup the bridge polls every unfinished task again. If the server no longer knows it (for example anInMemoryMcpTaskStorethat restarted), or the server is no longer configured, the session is told the task was lost. A question the task had open is asked again, not announced as an interruption. The ledger keeps redacted arguments, never results or answers. - Limits.
McpBridge:MaxTasksPerSession(3) andMcpBridge:MaxTasks(20). Past them the call is refused with an error that names the session’s running tasks.taskTimeoutMinutes(defaultMcpBridge:DefaultTaskTimeoutMinutes, 60) bounds a task’s run; the bridge then cancels it and tells the session.McpBridge:TaskPollFailureLimit(10) consecutive failed polls also end it as lost. - Cancelling.
mcp_cancel_task(task_id)stops a task the same conversation started (the bridge sendstasks/cancel; the ending is recorded without an extra reply). Removing a server, turningtasksoff, or pointing the entry at another URL cancels its tasks. - Ownership.
tasksis operator policy likeelicitation:mcp_register_servercan’t set it, and an entry that has it can’t be removed by the model.
A task server must ask its mid-run questions with ElicitAsync, which the Tasks extension routes
through the task store. In SDK 2.2.0 a tool that throws InputRequiredException (MRTR) inside a
task fails it (“MRTR and tasks cannot be composed via [McpServerTool] yet”); the session is told
the task failed.
See design/mcp-bridge.md (“Long-running tools”) for the design.
Multimodal input: analyze_file
Attachment passthrough gets a file onto the shared volume. analyze_file is the other half:
it gets that file in front of a model as real image content.
analyze_file({ path: "attachments/architecture.png",
prompt: "Describe the components and how they connect.",
tier: "high" })
│
▼
AnalyzeFileToolExecutor (RockBot.Tools.FileSystem)
│ SafeResolvePath containment under FileSystemOptions.BasePath
│ extension → MIME, checked against AnalyzeFileMimeTypes
│ size checked against AnalyzeFileMaxBytes
│
▼
ILlmClient.GetResponseAsync(
[ ChatMessage(User, [ TextContent(prompt), DataContent(bytes, mime) ]) ], tier, …)
│
▼
ToolInvokeResponse { Content = "Three services arranged left to right. …" }
The analysis runs as its own LLM call rather than as content in the agent’s own loop. That is
not an implementation shortcut — on OpenAI-compatible APIs, which is every provider RockBot
talks to, tool-role messages accept text only, so bytes can enter a conversation solely as
content parts on a user message. Running the look-up as a side call sidesteps that and keeps
the agent’s context free of bytes: a path goes in, prose comes out. The full reasoning, and the
sequencing of the remaining multimodal work, is in design/multimodal-input.md.
Enabling it
analyze_file is registered only when a configured tier declares that its model accepts image
input:
{
"LLM": {
"High": {
"ModelId": "openai/gpt-5.5",
"SupportsImageInput": true
}
}
}
Or LLM__High__SupportsImageInput=true as an environment variable. With no such tier the tool
is not registered and the file-tools skill guide omits it — an agent is never taught a
capability its deployment does not have.
When the requested tier is not one that can see, the executor substitutes the nearest tier
that can (High → Balanced → Low). This matters because ILlmClient retries a failed Low/High
call on Balanced: sending a vision request to a blind tier would fail twice and report the
second, less informative error.
Limits
| Option | Default | Purpose |
|---|---|---|
FileSystemOptions.AnalyzeFileMaxBytes |
8 MiB | Refused above this, before any bytes are read. Providers cap the encoded request well above it; the limit keeps a mistake cheap. |
FileSystemOptions.AnalyzeFileMimeTypes |
PNG, JPEG, GIF, WebP | The formats every vision-capable provider accepts. Adding application/pdf or an audio type is a deployment decision — whether it works depends on the provider behind the tier. |
Service search (RockBot.ServiceSearch)
search_known_services is a unified BM25 keyword search over all known A2A agents and
MCP servers in a single tool call. It removes the need to call list_known_agents and
mcp_list_services separately when routing a task to the right backend.
How it works
ServiceSearchIndex reads live from two in-memory singletons on every search:
IAgentDirectory— all known A2A agents (from live announcements +well-known-agents.json), including their LLM-generated summaries, skills, tags, and examples.McpServerIndex— all connected MCP servers (from the bridge index), including their LLM-generated summaries and tool names.
Each source document is flattened into a single text string and ranked with Bm25Ranker.RankWithScores<T>. No separate cache or background sync is needed — both sources are already singletons that stay current as agents announce and MCP servers connect.
Tool
search_known_services(query)
| Parameter | Required | Description |
|---|---|---|
query |
yes | Keywords describing the task (e.g. "reschedule meeting", "aws spend audit") |
Returns up to 5 ranked results:
{
"results": [
{
"id": "SalesOpsAgent",
"type": "a2a",
"summary": "Autonomous agent for complex sales workflows and multi-step reporting.",
"relevance_score": 1.0,
"top_skills": ["generate-qbr-report", "audit-pipeline"]
},
{
"id": "salesforce-mcp",
"type": "mcp",
"summary": "Direct access to Salesforce objects (Accounts, Leads, Opportunities).",
"relevance_score": 0.72,
"top_tools": ["search_leads", "update_opportunity"]
}
]
}
| Field | Description |
|---|---|
id |
Pass to get_agent_details(agent_name) or mcp_get_service_details(server_name) for full details |
type |
"a2a" → use invoke_agent; "mcp" → call the typed tools in top_tools, or mcp_invoke_tool when they aren’t typed names |
summary |
LLM-generated description — the primary signal for choosing between candidates |
relevance_score |
BM25 score normalized to [0, 1]; below ~0.3 consider browsing manually |
top_skills |
(A2A only) Top 3 skill IDs — immediate scouting report without a details call |
top_tools |
(MCP only) Top 3 tool names — immediate scouting report without a details call. With typed tools on, these are the typed {server}__{tool} names that best fit the query |
Context hints
AgentContextBuilder runs search_known_services automatically each turn and injects the top
2 matches into the system prompt before the LLM sees the user message:
Potentially relevant services for this request (call search_known_services for full search):
- SalesOpsAgent (a2a): Autonomous agent for complex sales workflows, top skills: generate-qbr-report
- salesforce-mcp (mcp): Direct Salesforce CRM access, top tools: search_leads, update_opportunity
When the hint already identifies the right service with a high score, the agent can skip the
explicit tool call and go straight to the service: invoke_agent for an agent, or for an MCP
server its typed tool (mcp_invoke_tool when there is none).
Bm25Ranker
Bm25Ranker (in RockBot.Host) is now a public static class exposable to other projects.
It provides two overloads:
| Method | Returns |
|---|---|
Rank<T>(candidates, getDocumentText, query) |
IReadOnlyList<T> sorted by relevance (zero-score entries excluded) |
RankWithScores<T>(candidates, getDocumentText, query) |
IReadOnlyList<(T Item, double Score)> — same ordering but with raw BM25 scores for normalization |
Both use Okapi BM25 (k1=1.5, b=0.75) with a 2× phrase bonus for adjacent two-word query terms.
DI registration
agent.AddServiceSearch(); // registers search_known_services tool + IServiceSearchIndex
// + ServiceSearchSkillProvider (tool guide)
// + per-turn context hints in AgentContextBuilder
AddServiceSearch() must be called after AddA2ACaller() and AddMcpToolProxy() so
IAgentDirectory and McpServerIndex are already registered.
Web tools (RockBot.Tools.Web)
Two tools — web_search and web_browse — give the agent access to the internet.
web_search(query, count?)
Calls the Brave Search API and returns a numbered markdown list:
1. [Title](https://example.com) — Snippet text
2. ...
Configuration:
opts.ApiKey = "..."; // or opts.ApiKeyEnvVar = "BRAVE_API_KEY"
opts.MaxSearchResults = 5; // default
web_browse(url, render?)
Fetches a web page and converts it to markdown using AngleSharp (HTML parsing) and
ReverseMarkdown (HtmlPageConverter). Noise elements (scripts, styles, nav, footer, sidebars,
[hidden] elements, recognised cookie-consent banners) are stripped before conversion.
Unrecognised wrappers such as <main>, <article> and <section> are bypassed, not dropped,
so their content survives.
Every result starts with a Source: line naming the URL and how it was fetched (http,
headless browser, or site API).
Shell detection and the headless browser: PageShellDetector judges whether the result is
the page or a shell standing in for it: a cookie-consent wall, a JavaScript loader, or an
empty body. It measures readable text with consent banners removed (ShellMinContentChars,
default 200). A shell is retried in the headless browser of the RockBot.Browser render
service (FallbackWebBrowseProvider), as is an HTTP 403/503 refusal. render: true renders
first. Without a render service, or when rendering also fails, the result carries a status line
saying the body was not read:
Source: https://conf.example/schedule (via http)
[web_browse status: blocked_by_consent_wall] The site answered with a cookie-consent wall; the
page body was NOT read. Headless rendering is not available in this deployment. Do not present
facts about this page as confirmed: ...
Statuses: blocked_by_consent_wall, js_required, empty. The result is not an error, so the
model can still use whatever text came back, but it is told plainly not to treat it as the page.
Render service (RockBot.Browser): an ASP.NET sidecar running headless Chromium via
Microsoft.Playwright (POST /render {url, timeoutMs}, GET /health). Each page gets a fresh
browser context, so no cookies or storage carry over. It clicks away common consent banners,
refusing before accepting. It is anonymous by design: pages behind a login stay out of reach.
Because it shares the agent pod’s network, it refuses loopback, private, link-local and
metadata addresses for the page, every subresource and the landing URL. Helm runs it in the
agent pod when browser.enabled is true (the default) and sets
WebTools__RenderServiceUrl=http://localhost:8083. Image: deploy/Dockerfile.browser.
Large page chunking: When the markdown content exceeds ChunkingThreshold (default 8000
characters), the page is split into chunks using ContentChunker (from RockBot.Host):
- Splits on H1/H2/H3 headings first (respects document structure)
- Falls back to blank-line splitting for oversized sections
- Hard-splits at
ChunkMaxLengthas a last resort - Stores each chunk in working memory under the session namespace:
session/{sessionId}/web-{sanitized-url}-chunk{n} - Stores a hierarchical document outline as an index chunk at
session/{sessionId}/web-{sanitized-url}-index - Returns a chunk index table listing heading and key for each chunk, plus the index chunk key
The agent can then call get_from_working_memory for specific chunks rather than loading the
full page into context at once.
GitHub API routing: GitHubApiWebBrowseProvider intercepts GitHub issue and PR URLs and
routes them through the GitHub REST API instead of the browser view:
github.com/{owner}/{repo}/issues/{number}→api.github.com/repos/{owner}/{repo}/issues/{number}github.com/{owner}/{repo}/pull/{number}→api.github.com/repos/{owner}/{repo}/pulls/{number}
This returns cleaner structured data (title, state, author, labels, body) rather than HTML rendered for humans. Public repos work without authentication.
DI registration
agent.AddWebTools(opts =>
{
opts.ApiKey = config["WebTools:ApiKey"];
opts.ChunkingThreshold = 8000;
opts.ChunkMaxLength = 4000;
opts.ChunkTtlMinutes = 30;
opts.RenderServiceUrl = "http://localhost:8083"; // optional; null disables rendering
opts.RenderTimeoutSeconds = 30;
});
Tool result chunking (all tools)
Any tool — MCP, REST, web, or built-in — can return a response large enough to overflow the
model’s context window when the result is appended to the conversation history. The agent host
defends against this automatically in UserMessageHandler.
How it works:
After each tool call (both native function calls and text-based calls), the result string is checked against a per-model threshold. If the result exceeds that threshold:
ContentChunkersplits it into chunks (heading-aware, then blank-line, then hard-split)- Each chunk is stored in working memory under the session namespace:
{namespace}/tool-{name}-{runId}-chunk{n}, TTL 20 minutes - A hierarchical index chunk is stored at
{namespace}/tool-{name}-{runId}-indexcontaining a document outline that maps section headings (with H1/H2/H3 nesting preserved) to chunk keys - A compact index table is returned to the LLM instead of the raw content, including the index chunk key:
Tool result for 'list_models' is large (462 000 chars) and has been split into 23 chunk(s)
stored in working memory.
A document outline is stored at key `session/abc123/tool-list_models-a1b2c3-index` — retrieve
it with get_from_working_memory to navigate the content by section heading.
Call get_from_working_memory(key) for each relevant chunk BEFORE drawing conclusions.
| # | Heading | Key |
|---|---------|--------------------------------------------------|
| 0 | Part 0 | `session/abc123/tool-list_models-a1b2c3-chunk0` |
| 1 | Part 1 | `session/abc123/tool-list_models-a1b2c3-chunk1` |
...
The index chunk contains a hierarchical outline like:
## Document Outline
- **Models Overview** → `session/abc123/tool-list_models-a1b2c3-chunk0`
- **Pricing** → `session/abc123/tool-list_models-a1b2c3-chunk1`
- **Context Windows** → `session/abc123/tool-list_models-a1b2c3-chunk2`
- **Token Limits** → `session/abc123/tool-list_models-a1b2c3-chunk3`
If the inline index is lost due to context trimming, the agent can retrieve the index chunk from working memory to rediscover the document structure and navigate to specific sections.
Since chunk keys are full path strings (contain /), get_from_working_memory treats them as
absolute — no namespace prefix is prepended.
If working memory is unavailable (no session context), the result is truncated at the threshold
with a [result truncated — N chars omitted] notice — same fallback as web_browse.
Per-model threshold configuration:
The default threshold is 64 000 characters (~16 000 tokens), appropriate for models with 120K+ token context windows. When chunking occurs, each chunk is sized up to the threshold (minimum 20 000 chars) to minimise the number of working-memory retrievals.
Tune it per model in appsettings.json:
{
"ModelBehaviors": {
"Models": {
"openrouter/deepseek": {
"ToolResultChunkingThreshold": 32000
}
}
}
}
Lower the threshold for small-context models. Setting it very high effectively disables
proactive chunking while still relying on the reactive TrimLargeToolResults overflow
recovery as a safety net.
Scheduling tools (RockBot.Tools.Scheduling)
Three tools for managing recurring and one-time scheduled tasks.
Tools
| Tool | Purpose |
|---|---|
schedule_task(name, cron, description, run_once?) |
Create or replace a scheduled task |
list_scheduled_tasks() |
Markdown table of all tasks with next-fire times |
cancel_scheduled_task(name) |
Remove a task by name |
Cron format
Both 5-field (minute precision) and 6-field (second precision) cron expressions are supported:
# 5-field: minute hour day-of-month month day-of-week
0 9 * * 1-5 # 9 AM every weekday
*/15 * * * * # every 15 minutes
# 6-field: second minute hour day-of-month month day-of-week
0 0 9 * * 1-5 # 9:00:00 AM every weekday
One-time tasks: Set run_once: true. Pin all time fields to the exact target time; use *
for day-of-week. The scheduler automatically removes the task after it fires.
Relative times: The scheduler always shows current time and timezone in list_scheduled_tasks
output so the agent can compute correct cron expressions from requests like “remind me in 2 hours”.
DI registration
agent.AddSchedulingTools();
Script execution
Python scripts can be executed on-demand via execute_python_script. The execution model
differs between development and production.
Tool interface
execute_python_script(
script, // Python source code (required)
input_data?, // Arbitrary string passed as ROCKBOT_INPUT env var
timeout_seconds?, // Default 30s
pip_packages? // ["numpy", "requests"] — installed before execution
)
Environment: Python 3.12-slim. Only stdout is returned. The script should print() its
results (JSON recommended) and exit(0) on success.
pip packages: Installing packages adds startup latency (network + compilation). Cache results in working memory or save as a skill to avoid re-installing on every call.
Production: Kubernetes pods
MessageBusScriptRunner delegates to the Scripts Manager sidecar via RabbitMQ:
Agent: execute_python_script(...)
│
▼
MessageBusScriptRunner
│ Publishes ScriptInvokeRequest to "script.invoke"
│ Awaits on "script.result.{agentName}"
│
▼
Scripts Manager (trusted sidecar, separate pod)
│ Has Kubernetes API access
│ Creates ephemeral pod in "rockbot-scripts" namespace
│ python:3.12-slim, 500m CPU, 256Mi RAM
│ No network access, no persistent storage
│ Runs script, streams stdout
│ Deletes pod immediately after completion
│
▼
ScriptInvokeResponse on "script.result.{agentName}"
The agent pod has no Kubernetes API permissions — it cannot create pods directly. All
script execution is delegated to the Scripts Manager, which has the minimal RBAC role needed
to create, watch, and delete pods in the rockbot-scripts namespace only.
Development: local runner
LocalScriptRunner executes Python scripts directly on the local machine using the system
Python installation. No Kubernetes required.
// Development
agent.AddLocalScriptRunner();
// Production
agent.AddRemoteScriptRunner(agentName: identity.Name);
Security model
| Constraint | Kubernetes | Local |
|---|---|---|
| Network access | Denied (no network policy) | Unrestricted |
| Filesystem | Ephemeral pod only | Host filesystem |
| Credentials | None mounted | Inherits process env |
| Resource limits | 500m CPU, 256Mi RAM | Unrestricted |
| Cleanup | Pod deleted after completion | Process exits |
OpenRouter MCP server (McpServer.OpenRouter)
An optional standalone MCP server that exposes read-only tools for querying OpenRouter account
information. Deployed as rockbot-openrouter-mcp when openrouterMcp.enabled: true in Helm
values.
Tools
| Tool | Purpose |
|---|---|
get_credits |
Current account credit balance |
get_api_key_info |
Rate limits and usage for the active API key |
list_models |
Available models with context lengths and pricing |
list_api_keys |
Provisioned API keys (requires management key) |
get_api_key(keyHash) |
Details for a specific API key |
get_generation(generationId) |
Completion details including token counts and cost |
All tools return JSON strings from the OpenRouter REST API. No write operations are exposed — this server cannot spend credits, create keys, or modify account settings.
Configuration
# In values.personal.yaml
openrouterMcp:
enabled: true
secrets:
openRouter:
apiKey: "<your-openrouter-management-api-key>"
The agent connects to this server via mcp.json on the data volume.
Diagnostics
ToolDiagnostics records zero-allocation metrics via System.Diagnostics.Metrics:
| Metric | Type | Tags |
|---|---|---|
rockbot.tool.invoke.duration |
Histogram (ms) | tool_name, status |
rockbot.tool.invocations |
Counter | tool_name, status |
Trace activities (kind Internal) are created per invocation and are automatically connected
to the parent distributed trace from the incoming message envelope.
DI registration summary
services.AddRockBotHost(agent =>
{
// Core tool infrastructure (required for all tools)
agent.AddToolHandler(); // IToolRegistry + ToolGuideTools + ToolInvokeHandler
// Tool subsystems (add as needed)
agent.AddMcpToolProxy(); // MCP management + typed tools (message-bus proxy to the bridge)
// OR (non-production):
agent.AddMcpTools(opts => ...); // stdio servers registered in-process; no bridge
agent.AddWebTools(opts => ...); // web_search + web_browse
agent.AddSchedulingTools(); // schedule_task + list/cancel
agent.AddSubagents(); // spawn_subagent + cancel/list + whiteboard
agent.AddWisps(opts => ...); // spawn_wisps (lightweight procedural pipelines, parallel batches)
agent.AddRemoteScriptRunner(); // execute_python_script (Kubernetes)
// OR:
agent.AddLocalScriptRunner(); // execute_python_script (local dev)
agent.AddA2ACaller(opts => ...); // invoke_agent + list_known_agents + get_agent_details
agent.AddServiceSearch(); // search_known_services (after AddA2ACaller + AddMcpToolProxy)
});