pleach
Gateway

@pleach/mcp

The `@pleach/mcp` package — Model Context Protocol server SKU for `@pleach/core` hosts that want to expose their tool registry to stdio MCP clients.

Grafting your tools onto another agent's branch. @pleach/mcp is the Model Context Protocol server SKU for the Pleach ecosystem. It wraps a SessionRuntime from @pleach/core and projects its tool registry onto the MCP tools/list + tools/call wire so that stdio-based MCP clients — Claude Desktop, IDE extensions, and similar — can call into a Pleach host without the host writing the JSON-RPC plumbing.

This page is the SKU reference — what the package exposes, how to install it, and what works today versus what ships in the API but throws. If you're integrating with an MCP server from @pleach/core today without adopting this SKU, read MCP integration — it documents the defineTool wrapper pattern for consuming external MCP servers as runtime tools.

Install

npm install @pleach/mcp @pleach/core @modelcontextprotocol/sdk
pnpm add @pleach/mcp @pleach/core @modelcontextprotocol/sdk

The @modelcontextprotocol/sdk dependency is declared on the package but loaded dynamically inside start({ transport: "stdio" }). Type-checking, DTS emission, and the package's unit tests all compile without the SDK installed; the import only resolves at the moment a host actually starts the server. If the SDK is missing when start() runs, the load throws with an actionable message naming the install command.

That arrangement is deliberate. Consumers who only use the in-process surface — projecting harness tools, dispatching via callTool, inspecting listTools() — never need the SDK on disk.

import {
  MCPServer,
  NotImplementedError,
  adaptHarnessTools,
  projectToolDefinitionToMCP,
  createMcpRuntime,
  createPluggableStdioTransport,
  createSSETransport,
  createWebSocketTransport,
} from "@pleach/mcp";

import type {
  McpServerConfig,
  McpTransport,
  MCPToolDefinition,
  MCPToolContext,
  MCPToolResult,
  SessionRuntimeLike,
  MCPTransport,
  McpRuntime,
  McpRuntimeConfig,
} from "@pleach/mcp";

MCPServer

The package consolidates the two factory functions of the reference implementation (createHarnessMCPServer and createMCPSDKServer) into a single class. One construction call, one start call, one stop call.

import {
  MCPServer,
  createPluggableStdioTransport,
  type HarnessToolExecutor,
} from "@pleach/mcp";
import { createPleachRuntime } from "@pleach/core";

const runtime = createPleachRuntime({ /* ... */ });

// SessionRuntimeLike deliberately does NOT declare an executeTool surface.
// Wire the executor against whichever tool registry your runtime uses
// (a domain-specific registry on `runtime.toolRegistry`, an HTTP shim,
// a direct function map, etc.). The closure below shows the shape.
const executor: HarnessToolExecutor = async (name, args) => {
  // your dispatch — return { success: true, output: ... } on success
  // or { success: false, error: ... } on failure.
  throw new Error(`tool '${name}' not wired — see Quickstart`);
};

const server = new MCPServer(
  {
    name:           "my-mcp-server",
    version:        "1.0.0",
    runtime,
    autoAdaptTools: true,
    maxTools:       100,
  },
  executor,
);

await server.start(createPluggableStdioTransport());

The constructor accepts an McpServerConfig and an optional HarnessToolExecutor. The executor is the closure the server calls when it dispatches a tools/call request; without an executor the server still responds to tools/list (so external clients can discover the tool surface) but every callTool returns a structured "no executor configured" error.

The recommended transport for new code is createPluggableStdioTransport() — it is SDK-free (no runtime dependency on @modelcontextprotocol/sdk for the transport itself) and is the Phase B §4 production path Claude Desktop and stdio-based IDE clients use. The legacy start({ transport: "stdio" }) form continues to work for back-compat and dynamically loads @modelcontextprotocol/sdk/server; reach for it only if you specifically want the SDK's own server object. The pluggable dispatcher implements the initialize handshake itself (dispatchPluggableMethod routes initialize to handleInitialize), so the SDK is no longer required for the handshake.

Constructor options

