Skip to content
pleach
Gateway

@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
PackageRangeKind
@pleach/core^0.1.0peer dependency
@modelcontextprotocol/sdk^1.20.1dependency, installed for you
ws^8.0.0optional 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/call returns "No runtime bound".
  • Tools projected from runtime.tools.list(). That's the facet createPleachRuntime exposes for plugin-contributed tools. A legacy runtime.toolRegistry (anything with getTools()) is the fallback when the facet is absent or empty.
  • A way to execute them. Either set nativeExecution: true, as above, or pass a HarnessToolExecutor as 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

FieldDefaultPurpose
name"pleach-mcp"Server name surfaced to the MCP client.
version"0.2.0"Version string the client sees.
runtimeundefinedThe runtime whose tools back the server.
autoAdaptToolstrueProject the runtime's tools at construction. Set false to register tools by hand.
maxTools100Cap on projected tools.
nativeExecutionfalseRun a projected tool's own execute when no executor is passed.
toolCacheTtlMs30000Lifetime of a per-connection runtime's cached tool projection. 0 disables the cache.
multiTenantfalseRequire every request to resolve to a registered tenant. Also turned on by a transport with a tenantIdExtractor.
allowUntenantedSessionsfalseLet a connection reach a session registered without a tenantId by its session id alone.
resolveRuntimeundefined({ 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, a needsApproval that is true or a function that doesn't return exactly false (a throwing function refuses), or requiresGPU: true refuses 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 on tools/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 every resources/list and resources/read with { 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 returns false or 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

FactoryWireNotes
createPluggableStdioTransport()stdioOwns 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 HTTPHTTP+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, ... })WebSocketSubprotocol 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. allowedHosts lists hostnames the Host header must match, on any port. On a loopback bind (localhost, ::1, or 127.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, Host isn't checked.
  • Origin allowlist. allowedOrigins defaults to []. A request carrying an Origin header, which every browser request does, gets HTTP 403 unless that origin is listed or the list contains "*". Requests without Origin, such as Node and CLI clients, are unaffected. On SSE, cors.origins are allowed too.
  • No unauthenticated public bind. Binding a non-loopback host makes start() throw unless connections are authenticated: configure a tenantIdExtractor, give the server a resolveRuntime hook, or set allowUnauthenticated: true to 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.

OptionDefaultApplies toBehavior
maxConnections1000SSE, WebSocketFurther connections get HTTP 503.
maxConnectionsPerTenantnoneSSE, WebSocketPer-tenant cap, HTTP 503 past it. Needs a tenantIdExtractor.
maxInFlightPerConnection16all networkExtra requests on one connection get JSON-RPC -32000 instead of queueing.
maxExecutingRequests256all networkCap on requests whose handlers are still running, across the transport. A request keeps its slot until its handler settles, even after its connection closed.
maxExecutingRequestsPerTenantnoneall networkSame, per tenant. Needs a tenantIdExtractor.
requestTimeoutMs0 (off)all networkAn unsettled request is answered with -32000, its signal is aborted, and its per-connection slot is freed. A late reply is dropped.
idleTimeoutMs15 minutesSSE, Streamable HTTPA session with no traffic and no request in flight is closed.
heartbeatIntervalMs30000WebSocketThe server pings each socket and terminates one that missed the previous ping.
maxPayloadBytes4 MiBWebSocketLargest 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:

  1. A resolveRuntime({ connectionId, tenantId, sessionId }) hook, when configured, decides. Returning undefined refuses. This is the seam for a gateway-backed resolver; @pleach/mcp doesn't depend on @pleach/gateway.
  2. Otherwise a sessionId must name a registered session whose tenantId matches the connection's. A session registered without a tenantId is refused on a session-only lookup unless allowUntenantedSessions: true. Session ids are then the only credential, so make them unguessable. A tenantId alone must match exactly one registered session.
  3. 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? }) reads runtime.tools.list(), falling back to runtime.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 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. Native execution still reads it and refuses the call.
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.

Resource and prompt projectors

  • adaptHarnessArtifacts(runtime, opts?) mounts harness artifacts at pleach://artifact/<chat_id>/<artifact_id>.
  • adaptSandboxFiles(runtime, opts?) mounts sandbox files at pleach://sandbox/<chat_id>/<path>.
  • adaptHarnessPrompts(runtime, opts?) projects each runtime.prompts contribution 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:

ExportKindRole
resolveSandboxMime(entry, resolver?)functionRuns the four-step resolution and returns the MIME string.
SANDBOX_MIME_EXTENSION_TABLEconstThe extension → MIME map (frozen).
SANDBOX_MIME_FALLBACKconst"application/octet-stream" — the last-resort type.
SandboxMimeResolvertype(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:

PageWhat 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 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