pleach
Integrate

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:

DirectionWhat 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 serverHand-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";
Sourcesrc/mcp/

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:

KindWhat it carriesPleach equivalent
ToolsNamed functions with JSON-schema args + returndefineTool
ResourcesRead-only data (files, URIs)Tool that fetches
PromptsReusable prompt templatesPrompt 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.failed stream 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/unsubscribe surface for resource updates doesn't map cleanly to defineTool. 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

On this page