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:
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user