343 lines
9.9 KiB
TypeScript
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') {
|
|
|
|
}
|
|
}
|
|
|