@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/sdkpnpm add @pleach/mcp @pleach/core @modelcontextprotocol/sdkThe @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
| Field | Default | Purpose |
|---|---|---|
name | "pleach-mcp" | Server name surfaced to the MCP client. |
version | "0.2.0" | Semver string the client sees. |
runtime | undefined | The SessionRuntime-shaped object whose tool registry backs the server. |
autoAdaptTools | true | Seed the tool list from adaptHarnessTools(runtime) at construction. Set false to register tools manually via register(). |
maxTools | 100 | Soft 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:
| Variant | Phase 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.
| Factory | What 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)— readsruntime.toolRegistry, projects each entry, wraps it with a handler that delegates to the executor. ReturnsMCPToolDefinition[]. If the runtime has no tool registry, returns an empty array.projectToolDefinitionToMCP(tool)— single-tool variant. Returns anMCPToolDefinitionwhose 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 field | Why it doesn't ship on the MCP wire |
|---|---|
category | Internal classification used by Pleach's intent detector. No counterpart in the MCP spec. |
tier | Pleach budget gating signal. Specific to the runtime's call-class ladder. |
costCategory | Cost-attribution metadata. Routed through the audit ledger, not the tool surface. |
requiresGPU | Sandbox-execution hint. Meaningful inside the runtime, not at the MCP client. |
entryPoint | Pleach-side entry-tool flag — orders the tool catalog. No MCP semantics. |
role | File-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:
| Surface | Phase 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 / unregisterSession | Live. 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 adoption | Deferred 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:
| Page | What it documents |
|---|---|
@pleach/mcp (this page) | The shipping SKU's public surface — MCPServer, the adapter, transport status, the metadata-drop rules. |
| MCP integration | The 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
MCP integration
The integration narrative — both server and client sides, including consumer-side wrapping of external MCP servers.
Tools
The `defineTool` contract and tool-registry surface this SKU projects onto MCP.
Multi-tenant
Where `tenantId` lives in the substrate — the substrate that `registerSession` will eventually consume once `@pleach/gateway` C3 lands.
@pleach/observe
Destination-flexible observability SDK for AI agents. Drop it into an existing agent loop (LangChain, CrewAI, OpenAI SDK, hand-rolled) and write typed audit rows to Postgres, Supabase, your OpenTelemetry collector, or an in-memory buffer — your storage, your control plane.
Compliance · HashChainMiddleware
Consumer-facing wire-up between the host event-log writer and @pleach/core's hash-chain primitives. beforeWrite decorates each event; verifyAfterBatch checks substrate integrity.