Files
openclaw/src/node-host/mcp.ts
Peter Steinberger 49b5b862ac feat: node-hosted plugins — dynamic tools, MCP servers, and skills (#90431)
* feat: node-hosted plugins — dynamic tools, MCP servers, and skills

Nodes become declarative plugin hosts:
- node.pluginTools.update: node hosts publish plugin-registered agent tool
  descriptors; gateway materializes them as agent tools executing via
  node.invoke under the node command allowlist, with tools.effective
  invalidation and node online/offline removal.
- Trusted paired-node descriptors: no gateway-side plugin registration
  required; gateway.nodes.pluginTools.enabled off-switch (default on);
  description/count caps; deterministic node-prefixed collision names.
- Declarative node-hosted MCP: nodeHost.mcp.servers (McpServerConfig shape)
  starts MCP clients on the node host, publishes tools as pluginId node-mcp,
  executes via built-in mcp.tools.call.v1 with per-layer timeouts, failure
  isolation, and orphan-safe shutdown. No re-pairing when servers change.
- Node-hosted skills: node.skills.update publishes ~/.openclaw/skills
  content (64 skills/64KB/512KB caps both sides); gateway merges them into
  the skills snapshot while connected and exec host=node is available, with
  node:// locators, node-prefixed collisions, disabled command dispatch,
  and gateway.nodes.skills.enabled + nodeHost.skills.enabled switches.
- Security: node-supplied pluginIds cannot satisfy pluginId-scoped tool
  allowlists unless gateway-registered; reserved node-mcp id requires the
  core MCP descriptor shape; protocol registry kept out of public
  plugin-sdk dts.
- E2E: pond harness proves publication, MCP round-trip, skills locator, and
  disconnect/reconnect for all three surfaces.

* style: format node-plugin-tools test

* fix(skills): keep status loader unfiltered when eligibility is passed

skills.status started passing eligibility for the node-skill merge, which
flipped loadWorkspaceSkillEntries into filtered mode and dropped disabled
skills from status reports (QA plugin-lifecycle-hot-reload timeout). Status
now merges node skills explicitly around an unfiltered load. Also: regen
docs_map for new node docs sections; add the intentional node-host MCP
onclose suppression to the lint-suppression allowlist.
2026-07-11 10:16:34 -07:00

462 lines
15 KiB
TypeScript

/** Process-lifetime MCP clients owned by the headless node host. */
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { ErrorCode, type CallToolResult, type Tool } from "@modelcontextprotocol/sdk/types.js";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js";
import { matchesMcpToolFilterPattern } from "../agents/agent-bundle-mcp-filter.js";
import { createMcpJsonSchemaValidator } from "../agents/mcp-json-schema-validator.js";
import { sanitizeMcpMetadataText } from "../agents/mcp-metadata.js";
import { resolveMcpTransport } from "../agents/mcp-transport.js";
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
import type { McpServerConfig } from "../config/types.mcp.js";
import { toErrorObject } from "../infra/errors.js";
import {
NODE_MCP_TOOL_CALL_TIMEOUT_MS,
NODE_MCP_TOOLS_CALL_COMMAND,
} from "../infra/node-commands.js";
import { VERSION } from "../version.js";
const NODE_MCP_PLUGIN_ID = "node-mcp";
const NODE_MCP_DESCRIPTION_MAX_CHARS = 1_024;
const NODE_MCP_NAME_MAX_CHARS = 64;
const NODE_MCP_SERVER_FRAGMENT_MAX_CHARS = 31;
const NODE_MCP_ERROR_MAX_CHARS = 1_024;
const NODE_MCP_MAX_DESCRIPTORS = 128;
const NODE_MCP_MAX_DESCRIPTOR_BYTES = 1024 * 1024;
const NODE_MCP_MAX_CATALOG_BYTES = 10 * 1024 * 1024;
type NodeHostMcpClient = {
onclose?: () => void;
connect(transport: Transport): Promise<void>;
listTools(
params?: { cursor?: string },
options?: { timeout?: number },
): Promise<{ tools: Tool[]; nextCursor?: string }>;
callTool(
params: { name: string; arguments?: Record<string, unknown> },
resultSchema?: undefined,
options?: { timeout?: number },
): Promise<CallToolResult>;
close(): Promise<void>;
};
type NodeHostMcpTransport = {
transport: Transport;
connectionTimeoutMs: number;
requestTimeoutMs: number;
detachStderr?: () => void;
};
type NodeHostMcpSession = {
client: NodeHostMcpClient;
connected: boolean;
tools: Set<string>;
toolCallTimeoutMs: number;
detachStderr?: () => void;
};
export type NodeHostMcpErrorCode =
| "MCP_SERVER_UNAVAILABLE"
| "MCP_TOOL_UNAVAILABLE"
| "MCP_TOOL_TIMEOUT"
| "MCP_TOOL_ERROR";
export class NodeHostMcpError extends Error {
readonly code: NodeHostMcpErrorCode;
constructor(code: NodeHostMcpErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.name = "NodeHostMcpError";
this.code = code;
}
}
export type NodeHostMcpManager = {
configuredServerCount: number;
descriptors: NodePluginToolDescriptor[];
callMcpTool(params: {
server: string;
tool: string;
arguments?: Record<string, unknown>;
timeoutMs?: number;
}): Promise<CallToolResult>;
close(): Promise<void>;
};
type NodeHostMcpManagerDeps = {
createClient?: (serverName: string) => NodeHostMcpClient;
resolveTransport?: (serverName: string, config: McpServerConfig) => NodeHostMcpTransport | null;
warn?: (message: string) => void;
signal?: AbortSignal;
};
type ListedNodeMcpTool = {
serverName: string;
tool: Tool;
};
function defaultWarn(message: string): void {
process.stderr.write(`${message}\n`);
}
function formatMcpError(error: unknown): string {
return truncateUtf16Safe(
redactSensitiveUrlLikeString(toErrorObject(error, "MCP request failed").message),
NODE_MCP_ERROR_MAX_CHARS,
);
}
function sanitizeDescriptorFragment(raw: string, fallback: string): string {
const cleaned = raw
.trim()
.replace(/[^A-Za-z0-9_-]+/g, "_")
.replace(/^[_-]+|[_-]+$/g, "");
const normalized = cleaned || fallback;
return /^[A-Za-z]/.test(normalized) ? normalized : `${fallback}_${normalized}`;
}
function buildDescriptorBaseName(serverName: string, toolName: string): string {
const server = sanitizeDescriptorFragment(serverName, "mcp").slice(
0,
NODE_MCP_SERVER_FRAGMENT_MAX_CHARS,
);
const toolBudget = Math.max(1, NODE_MCP_NAME_MAX_CHARS - server.length - 1);
const tool = sanitizeDescriptorFragment(toolName, "tool").slice(0, toolBudget);
return `${server}_${tool}`;
}
function reserveDescriptorName(baseName: string, usedNames: Set<string>): string {
let index = 1;
while (true) {
const suffix = index === 1 ? "" : `_${index}`;
const candidate = `${baseName.slice(0, NODE_MCP_NAME_MAX_CHARS - suffix.length)}${suffix}`;
const key = normalizeLowercaseStringOrEmpty(candidate);
if (!usedNames.has(key)) {
usedNames.add(key);
return candidate;
}
index += 1;
}
}
function normalizeInputSchema(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return { type: "object", properties: {}, additionalProperties: true };
}
/** Builds provider-safe MCP descriptors in stable server/tool order. */
export function buildNodeMcpToolDescriptors(
listedTools: readonly ListedNodeMcpTool[],
): NodePluginToolDescriptor[] {
const usedNames = new Set<string>();
const descriptors: NodePluginToolDescriptor[] = [];
let catalogBytes = 0;
for (const { serverName, tool } of listedTools.toSorted(
(left, right) =>
left.serverName.localeCompare(right.serverName) ||
left.tool.name.localeCompare(right.tool.name),
)) {
const toolName = tool.name.trim();
const descriptor: NodePluginToolDescriptor = {
pluginId: NODE_MCP_PLUGIN_ID,
name: reserveDescriptorName(buildDescriptorBaseName(serverName, toolName), usedNames),
description: truncateUtf16Safe(
sanitizeMcpMetadataText(tool.description) ||
sanitizeMcpMetadataText(toolName) ||
"MCP tool",
NODE_MCP_DESCRIPTION_MAX_CHARS,
),
parameters: normalizeInputSchema(tool.inputSchema),
command: NODE_MCP_TOOLS_CALL_COMMAND,
mcp: { server: serverName, tool: toolName },
};
const descriptorBytes = Buffer.byteLength(JSON.stringify(descriptor));
if (
descriptorBytes > NODE_MCP_MAX_DESCRIPTOR_BYTES ||
catalogBytes + descriptorBytes > NODE_MCP_MAX_CATALOG_BYTES
) {
continue;
}
descriptors.push(descriptor);
catalogBytes += descriptorBytes;
if (descriptors.length >= NODE_MCP_MAX_DESCRIPTORS) {
break;
}
}
return descriptors;
}
function isOAuthServer(config: McpServerConfig): boolean {
return config.auth === "oauth" || Boolean(config.oauth);
}
function shouldExposeTool(config: McpServerConfig, toolName: string): boolean {
const include = config.toolFilter?.include ?? [];
const exclude = config.toolFilter?.exclude ?? [];
if (
include.length > 0 &&
!include.some((pattern) => matchesMcpToolFilterPattern(pattern, toolName))
) {
return false;
}
return !exclude.some((pattern) => matchesMcpToolFilterPattern(pattern, toolName));
}
async function connectWithTimeout(
client: NodeHostMcpClient,
transport: Transport,
timeoutMs: number,
): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
client.connect(transport),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`MCP server connection timed out after ${timeoutMs}ms`)),
timeoutMs,
);
timer.unref?.();
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async function withAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) {
return await promise;
}
if (signal.aborted) {
throw new Error("MCP startup aborted");
}
return await new Promise<T>((resolve, reject) => {
const onAbort = () => reject(new Error("MCP startup aborted"));
signal.addEventListener("abort", onAbort, { once: true });
void promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
async function listAllTools(
client: NodeHostMcpClient,
timeoutMs: number,
signal?: AbortSignal,
): Promise<Tool[]> {
const tools: Tool[] = [];
let cursor: string | undefined;
do {
const page = await withAbort(
client.listTools(cursor ? { cursor } : undefined, { timeout: timeoutMs }),
signal,
);
tools.push(...page.tools);
cursor = page.nextCursor;
} while (cursor);
return tools;
}
function resolveCallTimeoutMs(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: NODE_MCP_TOOL_CALL_TIMEOUT_MS;
}
function resolveServerToolCallTimeoutMs(config: McpServerConfig): number {
if (
typeof config.requestTimeoutMs === "number" &&
Number.isFinite(config.requestTimeoutMs) &&
config.requestTimeoutMs > 0
) {
return Math.floor(config.requestTimeoutMs);
}
if (typeof config.timeout === "number" && Number.isFinite(config.timeout) && config.timeout > 0) {
return Math.floor(config.timeout * 1_000);
}
return NODE_MCP_TOOL_CALL_TIMEOUT_MS;
}
function isMcpTimeoutError(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === ErrorCode.RequestTimeout,
);
}
/** Starts configured MCP servers once for the lifetime of the node host. */
export async function startNodeHostMcpManager(
servers: Record<string, McpServerConfig> | undefined,
deps: NodeHostMcpManagerDeps = {},
): Promise<NodeHostMcpManager> {
const warn = deps.warn ?? defaultWarn;
const createClient =
deps.createClient ??
(() =>
new Client(
{ name: "openclaw-node-host", version: VERSION },
{ jsonSchemaValidator: createMcpJsonSchemaValidator() },
) as NodeHostMcpClient);
const resolveTransport = deps.resolveTransport ?? resolveMcpTransport;
const configured = listEnabledNodeHostMcpServers(servers);
const sessions = new Map<string, NodeHostMcpSession>();
const listedTools: ListedNodeMcpTool[] = [];
await Promise.all(
configured.map(async ([serverName, config]) => {
if (isOAuthServer(config)) {
warn(`node host MCP server "${serverName}" skipped: OAuth is not supported`);
return;
}
let client: NodeHostMcpClient | undefined;
let resolved: NodeHostMcpTransport | null | undefined;
let session: NodeHostMcpSession | undefined;
try {
resolved = resolveTransport(serverName, config);
if (!resolved) {
warn(`node host MCP server "${serverName}" skipped: invalid or unsupported transport`);
return;
}
client = createClient(serverName);
session = {
client,
connected: false,
tools: new Set<string>(),
toolCallTimeoutMs: resolveServerToolCallTimeoutMs(config),
detachStderr: resolved.detachStderr,
};
// MCP Client exposes callback properties rather than an EventTarget surface.
// oxlint-disable-next-line unicorn/prefer-add-event-listener
client.onclose = () => {
if (session) {
session.connected = false;
}
};
await withAbort(
connectWithTimeout(client, resolved.transport, resolved.connectionTimeoutMs),
deps.signal,
);
session.connected = true;
const tools = (await listAllTools(client, resolved.requestTimeoutMs, deps.signal)).filter(
(tool) => {
const toolName = tool.name.trim();
return Boolean(toolName) && shouldExposeTool(config, toolName);
},
);
for (const tool of tools) {
const toolName = tool.name.trim();
session.tools.add(toolName);
listedTools.push({ serverName, tool: { ...tool, name: toolName } });
}
sessions.set(serverName, session);
} catch (error) {
if (session) {
session.connected = false;
}
resolved?.detachStderr?.();
if (client) {
await Promise.allSettled([client.close()]);
}
if (!deps.signal?.aborted) {
warn(`node host MCP server "${serverName}" failed: ${formatMcpError(error)}`);
}
}
}),
);
const descriptors = buildNodeMcpToolDescriptors(listedTools);
if (descriptors.length < listedTools.length) {
warn(
`node host MCP catalog bounded: published ${descriptors.length} of ${listedTools.length} tools`,
);
}
let closed = false;
return {
configuredServerCount: configured.length,
descriptors,
async callMcpTool(params) {
const session = sessions.get(params.server);
if (!session?.connected) {
throw new NodeHostMcpError(
"MCP_SERVER_UNAVAILABLE",
`MCP server "${params.server}" is unavailable`,
);
}
if (!session.tools.has(params.tool)) {
throw new NodeHostMcpError(
"MCP_TOOL_UNAVAILABLE",
`MCP tool "${params.tool}" is unavailable on server "${params.server}"`,
);
}
try {
return await session.client.callTool(
{ name: params.tool, arguments: params.arguments ?? {} },
undefined,
{
timeout: Math.min(resolveCallTimeoutMs(params.timeoutMs), session.toolCallTimeoutMs),
},
);
} catch (error) {
if (!session.connected) {
throw new NodeHostMcpError(
"MCP_SERVER_UNAVAILABLE",
`MCP server "${params.server}" disconnected`,
{ cause: error },
);
}
if (isMcpTimeoutError(error)) {
throw new NodeHostMcpError("MCP_TOOL_TIMEOUT", formatMcpError(error), { cause: error });
}
throw new NodeHostMcpError("MCP_TOOL_ERROR", formatMcpError(error), { cause: error });
}
},
async close() {
if (closed) {
return;
}
closed = true;
for (const session of sessions.values()) {
session.connected = false;
session.detachStderr?.();
}
await Promise.allSettled(Array.from(sessions.values(), (session) => session.client.close()));
sessions.clear();
},
};
}
function listEnabledNodeHostMcpServers(
servers: Record<string, McpServerConfig> | undefined,
): ReadonlyArray<readonly [string, McpServerConfig]> {
return Object.entries(normalizeConfiguredMcpServers(servers))
.filter(
([serverName, config]) =>
serverName.length > 0 && serverName === serverName.trim() && config.enabled !== false,
)
.map(([serverName, config]) => [serverName, config as McpServerConfig] as const)
.toSorted(([left], [right]) => left.localeCompare(right));
}
export function countConfiguredNodeHostMcpServers(
servers: Record<string, McpServerConfig> | undefined,
): number {
return listEnabledNodeHostMcpServers(servers).length;
}