# @pleach/mcp (/docs/mcp)



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`](/docs/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](/docs/mcp-integration) — it documents the
[`defineTool`](/docs/tools) wrapper pattern for consuming external MCP
servers as runtime tools.

<SourceMeta pkg="{ name: &#x22;@pleach/mcp&#x22;, href: &#x22;https://www.npmjs.com/package/@pleach/mcp&#x22; }" source="{ label: &#x22;github.com/pleachhq/mcp&#x22;, href: &#x22;https://github.com/pleachhq/mcp&#x22; }" />

## Install [#install]

```bash
npm install @pleach/mcp @pleach/core @modelcontextprotocol/sdk
```

```bash
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.

```typescript
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` [#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.

```typescript
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 [#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)` [#registertool]

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.

```typescript
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-and-calltoolname-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.

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

### `start({ transport })` and `stop()` [#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](#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.

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

### `registerSession(sessionId, runtime)` — live [#registersessionsessionid-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`](/docs/packages).

## `McpTransport` [#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 [#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` [#harness-tool-projection--adapterts]

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.

```typescript
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 [#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` [#registering-custom-capabilities--mcpruntimeregistercapability]

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:

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

### Custom tools — works today [#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.

```typescript
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 [#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.

```typescript
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` [#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.

```typescript
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 [#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` [#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](/docs/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](/docs/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 [#where-to-go-next]

<Cards>
  <Card title="MCP integration" href="/docs/mcp-integration" description="The integration narrative — both server and client sides, including consumer-side wrapping of external MCP servers." />

  <Card title="Tools" href="/docs/tools" description="The `defineTool` contract and tool-registry surface this SKU projects onto MCP." />

  <Card title="Multi-tenant" href="/docs/multi-tenant" description="Where `tenantId` lives in the substrate — the substrate that `registerSession` will eventually consume once `@pleach/gateway` C3 lands." />
</Cards>
