fix: 修复 MCP 不可用的问题

This commit is contained in:
fisherdaddy
2026-03-16 02:34:59 +08:00
parent 30509c003e
commit 9fb5901e3e
9 changed files with 553 additions and 41 deletions
+257
View File
@@ -0,0 +1,257 @@
import { Type } from '@sinclair/typebox';
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
type McpBridgeToolConfig = {
server: string;
name: string;
description?: string;
inputSchema: Record<string, unknown>;
};
type McpBridgePluginConfig = {
callbackUrl: string;
secret: string;
requestTimeoutMs: number;
tools: McpBridgeToolConfig[];
};
type ToolResultPayload = {
content: Array<{ type: string; text?: string; [key: string]: unknown }>;
isError?: boolean;
details?: unknown;
};
const DEFAULT_TIMEOUT_MS = 120_000;
const FALLBACK_INPUT_SCHEMA = Type.Object({}, { additionalProperties: true });
const isRecord = (value: unknown): value is Record<string, unknown> => {
return !!value && typeof value === 'object' && !Array.isArray(value);
};
const sanitizeToolSegment = (value: string): string => {
const sanitized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return sanitized || 'tool';
};
const buildRegisteredToolName = (
server: string,
tool: string,
usedNames: Set<string>,
): string => {
const base = `mcp_${sanitizeToolSegment(server)}_${sanitizeToolSegment(tool)}`;
let next = base;
let index = 2;
while (usedNames.has(next)) {
next = `${base}_${index}`;
index += 1;
}
usedNames.add(next);
return next;
};
const normalizeInputSchema = (value: unknown): Record<string, unknown> => {
return isRecord(value) ? value : FALLBACK_INPUT_SCHEMA;
};
const parseToolConfig = (value: unknown): McpBridgeToolConfig | null => {
if (!isRecord(value)) {
return null;
}
const server = typeof value.server === 'string' ? value.server.trim() : '';
const name = typeof value.name === 'string' ? value.name.trim() : '';
if (!server || !name) {
return null;
}
return {
server,
name,
description: typeof value.description === 'string' ? value.description.trim() : undefined,
inputSchema: normalizeInputSchema(value.inputSchema),
};
};
const parsePluginConfig = (value: unknown): McpBridgePluginConfig => {
const raw = isRecord(value) ? value : {};
const tools = Array.isArray(raw.tools)
? raw.tools.map(parseToolConfig).filter((tool): tool is McpBridgeToolConfig => !!tool)
: [];
return {
callbackUrl: typeof raw.callbackUrl === 'string' ? raw.callbackUrl.trim() : '',
secret: typeof raw.secret === 'string' ? raw.secret.trim() : '',
requestTimeoutMs:
typeof raw.requestTimeoutMs === 'number' && Number.isFinite(raw.requestTimeoutMs) && raw.requestTimeoutMs > 0
? Math.max(1_000, Math.floor(raw.requestTimeoutMs))
: DEFAULT_TIMEOUT_MS,
tools,
};
};
const extractErrorMessage = (payload: unknown): string | null => {
if (!payload) {
return null;
}
if (typeof payload === 'string') {
return payload.trim() || null;
}
if (!isRecord(payload)) {
return null;
}
if (typeof payload.error === 'string' && payload.error.trim()) {
return payload.error.trim();
}
const content = Array.isArray(payload.content) ? payload.content : [];
for (const block of content) {
if (!isRecord(block)) {
continue;
}
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
return block.text.trim();
}
}
return null;
};
const ensureToolResultPayload = (
payload: unknown,
details: Record<string, unknown>,
): ToolResultPayload => {
if (isRecord(payload) && Array.isArray(payload.content)) {
return {
...payload,
details: payload.details ?? details,
} as ToolResultPayload;
}
const text =
typeof payload === 'string'
? payload
: JSON.stringify(payload ?? { ok: true }, null, 2);
return {
content: [{ type: 'text', text }],
details: payload ?? details,
};
};
const buildToolDescription = (tool: McpBridgeToolConfig): string => {
const parts = [
`Proxy to MCP tool "${tool.name}" on server "${tool.server}".`,
];
if (tool.description) {
parts.push(tool.description);
}
return parts.join(' ');
};
const invokeBridge = async (
config: McpBridgePluginConfig,
tool: McpBridgeToolConfig,
args: Record<string, unknown>,
): Promise<unknown> => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs);
try {
const response = await fetch(config.callbackUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-mcp-bridge-secret': config.secret,
},
body: JSON.stringify({
server: tool.server,
tool: tool.name,
args,
}),
signal: controller.signal,
});
const responseText = await response.text();
let payload: unknown = null;
if (responseText.trim()) {
try {
payload = JSON.parse(responseText);
} catch {
if (!response.ok) {
throw new Error(`MCP bridge HTTP ${response.status}: ${responseText.trim()}`);
}
payload = responseText;
}
}
if (!response.ok) {
const message = extractErrorMessage(payload) || response.statusText || 'Unknown MCP bridge error';
throw new Error(`MCP bridge HTTP ${response.status}: ${message}`);
}
return payload;
} finally {
clearTimeout(timer);
}
};
const plugin = {
id: 'mcp-bridge',
name: 'MCP Bridge',
description: 'Expose LobsterAI-managed MCP servers as native OpenClaw tools.',
configSchema: {
parse(value: unknown): McpBridgePluginConfig {
return parsePluginConfig(value);
},
uiHints: {
callbackUrl: { label: 'Callback URL', advanced: true },
secret: { label: 'Secret', sensitive: true, advanced: true },
requestTimeoutMs: { label: 'Request Timeout (ms)', advanced: true },
},
},
register(api: OpenClawPluginApi) {
const config = parsePluginConfig(api.pluginConfig);
if (!config.callbackUrl || !config.secret || config.tools.length === 0) {
api.logger.info('[mcp-bridge] skipped registration because callbackUrl/secret/tools are incomplete.');
return;
}
const usedToolNames = new Set<string>();
for (const tool of config.tools) {
const registeredName = buildRegisteredToolName(tool.server, tool.name, usedToolNames);
const details = {
alias: registeredName,
server: tool.server,
tool: tool.name,
};
api.registerTool({
name: registeredName,
label: `MCP ${tool.server}/${tool.name}`,
description: buildToolDescription(tool),
parameters: tool.inputSchema,
async execute(_id: string, params: Record<string, unknown>) {
try {
const payload = await invokeBridge(config, tool, params);
return ensureToolResultPayload(payload, details);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text', text: message }],
isError: true,
details,
};
}
},
});
}
api.logger.info(`[mcp-bridge] registered ${config.tools.length} tool(s).`);
},
};
export default plugin;
@@ -0,0 +1,46 @@
{
"id": "mcp-bridge",
"name": "MCP Bridge",
"description": "Expose LobsterAI-managed MCP servers as native OpenClaw tools.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"callbackUrl": {
"type": "string",
"minLength": 1
},
"secret": {
"type": "string",
"minLength": 1
},
"requestTimeoutMs": {
"type": "number",
"minimum": 1000
},
"tools": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"server": {
"type": "string",
"minLength": 1
},
"name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"inputSchema": {
"type": "object"
}
}
}
}
}
}
}
@@ -0,0 +1,11 @@
{
"name": "@lobsterai/openclaw-mcp-bridge",
"version": "0.0.0",
"description": "Expose LobsterAI-managed MCP servers as native OpenClaw tools.",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
+8 -7
View File
@@ -70,15 +70,16 @@
"openclaw:ensure": "node scripts/ensure-openclaw-version.cjs",
"openclaw:patch": "node scripts/apply-openclaw-patches.cjs",
"openclaw:plugins": "node scripts/ensure-openclaw-plugins.cjs",
"openclaw:extensions:local": "node scripts/sync-local-openclaw-extensions.cjs",
"openclaw:bundle": "node scripts/bundle-openclaw-gateway.cjs",
"openclaw:precompile": "node scripts/precompile-openclaw-extensions.cjs",
"openclaw:runtime:host": "node scripts/openclaw-runtime-host.cjs",
"openclaw:runtime:mac-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-arm64 && node scripts/sync-openclaw-runtime-current.cjs mac-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile",
"openclaw:runtime:mac-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-x64 && node scripts/sync-openclaw-runtime-current.cjs mac-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile",
"openclaw:runtime:win-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-x64 && node scripts/sync-openclaw-runtime-current.cjs win-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile",
"openclaw:runtime:win-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-arm64 && node scripts/sync-openclaw-runtime-current.cjs win-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile",
"openclaw:runtime:linux-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-x64 && node scripts/sync-openclaw-runtime-current.cjs linux-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile",
"openclaw:runtime:linux-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-arm64 && node scripts/sync-openclaw-runtime-current.cjs linux-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile",
"openclaw:runtime:mac-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-arm64 && node scripts/sync-openclaw-runtime-current.cjs mac-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile",
"openclaw:runtime:mac-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-x64 && node scripts/sync-openclaw-runtime-current.cjs mac-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile",
"openclaw:runtime:win-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-x64 && node scripts/sync-openclaw-runtime-current.cjs win-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile",
"openclaw:runtime:win-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-arm64 && node scripts/sync-openclaw-runtime-current.cjs win-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile",
"openclaw:runtime:linux-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-x64 && node scripts/sync-openclaw-runtime-current.cjs linux-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile",
"openclaw:runtime:linux-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-arm64 && node scripts/sync-openclaw-runtime-current.cjs linux-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile",
"regenerate:icon": "bash scripts/regenerate-mac-icon.sh",
"fix:mac-icon": "bash scripts/fix-mac-icon-display.sh"
},
@@ -157,4 +158,4 @@
"vite-plugin-electron-renderer": "^0.14.5",
"wait-on": "^7.2.0"
}
}
}
+6
View File
@@ -6,6 +6,7 @@ const { spawnSync } = require('child_process');
const asar = require('@electron/asar');
const { ensurePortableGit } = require('./setup-mingit.js');
const { ensurePortablePythonRuntime, checkRuntimeHealth } = require('./setup-python-runtime.js');
const { syncLocalOpenClawExtensions } = require('./sync-local-openclaw-extensions.cjs');
function isWindowsTarget(context) {
return context?.electronPlatformName === 'win32';
@@ -126,6 +127,11 @@ function ensureBundledOpenClawRuntime(context) {
const { runtimeRoot, targetId } = syncCurrentOpenClawRuntimeForTarget(context);
const buildHint = getOpenClawRuntimeBuildHint(targetId);
const localMcpBridgeDir = path.join(runtimeRoot, 'extensions', 'mcp-bridge');
if (!existsSync(localMcpBridgeDir)) {
syncLocalOpenClawExtensions(runtimeRoot);
}
const requiredExternalPaths = [
path.join(runtimeRoot, 'node_modules'),
];
@@ -0,0 +1,61 @@
'use strict';
const fs = require('fs');
const path = require('path');
function syncLocalOpenClawExtensions(runtimeRoot) {
const rootDir = path.resolve(__dirname, '..');
const sourceDir = path.join(rootDir, 'openclaw-extensions');
const targetRoot = runtimeRoot
? path.resolve(runtimeRoot)
: path.join(rootDir, 'vendor', 'openclaw-runtime', 'current');
const targetExtensionsDir = path.join(targetRoot, 'extensions');
if (!fs.existsSync(sourceDir)) {
return { sourceDir, targetRoot, copied: [] };
}
if (!fs.existsSync(targetExtensionsDir)) {
throw new Error(`Runtime extensions directory does not exist: ${targetExtensionsDir}`);
}
const copied = [];
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const src = path.join(sourceDir, entry.name);
const dest = path.join(targetExtensionsDir, entry.name);
fs.cpSync(src, dest, { recursive: true, force: true });
copied.push(entry.name);
}
return { sourceDir, targetRoot, copied };
}
function main() {
try {
const runtimeRoot = (process.argv[2] || '').trim() || undefined;
const result = syncLocalOpenClawExtensions(runtimeRoot);
if (result.copied.length === 0) {
console.log('[sync-local-openclaw-extensions] No local extensions to sync.');
return;
}
console.log(
`[sync-local-openclaw-extensions] Synced ${result.copied.join(', ')} -> ${path.join(result.targetRoot, 'extensions')}`,
);
} catch (error) {
console.error(
`[sync-local-openclaw-extensions] ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
syncLocalOpenClawExtensions,
};
+39 -34
View File
@@ -8,6 +8,7 @@ import { resolveRawApiConfig } from './claudeSettings';
import type { OpenClawEngineManager } from './openclawEngineManager';
import { parseChannelSessionKey } from './openclawChannelSessionSync';
import type { McpToolManifestEntry } from './mcpServerManager';
import { hasBundledOpenClawExtension } from './openclawLocalExtensions';
import { buildScheduledTaskEnginePrompt } from './scheduledTaskEnginePrompt';
export type McpBridgeConfig = {
@@ -278,6 +279,10 @@ const readPreinstalledPluginIds = (): string[] => {
}
};
const isBundledPluginAvailable = (pluginId: string): boolean => {
return hasBundledOpenClawExtension(pluginId);
};
export type OpenClawConfigSyncResult = {
ok: boolean;
changed: boolean;
@@ -367,6 +372,7 @@ export class OpenClawConfigSync {
const workspaceDir = (coworkConfig.workingDirectory || '').trim();
const preinstalledPluginIds = readPreinstalledPluginIds();
const hasMcpBridgePlugin = isBundledPluginAvailable('mcp-bridge');
const dingTalkConfig = this.getDingTalkConfig();
// DingTalk runs through OpenClaw plugin but still needs the gateway HTTP endpoint (chatCompletions)
@@ -435,46 +441,45 @@ export class OpenClawConfigSync {
maxConcurrentRuns: 3,
sessionRetention: '7d',
},
...(preinstalledPluginIds.length > 0
? {
plugins: {
entries: {
...Object.fromEntries(
preinstalledPluginIds.map((id) => {
// Sync plugin enabled state with the corresponding channel config.
// When a channel is disabled in the UI, its plugin must also be
// disabled so OpenClaw doesn't load it at all.
const pluginEnabled = (() => {
if (id === 'dingtalk-connector') return !!(dingTalkConfig?.enabled && dingTalkConfig.clientId);
if (id === 'feishu-openclaw-plugin') return !!(feishuConfig?.enabled && feishuConfig.appId);
if (id === 'qqbot') return !!(qqConfig?.enabled && qqConfig.appId);
if (id === 'wecom-openclaw-plugin') return !!(wecomConfig?.enabled && wecomConfig.botId);
return true; // other plugins stay enabled
})();
return [id, { enabled: pluginEnabled }];
}),
),
// Disable the built-in feishu plugin when the official one is preinstalled
...(preinstalledPluginIds.includes('feishu-openclaw-plugin')
? { feishu: { enabled: false } }
: {}),
'mcp-bridge': { enabled: true },
...((() => {
const pluginEntries: Record<string, unknown> = {
...Object.fromEntries(
preinstalledPluginIds.map((id) => {
// Sync plugin enabled state with the corresponding channel config.
// When a channel is disabled in the UI, its plugin must also be
// disabled so OpenClaw doesn't load it at all.
const pluginEnabled = (() => {
if (id === 'dingtalk-connector') return !!(dingTalkConfig?.enabled && dingTalkConfig.clientId);
if (id === 'feishu-openclaw-plugin') return !!(feishuConfig?.enabled && feishuConfig.appId);
if (id === 'qqbot') return !!(qqConfig?.enabled && qqConfig.appId);
if (id === 'wecom-openclaw-plugin') return !!(wecomConfig?.enabled && wecomConfig.botId);
return true; // other plugins stay enabled
})();
return [id, { enabled: pluginEnabled }];
}),
),
...(preinstalledPluginIds.includes('feishu-openclaw-plugin')
? { feishu: { enabled: false } }
: {}),
...(hasMcpBridgePlugin
? { 'mcp-bridge': { enabled: true } }
: {}),
};
return Object.keys(pluginEntries).length > 0
? {
plugins: {
entries: pluginEntries,
},
},
}
: {
plugins: {
entries: {
'mcp-bridge': { enabled: true },
},
},
}),
}
: {};
})())
};
// Sync MCP Bridge config into the plugin's own config section
// (root-level keys are rejected by OpenClaw's strict schema validation)
const mcpBridgeCfg = this.getMcpBridgeConfig?.();
if (mcpBridgeCfg && mcpBridgeCfg.tools.length > 0) {
if (hasMcpBridgePlugin && mcpBridgeCfg && mcpBridgeCfg.tools.length > 0 && managedConfig.plugins) {
const plugins = managedConfig.plugins as Record<string, unknown>;
const entries = plugins.entries as Record<string, Record<string, unknown>>;
entries['mcp-bridge'] = {
+6
View File
@@ -5,6 +5,7 @@ import fs from 'fs';
import net from 'net';
import path from 'path';
import { getElectronNodeRuntimePath } from './coworkUtil';
import { syncLocalOpenClawExtensionsIntoRuntime } from './openclawLocalExtensions';
import { applyBundledOpenClawRuntimeHotfixes } from './openclawRuntimeHotfix';
const DEFAULT_OPENCLAW_VERSION = '2026.2.23';
@@ -256,6 +257,11 @@ export class OpenClawEngineManager extends EventEmitter {
return this.getStatus();
}
const localExtensionSync = syncLocalOpenClawExtensionsIntoRuntime(runtime.root);
if (localExtensionSync.copied.length > 0) {
console.log(`[OpenClaw] synced local extensions: ${localExtensionSync.copied.join(', ')}`);
}
if (this.status.phase === 'running') {
return this.getStatus();
}
+119
View File
@@ -0,0 +1,119 @@
import { app } from 'electron';
import fs from 'fs';
import path from 'path';
const LOCAL_EXTENSIONS_DIR = 'openclaw-extensions';
const findLocalExtensionsSourceDir = (): string | null => {
if (app.isPackaged) {
return null;
}
const candidates = [
path.join(app.getAppPath(), LOCAL_EXTENSIONS_DIR),
path.join(process.cwd(), LOCAL_EXTENSIONS_DIR),
];
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isDirectory()) {
return candidate;
}
} catch {
// Ignore missing candidates.
}
}
return null;
};
const findBundledExtensionsDir = (): string | null => {
const candidates = app.isPackaged
? [path.join(process.resourcesPath, 'cfmind', 'extensions')]
: [
path.join(app.getAppPath(), 'vendor', 'openclaw-runtime', 'current', 'extensions'),
path.join(process.cwd(), 'vendor', 'openclaw-runtime', 'current', 'extensions'),
];
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isDirectory()) {
return candidate;
}
} catch {
// Ignore missing candidates.
}
}
return null;
};
export const syncLocalOpenClawExtensionsIntoRuntime = (
runtimeRoot: string,
): { sourceDir: string | null; copied: string[] } => {
const sourceDir = findLocalExtensionsSourceDir();
if (!sourceDir) {
return { sourceDir: null, copied: [] };
}
const targetExtensionsDir = path.join(runtimeRoot, 'extensions');
try {
if (!fs.statSync(targetExtensionsDir).isDirectory()) {
return { sourceDir, copied: [] };
}
} catch {
return { sourceDir, copied: [] };
}
const copied: string[] = [];
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
fs.cpSync(
path.join(sourceDir, entry.name),
path.join(targetExtensionsDir, entry.name),
{ recursive: true, force: true },
);
copied.push(entry.name);
}
return { sourceDir, copied };
};
export const listLocalOpenClawExtensionIds = (): string[] => {
const sourceDir = findLocalExtensionsSourceDir();
if (!sourceDir) {
return [];
}
try {
return fs.readdirSync(sourceDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) => fs.existsSync(path.join(sourceDir, entry.name, 'openclaw.plugin.json')))
.map((entry) => entry.name);
} catch {
return [];
}
};
export const listBundledOpenClawExtensionIds = (): string[] => {
const extensionsDir = findBundledExtensionsDir();
if (!extensionsDir) {
return [];
}
try {
return fs.readdirSync(extensionsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) => fs.existsSync(path.join(extensionsDir, entry.name, 'openclaw.plugin.json')))
.map((entry) => entry.name);
} catch {
return [];
}
};
export const hasBundledOpenClawExtension = (extensionId: string): boolean => {
return listBundledOpenClawExtensionIds().includes(extensionId)
|| listLocalOpenClawExtensionIds().includes(extensionId);
};