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:

  1. Encounter a new capability (e.g. MCP server)
  2. Call list_tool_guides to discover available guides
  3. Call get_tool_guide("mcp") to read the full procedure
  4. Follow the guide; call save_skill to 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 → ToolError with code Timeout, IsRetryable = true
  • ArgumentException → ToolError with code InvalidArguments, IsRetryable = false
  • Other exceptions → ToolError with code ExecutionFailed, IsRetryable = false
  • Unregistered tool name → ToolError with code ToolNotFound

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 with agent.AddMcpToolProxy(...)): the mcp_* management tools, the typed {server}__{tool} tools, McpToolProxy, and the server index the agent reads.
  • Bridge (McpBridgeService in src/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:

  1. Loads mcp.json (McpBridge:ConfigPath), seeds any McpBridge:DefaultServers entries, and assigns each entry a stable id
  2. Connects to each server, reads its tools and prompts, and applies the entry’s allowedTools/deniedTools filters
  3. Writes a one-sentence LLM summary of each server (McpBridge:GenerateLlmSummaries, default on)
  4. Publishes McpServersIndexed on tool.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
    }
  }
}
  • type sse, http and streamable-http all mean an HTTP server; transportMode (default auto) picks Streamable HTTP or legacy SSE. Entries with command/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, tasks and taskTimeoutMinutes (the last five are described below). id and origin are written by the bridge.
  • McpBridge:DefaultServers:{name} (Helm configmap) maps a name to a URL; each is seeded into mcp.json as an sse entry unless an entry already points at that URL.
  • The bridge watches mcp.json (file watcher plus a ConfigPollIntervalSeconds poll) 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" }] }):

  1. Bridge reads bytes from the path via IAttachmentStorage.
  2. Below threshold → replaces the entry with { name, base64Content }.
  3. 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-forget DELETE /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 handler name or invalid options refuses the server connection entirely (logged as an error). A declared policy that cannot be enforced never silently degrades.
  • path-prefix rejects relative paths and traversal that escapes the prefixes (/rockbot/shared/../../tmp); comparison is Ordinal (Linux semantics) and boundary-aware (/rockbot/shared does not match /rockbot/shared-evil). Missing arguments pass unless requireArgs is set. Empty allowedPrefixes is a config error, not allow-all.
  • Handlers are resolved from a DI registry by name — mcp.json never names CLR types (mcp_register_server is 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” in design/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 form mode is answered. url mode 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 defaults pre-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. IMcpElicitationResponder is 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’s responder to 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 elicitation block — it is refused in McpBridge: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": 30000 or more and a toolTimeoutMs above that.

  • "mode": "handback" lets the agent, or the user, answer. The tool call returns at once with the server’s question and a question_id. The agent answers from what it knows, or asks the user and answers on a later turn, with the mcp_answer tool. The paused call then resumes and mcp_answer returns 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 responder instead, 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.
    • It must be in the server’s own elicitation block. McpBridge:DefaultElicitation treats it as auto.
    • Everything above still runs first: credential and denied fields, url mode, defaults that settle the whole form, and maxPerCall.
    • 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) and McpBridge: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 (default mcp/pending-questions.json beside mcp.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_answer is offered only while a connected server can hand questions back.
    • See design/mcp-elicitation-handback.md.
  • Servers registered at runtime via mcp_register_server always get DefaultElicitation — a model-registered server cannot ship its own defaults or relax deniedFields. The model can’t replace or remove a server you configured, so it can’t shed that server’s elicitation block.
  • McpBridge:DefaultElicitation:Defaults works 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 a task_id (t_…, the bridge’s own id). A tool the server runs synchronously just answers.
  • Poll. The bridge polls tasks/get on its own lifetime, at the interval the server suggests (clamped to McpBridge: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 to mcp.task.result.{agent}. McpTaskResultHandler takes 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. SubagentRunner waits 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/create goes through the server’s elicitation policy exactly as a synchronous call’s does: credential fields declined, defaults, the responder, maxPerCall. In handback mode, for a conversation whose run has mcp_answer, the question arrives as its own turn (and as a form in a chat that can show one). The agent answers with mcp_answer as 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 the rb-mcp-tasks header. Wisps, workers, scheduled tasks and patrols make an ordinary synchronous call, even to a server with tasks on.
  • Restarts. A task lives on its server, so it survives a restart of the agent. Each task is written to a ledger, McpBridge:TaskLedgerPath (default mcp/tasks.json beside mcp.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 an InMemoryMcpTaskStore that 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) and McpBridge:MaxTasks (20). Past them the call is refused with an error that names the session’s running tasks. taskTimeoutMinutes (default McpBridge: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 sends tasks/cancel; the ending is recorded without an extra reply). Removing a server, turning tasks off, or pointing the entry at another URL cancels its tasks.
  • Ownership. tasks is operator policy like elicitation: mcp_register_server can’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):

  1. Splits on H1/H2/H3 headings first (respects document structure)
  2. Falls back to blank-line splitting for oversized sections
  3. Hard-splits at ChunkMaxLength as a last resort
  4. Stores each chunk in working memory under the session namespace: session/{sessionId}/web-{sanitized-url}-chunk{n}
  5. Stores a hierarchical document outline as an index chunk at session/{sessionId}/web-{sanitized-url}-index
  6. 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:

  1. ContentChunker splits it into chunks (heading-aware, then blank-line, then hard-split)
  2. Each chunk is stored in working memory under the session namespace: {namespace}/tool-{name}-{runId}-chunk{n}, TTL 20 minutes
  3. A hierarchical index chunk is stored at {namespace}/tool-{name}-{runId}-index containing a document outline that maps section headings (with H1/H2/H3 nesting preserved) to chunk keys
  4. 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)
});

© 2026 Marimer LLC. RockBot is released under the MIT license.

This site uses Just the Docs, a documentation theme for Jekyll.