openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
776 lines (775 loc) • 43.2 kB
JavaScript
import { x as parseStrictFiniteNumber } from "./number-coercion-CLj0HTDM.js";
import "./src-vebZIeLe.js";
import { t as expectDefined } from "./expect-CyE8FADM.js";
import { r as asNullableRecord } from "./record-coerce-DItp3I4t.js";
import { o as normalizeLowercaseStringOrEmpty, p as normalizeStringifiedOptionalString } from "./string-coerce-CIXf7egm.js";
import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js";
import { n as requestExitAfterOneShotOutput } from "./one-shot-exit-BkFPnjbM.js";
import { t as createLazyRuntimeMethod } from "./lazy-runtime-CgCh8H_K.js";
import { t as formatCliCommand } from "./command-format-C7YfyMTd.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { r as resolveEnvironmentValue } from "./process-env-DlZFJzq6.js";
import { t as runTasksWithConcurrency } from "./run-with-concurrency-Dtu208ef.js";
import { r as formatCliJsonFailure } from "./failure-output-Dc5ce9_F.js";
import { l as readMcpOAuthStoreReadOnly, p as operatorMcpOAuthIdentity } from "./mcp-oauth-store-rMpCI-eU.js";
import { i as completeMcpOAuthAuthorization, o as countMcpOAuthPrincipals, s as readMcpOAuthCredentialsStatus, t as clearMcpOAuthCredentials, u as startMcpOAuthAuthorization } from "./mcp-oauth-bxRFfdN1.js";
import { n as resolveMcpTransportConfig } from "./mcp-transport-config-mph-T0Ys.js";
import { t as startOAuthLoopbackCallbackServer } from "./oauth-loopback-callback-BTvKedhW.js";
import { t as buildBundleMcpToolsFromCatalog } from "./agent-bundle-mcp-materialize-CIHEU_uK.js";
import { t as parseConfigValue } from "./config-value-D2VJu0bm.js";
import { t as listConfiguredMcpServers } from "./mcp-config-BmNLJlVl.js";
import { i as updateConfiguredMcpServerTools, n as unsetConfiguredMcpServer, r as updateConfiguredMcpServer, t as setConfiguredMcpServer } from "./mcp-config-mutation-CrkzqS9C.js";
import { t as resolveGatewayAuthOptions } from "./gateway-secret-options-BZ9vZmJx.js";
import { t as applyParentDefaultHelpAction } from "./parent-default-help-DQUF3qKA.js";
import { constants } from "node:fs";
import path from "node:path";
import fs$1 from "node:fs/promises";
import "commander";
//#region src/cli/mcp-cli.ts
const createSessionMcpRuntime = createLazyRuntimeMethod(() => import("./agents/agent-bundle-mcp-runtime.js"), (runtime) => runtime.createSessionMcpRuntime);
const disposeAllSessionMcpRuntimes = createLazyRuntimeMethod(() => import("./agents/agent-bundle-mcp-manager-api.js"), (runtime) => runtime.disposeAllSessionMcpRuntimes);
function fail(message, json) {
if (json) printJson(formatCliJsonFailure(message));
else defaultRuntime.error(message);
defaultRuntime.exit(1);
throw new Error(message);
}
function printJson(value) {
defaultRuntime.writeJson(value);
}
const MCP_OAUTH_CALLBACK_TIMEOUT_MS = 3e5;
function parseCsvList(value) {
if (!value) return;
const entries = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
return entries.length > 0 ? entries : void 0;
}
function collectOption(value, previous = []) {
return [...previous, value];
}
function parseKeyValueEntries(values, label) {
const entries = {};
for (const raw of values ?? []) {
const separatorIndex = raw.indexOf("=");
if (separatorIndex <= 0) fail(`${label} entries must use KEY=VALUE.`);
const key = raw.slice(0, separatorIndex).trim();
const value = raw.slice(separatorIndex + 1);
if (!key) fail(`${label} entries must use a non-empty key.`);
entries[key] = value;
}
return Object.keys(entries).length > 0 ? entries : void 0;
}
function parsePositiveNumberOption(value, label) {
if (value === void 0) return;
const parsed = parseStrictFiniteNumber(value);
if (parsed === void 0 || parsed <= 0) fail(`${label} must be a positive number.`);
return parsed;
}
function parseMcpApprovalModeOption(value) {
if (value === void 0) return;
const mode = normalizeLowercaseStringOrEmpty(value);
if (mode !== "auto" && mode !== "prompt" && mode !== "approve") fail("--approval must be \"auto\", \"prompt\", or \"approve\".");
return mode;
}
function parseOAuthConfig(opts) {
const oauth = {
...normalizeStringifiedOptionalString(opts.scope) ? { scope: opts.scope.trim() } : {},
...normalizeStringifiedOptionalString(opts.redirectUrl) ? { redirectUrl: opts.redirectUrl.trim() } : {},
...normalizeStringifiedOptionalString(opts.clientMetadataUrl) ? { clientMetadataUrl: opts.clientMetadataUrl.trim() } : {}
};
return Object.keys(oauth).length > 0 ? oauth : void 0;
}
function setOptionalField(target, key, value) {
if (value !== void 0) target[key] = value;
}
const MCP_DOCTOR_CONCURRENCY = 4;
const MCP_CODEX_APPROVAL_ANNOTATION_HINT = "tools have no safety annotations; calls require approval in prompting session postures";
const SENSITIVE_HEADER_NAMES = /* @__PURE__ */ new Set([
"authorization",
"proxy-authorization",
"x-api-key",
"api-key",
"api_key"
]);
const SENSITIVE_KEY_PATTERN = /(?:^|[_-])(api[_-]?key|authorization|bearer|password|secret|token)$/i;
function issue(level, message) {
return {
level,
message
};
}
function hasSensitiveKey(name) {
return SENSITIVE_HEADER_NAMES.has(name.trim().toLowerCase()) || SENSITIVE_KEY_PATTERN.test(name);
}
function hasLiteralSensitiveValue(value) {
return typeof value === "string" && value.trim().length > 0 && !value.trim().startsWith("$");
}
function resolveConfiguredPath(filePath, cwd) {
if (path.isAbsolute(filePath)) return filePath;
const base = typeof cwd === "string" && cwd.trim() ? cwd.trim() : process.cwd();
return path.resolve(base, filePath);
}
async function fileExists(filePath) {
try {
return (await fs$1.stat(filePath)).isFile();
} catch {
return false;
}
}
async function directoryExists(filePath) {
try {
return (await fs$1.stat(filePath)).isDirectory();
} catch {
return false;
}
}
async function isExecutable(filePath) {
try {
await fs$1.access(filePath, process.platform === "win32" ? constants.F_OK : constants.X_OK);
return true;
} catch {
return false;
}
}
function executableCandidates(command) {
if (process.platform !== "win32") return [command];
const extensions = (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((entry) => entry.trim()).filter(Boolean);
if (path.extname(command)) return [command];
return [command, ...extensions.map((extension) => `${command}${extension.toLowerCase()}`)];
}
async function commandExists(command, cwd, env) {
if (path.isAbsolute(command) || command.includes("/") || command.includes("\\")) return isExecutable(resolveConfiguredPath(command, cwd));
const pathEntries = ((process.platform === "win32" ? resolveEnvironmentValue(env, "PATH") : env?.PATH) ?? process.env.PATH ?? "").split(path.delimiter).map((entry) => entry.trim() || ".");
for (const pathEntry of pathEntries) {
const resolvedPathEntry = path.isAbsolute(pathEntry) ? pathEntry : resolveConfiguredPath(pathEntry, cwd);
for (const candidate of executableCandidates(command)) if (await isExecutable(path.join(resolvedPathEntry, candidate))) return true;
}
return false;
}
async function collectMcpDoctorIssues(params) {
const issues = [];
const { name, server } = params;
const resolved = resolveMcpTransportConfig(name, server);
const disabled = server.enabled === false;
if (server.enabled === false) issues.push(issue("warning", "server is disabled"));
if (!disabled) {
if (!resolved) issues.push(issue("error", "server transport is invalid"));
if (resolved?.kind === "stdio") {
if (!await commandExists(resolved.command, resolved.cwd, resolved.env)) issues.push(issue("error", `stdio command not found or not executable: ${resolved.command}`));
if (resolved.cwd && !await directoryExists(resolved.cwd)) issues.push(issue("error", `stdio cwd does not exist: ${resolved.cwd}`));
}
if (resolved?.kind === "http") {
if (server.auth === "oauth") {
if (asNullableRecord(server.oauth)?.identity !== "per-requester") {
const authStatus = await readMcpOAuthCredentialsStatus(operatorMcpOAuthIdentity(name, resolved.url));
if (authStatus.state === "requires-authorization") issues.push(issue("warning", `OAuth credentials require additional authorization; run ${formatCliCommand(`openclaw mcp login ${name}`)}`));
else if (authStatus.state !== "authorized") issues.push(issue("warning", `OAuth credentials are not authorized; run ${formatCliCommand(`openclaw mcp login ${name}`)}`));
}
const headers = asNullableRecord(server.headers);
if (headers && Object.keys(headers).some((key) => key.toLowerCase() === "authorization")) issues.push(issue("warning", "OAuth is enabled and the static Authorization header is ignored"));
}
if (resolved.sslVerify === false) issues.push(issue("warning", "TLS certificate verification is disabled"));
if (resolved.clientCert && !await fileExists(resolveConfiguredPath(resolved.clientCert, ""))) issues.push(issue("error", `client certificate file does not exist: ${resolved.clientCert}`));
if (resolved.clientKey && !await fileExists(resolveConfiguredPath(resolved.clientKey, ""))) issues.push(issue("error", `client key file does not exist: ${resolved.clientKey}`));
}
}
for (const [field, values] of [["headers", asNullableRecord(server.headers)], ["env", asNullableRecord(server.env)]]) for (const [key, value] of Object.entries(values ?? {})) if (hasSensitiveKey(key) && hasLiteralSensitiveValue(value)) issues.push(issue("warning", `${field}.${key} contains a literal sensitive value; prefer an environment-backed value outside committed config`));
const toolFilter = asNullableRecord(server.toolFilter);
if (toolFilter && !Array.isArray(toolFilter.include) && !Array.isArray(toolFilter.exclude)) issues.push(issue("warning", "toolFilter is present but has no include or exclude list"));
if (params.probe && server.enabled !== false && !issues.some((entry) => entry.level === "error")) {
const probeIssues = await probeMcpServerIssues({
config: params.config,
name,
server
});
issues.push(...probeIssues);
}
return issues;
}
async function probeMcpServerIssues(params) {
const runtime = await createSessionMcpRuntime({
sessionId: "openclaw-cli-mcp-doctor",
workspaceDir: process.cwd(),
cfg: buildMcpProbeConfig({
config: params.config,
servers: { [params.name]: params.server }
}),
manifestRegistry: { plugins: [] }
});
try {
const result = formatMcpProbeResult(await runtime.getCatalog());
const diagnostic = result.diagnostics[0];
if (diagnostic) return [issue("error", `probe failed: ${diagnostic.message}`)];
const server = result.servers[params.name];
if (!server) return [issue("error", "probe did not connect to this server")];
return server.approvalHint ? [issue("info", `Codex approval mode: ${server.codexApprovalMode}; ${server.approvalHint}`)] : [];
} catch (err) {
return [issue("error", `probe failed: ${formatErrorMessage(err)}`)];
} finally {
await runtime.dispose();
}
}
function countConnectedMcpPrincipals(name, server) {
const resolved = resolveMcpTransportConfig(name, server);
if (server.auth !== "oauth" || resolved?.kind !== "http" || asNullableRecord(server.oauth)?.identity !== "per-requester") return;
return countMcpOAuthPrincipals(operatorMcpOAuthIdentity(name, resolved.url));
}
async function buildMcpStatusEntries(servers) {
const entries = Object.entries(servers).toSorted(([a], [b]) => a.localeCompare(b));
return Promise.all(entries.map(async ([name, server]) => {
const resolved = resolveMcpTransportConfig(name, server);
const enabled = server.enabled !== false;
const entry = {
name,
configured: true,
enabled,
ok: enabled && Boolean(resolved),
transport: resolved?.transportType,
launch: resolved?.description,
requestTimeoutMs: resolved?.requestTimeoutMs,
connectionTimeoutMs: resolved?.connectionTimeoutMs,
supportsParallelToolCalls: resolved?.supportsParallelToolCalls,
toolFilter: server.toolFilter,
codex: server.codex
};
if (server.auth) entry.auth = server.auth;
if (server.auth === "oauth" && resolved?.kind === "http" && asNullableRecord(server.oauth)?.identity !== "per-requester") {
const identity = operatorMcpOAuthIdentity(name, resolved.url);
const store = readMcpOAuthStoreReadOnly(identity.storeKey);
entry.authStatus = {
hasTokens: Boolean(store.tokens),
requiresAuthorization: store.pendingAuthorizationChallenge?.requiresAuthorization === true,
hasClientInformation: Boolean(store.clientInformation),
hasCodeVerifier: Boolean(store.codeVerifier),
hasDiscoveryState: Boolean(store.discoveryState),
hasLastAuthorizationUrl: Boolean(store.lastAuthorizationUrl),
...await readMcpOAuthCredentialsStatus(identity)
};
} else entry.connectedPrincipals = countConnectedMcpPrincipals(name, server);
return entry;
}));
}
function formatMcpProbeResult(catalog) {
const projectedTools = buildBundleMcpToolsFromCatalog({
catalog,
createResourceListExecute: () => async () => {
throw new Error("probe projection cannot execute MCP resources_list");
},
createResourceReadExecute: () => async () => {
throw new Error("probe projection cannot execute MCP resources_read");
},
createPromptListExecute: () => async () => {
throw new Error("probe projection cannot execute MCP prompts_list");
},
createPromptGetExecute: () => async () => {
throw new Error("probe projection cannot execute MCP prompts_get");
}
});
return {
generatedAt: new Date(catalog.generatedAt).toISOString(),
servers: Object.fromEntries(Object.entries(catalog.servers).toSorted(([a], [b]) => a.localeCompare(b)).map(([name, server]) => {
const codexApprovalMode = server.codexApprovalMode ?? "auto";
const serverTools = catalog.tools.filter((tool) => tool.serverName === name);
const approvalHint = codexApprovalMode === "auto" && serverTools.length > 0 && serverTools.every((tool) => Object.keys(tool.codexAnnotations ?? {}).length === 0) ? MCP_CODEX_APPROVAL_ANNOTATION_HINT : void 0;
return [name, {
launch: server.launchSummary,
tools: server.toolCount,
codexApprovalMode,
...approvalHint ? { approvalHint } : {},
...server.requestTimeoutMs ? { requestTimeoutMs: server.requestTimeoutMs } : {},
...server.supportsParallelToolCalls ? { supportsParallelToolCalls: server.supportsParallelToolCalls } : {},
...server.tools?.filteredCount ? { filteredTools: server.tools.filteredCount } : {},
...server.resources ? { resources: true } : {},
...server.prompts ? { prompts: true } : {},
...server.tools?.listChanged || server.resources?.listChanged || server.prompts?.listChanged ? { listChanged: {
tools: server.tools?.listChanged === true,
resources: server.resources?.listChanged === true,
prompts: server.prompts?.listChanged === true
} } : {}
}];
})),
tools: projectedTools.map((tool) => tool.name).toSorted(),
diagnostics: catalog.diagnostics ?? []
};
}
function buildMcpProbeConfig(params) {
return {
...params.config,
mcp: {
...params.config.mcp,
servers: params.servers
}
};
}
const DEFAULT_MCP_PROBE_INITIALIZE_TIMEOUT_MS = 5e3;
function applyMcpProbeInitializeTimeout(server) {
if (typeof server.connectionTimeoutMs === "number" && Number.isFinite(server.connectionTimeoutMs) && server.connectionTimeoutMs > 0) return server;
return {
...server,
connectionTimeoutMs: DEFAULT_MCP_PROBE_INITIALIZE_TIMEOUT_MS
};
}
function resolveMcpProbeIssue(params) {
if (params.result.diagnostics.length > 0) {
const first = expectDefined(params.result.diagnostics[0], "diagnostics entry at 0");
return `MCP probe failed for "${first.serverName}" in ${params.path}: ${first.message}`;
}
for (const [name, server] of Object.entries(params.servers)) if (server.enabled !== false && !params.result.servers[name]) return `MCP probe did not connect to "${name}" in ${params.path}.`;
}
function failOnMcpProbeIssues(params) {
const probeIssue = resolveMcpProbeIssue(params);
if (probeIssue) fail(probeIssue);
}
async function probeMcpServersOrFail(params) {
const probeServers = Object.fromEntries(Object.entries(params.servers).map(([name, server]) => [name, applyMcpProbeInitializeTimeout(server)]));
const runtime = await createSessionMcpRuntime({
sessionId: "openclaw-cli-mcp-probe",
workspaceDir: process.cwd(),
cfg: buildMcpProbeConfig({
config: params.config,
servers: probeServers
}),
manifestRegistry: { plugins: [] }
});
try {
const result = formatMcpProbeResult(await runtime.getCatalog());
failOnMcpProbeIssues({
result,
servers: params.servers,
path: params.path
});
return result;
} finally {
await runtime.dispose();
}
}
const OPENCLAW_MCP_REGISTRY_SCOPE_NOTE = "Note: this command only shows OpenClaw-managed mcp.servers entries and does not include mcporter servers from config/mcporter.json.";
function registerMcpCli(program) {
const mcp = program.command("mcp").description("Manage OpenClaw mcp.servers config and channel bridge");
mcp.command("serve").description("Expose OpenClaw channels over MCP stdio").option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)").option("--token <token>", "Gateway token (if required)").option("--token-file <path>", "Read gateway token from file").option("--password <password>", "Gateway password (if required)").option("--password-file <path>", "Read gateway password from file").option("--claude-channel-mode <mode>", "Claude channel notification mode: auto, on, or off", "auto").option("-v, --verbose", "Verbose logging to stderr", false).action(async (opts) => {
try {
const { gatewayToken, gatewayPassword } = resolveGatewayAuthOptions(opts);
const claudeChannelMode = normalizeLowercaseStringOrEmpty(normalizeStringifiedOptionalString(opts.claudeChannelMode) ?? "auto");
if (claudeChannelMode !== "auto" && claudeChannelMode !== "on" && claudeChannelMode !== "off") throw new Error("Invalid --claude-channel-mode value. Use \"auto\", \"on\", or \"off\".");
const { serveOpenClawChannelMcp } = await import("./channel-server-M5W2XBWJ.js");
await serveOpenClawChannelMcp({
gatewayUrl: opts.url,
gatewayToken,
gatewayPassword,
claudeChannelMode,
verbose: Boolean(opts.verbose)
});
} catch (err) {
defaultRuntime.error(`MCP server failed to start: ${formatErrorMessage(err)}. Run ${formatCliCommand("openclaw gateway status --deep --require-rpc")} to inspect Gateway health.`);
defaultRuntime.exit(1);
}
});
mcp.command("list").description("List OpenClaw-managed MCP servers from mcp.servers").option("--json", "Print JSON").action(async (opts) => {
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error, opts.json);
if (opts.json) {
printJson(loaded.mcpServers);
return;
}
const entries = Object.entries(loaded.mcpServers).toSorted(([a], [b]) => a.localeCompare(b));
if (entries.map(([name]) => name).length === 0) {
defaultRuntime.log(`No OpenClaw-managed MCP servers configured in ${loaded.path}. Add one with ${formatCliCommand("openclaw mcp set <name> '{\"command\":\"uvx\",\"args\":[\"context7-mcp\"]}'")}.`);
defaultRuntime.log(OPENCLAW_MCP_REGISTRY_SCOPE_NOTE);
return;
}
defaultRuntime.log(`OpenClaw-managed MCP servers (${loaded.path}):`);
for (const [name, server] of entries) {
const connectedPrincipals = countConnectedMcpPrincipals(name, server);
const connected = connectedPrincipals === void 0 ? "" : ` (${connectedPrincipals} connected principal${connectedPrincipals === 1 ? "" : "s"})`;
defaultRuntime.log(`- ${name}${connected}`);
}
defaultRuntime.log("");
defaultRuntime.log(OPENCLAW_MCP_REGISTRY_SCOPE_NOTE);
});
mcp.command("show").description("Show one OpenClaw-managed MCP server or the full mcp.servers config").argument("[name]", "MCP server name").option("--json", "Print JSON").action(async (name, opts) => {
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error, opts.json);
const value = name ? loaded.mcpServers[name] : loaded.mcpServers;
if (name && !value) fail(`No MCP server named "${name}" in ${loaded.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`, opts.json);
if (opts.json) {
printJson(value ?? {});
return;
}
if (name) defaultRuntime.log(`OpenClaw-managed MCP server "${name}" (${loaded.path}):`);
else defaultRuntime.log(`OpenClaw-managed MCP servers (${loaded.path}):`);
printJson(value ?? {});
});
mcp.command("status").description("Show configured MCP server transport status without connecting").option("-v, --verbose", "Show transport, auth, timeout, and filter details", false).option("--json", "Print JSON").action(async (opts) => {
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error, opts.json);
const status = await buildMcpStatusEntries(loaded.mcpServers);
if (opts.json) {
printJson({
path: loaded.path,
servers: status
});
return;
}
if (status.length === 0) {
defaultRuntime.log(`No MCP servers configured in ${loaded.path}.`);
return;
}
defaultRuntime.log(`MCP server status (${loaded.path}):`);
for (const entry of status) {
const transport = entry.enabled ? entry.transport ?? "invalid" : "disabled";
const auth = entry.auth === "oauth" ? " oauth" : "";
const oauth = entry.authStatus?.state === "requires-authorization" ? " authorization-required" : entry.authStatus?.state === "authorized" ? " authorized" : "";
const filters = entry.toolFilter ? " tool-filtered" : "";
const parallel = entry.supportsParallelToolCalls ? " parallel" : "";
const connected = entry.connectedPrincipals === void 0 ? "" : ` ${entry.connectedPrincipals}-principal${entry.connectedPrincipals === 1 ? "" : "s"}-connected`;
defaultRuntime.log(`- ${entry.name}: ${transport}${auth}${oauth}${connected}${filters}${parallel}`);
if (opts.verbose) {
defaultRuntime.log(` launch: ${entry.launch ?? "n/a"}`);
defaultRuntime.log(` timeouts: connect=${entry.connectionTimeoutMs ?? "n/a"}ms request=${entry.requestTimeoutMs ?? "n/a"}ms`);
if (entry.auth === "oauth") defaultRuntime.log(entry.connectedPrincipals === void 0 ? ` oauth: ${entry.authStatus?.state ?? "unauthenticated"}` : ` oauth: per-requester, connected principals: ${entry.connectedPrincipals}`);
if (entry.toolFilter) defaultRuntime.log(` tools: ${JSON.stringify(entry.toolFilter)}`);
}
}
});
mcp.command("probe").description("Connect to configured MCP servers and list available capabilities").argument("[name]", "MCP server name").option("--json", "Print JSON").action(async (name, opts) => {
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error, opts.json);
const servers = name ? loaded.mcpServers[name] ? { [name]: loaded.mcpServers[name] } : void 0 : loaded.mcpServers;
if (!servers) fail(`No MCP server named "${name}" in ${loaded.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`, opts.json);
if (name && loaded.mcpServers[name]?.enabled === false) fail(`MCP server "${name}" is disabled in ${loaded.path}. Run ${formatCliCommand(`openclaw mcp configure ${name} --enable`)} before probing it.`, opts.json);
if (!opts.json && Object.keys(servers).length === 0) {
defaultRuntime.log(`No MCP servers configured in ${loaded.path}. Add one with ${formatCliCommand("openclaw mcp add <name> --command <command>")}.`);
return;
}
const runtime = await createSessionMcpRuntime({
sessionId: "openclaw-cli-mcp-probe",
workspaceDir: process.cwd(),
cfg: buildMcpProbeConfig({
config: loaded.config,
servers
}),
manifestRegistry: { plugins: [] }
});
try {
const result = formatMcpProbeResult(await runtime.getCatalog());
if (opts.json) printJson(result);
else {
defaultRuntime.log(`MCP probe (${loaded.path}):`);
for (const [serverName, server] of Object.entries(result.servers)) {
defaultRuntime.log(`- ${serverName}: ${server.tools} tools${server.resources ? ", resources" : ""}${server.prompts ? ", prompts" : ""}, Codex approval ${server.codexApprovalMode}`);
if (server.approvalHint) defaultRuntime.log(` i ${server.approvalHint}`);
}
for (const diagnostic of result.diagnostics) defaultRuntime.log(`! ${diagnostic.serverName}: ${diagnostic.message}`);
}
const probeIssue = resolveMcpProbeIssue({
result,
servers,
path: loaded.path
});
if (probeIssue) {
defaultRuntime.error(probeIssue);
if (!requestExitAfterOneShotOutput(defaultRuntime, 1)) defaultRuntime.exit(1);
}
} finally {
await runtime.dispose();
}
});
mcp.command("doctor").description("Check configured MCP servers for static setup problems").argument("[name]", "MCP server name").option("--probe", "Also connect to each checked server", false).option("--json", "Print JSON").action(async (name, opts) => {
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error, opts.json);
const selected = name ? loaded.mcpServers[name] ? { [name]: loaded.mcpServers[name] } : void 0 : loaded.mcpServers;
if (!selected) fail(`No MCP server named "${name}" in ${loaded.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`, opts.json);
const tasks = Object.entries(selected).toSorted(([a], [b]) => a.localeCompare(b)).map(([serverName, server]) => async () => {
const issues = await collectMcpDoctorIssues({
name: serverName,
server,
config: loaded.config,
path: loaded.path,
probe: Boolean(opts.probe)
});
return {
name: serverName,
ok: !issues.some((entry) => entry.level === "error"),
issues
};
});
const { results: servers, firstError, hasError } = await runTasksWithConcurrency({
tasks,
limit: MCP_DOCTOR_CONCURRENCY
});
if (hasError) throw firstError;
const ok = servers.every((server) => server.ok);
if (opts.json) {
printJson({
path: loaded.path,
ok,
servers
});
if (!ok) fail("MCP doctor found errors.");
return;
}
if (servers.length === 0) {
defaultRuntime.log(`No MCP servers configured in ${loaded.path}. Add one with ${formatCliCommand("openclaw mcp add <name> --command <command>")}.`);
return;
}
defaultRuntime.log(`MCP doctor (${loaded.path}):`);
for (const server of servers) {
defaultRuntime.log(`- ${server.name}: ${server.ok ? "ok" : "issues"}`);
for (const entry of server.issues) {
const prefix = entry.level === "error" ? "!" : entry.level === "warning" ? "-" : "i";
defaultRuntime.log(` ${prefix} ${entry.level}: ${entry.message}`);
}
}
if (!ok) fail("MCP doctor found errors.");
});
mcp.command("add").description("Add one MCP server from flags and probe it before saving").argument("<name>", "MCP server name").option("--command <command>", "Stdio command to spawn").option("--arg <value>", "Repeatable stdio argument", collectOption, []).option("--env <key=value>", "Repeatable stdio environment entry", collectOption, []).option("--cwd <path>", "Working directory for stdio server").option("--url <url>", "HTTP MCP server URL").option("--transport <type>", "HTTP transport: streamable-http or sse").option("--header <key=value>", "Repeatable HTTP header", collectOption, []).option("--auth <mode>", "HTTP auth mode: oauth").option("--oauth-scope <scope>", "OAuth scope").option("--oauth-redirect-url <url>", "OAuth redirect URL").option("--oauth-client-metadata-url <url>", "OAuth client metadata URL").option("--include <csv>", "Comma-separated MCP tool names or '*' globs to expose").option("--exclude <csv>", "Comma-separated MCP tool names or '*' globs to hide").option("--timeout <seconds>", "Per-request timeout in seconds").option("--connect-timeout <seconds>", "Connection timeout in seconds").option("--parallel", "Mark this server safe for concurrent tool calls").option("--approval <mode>", "Codex MCP tool approval mode: auto, prompt, or approve").option("--disabled", "Save the server disabled", false).option("--ssl-verify <boolean>", "Verify HTTPS certificates: true or false").option("--client-cert <path>", "HTTP mutual TLS client certificate path").option("--client-key <path>", "HTTP mutual TLS client key path").option("--no-probe", "Save without connecting first").action(async (name, opts) => {
const server = {};
const command = normalizeStringifiedOptionalString(opts.command);
const url = normalizeStringifiedOptionalString(opts.url);
if (command && url) fail("Specify either --command for stdio or --url for HTTP, not both.");
if (!command && !url) fail("Specify --command for stdio or --url for HTTP.");
if (command) {
server.command = command;
if (opts.arg && opts.arg.length > 0) server.args = opts.arg;
setOptionalField(server, "env", parseKeyValueEntries(opts.env, "--env"));
setOptionalField(server, "cwd", normalizeStringifiedOptionalString(opts.cwd));
}
if (url) {
server.url = url;
setOptionalField(server, "transport", normalizeStringifiedOptionalString(opts.transport));
setOptionalField(server, "headers", parseKeyValueEntries(opts.header, "--header"));
const auth = normalizeLowercaseStringOrEmpty(normalizeStringifiedOptionalString(opts.auth) ?? "");
if (auth && auth !== "oauth") fail("Invalid --auth value. Use \"oauth\".");
if (auth) server.auth = auth;
setOptionalField(server, "oauth", parseOAuthConfig({
scope: opts.oauthScope,
redirectUrl: opts.oauthRedirectUrl,
clientMetadataUrl: opts.oauthClientMetadataUrl
}));
if (opts.sslVerify !== void 0) {
const sslVerify = normalizeLowercaseStringOrEmpty(opts.sslVerify);
if (sslVerify !== "true" && sslVerify !== "false") fail("--ssl-verify must be true or false.");
server.sslVerify = sslVerify === "true";
}
setOptionalField(server, "clientCert", normalizeStringifiedOptionalString(opts.clientCert));
setOptionalField(server, "clientKey", normalizeStringifiedOptionalString(opts.clientKey));
}
if (opts.disabled) server.enabled = false;
if (opts.parallel) server.supportsParallelToolCalls = true;
const approvalMode = parseMcpApprovalModeOption(opts.approval);
if (approvalMode) server.codex = { defaultToolsApprovalMode: approvalMode };
const requestTimeoutSeconds = parsePositiveNumberOption(opts.timeout, "--timeout");
setOptionalField(server, "requestTimeoutMs", requestTimeoutSeconds === void 0 ? void 0 : requestTimeoutSeconds * 1e3);
const connectionTimeoutSeconds = parsePositiveNumberOption(opts.connectTimeout, "--connect-timeout");
setOptionalField(server, "connectionTimeoutMs", connectionTimeoutSeconds === void 0 ? void 0 : connectionTimeoutSeconds * 1e3);
const include = parseCsvList(opts.include);
const exclude = parseCsvList(opts.exclude);
if (include || exclude) server.toolFilter = {
...include ? { include } : {},
...exclude ? { exclude } : {}
};
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error);
const targetName = name.trim();
if (targetName && Object.hasOwn(loaded.mcpServers, targetName)) fail(`MCP server ${JSON.stringify(targetName)} already exists.`);
if (opts.probe !== false && server.enabled !== false && server.auth !== "oauth") await probeMcpServersOrFail({
config: loaded.config,
path: loaded.path,
servers: { [name]: server }
});
const result = await setConfiguredMcpServer({
name,
server,
createOnly: true
});
if (!result.ok) fail(result.error);
defaultRuntime.log(`Saved MCP server "${name}" to ${result.path}.`);
if (server.auth === "oauth") defaultRuntime.log(`Run ${formatCliCommand(`openclaw mcp login ${name}`)} to authorize this MCP server.`);
});
mcp.command("set").description("Set one OpenClaw-managed MCP server from a JSON object").argument("<name>", "MCP server name").argument("<value>", "JSON object, for example {\"command\":\"uvx\",\"args\":[\"context7-mcp\"]}").action(async (name, rawValue) => {
const parsed = parseConfigValue(rawValue);
if (parsed.error) fail(parsed.error);
const result = await setConfiguredMcpServer({
name,
server: parsed.value
});
if (!result.ok) fail(result.error);
defaultRuntime.log(`Saved MCP server "${name}" to ${result.path}.`);
});
mcp.command("tools").description("Update per-server MCP tool include/exclude filters").argument("<name>", "MCP server name").option("--include <csv>", "Comma-separated MCP tool names or '*' globs to expose").option("--exclude <csv>", "Comma-separated MCP tool names or '*' globs to hide").option("--clear", "Clear this server's MCP tool filter", false).action(async (name, opts) => {
if (!opts.clear && opts.include === void 0 && opts.exclude === void 0) fail("Specify --include, --exclude, or --clear.");
const result = await updateConfiguredMcpServerTools({
name,
tools: opts.clear ? null : {
include: parseCsvList(opts.include),
exclude: parseCsvList(opts.exclude)
}
});
if (!result.ok) fail(result.error);
if (!result.updated) fail(`No MCP server named "${name}" in ${result.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`);
defaultRuntime.log(`Updated MCP tool selection for "${name}" in ${result.path}.`);
});
mcp.command("configure").description("Update MCP server operator controls without replacing the server").argument("<name>", "MCP server name").option("--enable", "Enable this saved server", false).option("--disable", "Disable this saved server", false).option("--include <csv>", "Comma-separated MCP tool names or '*' globs to expose").option("--exclude <csv>", "Comma-separated MCP tool names or '*' globs to hide").option("--clear-tools", "Clear this server's MCP tool filter", false).option("--timeout <seconds>", "Per-request timeout in seconds").option("--connect-timeout <seconds>", "Connection timeout in seconds").option("--clear-timeouts", "Clear request and connection timeout overrides", false).option("--parallel", "Mark this server safe for concurrent tool calls").option("--no-parallel", "Clear the concurrent tool-call marker").option("--approval <mode>", "Codex MCP tool approval mode: auto, prompt, or approve").option("--auth <mode>", "HTTP auth mode: oauth").option("--clear-auth", "Clear auth and OAuth metadata", false).option("--oauth-scope <scope>", "OAuth scope").option("--oauth-redirect-url <url>", "OAuth redirect URL").option("--oauth-client-metadata-url <url>", "OAuth client metadata URL").option("--ssl-verify <boolean>", "Verify HTTPS certificates: true or false").option("--client-cert <path>", "HTTP mutual TLS client certificate path").option("--client-key <path>", "HTTP mutual TLS client key path").option("--clear-tls", "Clear TLS verification and mTLS overrides", false).option("--probe", "Probe the updated server before saving", false).action(async (name, opts) => {
if (opts.enable && opts.disable) fail("Specify only one of --enable or --disable.");
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error);
const current = loaded.mcpServers[name];
if (!current) fail(`No MCP server named "${name}" in ${loaded.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`);
const next = { ...current };
if (opts.enable) delete next.enabled;
if (opts.disable) next.enabled = false;
if (opts.clearTools) delete next.toolFilter;
else {
const include = parseCsvList(opts.include);
const exclude = parseCsvList(opts.exclude);
if (include || exclude) next.toolFilter = {
...asNullableRecord(next.toolFilter),
...include ? { include } : {},
...exclude ? { exclude } : {}
};
}
if (opts.clearTimeouts) {
delete next.requestTimeoutMs;
delete next.connectionTimeoutMs;
}
const requestTimeoutSeconds = parsePositiveNumberOption(opts.timeout, "--timeout");
setOptionalField(next, "requestTimeoutMs", requestTimeoutSeconds === void 0 ? void 0 : requestTimeoutSeconds * 1e3);
const connectionTimeoutSeconds = parsePositiveNumberOption(opts.connectTimeout, "--connect-timeout");
setOptionalField(next, "connectionTimeoutMs", connectionTimeoutSeconds === void 0 ? void 0 : connectionTimeoutSeconds * 1e3);
if (opts.parallel === true) next.supportsParallelToolCalls = true;
else if (opts.parallel === false) {
delete next.supportsParallelToolCalls;
delete next.supports_parallel_tool_calls;
}
const approvalMode = parseMcpApprovalModeOption(opts.approval);
if (approvalMode) next.codex = {
...asNullableRecord(next.codex),
defaultToolsApprovalMode: approvalMode
};
if (opts.clearAuth) {
delete next.auth;
delete next.oauth;
}
const auth = normalizeLowercaseStringOrEmpty(normalizeStringifiedOptionalString(opts.auth) ?? "");
if (auth && auth !== "oauth") fail("Invalid --auth value. Use \"oauth\".");
if (auth) next.auth = auth;
const oauth = parseOAuthConfig({
scope: opts.oauthScope,
redirectUrl: opts.oauthRedirectUrl,
clientMetadataUrl: opts.oauthClientMetadataUrl
});
if (oauth) next.oauth = {
...asNullableRecord(next.oauth),
...oauth
};
if (opts.clearTls) {
delete next.sslVerify;
delete next.ssl_verify;
delete next.clientCert;
delete next.client_cert;
delete next.clientKey;
delete next.client_key;
}
if (opts.sslVerify !== void 0) {
const sslVerify = normalizeLowercaseStringOrEmpty(opts.sslVerify);
if (sslVerify !== "true" && sslVerify !== "false") fail("--ssl-verify must be true or false.");
next.sslVerify = sslVerify === "true";
}
setOptionalField(next, "clientCert", normalizeStringifiedOptionalString(opts.clientCert));
setOptionalField(next, "clientKey", normalizeStringifiedOptionalString(opts.clientKey));
if (opts.probe && next.enabled !== false && next.auth !== "oauth") await probeMcpServersOrFail({
config: loaded.config,
path: loaded.path,
servers: { [name]: next }
});
if (opts.enable && Object.keys(next).length === 0) {
const result = await unsetConfiguredMcpServer({ name });
if (!result.ok) fail(result.error);
defaultRuntime.log(`Removed disabled MCP override for "${name}" in ${result.path}.`);
return;
}
const result = await updateConfiguredMcpServer({
name,
update: () => next
});
if (!result.ok) fail(result.error);
if (!result.updated) fail(`No MCP server named "${name}" in ${result.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`);
defaultRuntime.log(`Updated MCP server "${name}" in ${result.path}.`);
});
mcp.command("login").description("Authorize an OAuth MCP server").argument("<name>", "MCP server name").option("--code <code>", "Authorization code from the OAuth redirect").action(async (name, opts) => {
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error);
const server = loaded.mcpServers[name];
if (!server) fail(`No MCP server named "${name}" in ${loaded.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`);
if (asNullableRecord(server.oauth)?.identity === "per-requester") fail(`MCP server "${name}" uses per-requester OAuth. Senders connect from the channel via the MCP connect flow.`);
if (server.auth !== "oauth") fail(`MCP server "${name}" is not configured with auth: "oauth".`);
if (typeof server.url !== "string" || server.url.trim().length === 0) fail(`MCP server "${name}" needs a URL for OAuth login.`);
const resolved = resolveMcpTransportConfig(name, server);
if (!resolved || resolved.kind !== "http") fail(`MCP server "${name}" needs a valid HTTP transport for OAuth login.`);
const identity = operatorMcpOAuthIdentity(name, resolved.url);
if (opts.code) {
await completeMcpOAuthAuthorization(identity, resolved, { code: opts.code });
defaultRuntime.log(`MCP OAuth credentials saved for "${name}".`);
return;
}
let callbackServer;
const manualCommand = formatCliCommand(`openclaw mcp login ${name} --code <code>`);
try {
const session = await startMcpOAuthAuthorization(identity, resolved, {});
if (session.status === "authorized") {
defaultRuntime.log(`MCP OAuth credentials saved for "${name}".`);
return;
}
if (session.state.length >= 16) try {
callbackServer = await startOAuthLoopbackCallbackServer({
redirectUrl: session.redirectUrl,
expectedState: session.state,
timeoutMs: MCP_OAUTH_CALLBACK_TIMEOUT_MS
});
} catch (error) {
defaultRuntime.log(`Could not start the local OAuth callback (${formatErrorMessage(error)}).`);
}
defaultRuntime.log(`Open this URL to authorize "${name}":`);
defaultRuntime.log(session.authorizationUrl);
if (callbackServer) {
defaultRuntime.log("Waiting for the browser to return to OpenClaw...");
defaultRuntime.log(`If the callback cannot reach this terminal, run ${manualCommand}.`);
} else defaultRuntime.log(`After approval, run ${manualCommand}.`);
if (!callbackServer) return;
let callback;
try {
callback = await callbackServer.waitForCallback();
} catch (error) {
fail(`${formatErrorMessage(error)}. Complete login manually with ${manualCommand}.`);
}
if (callback.type === "oauth_error") fail(`OAuth authorization did not complete. Retry login or use ${manualCommand}.`);
await completeMcpOAuthAuthorization(identity, resolved, { code: callback.code });
defaultRuntime.log(`MCP OAuth credentials saved for "${name}".`);
} finally {
await callbackServer?.close();
}
});
mcp.command("logout").description("Clear stored OAuth credentials for an MCP server").argument("<name>", "MCP server name").action(async (name) => {
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) fail(loaded.error);
const server = loaded.mcpServers[name];
if (!server) fail(`No MCP server named "${name}" in ${loaded.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`);
if (asNullableRecord(server.oauth)?.identity === "per-requester") fail(`MCP server "${name}" uses per-requester OAuth. Remove or replace the server to clear requester credentials.`);
const resolved = resolveMcpTransportConfig(name, server);
if (!resolved || resolved.kind !== "http") fail(`MCP server "${name}" needs a valid HTTP transport for OAuth logout.`);
await clearMcpOAuthCredentials(operatorMcpOAuthIdentity(name, resolved.url));
defaultRuntime.log(`MCP OAuth credentials cleared for "${name}".`);
});
mcp.command("reload").description("Dispose cached MCP runtimes so new config is used on the next turn").action(async () => {
await disposeAllSessionMcpRuntimes();
defaultRuntime.log("Disposed cached MCP runtimes. Active agents use new MCP config on their next runtime build.");
});
mcp.command("unset").description("Remove one OpenClaw-managed MCP server").argument("<name>", "MCP server name").action(async (name) => {
const result = await unsetConfiguredMcpServer({ name });
if (!result.ok) fail(result.error);
if (!result.removed) fail(`No MCP server named "${name}" in ${result.path}. Run ${formatCliCommand("openclaw mcp list")} to see configured servers.`);
defaultRuntime.log(`Removed MCP server "${name}" from ${result.path}.`);
});
applyParentDefaultHelpAction(mcp);
}
//#endregion
export { registerMcpCli };