Systematic documentation review: 12 new docs pages (coordinator, streaming, budgets and limits, errors, hooks and callbacks, glossary, production checklist, Run Viewer, MCP, sandbox and shell, evaluation in CI, routing evaluation) plus a docs index; the three longest guides split with stub sections that keep existing anchors; observability release records moved to docs/internal; statements re-verified against the code and corrected; READMEs, AGENTS.md, CONTRIBUTING.md, and issue templates synced. Documentation only, no behavior change.
10 KiB
MCP tools (Model Context Protocol)
This page answers what connectMCPTools() actually does: which options it
takes, how an MCP server's tools become ToolDefinition objects, how MCP
result blocks reach the model, and which OMA controls do and do not extend into
the MCP child process.
MCP tools are ordinary OMA tools once connected. Everything in
tool configuration still applies to them: default-deny
grants, disallowedTools, the per-call onToolCall gate, output truncation,
and rich modelOutput handling.
Connecting to a server
import { connectMCPTools } from '@open-multi-agent/core/mcp'
const { tools, disconnect } = await connectMCPTools({
command: 'npx',
args: ['--no-install', '@modelcontextprotocol/server-github'],
env: {
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
HOME: process.env.HOME,
PATH: process.env.PATH,
},
namePrefix: 'github',
})
try {
// Register each MCP tool in your ToolRegistry, then grant their names
// through AgentConfig.tools.
} finally {
await disconnect()
}
connectMCPTools() spawns the configured child, connects an MCP client over
stdio, pages through tools/list until the server stops returning a
nextCursor, and converts each descriptor into a ToolDefinition. If connect
or discovery throws, both the client and the transport are closed before the
error propagates, so a failed connection does not leak the child process.
The function is exported from the @open-multi-agent/core/mcp subpath, not
from the package root.
Options
ConnectMCPToolsConfig:
| Field | Type | Default | Meaning |
|---|---|---|---|
command |
string |
required | Executable to spawn for the stdio server. |
args |
string[] |
[] |
Arguments passed to command. |
env |
Record<string, string | undefined> |
(transport default) | Environment handed to the child process. |
cwd |
string |
(transport default) | Working directory for the child process. |
namePrefix |
string |
none | Segment prepended to every discovered tool name. |
requestTimeoutMs |
number |
60000 |
Timeout applied to connect, each tools/list page, and each tools/call. |
clientName |
string |
'open-multi-agent' |
Client name reported to the server. |
clientVersion |
string |
'0.0.0' |
Client version reported to the server. |
Returns ConnectedMCPTools:
interface ConnectedMCPTools {
tools: ToolDefinition[]
disconnect: () => Promise<void>
}
disconnect() closes the MCP client, which tears down the stdio transport and
the child process. It is the only shutdown hook: OMA does not close the
connection when a run ends, because one connection is meant to serve many runs.
Call it from a finally block, as every example below does, so an aborted or
failing run still stops the child.
Tool names
The framework tool name is <namePrefix>_<mcpToolName>, and every / in the
result is replaced with _. MCP and earlier examples used prefix/name, but
Anthropic and other providers reject / in tool names.
namePrefix: 'github'plus the MCP toolsearch_issuesyieldsgithub_search_issues.- An omitted, empty, or whitespace-only
namePrefixleaves the server's name unchanged apart from the/replacement. - If two discovered tools normalize to the same name,
connectMCPTools()throws before returning, naming the collision and both source names. It never silently drops one of them.
Prefixes matter beyond cosmetics: ToolRegistry.register() throws on a
duplicate name, so two servers exposing a search tool need distinct prefixes.
Granting MCP tools to an agent
Registration is not a grant. MCP tools follow the same default-deny rule as the
built-ins, so their names must appear in AgentConfig.tools:
import { Agent, ToolExecutor, ToolRegistry, registerBuiltInTools } from '@open-multi-agent/core'
const registry = new ToolRegistry()
registerBuiltInTools(registry)
for (const tool of tools) registry.register(tool)
const agent = new Agent(
{
name: 'github-agent',
model: 'gemini-2.5-flash',
provider: 'gemini',
tools: tools.map((tool) => tool.name), // the grant
},
registry,
new ToolExecutor(registry),
)
The alternative is AgentConfig.customTools, which registers the tools as
runtime-added: registration is then the grant and no tools allowlist is
needed, though disallowedTools is still honored. That route applies when OMA
builds the agent, so use it with runAgent(), runTeam(), or runTasks(); a
hand-constructed new Agent(config, registry, executor) like the one above
takes its tools from the registry you pass and ignores customTools. Both
routes reach the same executor, so the onToolCall gate sees MCP calls either
way.
MCP tools are not marked consequential, so they do not by themselves raise
the consequential-no-independence flag or trigger
requireConsequentialConfirmation. Wrap an MCP tool in your own defineTool()
when a specific server operation should be treated as a real side effect.
Input validation is delegated to the server
Each generated tool uses inputSchema: z.any(), so OMA performs no runtime
validation of MCP arguments. The server's JSON Schema from tools/list is
forwarded to the LLM unchanged through llmInputSchema; when a descriptor
carries no usable object schema, { type: 'object' } is sent instead.
Two consequences worth designing around: a malformed argument object reaches
the server rather than being rejected locally, and the onToolCall gate
receives the raw arguments as-is because there is no parsed shape to narrow
them to.
Result mapping
Every MCP call produces a text data value. Media additionally produces a rich
modelOutput; see
rich image and file results
for the contract those parts satisfy.
Text (data). Content blocks are serialized in order and joined with
newlines:
| MCP block | Text form |
|---|---|
text |
The text verbatim. |
image |
[image <mimeType>; base64 length=<n>], using image/* when the server omits mimeType. |
audio |
[audio <mimeType>; base64 length=<n>], using audio/* when the server omits mimeType. |
resource with text |
[resource <uri>] followed by the text. |
resource with blob |
[resource <uri>; mimeType=<type>; blob base64 length=<n>] |
resource_link |
[resource_link name=<name> uri=<uri>] plus the description when present. |
| Anything else | [<type>] followed by the block as pretty-printed JSON. |
A legacy toolResult field wins over content and is serialized as
pretty-printed JSON. When content yields no lines at all, OMA falls back to
structuredContent, and then to the whole response object.
Model-visible parts (modelOutput). A rich modelOutput is emitted only
when at least one block converts to media:
imagewhosemimeTypeis a baretype/subtype(no parameters) becomes a base64 image part.resourcewith ablobbecomes a[resource <uri>]text part plus a base64 file part. The filename comes from the resourcename, else the last segment of itsuri, elsemcp-resource.resource_linkwith anhttp:orhttps:uribecomes an optional description text part plus a URL file part.- A missing or malformed MIME type on a blob or link falls back to
application/octet-stream.
Audio, an image with an invalid MIME type, a non-HTTP resource_link, and any
future block type keep the text representation above. Nothing is silently
dropped.
When the legacy toolResult field is present alongside media, modelOutput
becomes the serialized toolResult text followed by the non-text media parts,
so the historical model-visible value is preserved.
Errors
- A server result with
isError: trueproducesisError: trueon theToolResult, and stays text-only: nomodelOutputis attached even when the server included media. This preserves the framework's text-only error contract. - A thrown or rejected
callToolbecomesMCP tool "<name>" failed: <message>withisError: true. The tool loop treats it as a normal tool error, so the model can react instead of the run crashing. - The tool's
context.abortSignalis forwarded tocallTool, so an aborted run cancels an in-flight MCP request.
Transport and dependency
- stdio only.
connectMCPTools()constructsStdioClientTransport. HTTP and SSE MCP transports are not wired up. @modelcontextprotocol/sdkis an optional peer dependency. It is loaded through dynamicimport()on the firstconnectMCPTools()call, so an installation that never uses MCP never resolves it. Install it yourself when you do.
Egress and process boundaries
egressPolicy does not constrain connections opened inside the MCP child, by
bash, or by custom tools. OMA starts the configured child and exchanges stdio
messages; it cannot see or guard sockets the server opens. See the
egress enforcement matrix.
Two practices follow from that:
- Prefer locally installed or pinned MCP server binaries over resolving a package at launch time.
- Pass only the environment variables the server needs. Avoid spreading
process.envinto an MCP subprocess; that is how an unrelated provider API key ends up inside a third-party server.
When an MCP server must be network-contained, use a process or container
network namespace, an egress proxy, or an OS firewall. An agent configured with
egressPolicy: { mode: 'offline' } is not evidence that the MCP child is
offline.
Examples
integrations/mcp-github: the minimal shape, a single server registered into a freshToolRegistryand granted by name.integrations/mcp-bilig-workpaper: a writable file-backed server, with the agent's filesystem sandboxcwdpointed at the same scratch directory the server operates on.integrations/mcp-open-design: MCP tools looked up by their prefixed names and wrapped in a custom tool, so deterministic TypeScript drives an asynchronous job whilerunTasks()fans several of them out in parallel.