Files
social-mcp/web/src/pages/ApiTesterPage.tsx
T
kurihada 785ec84e56 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 不被清除
2026-03-01 16:33:33 +08:00

490 lines
18 KiB
TypeScript

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;
mode: 'REST' | 'MCP';
label: string;
status: 'success' | 'error';
time: string;
duration: number;
response: unknown;
}
let historyId = 0;
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function ApiTesterPage() {
const [mode, setMode] = useState<'rest' | 'mcp'>('rest');
const [response, setResponse] = useState<unknown>(null);
const [responseStatus, setResponseStatus] = useState<'success' | 'error' | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const addHistory = useCallback((entry: Omit<HistoryEntry, 'id'>) => {
setHistory((prev) => [{ ...entry, id: historyId++ }, ...prev].slice(0, 30));
}, []);
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">API Tester</h1>
<Tabs
tabs={[
{ key: 'rest', label: 'REST API' },
{ key: 'mcp', label: 'MCP Tools' },
]}
active={mode}
onChange={(k) => setMode(k as 'rest' | 'mcp')}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 space-y-4">
{mode === 'rest' ? (
<RestPanel
response={response}
setResponse={setResponse}
setResponseStatus={setResponseStatus}
setDuration={setDuration}
addHistory={addHistory}
/>
) : (
<McpPanel
response={response}
setResponse={setResponse}
setResponseStatus={setResponseStatus}
setDuration={setDuration}
addHistory={addHistory}
/>
)}
{/* Response */}
{response !== null && (
<Card>
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">Response</h2>
<div className="flex items-center gap-2">
{responseStatus && (
<Badge variant={responseStatus === 'success' ? 'success' : 'danger'}>
{responseStatus}
</Badge>
)}
{duration !== null && (
<span className="text-xs text-dark-muted">{duration}ms</span>
)}
</div>
</div>
<JsonViewer data={response} maxHeight="500px" />
</Card>
)}
</div>
{/* History */}
<div>
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">History</h2>
<Button variant="ghost" size="sm" onClick={() => setHistory([])}>Clear</Button>
</div>
{history.length === 0 ? (
<p className="text-sm text-dark-muted">No requests yet</p>
) : (
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{history.map((entry) => (
<div
key={entry.id}
className="p-2 rounded-lg border border-dark-border/50 hover:bg-dark-hover cursor-pointer text-xs"
onClick={() => {
setResponse(entry.response);
setResponseStatus(entry.status);
setDuration(entry.duration);
}}
>
<div className="flex items-center gap-2">
<Badge
variant={entry.mode === 'MCP' ? 'info' : 'success'}
className="text-[10px]"
>
{entry.mode}
</Badge>
<span className="font-mono truncate">{entry.label}</span>
</div>
<div className="flex items-center gap-2 mt-1 text-dark-muted">
<Badge variant={entry.status === 'success' ? 'success' : 'danger'} className="text-[10px]">
{entry.status}
</Badge>
<span>{entry.time}</span>
<span>{entry.duration}ms</span>
</div>
</div>
))}
</div>
)}
</Card>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// REST Panel
// ---------------------------------------------------------------------------
function RestPanel({
setResponse,
setResponseStatus,
setDuration,
addHistory,
}: {
response: unknown;
setResponse: (v: unknown) => void;
setResponseStatus: (v: 'success' | 'error' | null) => void;
setDuration: (v: number | null) => void;
addHistory: (e: Omit<HistoryEntry, 'id'>) => void;
}) {
const [selectedKey, setSelectedKey] = useState<string>(API_ENDPOINTS[0]!.key);
const [bodyText, setBodyText] = useState('');
const [loading, setLoading] = useState(false);
const endpoint = API_ENDPOINTS.find((e) => e.key === selectedKey)!;
const categories = [...new Set(API_ENDPOINTS.map((e) => e.category))];
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('');
}
}, [setResponse, setResponseStatus, setDuration]);
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<unknown>(endpoint.path, {
method: endpoint.method,
...(body ? { body: JSON.stringify(body) } : {}),
});
const dur = Date.now() - start;
setResponse(res);
setResponseStatus('success');
setDuration(dur);
addHistory({ mode: 'REST', label: `${endpoint.method} ${endpoint.path}`, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res });
} catch (err) {
const dur = Date.now() - start;
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
setResponse(errData);
setResponseStatus('error');
setDuration(dur);
addHistory({ mode: 'REST', label: `${endpoint.method} ${endpoint.path}`, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData });
} finally {
setLoading(false);
}
}, [endpoint, bodyText, setResponse, setResponseStatus, setDuration, addHistory]);
const curl = generateCurl(
endpoint.method,
endpoint.path,
bodyText.trim() && endpoint.method !== 'GET' ? (() => { try { return JSON.parse(bodyText) as unknown; } catch { return undefined; } })() : undefined,
);
return (
<>
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">REST Request</h2>
<div className="space-y-3">
<Select
label="Endpoint"
options={categories.flatMap((cat) => [
{ value: `__cat_${cat}`, label: `── ${cat} ──` },
...API_ENDPOINTS.filter((e) => e.category === cat).map((e) => ({
value: e.key,
label: `${e.method} ${e.path}`,
})),
])}
value={selectedKey}
onChange={(e) => {
if (!e.target.value.startsWith('__cat_')) {
handleEndpointChange(e.target.value);
}
}}
/>
<div className="flex items-center gap-2">
<Badge variant={endpoint.method === 'GET' ? 'success' : endpoint.method === 'DELETE' ? 'danger' : 'info'}>
{endpoint.method}
</Badge>
<code className="text-sm text-dark-text font-mono">{endpoint.path}</code>
</div>
{endpoint.method !== 'GET' && (
<div className="flex flex-col gap-1.5">
<label className="text-sm text-dark-muted">Request Body (JSON)</label>
<textarea
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
className="bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text font-mono focus:outline-none focus:border-dark-accent transition-colors resize-y min-h-[120px]"
spellCheck={false}
/>
</div>
)}
<div className="flex gap-2">
<Button onClick={() => void handleSend()} loading={loading}>Send Request</Button>
<Button variant="ghost" size="sm" onClick={() => void navigator.clipboard.writeText(curl)}>Copy cURL</Button>
</div>
</div>
</Card>
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-2">cURL</h2>
<pre className="bg-dark-bg border border-dark-border rounded-lg p-3 text-xs font-mono text-dark-text overflow-x-auto whitespace-pre-wrap">{curl}</pre>
</Card>
</>
);
}
// ---------------------------------------------------------------------------
// MCP Panel
// ---------------------------------------------------------------------------
function McpPanel({
setResponse,
setResponseStatus,
setDuration,
addHistory,
}: {
response: unknown;
setResponse: (v: unknown) => void;
setResponseStatus: (v: 'success' | 'error' | null) => void;
setDuration: (v: number | null) => void;
addHistory: (e: Omit<HistoryEntry, 'id'>) => void;
}) {
const [connState, setConnState] = useState<McpConnectionState>(getMcpConnectionState());
const [connecting, setConnecting] = useState(false);
const [tools, setTools] = useState<McpToolInfo[]>([]);
const [selectedTool, setSelectedTool] = useState('');
const [argsText, setArgsText] = useState('{}');
const [loading, setLoading] = useState(false);
useEffect(() => {
onConnectionStateChange(setConnState);
return () => onConnectionStateChange(() => {});
}, []);
const handleConnect = useCallback(async () => {
setConnecting(true);
try {
await connect();
const result = await listTools();
setTools(result.tools);
if (result.tools.length > 0 && !selectedTool) {
setSelectedTool(result.tools[0]!.name);
// Generate default args from schema
const schema = result.tools[0]!.inputSchema;
setArgsText(generateDefaultArgs(schema));
}
} catch (err) {
setResponse({ error: err instanceof Error ? err.message : String(err) });
setResponseStatus('error');
} finally {
setConnecting(false);
}
}, [selectedTool, setResponse, setResponseStatus]);
const handleDisconnect = useCallback(() => {
disconnect();
setTools([]);
setSelectedTool('');
}, []);
const handleToolChange = useCallback((name: string) => {
setSelectedTool(name);
setResponse(null);
setResponseStatus(null);
setDuration(null);
const tool = tools.find((t) => t.name === name);
if (tool) {
setArgsText(generateDefaultArgs(tool.inputSchema));
}
}, [tools, setResponse, setResponseStatus, setDuration]);
const handleCall = useCallback(async () => {
if (!selectedTool) return;
setLoading(true);
setResponse(null);
const start = Date.now();
try {
let args: Record<string, unknown> = {};
if (argsText.trim()) {
args = JSON.parse(argsText) as Record<string, unknown>;
}
const res = await callTool(selectedTool, args);
const dur = Date.now() - start;
setResponse(res);
setResponseStatus('success');
setDuration(dur);
addHistory({ mode: 'MCP', label: selectedTool, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res });
} catch (err) {
const dur = Date.now() - start;
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
setResponse(errData);
setResponseStatus('error');
setDuration(dur);
addHistory({ mode: 'MCP', label: selectedTool, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData });
} finally {
setLoading(false);
}
}, [selectedTool, argsText, setResponse, setResponseStatus, setDuration, addHistory]);
const currentTool = tools.find((t) => t.name === selectedTool);
return (
<>
{/* Connection */}
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">MCP Connection</h2>
<div className="flex items-center gap-3">
<Badge variant={connState === 'connected' ? 'success' : connState === 'connecting' ? 'warning' : 'default'}>
{connState}
</Badge>
{connState === 'disconnected' && (
<Button size="sm" onClick={() => void handleConnect()} loading={connecting}>
Connect
</Button>
)}
{connState === 'connected' && (
<>
<span className="text-xs text-dark-muted">{tools.length} tools available</span>
<Button size="sm" variant="ghost" onClick={handleDisconnect}>Disconnect</Button>
</>
)}
{connState === 'connecting' && <Spinner size="sm" />}
</div>
</Card>
{/* Tool Call */}
{connState === 'connected' && tools.length > 0 && (
<Card>
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">MCP Tool Call</h2>
<div className="space-y-3">
<Select
label="Tool"
options={tools.map((t) => ({ value: t.name, label: t.name }))}
value={selectedTool}
onChange={(e) => handleToolChange(e.target.value)}
/>
{/* Tool info */}
{currentTool && (
<div className="bg-dark-bg rounded-lg p-3 text-xs space-y-2">
{currentTool.description && (
<p className="text-dark-muted">{currentTool.description}</p>
)}
{currentTool.inputSchema?.properties && (
<div>
<p className="text-dark-muted font-semibold mb-1">Parameters:</p>
{Object.entries(currentTool.inputSchema.properties).map(([key, prop]) => (
<div key={key} className="flex gap-2 ml-2">
<code className="text-dark-accent">{key}</code>
<span className="text-dark-muted">
{prop.type || ''}
{currentTool.inputSchema?.required?.includes(key) ? ' (required)' : ' (optional)'}
{prop.description ? `${prop.description}` : ''}
</span>
</div>
))}
</div>
)}
</div>
)}
{/* Arguments editor */}
<div className="flex flex-col gap-1.5">
<label className="text-sm text-dark-muted">Arguments (JSON)</label>
<textarea
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
className="bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text font-mono focus:outline-none focus:border-dark-accent transition-colors resize-y min-h-[120px]"
spellCheck={false}
/>
</div>
<Button onClick={() => void handleCall()} loading={loading}>
Call Tool
</Button>
</div>
</Card>
)}
{connState === 'disconnected' && (
<Card>
<div className="text-center py-8 text-dark-muted">
<p>Connect to the MCP server to discover and test tools.</p>
<p className="text-xs mt-2">This uses the same SSE + JSON-RPC protocol that AI clients use.</p>
</div>
</Card>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function generateDefaultArgs(schema?: McpToolInfo['inputSchema']): string {
if (!schema?.properties) return '{}';
const obj: Record<string, unknown> = {};
for (const [key, prop] of Object.entries(schema.properties)) {
if (prop.default !== undefined) {
obj[key] = prop.default;
} else if (prop.enum && prop.enum.length > 0) {
obj[key] = prop.enum[0];
} else if (prop.type === 'string') {
obj[key] = '';
} else if (prop.type === 'boolean') {
obj[key] = false;
} else if (prop.type === 'number' || prop.type === 'integer') {
obj[key] = 0;
} else if (prop.type === 'array') {
obj[key] = [];
} else {
obj[key] = null;
}
}
return JSON.stringify(obj, null, 2);
}