MCP (Model Context Protocol) - Developer Guide
indusagi ships two MCP layers.
indusagi/mcpis a self-contained implementation that adapts MCP tools intoAgentTools.indusagi/interopis the clean-room bridge built on@modelcontextprotocol/sdkthat grafts remote tools into the kernelToolRegistryand can host the agent's own tools. This guide covers connecting clients and servers on both layers.
Table of Contents
- Connecting to a server with
MCPClient - Managing many servers with
MCPClientPool - Adapting MCP tools into
AgentTools - One-call setup with
initializeMCP - Hosting your tools with
MCPServer - Configuration files
- Schema conversion
- Error handling
- The interop bridge: endpoints, fleets, mounting
- The interop provider host
1. Connecting to a server with `MCPClient`
Source: src/facade/mcp-core/client.ts. A client wraps one server. Stdio servers are spawned as subprocesses; HTTP servers are reached per-request.
import { MCPClient } from "indusagi/mcp";
const client = new MCPClient({
name: "filesystem",
config: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "."],
},
timeout: 60000,
logger: (msg) => console.log(`[${msg.level}] ${msg.message}`),
enableServerLogs: true,
});
await client.connect();
const tools = await client.listTools();
console.log(tools.map((t) => t.name).join(", "));
const result = await client.callTool("read_file", { path: "./README.md" });
// result is an MCPToolCallResult: { content: MCPContentBlock[], isError?, structuredContent? }
await client.disconnect();
callTool returns the structured MCPToolCallResult. Resources and prompts are also available:
const resources = await client.listResources(); // [] if the server has no resources capability
const content = await client.readResource("file:///path/to/file");
const prompts = await client.listPrompts(); // [] if no prompts capability
const prompt = await client.getPrompt("review", { language: "typescript" });
HTTP transport uses a URL object (not a string):
const http = new MCPClient({
name: "remote",
config: {
url: new URL("http://localhost:8080/mcp"),
headers: { Authorization: "Bearer token" },
},
});
await http.connect();
The HTTP path accepts both plain JSON and SSE-framed (event: / data:) responses.
2. Managing many servers with `MCPClientPool`
Source: src/facade/mcp-core/client-pool.ts.
import { MCPClientPool } from "indusagi/mcp";
const pool = new MCPClientPool({
servers: [
{ name: "filesystem", config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."] } },
{ name: "github", config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"], env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN || "" } } },
],
});
await pool.connectAll(); // a server that fails to connect is logged and skipped, not fatal
for (const client of pool.getAllClients()) {
console.log(`Connected: ${client.serverName}`);
}
const status = await pool.getStatus(); // NOTE: getStatus() is async
// [{ name: "filesystem", connected: true, toolCount: 8, resourceCount: 0, promptCount: 0 }, ...]
await pool.disconnectAll();
Note that the server env lives on the inner config object (config.env), matching StdioServerConfig. Dynamic add/remove return a boolean instead of throwing:
const added = await pool.addServer({
name: "postgres",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-postgres"], env: { DATABASE_URL: "postgresql://..." } },
});
// added === false if the name already exists or the connection failed
await pool.removeServer("postgres"); // false if no such server
await pool.reload(); // disconnectAll() then connectAll()
Aggregate listings are also available: listAllTools(), listAllResources(), listAllPrompts() each return a Record<serverName, ...>.
3. Adapting MCP tools into `AgentTool`s
Source: src/facade/mcp-core/tool-factory.ts. Adapted tools are namespaced ${serverName}_${toolName} with category: "mcp". The argument order is tools/tool first, client second.
import {
registerMCPToolsInRegistry,
createMCPAgentToolFactory,
createMCPToolsMap,
createMCPToolsRecord,
} from "indusagi/mcp";
import { ToolRegistry } from "indusagi/agent";
const registry = new ToolRegistry();
const mcpTools = await client.listTools();
// Register all tools (returns count)
const count = await registerMCPToolsInRegistry(registry, client, mcpTools);
// Or build a single tool factory
const factory = createMCPAgentToolFactory(mcpTools[0], client);
const agentTool = factory();
// Or build a Map / Record of namespaced tools
const toolMap = createMCPToolsMap(mcpTools, client); // Map<"filesystem_read_file", AgentTool>
const toolRecord = createMCPToolsRecord(mcpTools, client); // Record<"filesystem_read_file", AgentTool>
Inside the generated AgentTool.execute, the result's text blocks are truncated for display (first 4 lines / 500 chars) while the full MCPToolCallResult is preserved in details. Image blocks are passed through. Any MCPError is converted into an isError: true result rather than thrown.
4. One-call setup with `initializeMCP`
import { initializeMCP } from "indusagi/mcp";
import { ToolRegistry } from "indusagi/agent";
const registry = new ToolRegistry();
const { pool, toolCount } = await initializeMCP(registry, process.cwd());
console.log(`Connected to ${pool.getAllClients().length} servers with ${toolCount} tools`);
initializeMCP calls loadMCPConfig(cwd), connects the pool, and registers every client's tools. With no configured servers it returns an empty pool and toolCount: 0.
5. Hosting your tools with `MCPServer`
Source: src/facade/mcp-core/server.ts. MCPServer exposes existing AgentTools (not free-form handlers) over stdio. Provide the tools at construction or add them later.
import { MCPServer, createMCPServer } from "indusagi/mcp";
import { createBashTool } from "indusagi/agent"; // createBashTool(cwd, options?)
const server = createMCPServer({
name: "My Tools Server",
version: "1.0.0",
tools: [createBashTool(process.cwd())],
});
server.addTool(someOtherAgentTool);
server.removeTool("some_tool");
await server.startStdio(); // reads JSON-RPC from stdin, writes to stdout; never resolves
The server answers initialize, tools/list, and tools/call; resources/list and prompts/list return empty lists. All diagnostics print to stderr so stdout stays a clean protocol stream — wire this server into a client (e.g. Claude Desktop, Cursor) as a stdio command.
6. Configuration files
Source: src/facade/mcp-core/config.ts. loadMCPConfig(configPathOrCwd):
- If the argument is an existing file, it is parsed directly.
- Otherwise the argument is treated as a working directory, and these files are merged in order:
- Project:
<cwd>/.indusvx/mcp.json - User (XDG):
$XDG_CONFIG_HOME/indusvx/mcp.json(defaults to~/.config/indusvx/mcp.json) - Legacy:
~/.indusvx/agent/mcp.json - Legacy alt:
~/.indusvx/agent/mcp-servers.json
- Project:
The servers field may be an array or an object keyed by server name. Entries with enabled: false are skipped. A url entry yields HTTP transport (the string is parsed into a URL); a command entry yields stdio transport.
import {
loadMCPConfig,
saveUserConfig,
saveProjectConfig,
saveConfig,
getUserConfigPath,
getProjectConfigPath,
createDefaultConfig,
EXAMPLE_CONFIG,
} from "indusagi/mcp";
const configs = loadMCPConfig(process.cwd()); // MCPConnectionOptions[]
// Save an MCPConfigFile (not a single entry):
saveProjectConfig({ servers: [{ name: "fs", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."] }] });
saveUserConfig(EXAMPLE_CONFIG);
saveConfig("/abs/path/mcp.json", createDefaultConfig());
getUserConfigPath(); // $XDG_CONFIG_HOME/indusvx/mcp.json
getProjectConfigPath(); // <cwd>/.indusvx/mcp.json
saveConfig, saveUserConfig, and saveProjectConfig all take a whole MCPConfigFile ({ servers: [...] }) and are synchronous.
7. Schema conversion
Source: src/facade/mcp-core/schema-converter.ts. MCP tool inputSchema (JSON Schema) is converted to a TypeBox TSchema, then made permissive so unexpected server fields never fail validation.
import {
jsonSchemaToTypeBox,
convertMCPInputSchema,
convertMCPOutputSchema,
applyPassthrough,
} from "indusagi/mcp";
const schema = convertMCPInputSchema({
type: "object",
properties: { name: { type: "string" }, age: { type: "number" } },
required: ["name"],
});
// jsonSchemaToTypeBox() followed by applyPassthrough()
jsonSchemaToTypeBox handles primitives, objects, arrays/tuples, enum, const, oneOf/anyOf (union), and allOf (intersect). Properties not in required become optional. applyPassthrough recursively sets additionalProperties: true on object schemas.
8. Error handling
Source: src/facade/mcp-core/errors.ts. Failures are MCPErrors with an MCPErrorCode.
import { MCPError, MCPErrorCode, isMCPError, isSessionError } from "indusagi/mcp";
try {
await client.callTool("missing_tool", {});
} catch (error) {
if (isMCPError(error)) {
if (error.code === MCPErrorCode.TOOL_NOT_FOUND) {
console.log("tool not found");
} else if (isSessionError(error)) {
// SESSION_ERROR or NOT_CONNECTED — reconnect
await client.connect();
}
console.error(error.toString(), error.serverName, error.toolName);
}
}
Set INDUSAGI_DEBUG in the environment to surface the client's debug/info logs on the console (errors always log, even without it).
9. The interop bridge: endpoints, fleets, mounting
Source: src/interop/, imported from indusagi/interop. This layer is built on @modelcontextprotocol/sdk and integrates with the kernel capabilities/runtime contracts rather than the AgentTool facade.
A ServerConfig is discriminated by its kind and carries a stable id used to namespace tools:
import { createServerEndpoint, startServerFleet, mountProtocolBridge } from "indusagi/interop";
import type { ServerConfig, BridgeConfig } from "indusagi/interop";
const fs: ServerConfig = {
id: "filesystem",
kind: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "."],
};
const remote: ServerConfig = {
id: "remote",
kind: "sse",
url: "http://localhost:8080/sse",
headers: { Authorization: "Bearer token" },
};
A single endpoint
const endpoint = createServerEndpoint(fs);
await endpoint.open(); // idle -> connecting -> ready (or faulted)
const tools = await endpoint.listTools(); // readonly RemoteTool[], cached after first call
const out = await endpoint.invoke("read_file", { path: "./README.md" });
// out: RemoteCallResult { content: unknown[], isError: boolean }
console.log(endpoint.status()); // { server, phase, toolCount, fault? }
await endpoint.close();
invoke uses the unqualified server-side name. A result the server flagged isError is returned faithfully (not thrown); only genuine transport/protocol failures throw a ProtocolFault.
A fleet (failure isolation)
const config: BridgeConfig = { servers: [fs, remote] };
const fleet = await startServerFleet(config); // connects all in parallel, isolating per-server failures
for (const ep of fleet.endpoints()) {
console.log(ep.config.id, ep.phase);
}
console.log(fleet.status()); // FleetStatus: Record<id, EndpointStatus>
await fleet.tearDown();
A server that fails to connect is left in the faulted phase with its fault recorded; its healthy peers keep serving.
Mounting into the kernel
import { mountProtocolBridge } from "indusagi/interop";
const { box, fleet } = await mountProtocolBridge({ servers: [fs, remote] });
// box: a runtime ToolBox of every grafted remote tool, named "<id>__<tool>"
// fleet: the live ServerFleet backing those tools
mountProtocolBridge starts the fleet, lists each ready endpoint's tools, normalizes each remote inputSchema with normalizeSchema, and registers each as a kernel tool under its qualified ${id}__${tool} name (QUALIFIER is "__"). Remote tools run with inert filesystem/shell backends since they execute on the remote server.
Fault handling
import { isProtocolFault, isUsablePhase, isTerminalPhase } from "indusagi/interop";
try {
await endpoint.invoke("do_thing", {});
} catch (err) {
if (isProtocolFault(err)) {
// err.kind: "transport" | "protocol" | "timeout" | "tool_error" | "not_connected" | "spawn_failed"
console.error(err.kind, err.message);
}
}
isUsablePhase(endpoint.phase); // true only when "ready"
isTerminalPhase(endpoint.phase); // true when "closed" or "faulted"
10. The interop provider host
Source: src/interop/protocol-bridge/host.ts. createProviderHost stands up an SDK Server that publishes a runtime ToolBox's tools to an external MCP client.
import { createProviderHost } from "indusagi/interop";
const host = createProviderHost(box, { name: "my-host", version: "1.0.0" });
await host.connect(transport); // transport is an @modelcontextprotocol/sdk Transport
The host registers tools/list (mapping each box.descriptors() entry to { name, description, inputSchema }) and tools/call (routing into box.runner.run and projecting the ToolOutcome to { content: [{ type: "text", text }], isError }). It is inert until connect binds it to a transport (stdio pipe, SSE stream, or an in-memory pair). When info is omitted it defaults to { name: "indus-provider-host", version: "0.1.0" }.
For the complete export and type surface, see api-reference.txt. For the module map and the two-layer overview, see README.txt.