FieldDefaultPurpose
name"pleach-mcp"Server name surfaced to the MCP client.
version"0.2.0"Semver string the client sees.
runtimeundefinedThe SessionRuntime-shaped object whose tool registry backs the server.
autoAdaptToolstrueSeed the tool list from adaptHarnessTools(runtime) at construction. Set false to register tools manually via register().
maxTools100Soft cap on tools projected from the registry.

register(tool)

Registers a tool against the server's internal table. Tools registered explicitly win against tools projected from the runtime — a name collision overwrites the adapted projection with the explicit entry. That ordering is the load-bearing piece for hosts that want to ship a hand-rolled tool alongside the auto-adapted surface.

server.register({
  name:        "ping",
  description: "Health check — returns 'pong'.",
  inputSchema: { type: "object", properties: {} },
  handler: async () => ({ success: true, output: "pong" }),
});

registerTool(tool) is preserved as a Phase 0 back-compat alias and delegates to register.

listTools() and callTool(name, args, signal?)

listTools() returns the full MCPToolDefinition[] — the same shape the wire tools/list method serves. callTool(name, args, signal?) dispatches against the bound runtime via the tool's handler closure. Both return immediately if no tool matches; callTool also returns a structured error (rather than throwing) if no runtime is bound.

const result = await server.callTool("math", { mode: "rpn", expr: "3 4 +" });
// { success: true, output: { result: 7 } }

start({ transport }) and stop()

start() wires the configured transport and begins serving. Phase A supports transport: "stdio" only — the SSE and WebSocket arms exist in the McpTransport union and on MCPServerStartOptions, but the implementation throws NotImplementedError until Phase B lands. See Phase A status below.

stop() is idempotent. Calling it on an unstarted server is a no-op. Calling start() twice on the same instance throws — MCPServer is not a connection pool, and a host that wants two servers should construct two instances.

await server.start({ transport: "stdio" });
// ... server runs over stdin/stdout ...
await server.stop();

registerSession(sessionId, runtime) — live

The server ships a live in-memory session registry: registerSession(sessionId, runtime, { tenantId? }) stores the runtime (last-wins on a duplicate sessionId), and the companion getSession(sessionId), listSessions(), and unregisterSession(sessionId) methods round out the surface. When no sessions are registered, the constructor-bound runtime (from config.runtime) continues to drive all tool/resource/prompt dispatch, so single-runtime hosts need not touch the registry at all. The broader multi-tenant routing story — resolving which registered runtime serves an inbound request — lands with @pleach/gateway.

McpTransport

The transport union enumerates what the MCP spec allows. Phase A ships only stdio end-to-end; the other arms exist in the union and on MCPServerStartOptions so consumer code can lock against the final shape today:

VariantPhase A status
"stdio"Live. Line-delimited JSON over stdin/stdout — Claude Desktop's default.
"sse"Throws NotImplementedError("D-PA-181") from server.start({ transport: "sse" }). Server-Sent Events transport; Phase B candidate.
"websocket"Throws NotImplementedError("D-PA-181") from server.start({ transport: "websocket" }). Bidirectional transport some IDE clients require; Phase B candidate.

The point of shipping the throwing arms now is to let consumer code lock against the final shape today. A host that writes server.start({ transport: "sse" }) against the current cut catches a typed error with a decision field naming the deferral; the same call site picks up the real transport transparently when Phase B publishes.

The MCPTransport abstraction

@pleach/mcp also exports a pluggable MCPTransport interface alongside three factory functions: createPluggableStdioTransport, createSSETransport, and createWebSocketTransport. This is the production path the MCPServer start switch dispatches through for new code; today it's also the seam for hosts that want to drive a custom transport directly.

FactoryWhat ships
createPluggableStdioTransport(opts)Live. SDK-free stdio transport conforming to the pluggable MCPTransport interface — no runtime dependency on @modelcontextprotocol/sdk. Recommended for new code.
createSSETransport(opts)Scaffold landed; the factory's start() throws a generic Error naming the missing SDK-side supervised-session execution (the typed NotImplementedError("D-PA-181") is the options-bag server.start({ transport: "sse" }) path, not the factory). The contract shape and cors / port wiring are stable.
createWebSocketTransport(opts)Deferred-stub landed; throws at start() with a message naming the missing supervised-session execution. Same stable shape.

