MCP integration
Integrate MCP servers from `@pleach/core` today — the `defineTool` wrapper pattern. Distinct from the `@pleach/mcp` SKU (see [/docs/mcp]).
This page is the consumer-side pattern for wrapping MCP server calls
as @pleach/core tools — the integration you write yourself today,
using defineTool over @modelcontextprotocol/sdk. For the
@pleach/mcp package itself — the SKU that projects a runtime's tool
registry onto the MCP wire — see @pleach/mcp.
@pleach/core integrates with the Model Context Protocol in
both directions: exposing its own tool registry as an MCP server,
and consuming tools from external MCP servers through a
defineTool wrapper. The dedicated @pleach/mcp SKU collapses the
server-side projection into a typed package; the consumer-side
wrapper documented here remains the supported path for pulling
external MCP tools into a runtime.
The Model Context Protocol is a standard for exposing tools, resources, and prompts to AI agents. MCP servers expose capabilities (filesystem access, GitHub queries, database introspection); MCP clients connect and call them.
The two integration directions are independent:
| Direction | What ships in @pleach/core today |
|---|---|
| Pleach exposes its tools to an external MCP client (Claude Code, an IDE) | @pleach/core root barrel — createHarnessMCPServer, createMCPSDKServer |
| Pleach consumes tools from an external MCP server | Hand-rolled defineTool wrapper over @modelcontextprotocol/sdk. The dedicated @pleach/mcp SKU is reserved on npm and will collapse the boilerplate. |
This page documents both directions.
import { defineTool } from "@pleach/core";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";Pleach as MCP server (@pleach/core)
createHarnessMCPServer builds a typed MCPServer from a
ToolRegistryWrapper. The in-process projection — calling
listTools / callTool on the returned object directly — is the
stable, recommended path and is what the substrate's own
integration tests exercise. Wiring it onto a live stdio transport
so external MCP clients can call your registered tools is the
early-stage (beta) arm; lock against the in-process shape first
and adopt the stdio wiring as it matures.
The MCP server symbols ship from the @pleach/core root barrel —
there is no @pleach/core/mcp subpath:
import {
createHarnessMCPServer,
createMCPSDKServer,
ToolRegistryWrapper,
} from "@pleach/core";
const server = createHarnessMCPServer({
registry: new ToolRegistryWrapper(),
name: "my-pleach-host",
version: "1.0.0",
toolFilter: (tool) => tool.category !== "internal",
toolExecutor: async (name, args) => myExecutor(name, args),
maxTools: 100,
});
// Or wire through the official SDK:
const sdkServer = await createMCPSDKServer({ registry, toolExecutor });HarnessMCPServerOptions carries the registry, an optional
filter, the executor ((name, args) => Promise<ToolResult>), and
a maxTools cap. Without toolExecutor, callTool reports
"No tool executor configured" — wire it explicitly.
The returned MCPServer exposes:
interface MCPServer {
name: string;
version: string;
capabilities: { tools: boolean; resources: boolean };
listTools: () => Promise<MCPTool[]>;
callTool: (name: string, args: Record<string, unknown>) => Promise<ToolResult>;
listResources: () => Promise<MCPResource[]>;
readResource: (uri: string) => Promise<{ contents: string; mimeType: string }>;
}generateToolsMarkdown(registry) and
generateToolsManifest(registry) ship as helpers for static
exports of the catalog.
Pleach as MCP client
An MCP server exposes three resource kinds:
| Kind | What it carries | Pleach equivalent |
|---|---|---|
| Tools | Named functions with JSON-schema args + return | defineTool |
| Resources | Read-only data (files, URIs) | Tool that fetches |
| Prompts | Reusable prompt templates | Prompt contribution |
The substrate's primitives map cleanly. The integration is one translation layer.
Pattern: wrap an MCP server as defineTool calls
A function that introspects an MCP server's tool list and
generates a ToolDefinition for each:
// lib/mcp/mcpTools.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { defineTool, type ToolDefinition } from "@pleach/core";
import { z } from "zod";
export async function mcpTools(opts: {
serverCommand: string;
serverArgs?: string[];
toolPrefix?: string;
}): Promise<ToolDefinition[]> {
const transport = new StdioClientTransport({
command: opts.serverCommand,
args: opts.serverArgs ?? [],
});
const client = new Client(
{ name: "@pleach/core-consumer", version: "1.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
const { tools } = await client.listTools();
return tools.map((mcpTool) =>
defineTool({
name: `${opts.toolPrefix ?? "mcp_"}${mcpTool.name}`,
description: mcpTool.description ?? "(MCP-sourced tool)",
// Convert JSON Schema → Zod. Use a small adapter or the
// `zod-from-json-schema` library; pass-through for unknown
// shapes (the MCP server validates server-side anyway).
inputSchema: zodFromJsonSchema(mcpTool.inputSchema) as z.ZodType<unknown>,
async execute(input, ctx) {
const result = await client.callTool({
name: mcpTool.name,
arguments: input as Record<string, unknown>,
});
if (result.isError) {
throw new Error(`MCP tool ${mcpTool.name} failed: ${JSON.stringify(result.content)}`);
}
return result.content;
},
}),
);
}Register the proxied tools with the runtime:
const mcpToolDefs = await mcpTools({
serverCommand: "uvx",
serverArgs: ["mcp-server-filesystem", "--root", "/data"],
toolPrefix: "fs_",
});
setOrchestratorRegistry({ tools: mcpToolDefs });
const runtime = new SessionRuntime({
/* ... */
});
const session = await runtime.createSession({
tools: { enabled: mcpToolDefs.map((t) => t.name) },
});The MCP server runs as a subprocess; the client talks to it over
stdio. The runtime sees ordinary defineTool-shaped tools.
What the wrapper handles
- Schema translation. MCP servers publish JSON Schema; the
runtime expects Zod. A small adapter library handles the
common cases; complex schemas can fall back to
z.unknown()with server-side validation. - Transport. Stdio is the simplest; HTTP / SSE transports are also supported by the MCP SDK.
- Error propagation. MCP errors become tool failures —
surfacing as
tool.failedstream events with the structured error. - Lifecycle. The client connection is per-runtime. Close it in your cleanup path; the substrate doesn't manage subprocess lifecycles.
What the wrapper doesn't handle
- Connection pooling. Multiple runtimes sharing one MCP server need a shared client. Wrap the client construction in a singleton.
- Resource subscriptions. The MCP
subscribe/unsubscribesurface for resource updates doesn't map cleanly todefineTool. Use the lower-level client directly for those. - Server-pushed prompts. MCP server-provided prompts could be threaded as static prompt contributions; the wrapper above doesn't do that automatically.
Pattern: MCP as a prompt source
For MCP servers that publish prompts, register them as
PromptContribution entries:
import type { HarnessPlugin } from "@pleach/core";
async function mcpPromptPlugin(client: Client): Promise<HarnessPlugin> {
const { prompts } = await client.listPrompts();
return {
name: "mcp-prompts",
contributePrompts: () =>
prompts.map((mcpPrompt) => ({
id: `mcp-prompts.${mcpPrompt.name}`,
mode: "append" as const,
scope: undefined,
content: async (ctx) => {
const resolved = await client.getPrompt({
name: mcpPrompt.name,
arguments: { callClass: ctx.callClass },
});
return resolved.messages.map((m) =>
typeof m.content === "string" ? m.content : m.content.text,
).join("\n\n");
},
})),
};
}The content function runs at prompt-composition time, so it
re-fetches per call. For static prompts, cache the result at
plugin construction.
The runtime-aware contribution hook is the right home for prompts that depend on session state — MCP doesn't model that directly, but a wrapper plugin can.
Pattern: MCP resources as retrieval
For MCP servers that publish resources (files, URIs, database
schemas), the cleanest integration is a single retrieval tool
that proxies to resources/list + resources/read:
// lib/mcp/mcpRetrievalTool.ts
import { defineTool } from "@pleach/core";
export function mcpRetrievalTool(client: Client) {
return defineTool({
name: "mcp_retrieve",
description: "Retrieve a resource from the MCP server by URI.",
inputSchema: z.object({
uri: z.string(),
limit: z.number().int().optional(),
}),
async execute(input, ctx) {
const result = await client.readResource({ uri: input.uri });
return { contents: result.contents };
},
});
}For more sophisticated retrieval (RAG over MCP resources), pair this with a vector store — embed the resource list, retrieve top-k URIs, and read them via the tool.
Connecting to a remote MCP server
The MCP SDK supports HTTP and SSE transports as well as stdio. For a remote MCP server:
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
const transport = new SSEClientTransport(
new URL("https://mcp.example.com/sse"),
{ /* auth headers */ },
);
const client = new Client(/* ... */);
await client.connect(transport);For production deployments, the SSE transport pairs naturally with the substrate's own SSE wire — the same long-lived stream shape on both ends.
Health checks
MCP servers can fail (subprocess died, network blip, auth expired). Add a periodic ping to the connection management layer:
async function pingMcp(client: Client): Promise<boolean> {
try {
await client.ping();
return true;
} catch {
return false;
}
}
setInterval(async () => {
if (!(await pingMcp(client))) {
log.warn("MCP server unreachable; reconnecting");
await reconnect();
}
}, 30_000);Surface the MCP server's health through the substrate's
/api/harness/health route by adding a custom component check.
What @pleach/mcp adds today
@pleach/mcp@0.1.0 bundles the server primitive (MCPServer),
the harness-tools-to-MCP projection (adaptHarnessTools,
projectToolDefinitionToMCP), and the stdio transport end-to-end.
The SSE and WebSocket arms ship in the transport union and on
MCPServerStartOptions so consumer code can lock against the
final shape today; server.start({ transport: "sse" }) and
"websocket" throw NotImplementedError("D-PA-181") until the
next slice. registerSession() throws
NotImplementedError("D-PA-184") until @pleach/gateway C3
multi-tenant routing lands — see @pleach/mcp for
the per-arm status table.
To serve over stdio today, hand the pluggable transport to
start(). createPluggableStdioTransport() owns the JSON-RPC
initialize handshake natively — prefer it over the legacy
start({ transport: "stdio" }) options-bag form, which builds the
handlers but does not bind a transport:
import {
MCPServer,
createPluggableStdioTransport,
type HarnessToolExecutor,
} from "@pleach/mcp";
const executor: HarnessToolExecutor = async (name, args) => {
// Bind `name` to a dispatch path of your choice.
return { success: true, output: { name, args } };
};
const server = new MCPServer(
{ name: "my-mcp-server", version: "1.0.0", runtime, autoAdaptTools: true },
executor,
);
await server.start(createPluggableStdioTransport());
// Server now answers tools/list + tools/call on stdio.For the consumer side (running the runtime as an MCP client into
other servers), the hand-rolled wrapper patterns above remain the
supported path — @pleach/mcp Phase A focuses on the
runtime-as-server projection. The substrate's own integration
tests still use the hand-rolled patterns above against reference
MCP servers.
Where to go next
Pleach + Inngest
Coexist with Inngest (or Trigger.dev / DBOS / Temporal) — keep durable steps, retries, and scheduling; add `@pleach/core` as the LLM-turn substrate inside the step. Not a migration.
Host adapter
`setHarnessModuleLoader` — the dynamic-import seam hosts mid-migration use to wire their own implementations of capabilities the substrate hasn't fully absorbed yet.