MCP Interaction and Task SDK
Tracker references: pm-rz9gep, pm-rzs24j, and pm-hv1x1x. Transport and remote trust primitives are tracked by pm-v7e337 and pm-3zh9s4.
The aggregate @unbrained/pm-cli/sdk entrypoint exposes transport-neutral
contracts for MCP 2026-07-28 multi round-trip requests (MRTR), explicit cache
policy, bounded JSON Schema 2020-12 validation, and the official durable tasks
extension. A custom stdio or HTTP host can reuse these primitives without
importing pm's executable server.
Request more host input
Throw PmMcpInputRequiredError from domain code. The host adapter converts the
signal into an input_required result after validating request method,
payload bounds, and the request-local client capability.
import {
PmMcpInputRequiredError,
digestMcpRequestParameters,
sealMcpRequestState,
} from "@unbrained/pm-cli/sdk";
const requestState = sealMcpRequestState(
{
expiresAt: Date.now() + 5 * 60_000,
method: "tools/call",
parameterDigest: digestMcpRequestParameters({ name: "pm_mutate" }),
principal: "host-user-42",
state: { phase: "confirm" },
},
process.env.MCP_REQUEST_STATE_KEY!, // at least 32 bytes
);
throw new PmMcpInputRequiredError({
requestState,
inputRequests: {
confirmation: {
method: "elicitation/create",
params: {
mode: "form",
message: "Apply the proposed PM mutations?",
requestedSchema: {
type: "object",
properties: { approved: { type: "boolean" } },
required: ["approved"],
},
},
},
},
});
Use openMcpRequestState() on retry to verify signature, expiry, original
method, parameter digest, and principal. Call
PmMcpRequestStateReplayGuard.consume() only after successful verification.
The bundled guard provides bounded single-process replay detection; a
multi-process host should persist the consumed state digest in its shared
store. parseMcpInputResponses() validates and clones retry responses.
The permitted input request methods are
elicitation/create, roots/list, and sampling/createMessage. The client
must advertise the corresponding capability on that same request.
Validate schemas and attach cache policy
validateMcpJsonSchema() accepts object and boolean JSON Schema 2020-12 roots,
resolves local JSON Pointer references, and applies byte, depth, and node work
bounds. It returns a clone so callers cannot mutate the validated input by
alias. External references remain identifiers; this validator does not perform
network retrieval.
import {
validateMcpJsonSchema,
withMcpCachePolicy,
} from "@unbrained/pm-cli/sdk";
const inputSchema = validateMcpJsonSchema({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
});
const result = withMcpCachePolicy(
{ tools: [{ name: "get_item", inputSchema }] },
{ ttlMs: 30_000, cacheScope: "private" },
);
Use cacheScope: "private" whenever a result depends on a workspace,
principal, authorization decision, or user data. A public result must be
safe for shared intermediaries and all principals for its full TTL.
Run durable extension tasks
createMcpTaskStore() persists task records below the supplied tracker root's
ignored runtime/mcp-tasks directory. It creates the durable record before
returning a handle and serializes mutations with pm's cross-process lock.
import { createMcpTaskStore } from "@unbrained/pm-cli/sdk";
const tasks = createMcpTaskStore({
pmRoot: "/workspace/project/.agents/pm",
});
const handle = await tasks.create({
principal: "host-user-42",
ttlMs: 60 * 60_000,
statusMessage: "Validating the workspace.",
});
try {
const result = await validateWorkspace();
await tasks.complete(handle.taskId, "host-user-42", result);
} catch (error) {
await tasks.fail(handle.taskId, "host-user-42", {
code: -32603,
message: error instanceof Error ? error.message : "Validation failed",
});
}
The lifecycle is working to input_required, completed, failed, or
cancelled. get() applies retention expiry and restart recovery;
requireInput() records MRTR requests; update() accepts matching responses;
takeInputResponses() transfers them to a resumed worker; and cancel() is a
cooperative state transition. Completed, failed, and cancelled records are
immutable. Task ids and principal mismatches intentionally return the same
not-found refusal to avoid disclosing another principal's work.
The bundled pm-mcp server negotiates the extension through
io.modelcontextprotocol/tasks. Eligible validation, health, graph, import,
reindex, and test operations may return a task handle when the client requests
asynchronous execution. Clients retrieve state with tasks/get, provide MRTR
answers with tasks/update, and request cancellation with tasks/cancel.
Task progress notifications and cross-transport request-scoped streams are a
separate concern from change subscriptions. A client polls at pollIntervalMs
for task state; subscriptions/listen carries only explicitly acknowledged
tool, prompt, and resource changes.
Open change subscriptions
PmMcpSubscriptionRegistry is transport-neutral. A stdio or HTTP adapter
opens a record with the subscriptions/listen JSON-RPC id, requested filter,
and an asynchronous sink. The registry sends the acknowledgment before any
other notification, intersects filters with advertised server capabilities,
tags every notification with the subscription id, and awaits each sink so
transport backpressure is visible.
import { PmMcpSubscriptionRegistry } from "@unbrained/pm-cli/sdk";
const subscriptions = new PmMcpSubscriptionRegistry({
capabilities: { resources: { listChanged: true, subscribe: true } },
serverInfo: { name: "custom-pm-host", version: "1.0.0" },
});
await subscriptions.open({
id: "workspace-changes",
notifications: {
resourcesListChanged: true,
resourceSubscriptions: ["pm://workspace/context"],
},
sink: async (notification) => sendOnTransport(notification),
});
await subscriptions.emitResourceUpdated("pm://workspace/context");
Closing returns the final modern result envelope. Abrupt disconnects should delete the record without fabricating a replay cursor or redelivery promise.
Project and validate HTTP headers
buildMcpHttpRequestHeaders() constructs the required protocol, method, and
name headers from a request. validateMcpHttpRequestHeaders() checks the
received headers against both the JSON-RPC body and a tool's input schema.
collectMcpHeaderAnnotations() exposes the validated x-mcp-header mapping
when a custom adapter needs to inspect it.
Header values are strings, numbers, or booleans. The SDK Base64-encodes values that cannot be represented unambiguously and rejects control bytes, reserved MCP names, duplicate mappings, undeclared arguments, and body/header mismatches. Never copy arbitrary client headers into tool arguments.
Compose remote authorization
Use buildMcpProtectedResourceMetadata() for RFC 9728 metadata,
buildMcpAuthorizationDiscoveryUrls() and
validateMcpAuthorizationServerMetadata() for exact issuer discovery, and
selectMcpClientRegistrationMode() to prefer Client ID Metadata Documents
over deprecated Dynamic Client Registration. Store credentials with
PmMcpIssuerCredentialStore; its exact issuer key prevents cross-issuer
reuse and its cloned values prevent alias mutation.
At the resource boundary, authorizeMcpHttpRequest() accepts bearer tokens
only in the Authorization header and verifies issuer, audience, and required
scopes through a host-provided verifier. extractMcpTraceContext() validates
W3C trace fields and retains only allowlisted baggage before
runWithMcpTraceContext() creates a concurrent-request-local scope.
See MCP remote transport, authorization, and migration for executable configuration, lifecycle policy, and the threat model.
Failure and trust boundaries
- Keep signing keys outside request data and logs; rotate them using a bounded overlap strategy owned by the host.
- Bind continuation state and tasks to an authenticated principal chosen by the host, never to a caller-supplied display name.
- Treat
ttlMsas retention/freshness policy, not proof that underlying data is unchanged. - A worker lost across process restart becomes a terminal, non-recoverable task result. Create a new task instead of replaying side effects implicitly.
- The task store is durable local coordination, not a distributed queue. A multi-host deployment should implement the same public lifecycle on a shared transactional backend.