A consumer that needs a transport before Phase B lands can hand-roll an object conforming to the structural MCPTransport contract and pass it where the factory result would go — the interface is intentionally narrow (start(handler) boots, the handler dispatches inbound requests, stop() tears down).

Harness-tool projection — adapter.ts

The projection layer converts a Pleach harness tool definition into the minimal MCP wire shape. Two exports:

  • adaptHarnessTools(runtime, executor?, maxTools = 100) — reads runtime.toolRegistry, projects each entry, wraps it with a handler that delegates to the executor. Returns MCPToolDefinition[]. If the runtime has no tool registry, returns an empty array.
  • projectToolDefinitionToMCP(tool) — single-tool variant. Returns an MCPToolDefinition whose handler is a placeholder; the caller binds a real handler. This is the building block when a consumer wants to project a single tool without going through the full registry.
import { adaptHarnessTools, type HarnessToolExecutor } from "@pleach/mcp";

// SessionRuntimeLike does NOT carry an executeTool surface — wire your
// own dispatch closure against the runtime's tool registry of choice.
const executor: HarnessToolExecutor = async (name, args) => {
  // your dispatch here
  return { success: true, output: null };
};

const projected = adaptHarnessTools(runtime, executor);

// projected[i] is now a wire-shaped MCPToolDefinition ready to
// register with an MCPServer, or to surface via tools/list.

Phase A consumes the tool registry through the legacy ToolRegistryWrapper-shaped surface on SessionRuntimeLike.toolRegistry. The runtime.tools.* facet accessor on SessionRuntime is the Phase B target; the adapter switches to it once the facet stabilizes, with no change required at the call site.

Silent metadata drop at the wire boundary

The harness tool definition carries fields that aren't part of the MCP spec — they're Pleach-internal classifications the runtime uses for routing, budget gating, and prompt assembly. These fields are dropped silently at the projection boundary:

Dropped fieldWhy it doesn't ship on the MCP wire
categoryInternal classification used by Pleach's intent detector. No counterpart in the MCP spec.
tierPleach budget gating signal. Specific to the runtime's call-class ladder.
costCategoryCost-attribution metadata. Routed through the audit ledger, not the tool surface.
requiresGPUSandbox-execution hint. Meaningful inside the runtime, not at the MCP client.
entryPointPleach-side entry-tool flag — orders the tool catalog. No MCP semantics.
roleFile-frontmatter convention from the harness. Not a tool-surface field.

The MCP spec is intentionally minimal — name, description, inputSchema — and extensions break interop with stock clients. The adapter retains the original harness tool reference internally via the handler closure, so the runtime can still dispatch correctly with all its metadata; only the wire tools/list response is stripped.

If you need a field on the wire, the right move is the standard MCP extension path (a separate JSON-RPC method or a tool whose output carries the structured data), not bending the tools/list shape.

Registering custom capabilities — McpRuntime.registerCapability

The harness-tool projection above is the automatic path: every tool already registered with the runtime surfaces as an MCP tool without code. The explicit path is McpRuntime.registerCapability — for MCP-shaped tools, resources, and prompts that don't come from the harness tool registry.

The API takes a discriminated union:

import type {
  McpRuntime,
  McpToolRegistration,
  McpResourceRegistration,
  McpPromptRegistration,
} from "@pleach/mcp"

Custom tools — works today

McpToolRegistration registers a tool with its JSONSchema input shape and an async handler. The runtime routes the registration through the Phase A registerTool surface; the wire-level behavior is identical to a harness-projected tool.

const myTool: McpToolRegistration = {
  kind: "tool",
  name: "search-knowledge-base",
  description: "Searches the knowledge base by free-text query.",
  inputSchema: {
    type: "object",
    properties: { query: { type: "string" } },
    required: ["query"],
  },
  handler: async (input: unknown) => {
    const { query } = input as { query: string }
    return await searchKnowledgeBase(query)
  },
}

await mcpRuntime.registerCapability(myTool)

Use the explicit path when the tool doesn't fit the harness tool contract — a wire-only tool that exists for the MCP surface but doesn't make sense as a Pleach agent tool, or a tool whose handler needs MCP-specific context the harness adapter doesn't thread.

Resources and prompts

