@pleach/mcp
The `@pleach/mcp` package — Model Context Protocol server for `@pleach/core` runtimes over stdio, SSE, Streamable HTTP, or WebSocket, with fail-closed multi-tenant routing.
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
tools, resources, and prompts onto the MCP wire, so Claude Desktop,
IDE extensions, and remote MCP clients 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 the defaults each option carries. To consume an
external MCP server from a runtime instead, read
MCP integration; it documents the
defineTool wrapper pattern.
Install
npm install @pleach/mcp @pleach/core| Package | Range | Kind |
|---|---|---|
@pleach/core | ^0.1.0 | peer dependency |
@modelcontextprotocol/sdk | ^1.20.1 | dependency, installed for you |
ws | ^8.0.0 | optional peer, needed only by createWebSocketTransport |
The package is beta at 0.1.0. server.start(transport) with one of
the three transport factories is the supported path.
Quickstart
Serve a runtime's tools to Claude Desktop over stdio:
import { createPleachRuntime, definePleachPlugin } from "@pleach/core";
import { MCPServer, createPluggableStdioTransport } from "@pleach/mcp";
const runtime = createPleachRuntime({
plugins: [
definePleachPlugin("my-tools", {
tools: [
{
name: "echo",
description: "Echo the text back.",
parameters: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
execute: async (args) => ({ echoed: args.text }),
},
],
}),
],
});
const server = new MCPServer({
name: "my-mcp-server",
version: "1.0.0",
runtime, // tools/call needs a bound runtime
nativeExecution: true, // run each tool's own execute; off by default
});
await server.start(createPluggableStdioTransport());
// The server now answers tools/list and tools/call on stdio.Three pieces make tools/call work end to end:
- A bound runtime. Without one,
tools/callreturns "No runtime bound". - Tools projected from
runtime.tools.list(). That's the facetcreatePleachRuntimeexposes for plugin-contributed tools. A legacyruntime.toolRegistry(anything withgetTools()) is the fallback when the facet is absent or empty. - A way to execute them. Either set
nativeExecution: true, as above, or pass aHarnessToolExecutoras the second constructor argument. An executor, when passed, wins over native execution for every projected tool, so don't pass one that only throws.
Wire it into Claude Desktop's config:
{ "mcpServers": { "pleach": { "command": "node", "args": ["/path/to/server.js"] } } }MCPServer
new MCPServer(config?, executor?) is the server. createMcpServer(config, executor)
is a factory that returns the same class.
Constructor options
| Field | Default | Purpose |
|---|---|---|
name | "pleach-mcp" | Server name surfaced to the MCP client. |
version | "0.2.0" | Version string the client sees. |
runtime | undefined | The runtime whose tools back the server. |
autoAdaptTools | true | Project the runtime's tools at construction. Set false to register tools by hand. |
maxTools | 100 | Cap on projected tools. |
nativeExecution | false | Run a projected tool's own execute when no executor is passed. |
toolCacheTtlMs | 30000 | Lifetime of a per-connection runtime's cached tool projection. 0 disables the cache. |
multiTenant | false | Require every request to resolve to a registered tenant. Also turned on by a transport with a tenantIdExtractor. |
allowUntenantedSessions | false | Let a connection reach a session registered without a tenantId by its session id alone. |
resolveRuntime | undefined | ({ connectionId, tenantId, sessionId }) => runtime | undefined. Picks the runtime per request on multi-connection transports. |
Executing tools
On tools/call, a projected tool goes to the HarnessToolExecutor
when you pass one:
type HarnessToolExecutor = (
name: string,
args: Record<string, unknown>,
ctx?: MCPToolContext, // { runtime, signal?, tenantId?, sessionId? }
) => Promise<{ success: true; output: unknown } | { success: false; error: string }>;Pass one to route every projected tool through your own path, for
auth, auditing, or approval-gated tools. A throw becomes a
success: false result.
Without an executor, the call is refused unless nativeExecution is
true. With it, the tool's own execute(args, { toolCallId, signal, tenantId, sessionId })
runs only when nothing asks for approval. The check fails closed:
- A static
requiresApproval: true, aneedsApprovalthat istrueor a function that doesn't return exactlyfalse(a throwing function refuses), orrequiresGPU: truerefuses the call. - So does the runtime's plugin approval flow
(
runtime.plugins.collectApprovalFlow().checkApprovalNeeded) returning a request, or throwing. - The arguments must pass the tool's input schema.
Argument validation is shallow. It enforces type (primitive type
names), enum, required, properties, and
additionalProperties: false, on the arguments and on nested objects
up to three levels deep. Only own keys count. enum compares
primitives by value and objects or arrays by their JSON serialisation,
so key order matters. items, pattern, format, numeric ranges,
oneOf / anyOf / allOf, and $ref aren't enforced; validate those
in execute.
Approval hosts and interrupt managers injected by the host aren't
reachable from the runtime, so native execution doesn't consult them.
If you rely on them, pass an executor. A tool with neither an executor
nor an execute returns a "no executor configured" error.
register(tool, { tenantScoped? })
Registers a hand-written tool.
server.register({
name: "ping",
description: "Health check — returns 'pong'.",
inputSchema: { type: "object", properties: {} },
handler: async () => ({ success: true, output: "pong" }),
});- On the constructor runtime, a registered tool replaces a projected tool with the same name.
- On a tenant-resolved request, a registered tool whose name is also
projected from that tenant's runtime is an error (JSON-RPC
-32603), never a silent override. { tenantScoped: true }hides the tool from connections without a tenant and refuses it ontools/call.
registerTool(tool) is kept as an alias. Each runtime's projection is
cached; call server.invalidateToolCache(runtime) after its tool
catalog changes.
Resources and prompts
registerResource(resource), registerResources(provider, { perRequest? }),
registerPrompt(prompt), and registerPrompts(provider) add entries
served on resources/list, resources/read, prompts/list, and
prompts/get.
- A provider registered with
{ perRequest: true }is called on everyresources/listandresources/readwith{ runtime, tenantId, sessionId }for the routed runtime, so each tenant lists only its own resources. A static registration with the same URI wins. - A resource or prompt can set
visibleTo({ tenantId, sessionId }). When it returnsfalseor throws, the entry is left out of the list and refused on read or get.
server.registerResources(() => adaptHarnessArtifacts(runtime));
// Multi-tenant: project from the runtime each request was routed to.
server.registerResources((ctx) => adaptHarnessArtifacts(ctx!.runtime), { perRequest: true });
server.registerPrompts(() => adaptHarnessPrompts(runtime));start(), stop(), listTools(), callTool()
start(transport) takes a transport instance. The options-bag form
start({ transport: "sse" | "websocket", port, ... }) builds the same
transports and requires port. The legacy start({ transport: "stdio" })
form is kept for back-compat only: it builds the SDK server handlers
but binds no transport, so use createPluggableStdioTransport()
instead.
A second start() while one is in progress shares it; a start()
after the server started throws. stop() is idempotent.
getTransport() returns the running transport, and isStarted()
reports state.
listTools() returns the MCPToolDefinition[] that tools/list
serves. callTool(name, args, signal?) dispatches in process and
returns a structured error, rather than throwing, when no tool matches
or no runtime is bound.
NotImplementedError is still exported for code that narrows on it,
but nothing in the package throws it any more.
Transports
| Factory | Wire | Notes |
|---|---|---|
createPluggableStdioTransport() | stdio | Owns the initialize handshake itself (protocol versions 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07). Single client, no connection identity. |
createSSETransport({ port, ... }) | HTTP+SSE, optionally Streamable HTTP | HTTP+SSE is the transport from MCP revision 2024-11-05; revision 2025-03-26 replaced it with Streamable HTTP. Set streamableHttp: true (or { path }) to serve both from one server. |
createWebSocketTransport({ port, ... }) | WebSocket | Subprotocol mcp, one JSON-RPC message per text frame. Needs the optional ws peer; without it, start() rejects with an install hint. |
import { createSSETransport, createWebSocketTransport } from "@pleach/mcp";
await server.start(
createSSETransport({
port: 3001, // 0 picks an ephemeral port; read transport.port after start()
path: "/sse", // GET: event stream (default)
messagesPath: "/messages", // POST ?sessionId=... (default)
streamableHttp: true, // also serve Streamable HTTP at "/mcp"
cors: { origins: ["https://app.example.com"] },
}),
);
// Or: await server.start(createWebSocketTransport({ port: 3002, path: "/ws" }));Both network transports bind host, default 127.0.0.1. On
WebSocket, a malformed frame is answered with JSON-RPC -32700 (parse
error) or -32600 (invalid request).
Security defaults
These apply to SSE, Streamable HTTP, and WebSocket alike.
- Host allowlist.
allowedHostslists hostnames theHostheader must match, on any port. On a loopback bind (localhost,::1, or127.x.y.z) it defaults to["127.0.0.1", "localhost", "[::1]"]plus the bind address, which blocks DNS rebinding. Other requests get HTTP 403 before any routing or upgrade. On a non-loopback bind with no list,Hostisn't checked. - Origin allowlist.
allowedOriginsdefaults to[]. A request carrying anOriginheader, which every browser request does, gets HTTP 403 unless that origin is listed or the list contains"*". Requests withoutOrigin, such as Node and CLI clients, are unaffected. On SSE,cors.originsare allowed too. - No unauthenticated public bind. Binding a non-loopback
hostmakesstart()throw unless connections are authenticated: configure atenantIdExtractor, give the server aresolveRuntimehook, or setallowUnauthenticated: trueto accept the risk.
Limits
Numeric options must be positive integers, except requestTimeoutMs
and idleTimeoutMs, which accept 0 to disable. Anything else throws
when the transport is created.
| Option | Default | Applies to | Behavior |
|---|---|---|---|
maxConnections | 1000 | SSE, WebSocket | Further connections get HTTP 503. |
maxConnectionsPerTenant | none | SSE, WebSocket | Per-tenant cap, HTTP 503 past it. Needs a tenantIdExtractor. |
maxInFlightPerConnection | 16 | all network | Extra requests on one connection get JSON-RPC -32000 instead of queueing. |
maxExecutingRequests | 256 | all network | Cap on requests whose handlers are still running, across the transport. A request keeps its slot until its handler settles, even after its connection closed. |
maxExecutingRequestsPerTenant | none | all network | Same, per tenant. Needs a tenantIdExtractor. |
requestTimeoutMs | 0 (off) | all network | An unsettled request is answered with -32000, its signal is aborted, and its per-connection slot is freed. A late reply is dropped. |
idleTimeoutMs | 15 minutes | SSE, Streamable HTTP | A session with no traffic and no request in flight is closed. |
heartbeatIntervalMs | 30000 | WebSocket | The server pings each socket and terminates one that missed the previous ping. |
maxPayloadBytes | 4 MiB | WebSocket | Largest accepted frame. |
Every request carries an AbortSignal, passed as ctx.signal to
tool, resource, and prompt handlers. It aborts when the connection
closes, is reaped for idleness, or misses a heartbeat; when the
transport stops; or when requestTimeoutMs expires. Long-running
handlers should honour it: one that ignores the signal keeps its
executing slot until it settles.
Multi-tenant routing
Give the transport a tenantIdExtractor (and optionally a
sessionIdExtractor) and register one runtime per tenant:
const server = new MCPServer({ name: "my-mcp-server" });
server.registerSession(crypto.randomUUID(), acmeRuntime, { tenantId: "acme" });
server.registerSession(crypto.randomUUID(), globexRuntime, { tenantId: "globex" });
await server.start(
createSSETransport({
port: 3001,
tenantIdExtractor: (req) => req.headers?.["x-tenant-id"] as string | undefined,
}),
);The transport fixes a connection's identity when it opens. A request
the tenantIdExtractor yields nothing for gets HTTP 401. A later POST
naming an unknown session, or a session of another tenant, gets HTTP
404 either way, so a caller can't probe which session ids exist.
A transport with a tenantIdExtractor, or multiTenant: true, puts
the server in multi-tenant mode. There is no fallback runtime then:
start() throws if a constructor runtime is set. The server
resolves a runtime for every request:
- A
resolveRuntime({ connectionId, tenantId, sessionId })hook, when configured, decides. Returningundefinedrefuses. This is the seam for a gateway-backed resolver;@pleach/mcpdoesn't depend on@pleach/gateway. - Otherwise a
sessionIdmust name a registered session whosetenantIdmatches the connection's. A session registered without atenantIdis refused on a session-only lookup unlessallowUntenantedSessions: true. Session ids are then the only credential, so make them unguessable. AtenantIdalone must match exactly one registered session. - Outside multi-tenant mode, a connection with no tenant or session,
on a server whose registry has no tenants, uses the constructor
runtime.
Anything else is refused with JSON-RPC -32001 and the message
Unauthorized, the same for an unknown tenant and an ambiguous one.
It never falls back to another tenant's runtime. In multi-tenant mode
a message without a connection identity, such as one over stdio, is
refused the same way; only ping is answered. Run multi-tenant
servers on the HTTP or WebSocket transports.
On a tenant-resolved request, tools/list and tools/call see that
runtime's projected tools plus registered tools, never the constructor
runtime's. Handlers receive the routed runtime as ctx.runtime, plus
ctx.tenantId and ctx.sessionId.
registerSession(sessionId, runtime, { tenantId? }) is last-wins on a
duplicate sessionId; getSession, listSessions, and
unregisterSession round out the registry.
Errors
Only errors the package raises itself (unknown method, unknown
resource URI, missing prompt argument, tenant refusals) reach the
client with their own code and message. Any other error, including one
a handler or resolveRuntime hook throws with its own numeric code,
reaches the client as Internal error (ref <id>), with the detail
logged server side under that id.
On HTTP and WebSocket connections, a tool failure that came from an
exception (a throwing handler, executor, or native execute) is
reported as Tool failed (ref <id>). A failure a tool returns
deliberately ({ success: false, error }) keeps its text. Over stdio
the client is the local operator, so tool failure text stays verbatim.
Harness projection — adapter.ts
The projection layer converts a Pleach harness tool definition into the minimal MCP wire shape:
adaptHarnessTools(runtime, executor?, maxTools = 100, { nativeExecution? })readsruntime.tools.list(), falling back toruntime.toolRegistry.getTools(), and wraps each projection with a handler that applies the execution rules above. A runtime exposing neither projects an empty array.projectToolDefinitionToMCP(tool)projects one tool. Its handler is a placeholder that returns an error; the caller binds a real one.
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. Native execution still reads it and refuses the call. |
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.
Resource and prompt projectors
adaptHarnessArtifacts(runtime, opts?)mounts harness artifacts atpleach://artifact/<chat_id>/<artifact_id>.adaptSandboxFiles(runtime, opts?)mounts sandbox files atpleach://sandbox/<chat_id>/<path>.adaptHarnessPrompts(runtime, opts?)projects eachruntime.promptscontribution as one MCP prompt.
Resource listings are cached for 5 seconds (ADAPTER_RESOURCE_CACHE_TTL_MS)
per runtime and per (tenantId, chatId), with at most 256 snapshots
per runtime (ADAPTER_RESOURCE_CACHE_MAX_KEYS). A listing taken from
one runtime never answers for another.
refreshArtifactCache(runtime, opts?) and refreshSandboxCache(runtime, opts?)
clear one runtime's snapshots. The older refreshArtifactCache(opts?)
and refreshSandboxCache(opts?) forms still work and clear the
matching snapshots on every runtime. The snapshot store is shared by
every entry point of the package loaded in the process, so a refresh
imported from one subpath clears listings built through another.
adaptSandboxFiles resolves each file's MIME type through a fixed
order: a per-call mimeTypeResolver, then entry.mimeType, then an
extension lookup, then a fallback. That machinery is exported so you can
reuse or override it:
| Export | Kind | Role |
|---|---|---|
resolveSandboxMime(entry, resolver?) | function | Runs the four-step resolution and returns the MIME string. |
SANDBOX_MIME_EXTENSION_TABLE | const | The extension → MIME map (frozen). |
SANDBOX_MIME_FALLBACK | const | "application/octet-stream" — the last-resort type. |
SandboxMimeResolver | type | (entry) => string | undefined — return undefined to defer to the table. |
Registering custom capabilities — McpRuntime.registerCapability
McpRuntime is a preview surface.
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
McpToolRegistration registers a tool with its JSONSchema input
shape and an async handler. The runtime routes the registration
through the server's 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.
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, the transports and their limits, multi-tenant routing, 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, and how it lines up with the tenant a transport extracts.
@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.