785ec84e56
MCP 模式: - 新增浏览器端 MCP 客户端 (SSE + JSON-RPC + initialize 握手) - API Tester 页面双模式: REST API tab + MCP Tools tab - 连接后自动发现 13 个 tools,支持参数编辑和调用 - 与 AI 客户端走完全相同的协议路径 后端修复: - 每个 SSE 连接创建独立 McpServer 实例,支持多客户端并发 - express.json() 移到 SSE 路由之后,避免消费 MCP 请求体 - 新增 GET /api/xhs/login/cookie-check 轻量接口(不打开浏览器) 前端修复: - QR 轮询改用 cookie-check 接口,不再重复打开浏览器窗口 - useLoginStatus 默认不自动请求,避免页面加载触发浏览器 - Header 添加 Token 未配置警告横幅 - tsup clean:false 保护 dist/web 不被清除
220 lines
5.6 KiB
TypeScript
220 lines
5.6 KiB
TypeScript
/**
|
|
* Lightweight MCP client for the browser.
|
|
*
|
|
* Connects via SSE, performs the MCP initialize handshake,
|
|
* then allows tools/list and tools/call operations.
|
|
*/
|
|
|
|
let sseSessionId: string | null = null;
|
|
let sseSource: EventSource | null = null;
|
|
let initialized = false;
|
|
const pendingResolvers = new Map<number, {
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason: unknown) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}>();
|
|
let jsonRpcId = 1;
|
|
|
|
const getBaseUrl = (): string => localStorage.getItem('smcp_server_url') || '';
|
|
|
|
export type McpConnectionState = 'disconnected' | 'connecting' | 'connected';
|
|
|
|
let connectionState: McpConnectionState = 'disconnected';
|
|
let onStateChange: ((state: McpConnectionState) => void) | null = null;
|
|
|
|
export function onConnectionStateChange(cb: (state: McpConnectionState) => void) {
|
|
onStateChange = cb;
|
|
}
|
|
|
|
export function getMcpConnectionState(): McpConnectionState {
|
|
return connectionState;
|
|
}
|
|
|
|
function setState(s: McpConnectionState) {
|
|
connectionState = s;
|
|
onStateChange?.(s);
|
|
}
|
|
|
|
/**
|
|
* Connect to the MCP server via SSE and perform the initialization handshake.
|
|
*/
|
|
export async function connect(): Promise<void> {
|
|
if (sseSource && sseSessionId && initialized) {
|
|
return;
|
|
}
|
|
|
|
setState('connecting');
|
|
|
|
// Step 1: Establish SSE connection and get sessionId
|
|
await new Promise<void>((resolve, reject) => {
|
|
const baseUrl = getBaseUrl();
|
|
const es = new EventSource(`${baseUrl}/sse`);
|
|
sseSource = es;
|
|
|
|
es.addEventListener('endpoint', (ev: MessageEvent) => {
|
|
const url = new URL(ev.data as string, window.location.origin);
|
|
sseSessionId = url.searchParams.get('sessionId');
|
|
resolve();
|
|
});
|
|
|
|
es.addEventListener('message', (ev: MessageEvent) => {
|
|
try {
|
|
const data = JSON.parse(ev.data as string) as { id?: number; result?: unknown; error?: unknown };
|
|
if (data.id != null) {
|
|
const pending = pendingResolvers.get(data.id);
|
|
if (pending) {
|
|
pendingResolvers.delete(pending.timer as unknown as number);
|
|
clearTimeout(pending.timer);
|
|
pendingResolvers.delete(data.id);
|
|
if (data.error) {
|
|
pending.reject(data.error);
|
|
} else {
|
|
pending.resolve(data.result);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
|
|
es.onerror = () => {
|
|
if (connectionState === 'connecting' && !sseSessionId) {
|
|
reject(new Error('Failed to connect to MCP SSE endpoint'));
|
|
}
|
|
};
|
|
});
|
|
|
|
// Step 2: Send initialize request
|
|
const initResult = await sendJsonRpc('initialize', {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'social-mcp-dashboard', version: '0.1.0' },
|
|
});
|
|
|
|
if (!initResult) {
|
|
throw new Error('Initialize returned empty result');
|
|
}
|
|
|
|
// Step 3: Send initialized notification (no id = notification)
|
|
await sendNotification('notifications/initialized', {});
|
|
|
|
initialized = true;
|
|
setState('connected');
|
|
}
|
|
|
|
/**
|
|
* Disconnect from the MCP server.
|
|
*/
|
|
export function disconnect() {
|
|
if (sseSource) {
|
|
sseSource.close();
|
|
sseSource = null;
|
|
}
|
|
sseSessionId = null;
|
|
initialized = false;
|
|
for (const [, p] of pendingResolvers) {
|
|
clearTimeout(p.timer);
|
|
p.reject(new Error('Disconnected'));
|
|
}
|
|
pendingResolvers.clear();
|
|
setState('disconnected');
|
|
}
|
|
|
|
export interface McpToolInfo {
|
|
name: string;
|
|
description?: string;
|
|
inputSchema?: {
|
|
type: string;
|
|
properties?: Record<string, {
|
|
type?: string;
|
|
description?: string;
|
|
enum?: string[];
|
|
default?: unknown;
|
|
items?: unknown;
|
|
}>;
|
|
required?: string[];
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List available MCP tools.
|
|
*/
|
|
export async function listTools(): Promise<{ tools: McpToolInfo[] }> {
|
|
const result = await sendJsonRpc('tools/list', {});
|
|
return result as { tools: McpToolInfo[] };
|
|
}
|
|
|
|
/**
|
|
* Call an MCP tool by name with arguments.
|
|
*/
|
|
export async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
return sendJsonRpc('tools/call', { name, arguments: args });
|
|
}
|
|
|
|
/**
|
|
* Send a JSON-RPC request (with id, expects response).
|
|
*/
|
|
function sendJsonRpc(method: string, params: unknown): Promise<unknown> {
|
|
const id = jsonRpcId++;
|
|
const baseUrl = getBaseUrl();
|
|
|
|
const body = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
method,
|
|
params,
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pendingResolvers.delete(id);
|
|
reject(new Error(`MCP request "${method}" timed out (5m)`));
|
|
}, 300_000);
|
|
|
|
pendingResolvers.set(id, { resolve, reject, timer });
|
|
|
|
fetch(`${baseUrl}/messages?sessionId=${sseSessionId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body,
|
|
}).then((res) => {
|
|
if (!res.ok) {
|
|
clearTimeout(timer);
|
|
pendingResolvers.delete(id);
|
|
return res.text().then((text) => {
|
|
reject(new Error(`POST /messages failed: ${res.status} - ${text}`));
|
|
});
|
|
}
|
|
}).catch((err) => {
|
|
clearTimeout(timer);
|
|
pendingResolvers.delete(id);
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a JSON-RPC notification (no id, no response expected).
|
|
*/
|
|
async function sendNotification(method: string, params: unknown): Promise<void> {
|
|
const baseUrl = getBaseUrl();
|
|
|
|
const body = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
method,
|
|
params,
|
|
});
|
|
|
|
const res = await fetch(`${baseUrl}/messages?sessionId=${sseSessionId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Notification "${method}" failed: ${res.status} - ${text}`);
|
|
}
|
|
}
|