feat: add MCP research access and browser key management

This commit is contained in:
yuxuanhui
2026-09-09 16:18:27 +08:00
parent 4debca7dbd
commit 45238280e3
47 changed files with 2642 additions and 44 deletions
+3
View File
@@ -21,6 +21,7 @@ import { AppSidebar } from "./components/AppSidebar";
import "./components/sidebar.css";
import { api, patch, post } from "./api";
import type { Account, Job } from "./types";
import { MCPKeysPage } from "./pages/MCPKeysPage";
import { AccountPage } from "./pages/AccountPage";
import { DatasetPage } from "./pages/DatasetPage";
import { AlphaPage } from "./pages/AlphaPage";
@@ -318,6 +319,7 @@ export default function App() {
<main
className={`page-content ${page !== "account" ? "bounded-page" : "account-page"}`}
>
{page === "mcp-keys" && <MCPKeysPage />}
{pollError && (
<Banner
type="warning"
@@ -502,6 +504,7 @@ export default function App() {
datasets: datasetContext,
backtests: backtestContext,
account: { page: "account" as const },
"mcp-keys": { page: "account" as const },
}[page]
}
timezone={account?.timezone}
+7 -3
View File
@@ -1,7 +1,10 @@
import type { PageContext, UIAction } from "./types";
export type WorkspacePage = PageContext["page"];
const contextLabels: Record<WorkspacePage, (context: PageContext) => string> = {
export type WorkspacePage = PageContext["page"] | "mcp-keys";
const contextLabels: Record<
PageContext["page"],
(context: PageContext) => string
> = {
alphas: (context) =>
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
operators: () => "上下文:算子库",
@@ -24,8 +27,9 @@ export function contextLabel(context: PageContext): string {
}
export function pageFromHash(hash: string): WorkspacePage {
const value = hash.replace(/^#/, "");
const value = hash.replace(/^#/, "").split("?")[0];
const page = value.startsWith("quantflow/") ? "quantflow" : value;
if (page === "mcp-keys") return page;
return Object.hasOwn(contextLabels, page)
? (page as WorkspacePage)
: "alphas";
+11
View File
@@ -177,6 +177,17 @@ export function BacktestPage({
}
}, [action]);
useEffect(() => {
const locate = () => {
if (!location.hash.startsWith("#backtests?")) return;
const id = new URLSearchParams(location.hash.split("?")[1]).get("run_id");
if (id && /^[a-f0-9-]{36}$/i.test(id)) openRun(id);
};
locate();
window.addEventListener("hashchange", locate);
return () => window.removeEventListener("hashchange", locate);
}, []);
async function perform(work: () => Promise<void>) {
setBusy(true);
setError("");
+29 -26
View File
@@ -30,6 +30,7 @@ const navigation = [
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
{ id: "mcp-keys", label: "MCP Key", icon: IconCommand, group: "系统管理" },
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
] as const;
@@ -172,33 +173,35 @@ export function AppSidebar({
</div>
)}
<nav className="sidebar-nav" aria-label="主导航">
{["数据与素材", "研究实验", "研究编排", "研究成果"].map((group) => (
<div key={group}>
{!collapsed && (
<div className="sidebar-group-heading">{group}</div>
)}
{navigation
.filter((item) => item.group === group)
.map(({ id, label, icon: Icon }) => (
<Tooltip
key={id}
content={label}
position="right"
visible={collapsed ? undefined : false}
>
<button
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
aria-label={label}
aria-current={id === page ? "page" : undefined}
onClick={() => navigate(id)}
{["数据与素材", "研究实验", "研究编排", "研究成果", "系统管理"].map(
(group) => (
<div key={group}>
{!collapsed && (
<div className="sidebar-group-heading">{group}</div>
)}
{navigation
.filter((item) => item.group === group)
.map(({ id, label, icon: Icon }) => (
<Tooltip
key={id}
content={label}
position="right"
visible={collapsed ? undefined : false}
>
<Icon style={{ fontSize: 17 }} />
{!collapsed && <span>{label}</span>}
</button>
</Tooltip>
))}
</div>
))}
<button
className={`sidebar-nav-item ${id === page ? "active" : ""}`}
aria-label={label}
aria-current={id === page ? "page" : undefined}
onClick={() => navigate(id)}
>
<Icon style={{ fontSize: 17 }} />
{!collapsed && <span>{label}</span>}
</button>
</Tooltip>
))}
</div>
),
)}
</nav>
<div className="sidebar-footer">
{!collapsed && (
+332
View File
@@ -0,0 +1,332 @@
import { useEffect, useState } from "react";
import { Banner, Button, Input, Toast } from "@douyinfe/semi-ui-19";
import { api, formatTime, post } from "../api";
import "./mcp-keys.css";
type Token = {
id: string;
name: string;
scopes: string[];
created_at: string;
expires_at: string;
revoked_at: string | null;
status: "active" | "expired" | "revoked" | "invalid_binding";
};
type TokenPage = {
items: Token[];
total: number;
has_more: boolean;
enabled: boolean;
endpoint: string;
can_create: boolean;
};
const scopes = [
["research:read", "读取研究数据", "查询目录、历史、运行与结果"],
["research:refresh", "刷新研究数据", "更新元数据和 PnL 缓存"],
["backtests:execute", "执行回测", "提交新的回测批次"],
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
] as const;
const labels = {
active: "有效",
expired: "已过期",
revoked: "已撤销",
invalid_binding: "账户绑定失效",
};
export function MCPKeysPage() {
const [data, setData] = useState<TokenPage | null>(null);
const [offset, setOffset] = useState(0);
const [version, setVersion] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [name, setName] = useState("");
const [days, setDays] = useState("90");
const [permissions, setPermissions] = useState<string[]>(["research:read"]);
const [busy, setBusy] = useState(false);
const [created, setCreated] = useState<(Token & { token: string }) | null>(
null,
);
const [revoking, setRevoking] = useState<string | null>(null);
useEffect(() => {
let active = true;
setLoading(true);
api<TokenPage>(`/mcp-tokens?offset=${offset}&limit=25`)
.then((next) => {
if (active) {
setData(next);
setError("");
}
})
.catch((e: Error) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [offset, version]);
async function create(event: React.FormEvent) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
const result = await post<Token & { token: string }>("/mcp-tokens", {
name: name.trim(),
days: Number(days),
scopes: permissions,
});
setCreated(result);
setName("");
setOffset(0);
setVersion((v) => v + 1);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function revoke(id: string) {
setBusy(true);
try {
await post(`/mcp-tokens/${id}/revoke`);
if (created?.id === id) setCreated(null);
setRevoking(null);
setVersion((v) => v + 1);
Toast.success("MCP Key 已撤销");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function copy(value: string) {
try {
await navigator.clipboard.writeText(value);
Toast.success("已复制");
} catch {
Toast.error("复制失败,请手动选择并复制");
}
}
return (
<section className="mcp-keys-page">
<header>
<div>
<h1>MCP Key</h1>
<p>管理 Codex 等客户端访问研究数据和执行回测的权限。</p>
</div>
<Button
disabled={loading || busy}
onClick={() => setVersion((v) => v + 1)}
>
刷新列表
</Button>
</header>
{error && <Banner type="danger" description={error} />}
{data && !data.enabled && (
<Banner
type="warning"
description="MCP 服务未启用。可以管理 Key,启用服务后客户端才能连接。"
/>
)}
{data && !data.can_create && (
<Banner
type="warning"
description="请先在个人信息页连接并确认 WorldQuant 账户身份,再创建 Key。"
/>
)}
{data && (
<section className="mcp-key-card">
<h2>连接地址</h2>
<div className="mcp-key-address">
<code>{data.endpoint}</code>
<Button onClick={() => void copy(data.endpoint)}>复制地址</Button>
</div>
<p>
客户端使用 Bearer Token 认证。Key 仅用于 MCP,不提供网页管理或正式
Alpha 提交权限。
</p>
</section>
)}
{created ? (
<section
className="mcp-key-card mcp-key-secret"
aria-label="新创建的 MCP Key"
>
<h2>Key 已创建</h2>
<p>
明文仅在这里显示一次。请保存到客户端凭据配置中;关闭、刷新或离开此页后无法再次查看。
</p>
<Input aria-label="新 Key 明文" value={created.token} readOnly />
<div className="mcp-key-actions">
<Button onClick={() => void copy(created.token)}>复制 Key</Button>
<Button onClick={() => setCreated(null)}>已保存,关闭明文</Button>
</div>
</section>
) : (
<form className="mcp-key-card" onSubmit={(event) => void create(event)}>
<h2>创建 MCP Key</h2>
<div className="mcp-key-fields">
<label>
名称
<Input
aria-label="Key 名称"
value={name}
maxLength={100}
onChange={setName}
placeholder="例如 Codex 研究助手"
/>
</label>
<label>
有效期(天)
<input
aria-label="有效期(天)"
type="number"
min="1"
max="365"
step="1"
required
value={days}
onChange={(e) => setDays(e.target.value)}
/>
</label>
</div>
<fieldset>
<legend>访问权限</legend>
<div className="mcp-key-permissions">
{scopes.map(([value, label, description]) => (
<label key={value}>
<input
type="checkbox"
checked={permissions.includes(value)}
disabled={value === "research:read" || busy}
onChange={(e) =>
setPermissions((old) =>
e.target.checked
? [...old, value]
: old.filter((item) => item !== value),
)
}
/>
<span>
<strong>{label}</strong>
<small>
{description}
{value === "research:read" ? "(必选)" : ""}
</small>
</span>
</label>
))}
</div>
</fieldset>
<Button
htmlType="submit"
theme="solid"
loading={busy}
disabled={
!data?.can_create ||
!name.trim() ||
!Number.isInteger(Number(days)) ||
Number(days) < 1 ||
Number(days) > 365
}
>
创建 Key
</Button>
</form>
)}
<section
className="mcp-key-card"
aria-label="Key 列表"
aria-busy={loading}
>
<h2>
已创建的 Key <span className="mcp-key-count">{data?.total ?? 0}</span>
</h2>
<div className="mcp-key-table">
<table>
<thead>
<tr>
<th>名称</th>
<th>权限</th>
<th>状态</th>
<th>创建时间</th>
<th>到期时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{data?.items.map((item) => (
<tr key={item.id}>
<td>
<strong>{item.name}</strong>
<small>{item.id}</small>
</td>
<td>
{item.scopes.map((scope) => (
<small key={scope}>
{scopes.find(([value]) => value === scope)?.[1] ??
scope}
</small>
))}
</td>
<td>{labels[item.status]}</td>
<td>{formatTime(item.created_at)}</td>
<td>{formatTime(item.expires_at)}</td>
<td>
{item.revoked_at ? (
"—"
) : revoking === item.id ? (
<div>
<p>撤销后新请求立即失效,已受理回测继续执行。</p>
<Button
type="danger"
disabled={busy}
onClick={() => void revoke(item.id)}
>
确认撤销
</Button>
<Button
disabled={busy}
onClick={() => setRevoking(null)}
>
取消
</Button>
</div>
) : (
<Button
type="danger"
disabled={busy}
onClick={() => setRevoking(item.id)}
>
撤销
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{!loading && !data?.items.length && <p>暂无 MCP Key。</p>}
<div className="mcp-key-actions">
<Button
disabled={offset === 0 || loading || busy}
onClick={() => setOffset((v) => Math.max(0, v - 25))}
>
上一页
</Button>
<span>第 {Math.floor(offset / 25) + 1} 页</span>
<Button
disabled={!data?.has_more || loading || busy}
onClick={() => setOffset((v) => v + 25)}
>
下一页
</Button>
</div>
</section>
</section>
);
}
+118
View File
@@ -0,0 +1,118 @@
.mcp-keys-page {
overflow: auto;
width: 100%;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.mcp-keys-page > header,
.mcp-key-address {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.mcp-keys-page h1 {
margin: 0 0 8px;
font-size: 24px;
}
.mcp-keys-page h2 {
margin: 0 0 16px;
font-size: 16px;
}
.mcp-keys-page p {
color: var(--semi-color-text-2);
line-height: 1.6;
}
.mcp-key-card {
padding: 20px;
border: 1px solid var(--semi-color-border);
border-radius: 10px;
background: var(--semi-color-bg-1);
}
.mcp-key-fields {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-bottom: 20px;
}
.mcp-key-fields > label {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 200px;
}
.mcp-key-fields input[type="number"] {
padding: 6px 12px;
border: 1px solid var(--semi-color-border);
border-radius: 4px;
background: var(--semi-color-fill-0);
color: var(--semi-color-text-0);
font: inherit;
}
.mcp-key-card fieldset {
border: 0;
padding: 0;
margin: 0 0 20px;
}
.mcp-key-card legend {
margin-bottom: 12px;
}
.mcp-key-permissions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.mcp-key-permissions label {
display: flex;
gap: 8px;
align-items: flex-start;
cursor: pointer;
}
.mcp-key-card small {
display: block;
margin-top: 4px;
color: var(--semi-color-text-2);
}
.mcp-key-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.mcp-key-table {
overflow: auto;
}
.mcp-key-table table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.mcp-key-table th,
.mcp-key-table td {
padding: 12px;
border-bottom: 1px solid var(--semi-color-border);
vertical-align: top;
}
.mcp-key-table th {
color: var(--semi-color-text-2);
font-weight: 500;
white-space: nowrap;
}
.mcp-key-count {
color: var(--semi-color-text-2);
font-weight: 400;
}
.mcp-key-address code {
overflow-wrap: anywhere;
}
@media (max-width: 700px) {
.mcp-keys-page {
padding: 12px;
}
.mcp-key-address {
align-items: flex-start;
flex-direction: column;
}
}
+1
View File
@@ -7,6 +7,7 @@ export const sourceLabel = (kind: string) =>
({
chatbox: "Chatbox 研究",
manual: "手工研究",
mcp: "MCP 研究",
ai: "AI 研究(历史)",
template: "模板研究",
variant: "Alpha 变体",
+86 -1
View File
@@ -28,7 +28,9 @@ test("draft, immutable preview, mixed-result persistence and responsive workspac
await page.getByRole("button", { name: "新建回测" }).click();
await page.getByLabel("运行名称", { exact: true }).fill("浏览器回测验收");
await page.getByRole("textbox", { name: "Region", exact: true }).fill("USA");
await page.getByRole("textbox", { name: "Universe", exact: true }).fill("TOP3000");
await page
.getByRole("textbox", { name: "Universe", exact: true })
.fill("TOP3000");
await page.getByLabel("回测候选").fill("rank(close)\n-rank(volume)");
await page.getByRole("button", { name: "保存草稿", exact: true }).click();
await expect(page.getByText("草稿已保存", { exact: true })).toBeVisible();
@@ -103,3 +105,86 @@ test("AI prepares one fixed preview, confirms once, and shows live run independe
await chat.getByRole("button", { name: "打开回测详情", exact: true }).click();
await expect(page.getByText("1 / 1 已保存", { exact: true })).toBeVisible();
});
test("MCP direct batch appears through its run link and source filter", async ({
page,
}) => {
await login(page);
const token = (
await (
await page.request.post("/api/v1/test/mcp-token", { headers })
).json()
).token;
const response = await page.request.post("/api/v1/mcp/", {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json, text/event-stream",
},
data: {
jsonrpc: "2.0",
id: "browser-batch",
method: "tools/call",
params: {
name: "submit_backtests",
arguments: {
name: "MCP 浏览器留痕",
idempotency_key: "browser-mcp",
duplicate_policy: "rerun",
candidates: [
{
client_item_id: "browser-1",
expression: "rank(close) + 987",
settings: {
instrumentType: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
decay: 0,
neutralization: "INDUSTRY",
truncation: 0.08,
pasteurization: "ON",
unitHandling: "VERIFY",
nanHandling: "OFF",
language: "FASTEXPR",
visualization: false,
maxTrade: "OFF",
maxPosition: "OFF",
},
},
],
source: {
reference: "browser-conversation",
hypothesis: "验证直接回测留痕",
},
},
},
},
});
expect(response.ok()).toBeTruthy();
const result = (await response.json()).result;
expect(result.isError).toBeFalsy();
const run = result.structuredContent;
await page.goto(run.web_url);
await expect(page.getByText("1 / 1 已保存", { exact: true })).toBeVisible({
timeout: 20000,
});
await expect(
page.getByText("研究来源:MCP 研究", { exact: true }),
).toBeVisible();
await expect(
page.getByText("来源引用:browser-conversation", { exact: true }),
).toBeVisible();
await page.reload();
await expect(page.getByText("1 / 1 已保存", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page
.getByRole("dialog")
.getByRole("heading")
.getByRole("button")
.click();
await page.getByText("全部研究来源", { exact: true }).click();
await page.getByRole("option", { name: /MCP 研究/ }).click();
await expect(
page.getByRole("button", { name: "MCP 浏览器留痕", exact: true }),
).toBeVisible();
});
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test";
test("MCP Key menu creates, hides secrets on navigation, and revokes", async ({
page,
}) => {
await page.goto("/#mcp-keys");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(
page.getByRole("heading", { name: "MCP Key", exact: true }),
).toBeVisible();
const headers = { "X-WQ-Request": "1" };
await page.request.put("/api/v1/account/credentials", {
headers,
data: { email: "test@example.com", password: "synthetic-password" },
});
await page.request.post("/api/v1/account/connect", { headers });
await expect
.poll(
async () =>
(await (await page.request.get("/api/v1/account")).json())
.connection_status,
)
.toBe("connected");
await page.getByRole("button", { name: "刷新列表", exact: true }).click();
await page.getByLabel("Key 名称").fill("Browser test reader");
await page.getByRole("button", { name: "创建 Key", exact: true }).click();
await expect(page.getByLabel("新 Key 明文")).toBeVisible();
const secret = await page.getByLabel("新 Key 明文").inputValue();
expect(secret).toMatch(/^wqmcp_/);
const listed = await (await page.request.get("/api/v1/mcp-tokens")).json();
expect(JSON.stringify(listed)).not.toContain(secret);
const item = listed.items.find(
(row: { name: string }) => row.name === "Browser test reader",
);
expect(item.scopes).toEqual(["research:read"]);
await page.screenshot({
path: "test-results/mcp-key-page.png",
fullPage: true,
mask: [page.getByLabel("新 Key 明文")],
});
await page
.getByRole("navigation", { name: "主导航", exact: true })
.getByRole("button", { name: "数据目录", exact: true })
.click();
await page
.getByRole("navigation", { name: "主导航", exact: true })
.getByRole("button", { name: "MCP Key", exact: true })
.click();
await expect(page.getByLabel("新 Key 明文")).toHaveCount(0);
const row = page.getByRole("row").filter({ hasText: "Browser test reader" });
await row.getByRole("button", { name: "撤销", exact: true }).click();
await row.getByRole("button", { name: "确认撤销", exact: true }).click();
await expect(row).toContainText("已撤销");
await page.reload();
await expect(
page.getByRole("heading", { name: "MCP Key", exact: true }),
).toBeVisible();
await expect(page.getByLabel("新 Key 明文")).toHaveCount(0);
});
+7 -1
View File
@@ -4,7 +4,13 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: { "/api": process.env.WQ_DEV_API ?? "http://127.0.0.1:8000" },
proxy: {
"/api": {
target: process.env.WQ_DEV_API ?? "http://127.0.0.1:8000",
// Match production proxy behavior so MCP validates the public Host.
changeOrigin: false,
},
},
},
build: { chunkSizeWarningLimit: 1500 },
});