From 785ec84e56aa9b6d42ea09467a95f0d7966204d9 Mon Sep 17 00:00:00 2001 From: kurihada Date: Sun, 1 Mar 2026 16:33:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=20Tester=20=E6=94=AF=E6=8C=81=20MCP?= =?UTF-8?q?=20=E6=A8=A1=E5=BC=8F=20+=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 不被清除 --- package.json | 2 +- src/platforms/xiaohongshu/routes.ts | 18 + src/server/app.ts | 25 +- tsup.config.ts | 2 +- web/src/api/endpoints.ts | 4 + web/src/api/mcp-client.ts | 219 ++++++++++++ web/src/pages/ApiTesterPage.tsx | 533 +++++++++++++++++++++------- web/src/pages/LoginPage.tsx | 8 +- 8 files changed, 665 insertions(+), 146 deletions(-) create mode 100644 web/src/api/mcp-client.ts diff --git a/package.json b/package.json index 178d6d5..9de66b6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "tsup", "build:web": "cd web && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/", - "build:all": "npm run build && npm run build:web", + "build:all": "rm -rf dist/index.js dist/index.js.map && npm run build && npm run build:web", "dev": "tsup --watch", "dev:web": "cd web && npm run dev", "start": "node dist/index.js", diff --git a/src/platforms/xiaohongshu/routes.ts b/src/platforms/xiaohongshu/routes.ts index 15112f8..6da8eb5 100644 --- a/src/platforms/xiaohongshu/routes.ts +++ b/src/platforms/xiaohongshu/routes.ts @@ -7,6 +7,7 @@ import { logger } from '../../utils/logger.js'; import { classifyError, sanitizeErrorMessage } from '../../utils/errors.js'; import { validateMediaPath } from '../../utils/downloader.js'; import { rateLimiter } from '../../server/middleware.js'; +import { cookieStore } from '../../cookie/store.js'; import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js'; import { listFeeds } from './feeds.js'; @@ -217,6 +218,23 @@ export function createXhsRoutes(browser: BrowserManager): Router { })(); }); + // ----------------------------------------------------------------------- + // GET /login/cookie-check + // Lightweight check: does a cookie file exist on disk? + // Does NOT open a browser — safe for frequent polling. + // ----------------------------------------------------------------------- + router.get('/login/cookie-check', readRateLimiter, (_req, res) => { + void (async () => { + try { + const state = await cookieStore.load(PLATFORM); + const hasCookies = state !== null && state.cookies.length > 0; + res.json(successResponse({ hasCookies }) as ApiResponse<{ hasCookies: boolean }>); + } catch (err) { + handleError(res, err); + } + })(); + }); + // ========================================================================= // Content browsing // ========================================================================= diff --git a/src/server/app.ts b/src/server/app.ts index 793d765..0407d35 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -85,9 +85,8 @@ export class AppServer { // -- Constructor ---------------------------------------------------------- constructor() { - // 1. Express app + body parsing + // 1. Express app this.app = express(); - this.app.use(express.json()); // 2. Security & availability middleware this.app.use(shutdownGuard(() => this.shuttingDown)); @@ -97,13 +96,16 @@ export class AppServer { { name: 'social-mcp', version: PACKAGE_VERSION }, ); - // 4. SSE transport endpoints + // 4. SSE transport endpoints (BEFORE body parsing — MCP SDK reads raw body) this.setupSseEndpoints(); - // 5. Health endpoint + // 5. Body parsing for non-MCP routes + this.app.use(express.json()); + + // 6. Health endpoint this.setupHealthEndpoint(); - // 6. Bearer token auth for /api/* routes + // 7. Bearer token auth for /api/* routes initBearerToken(); this.app.use('/api', bearerAuth); @@ -245,9 +247,16 @@ export class AppServer { this.transports.delete(sessionId); }); - // Connect the transport to the MCP server. This starts the SSE - // stream and sends the initial endpoint event to the client. - void this.mcpServer.connect(transport).catch((err: unknown) => { + // Each SSE connection needs its own McpServer instance because the + // MCP SDK only allows one transport per server at a time. + const perSessionMcp = new McpServer( + { name: 'social-mcp', version: PACKAGE_VERSION }, + ); + for (const plugin of this.plugins) { + plugin.registerTools(perSessionMcp, browserManager); + } + + void perSessionMcp.connect(transport).catch((err: unknown) => { logger.error({ err, sessionId }, 'Failed to connect SSE transport to MCP server'); this.transports.delete(sessionId); }); diff --git a/tsup.config.ts b/tsup.config.ts index af45b9a..2398899 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ format: ['esm'], target: 'node22', outDir: 'dist', - clean: true, + clean: false, sourcemap: true, dts: false, splitting: false, diff --git a/web/src/api/endpoints.ts b/web/src/api/endpoints.ts index c131fe1..74e70a1 100644 --- a/web/src/api/endpoints.ts +++ b/web/src/api/endpoints.ts @@ -27,6 +27,10 @@ export const getLoginQRCode = () => export const deleteCookies = () => apiFetch>('/api/xhs/login/cookies', { method: 'DELETE' }); +// Lightweight cookie check (no browser opened) +export const checkLoginCookie = () => + apiFetch>('/api/xhs/login/cookie-check'); + // Feeds export const listFeeds = () => apiFetch>('/api/xhs/feeds'); diff --git a/web/src/api/mcp-client.ts b/web/src/api/mcp-client.ts new file mode 100644 index 0000000..8cc4391 --- /dev/null +++ b/web/src/api/mcp-client.ts @@ -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 void; + reject: (reason: unknown) => void; + timer: ReturnType; +}>(); +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 { + if (sseSource && sseSessionId && initialized) { + return; + } + + setState('connecting'); + + // Step 1: Establish SSE connection and get sessionId + await new Promise((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; + 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): Promise { + return sendJsonRpc('tools/call', { name, arguments: args }); +} + +/** + * Send a JSON-RPC request (with id, expects response). + */ +function sendJsonRpc(method: string, params: unknown): Promise { + 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 { + 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}`); + } +} diff --git a/web/src/pages/ApiTesterPage.tsx b/web/src/pages/ApiTesterPage.tsx index 0b1f1df..d361736 100644 --- a/web/src/pages/ApiTesterPage.tsx +++ b/web/src/pages/ApiTesterPage.tsx @@ -1,16 +1,32 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Select } from '@/components/ui/Select'; import { Badge } from '@/components/ui/Badge'; +import { Tabs } from '@/components/ui/Tabs'; import { JsonViewer } from '@/components/ui/JsonViewer'; +import { Spinner } from '@/components/ui/Spinner'; import { API_ENDPOINTS } from '@/lib/constants'; import { apiFetch, generateCurl } from '@/api/client'; +import { + connect, + disconnect, + listTools, + callTool, + getMcpConnectionState, + onConnectionStateChange, + type McpToolInfo, + type McpConnectionState, +} from '@/api/mcp-client'; + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- interface HistoryEntry { id: number; - method: string; - path: string; + mode: 'REST' | 'MCP'; + label: string; status: 'success' | 'error'; time: string; duration: number; @@ -19,142 +35,53 @@ interface HistoryEntry { let historyId = 0; +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + export function ApiTesterPage() { - const [selectedKey, setSelectedKey] = useState(API_ENDPOINTS[0]!.key); - const [bodyText, setBodyText] = useState(''); - const [loading, setLoading] = useState(false); + const [mode, setMode] = useState<'rest' | 'mcp'>('rest'); const [response, setResponse] = useState(null); - const [responseStatus, setResponseStatus] = useState(null); + const [responseStatus, setResponseStatus] = useState<'success' | 'error' | null>(null); const [duration, setDuration] = useState(null); const [history, setHistory] = useState([]); - const endpoint = API_ENDPOINTS.find((e) => e.key === selectedKey)!; - - const handleEndpointChange = useCallback((key: string) => { - setSelectedKey(key); - setResponse(null); - setResponseStatus(null); - setDuration(null); - const ep = API_ENDPOINTS.find((e) => e.key === key); - if (ep && 'body' in ep && ep.body) { - setBodyText(JSON.stringify(ep.body, null, 2)); - } else { - setBodyText(''); - } + const addHistory = useCallback((entry: Omit) => { + setHistory((prev) => [{ ...entry, id: historyId++ }, ...prev].slice(0, 30)); }, []); - const handleSend = useCallback(async () => { - setLoading(true); - setResponse(null); - const start = Date.now(); - try { - let body: unknown = undefined; - if (bodyText.trim() && endpoint.method !== 'GET') { - body = JSON.parse(bodyText); - } - const res = await apiFetch(endpoint.path, { - method: endpoint.method, - ...(body ? { body: JSON.stringify(body) } : {}), - }); - const dur = Date.now() - start; - setResponse(res); - setResponseStatus(200); - setDuration(dur); - const entry: HistoryEntry = { id: historyId++, method: endpoint.method, path: endpoint.path, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res }; - setHistory((prev) => [entry, ...prev].slice(0, 20)); - } catch (err) { - const dur = Date.now() - start; - const errData = err instanceof Error ? { error: err.message } : { error: String(err) }; - setResponse(errData); - setResponseStatus((err as { status?: number }).status || 500); - setDuration(dur); - const entry: HistoryEntry = { id: historyId++, method: endpoint.method, path: endpoint.path, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData }; - setHistory((prev) => [entry, ...prev].slice(0, 20)); - } finally { - setLoading(false); - } - }, [endpoint, bodyText]); - - const curl = generateCurl( - endpoint.method, - endpoint.path, - bodyText.trim() && endpoint.method !== 'GET' ? (() => { try { return JSON.parse(bodyText); } catch { return undefined; } })() : undefined, - ); - - // Group endpoints by category - const categories = [...new Set(API_ENDPOINTS.map((e) => e.category))]; - return (

API Tester

+ setMode(k as 'rest' | 'mcp')} + /> +
- {/* Left: Request */}
- -

Request

- - {/* Endpoint selector */} -
-