feat: API Tester 支持 MCP 模式 + 修复多个 bug

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 不被清除
This commit is contained in:
2026-03-01 16:33:33 +08:00
parent f464333a53
commit 785ec84e56
8 changed files with 665 additions and 146 deletions
+4
View File
@@ -27,6 +27,10 @@ export const getLoginQRCode = () =>
export const deleteCookies = () =>
apiFetch<ApiResponse<{ message: string }>>('/api/xhs/login/cookies', { method: 'DELETE' });
// Lightweight cookie check (no browser opened)
export const checkLoginCookie = () =>
apiFetch<ApiResponse<{ hasCookies: boolean }>>('/api/xhs/login/cookie-check');
// Feeds
export const listFeeds = () =>
apiFetch<ApiResponse<Feed[]>>('/api/xhs/feeds');
+219
View File
@@ -0,0 +1,219 @@
/**
* 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}`);
}
}