McpResourceRegistration and McpPromptRegistration register the same way tools do. registerCapability handles all three kinds uniformly — each registration lands in the runtime's last-wins ledger keyed by name, with no runtime throw for resource or prompt kinds.

const myResource: McpResourceRegistration = {
  kind: "resource",
  name: "agent-runbook",
  uri: "file:///workspace/runbooks/agent.md",
  mimeType: "text/markdown",
}

// Lands in the resource ledger (last-wins on a duplicate `name`),
// same as a tool registration.
await mcpRuntime.registerCapability(myResource)

A duplicate name within a kind replaces the prior entry rather than appending; the choice is regression-locked in the runtime tests.

NotImplementedError

Tagged error class for the deferred surfaces. Carries a decision field naming the decision record that documents the deferral, so callers can branch on the cause rather than parsing message text.

import { MCPServer, NotImplementedError } from "@pleach/mcp";

try {
  await server.start({ transport: "sse" });
} catch (error) {
  if (error instanceof NotImplementedError) {
    console.warn(`Deferred surface: ${error.decision}`);
    // Fall back to stdio, queue for retry once Phase B ships, etc.
  }
  throw error;
}

The class is exported from the package barrel so consumer code can narrow against it. Today's instances carry D-PA-181 — the legacy options-bag transport arms (server.start({ transport: "sse" | "websocket" })).

Phase A status

What works today, in one place:

SurfacePhase A status
new MCPServer(config, executor?)Live.
server.register(tool) / registerTool(tool)Live.
server.listTools()Live.
server.callTool(name, args, signal?)Live.
server.start({ transport: "stdio" })Live. Loads the MCP SDK dynamically.
server.start({ transport: "sse" })Throws NotImplementedError("D-PA-181").
server.start({ transport: "websocket" })Throws NotImplementedError("D-PA-181").
server.stop()Live, idempotent.
server.registerSession(sessionId, runtime, { tenantId? }) / getSession / listSessions / unregisterSessionLive. In-memory session registry, last-wins on duplicate sessionId.
adaptHarnessTools(...) / projectToolDefinitionToMCP(...)Live.
MCPTransport interface + createPluggableStdioTransport()Live. SDK-free pluggable transport substrate.
createSSETransport() / createWebSocketTransport()Scaffold + deferred-stub. Contract shape stable; start() throws naming the deferred substrate.
createMcpRuntime() + McpRuntime.registerCapability(input)Live. Tool / resource / prompt registrations land in the runtime ledger with last-wins semantics.
Resources (resources/list, resources/read)Live. Dispatched on the MCPServer wire — both the SDK-backed stdio path (setRequestHandler) and the pluggable dispatcher (dispatchPluggableMethod).
Prompts (prompts/list, prompts/get)Live. Dispatched on the MCPServer wire — both the SDK-backed stdio path and the pluggable dispatcher.
runtime.tools.* facet adoptionDeferred to Phase B. Phase A reads runtime.toolRegistry.

The 0.1.0 cut ships the contract, the stdio transport, the harness-tool projection, and the McpRuntime registration ledger. SSE and WebSocket transports continue to throw typed sentinels until the next phase wires the supervised-session execution.

The resource and prompt surfaces are already dispatched on the MCPServer wire, and the session registry ships live. Phase B adds the SSE and WebSocket transports and the runtime.tools.* facet adoption. Phase C wires multi-tenant routing via @pleach/gateway so an inbound request resolves to the right registered runtime.

The reserved API points — the throwing arms of the options-bag start() — are not a placeholder for a breaking change. The shape that ships today is the shape later phases land against; only the throw goes away.

Position vs mcp-integration

The two pages cover adjacent territory:

PageWhat it documents
@pleach/mcp (this page)The shipping SKU's public surface — MCPServer, the adapter, transport status, the metadata-drop rules.
MCP integrationThe integration story across both directions — exposing harness tools to external MCP clients (this SKU's job) and consuming external MCP servers as defineTool entries (a hand-rolled wrapper today).

Reach for this page when you want the reference for what @pleach/mcp ships. Reach for MCP integration when you want the broader integration patterns, including consumer-side wrapping and the futures around external resource and prompt forwarding.

Where to go next

On this page