Agent/utils/mcp-client.ts
2026-08-06 22:17:39 +08:00

343 lines
9.9 KiB
TypeScript

// @ts-nocheck
import { CONFIG, loadToken } from './config.js';
const TOKEN = loadToken();
const _subsysSessions = new Map();
const _toolToSubsys = new Map();
const FALLBACK_SUBSYSTEMS = [
{ id: 'assignment', name: 'Assignment', descPrefix: '司机任务排班', healthUrl: 'http://127.0.0.1:7777/health' },
{ id: 'profile', name: 'Profile', descPrefix: '员工能力画像', healthUrl: 'http://127.0.0.1:7778/health' },
];
function genId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
async function discoverSubsystems() {
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/subsystems`, {
signal: AbortSignal.timeout(2000),
});
if (!r.ok) return FALLBACK_SUBSYSTEMS;
const data = await r.json();
const list = data.subsystems || [];
return list.length > 0 ? list : FALLBACK_SUBSYSTEMS;
} catch (e) {
return FALLBACK_SUBSYSTEMS;
}
}
async function checkHealth(healthUrl) {
try {
const r = await fetch(healthUrl, { signal: AbortSignal.timeout(2000) });
if (!r.ok) return false;
const data = await r.json();
if (data.ok !== true) return false;
if (String(healthUrl).includes(':7777')) {
if (data.service === 'profile-mcp-http') return false;
if (data.service && data.service !== 'assignment-mcp-http') return false;
if (!data.service && !(data.result && typeof data.result.appStarted !== 'undefined')) return false;
}
return true;
} catch (e) {
return false;
}
}
function mcpUrlFromHealth(healthUrl) {
const url = new URL(healthUrl);
return `${url.protocol}//${url.host}/mcp`;
}
async function ensureSubsystemInitialized(subsys) {
if (!subsys.healthUrl) return null;
const existing = _subsysSessions.get(subsys.id);
if (existing?.initialized) return existing;
const healthy = await checkHealth(subsys.healthUrl);
if (!healthy) return null;
const mcpUrl = mcpUrlFromHealth(subsys.healthUrl);
const r = await fetch(mcpUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(TOKEN ? { 'X-MCP-Token': TOKEN } : {}),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: genId(),
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'ccsparkle-webui', version: '1.0.0' },
},
}),
});
if (!r.ok) return null;
const sessionHeader = r.headers.get('mcp-session-id');
const msg = await r.json();
if (msg.error) return null;
const entry = {
mcpUrl,
sessionId: sessionHeader,
initialized: true,
healthUrl: subsys.healthUrl,
};
_subsysSessions.set(subsys.id, entry);
try {
await fetch(mcpUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(TOKEN ? { 'X-MCP-Token': TOKEN } : {}),
...(sessionHeader ? { 'Mcp-Session-Id': sessionHeader } : {}),
},
body: JSON.stringify({ jsonrpc: '2.0', method: 'initialized' }),
});
} catch (e) {}
return entry;
}
export async function initialize() {
const subsystems = await discoverSubsystems();
for (const subsys of subsystems) {
if (!subsys.healthUrl) continue;
try {
await ensureSubsystemInitialized(subsys);
} catch (e) {
console.warn(`[mcp-client] init ${subsys.id} failed:`, e.message);
}
}
}
async function rpcOnSubsystem(subsysId, method, params, extraHeaders = {}) {
const entry = _subsysSessions.get(subsysId);
if (!entry) throw new Error(`Subsystem ${subsysId} not initialized`);
const headers = { 'Content-Type': 'application/json', ...extraHeaders };
if (TOKEN) headers['X-MCP-Token'] = TOKEN;
if (entry.sessionId) headers['Mcp-Session-Id'] = entry.sessionId;
const r = await fetch(entry.mcpUrl, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: genId(), method, params }),
});
if (!r.ok) {
throw new Error(`MCP HTTP ${r.status}: ${await r.text()}`);
}
const text = await r.text();
if (!text) return null;
const msg = JSON.parse(text);
if (msg.error) {
throw new Error(`MCP error [${msg.error.code}]: ${msg.error.message}`);
}
return msg.result;
}
export async function listTools() {
await initialize();
const subsystems = await discoverSubsystems();
const allTools = [];
_toolToSubsys.clear();
for (const subsys of subsystems) {
if (!subsys.healthUrl) continue;
const entry = _subsysSessions.get(subsys.id);
if (!entry?.initialized) continue;
try {
const result = await rpcOnSubsystem(subsys.id, 'tools/list', {});
const tools = result?.tools || [];
for (const t of tools) {
_toolToSubsys.set(t.name, subsys.id);
allTools.push(t);
}
} catch (e) {
console.warn(`[mcp-client] listTools from ${subsys.id} failed:`, e.message);
}
}
return allTools;
}
function prefixToSubsys(name) {
if (name.startsWith('metro_')) return 'assignment';
if (name.startsWith('profile_')) return 'profile';
return null;
}
export function getToolSubsystemId(toolName) {
const name = String(toolName || '');
if (!name) return null;
return _toolToSubsys.get(name) || prefixToSubsys(name);
}
export async function callTool(name, args, opts = {}) {
await initialize();
const t0 = performance.now();
let subsysId = _toolToSubsys.get(name);
if (!subsysId) {
subsysId = prefixToSubsys(name);
if (subsysId) {
const subsystems = await discoverSubsystems();
const subsys = subsystems.find(s => s.id === subsysId);
if (subsys) {
try { await ensureSubsystemInitialized(subsys); } catch (e) {}
}
}
}
if (!subsysId) {
throw new Error(`Tool ${name} not routed to any subsystem`);
}
const entry = _subsysSessions.get(subsysId);
if (!entry) {
throw new Error(`Subsystem ${subsysId} not available for tool ${name}`);
}
const headers = {};
if (opts.traceId) headers['X-Trace-Id'] = opts.traceId;
const result = await rpcOnSubsystem(subsysId, 'tools/call', { name, arguments: args }, headers);
return parseToolResult(result, t0);
}
function parseToolResult(result, t0) {
const durationMs = performance.now() - t0;
const content = (result?.content || [])
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
const trimmed = String(content || '').trim();
let peeled;
if (trimmed.startsWith('{')) {
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch (e) {
peeled = content;
parsed = null;
}
if (parsed !== null) {
peeled = peelHandlerEnvelope(parsed);
}
} else {
peeled = content;
}
return {
ok: !result?.isError,
result: peeled,
rawContent: content,
error: result?.isError ? { message: content } : null,
meta: result?._meta,
durationMs,
protocol: 'mcp',
};
}
function peelHandlerEnvelope(parsed) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return parsed;
if (typeof parsed.ok !== 'boolean') return parsed;
if (parsed.ok === false) return parsed;
if (parsed.result !== undefined) return parsed.result;
const { ok: _ok, ...rest } = parsed;
return Object.keys(rest).length ? rest : parsed;
}
export function getSessionId() {
return _subsysSessions.get('assignment')?.sessionId || null;
}
export function isSubsystemOnline(subsysId) {
const entry = _subsysSessions.get(subsysId);
return !!entry?.initialized;
}
export function listOnlineSubsystems() {
const ids = [];
for (const [id, entry] of _subsysSessions.entries()) {
if (entry?.initialized) ids.push(id);
}
return ids;
}
export async function notifyContextCleared() {
const targets = [];
for (const [id, entry] of _subsysSessions.entries()) {
if (entry?.initialized) targets.push([id, entry]);
}
await Promise.all(targets.map(async ([id, entry]) => {
try {
await fetch(entry.mcpUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(TOKEN ? { 'X-MCP-Token': TOKEN } : {}),
...(entry.sessionId ? { 'Mcp-Session-Id': entry.sessionId } : {}),
},
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/context_cleared' }),
});
} catch (e) {
console.warn(`[mcp-client] notifyContextCleared to ${id} failed:`, e.message);
}
}));
}
const _maskingConfigListeners = new Set();
export function onMaskingConfigChanged(fn) {
if (typeof fn === 'function') _maskingConfigListeners.add(fn);
return () => _maskingConfigListeners.delete(fn);
}
function _emitMaskingConfigChanged(payload) {
for (const fn of _maskingConfigListeners) {
try { fn(payload); } catch (e) {
console.warn('[mcp-client] masking_config_changed listener error:', e.message);
}
}
}
export function _handleIncomingNotification(msg) {
if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') return;
if (msg.method === 'notifications/masking_config_changed') {
_emitMaskingConfigChanged(msg.params || {});
} else if (msg.method === 'notifications/context_cleared') {
}
}