feat: add authenticated remote MCP access and complete business workflows

This commit is contained in:
陈煜 committed 2026-10-03 22:23:35 +08:00
1 parent f40f4da781
commit 027a8c1b6a
35 files changed
+4430 -183

No files matched your search

+395
View File
@@ -0,0 +1,395 @@
import { useEffect, useState } from 'react';
import { api } from './api';
type Connection = {
id: string;
name: string;
scopes: string[];
expiresAt: string;
revokedAt: string | null;
clientId: string | null;
};
type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string };
type Management = {
mcpUrl: string;
mode: string;
grants: Connection[];
operations: Operation[];
calls: { id: string; tool: string; status: string; createdAt: string }[];
};
type Preview = {
operationId: string;
tool: string;
status: string;
description: string;
impact: unknown;
web?: string;
sensitive: boolean;
result?: unknown;
};
type Consent = {
id: string;
name: string;
scopes: string[];
redirectUri: string;
resource: string;
};
export function AgentConnections() {
const [data, setData] = useState<Management | null>(null),
[error, setError] = useState(''),
[message, setMessage] = useState(''),
[token, setToken] = useState(''),
[preview, setPreview] = useState<Preview | null>(null),
[consent, setConsent] = useState<Consent | null>(null),
[busy, setBusy] = useState(false);
const load = async () => setData(await api<Management>('/agent'));
const act = async (work: () => Promise<unknown>) => {
if (busy) return;
setBusy(true);
setError('');
setMessage('');
try {
await work();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '操作失败');
} finally {
setBusy(false);
}
};
const show = async (id: string) => setPreview(await api<Preview>('/agent/operations/' + id));
useEffect(() => {
void act(async () => {
await load();
const params = new URLSearchParams(location.search);
const authorization = params.get('agent_authorization'),
operation = params.get('agent_operation');
if (authorization) setConsent(await api<Consent>('/agent/authorizations/' + authorization));
if (operation) await show(operation);
});
}, []);
return (
<section className="panel agent-panel">
<h2>连接 Agent</h2>
{error && (
<p role="alert" className="danger-text">
{error}
</p>
)}
{message && <p role="status">{message}</p>}
{data && (
<>
<p>远程 MCP 地址</p>
<code className="agent-address">{data.mcpUrl}</code>
<button
className="secondary"
type="button"
onClick={() => void navigator.clipboard.writeText(data.mcpUrl)}
>
复制地址
</button>
<p className="muted">
支持 Streamable HTTP。OAuth 客户端使用此地址发现授权信息,浏览器登录 WorthPath
后审核连接名称、回调地址和权限。访问令牌每小时过期,刷新令牌最多 30
天并在使用时轮换。个人令牌适用于支持 Bearer 头的客户端。
</p>
<p className="muted">
已验证客户端:官方 TypeScript SDK 1.31.0(OAuth / Bearer)。其他 Agent
尚未验证;不会保证任意客户端兼容。
</p>
<details>
<summary>官方 SDK 的已验证接入配置</summary>
<pre>{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}</pre>
<button
className="secondary"
type="button"
onClick={() =>
void navigator.clipboard.writeText(
`$env:MCP_SERVER_URL=${JSON.stringify(data.mcpUrl)}\npnpm --filter @worthpath/api mcp:probe`,
)
}
>
复制本项目诊断客户端命令
</button>
<p className="muted">
先在终端设置 MCP_ACCESS_TOKEN,再运行复制的命令。完整 OAuth 示例及安全存储说明见
docs/mcp.md;此配置仅针对官方 SDK 1.31.0。
</p>
</details>
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget),
form = e.currentTarget;
void act(async () => {
await api('/agent/policy', 'PUT', {
mode: f.get('mode'),
password: f.get('password'),
});
form.reset();
setMessage('写入策略已保存');
});
}}
>
<h3>写入策略</h3>
<select name="mode" defaultValue={data.mode} key={data.mode}>
<option value="readonly">只读</option>
<option value="draft">创建草稿,由网页确认</option>
<option value="direct">已授权 write 的连接可直接普通写入</option>
</select>
<input
name="password"
type="password"
autoComplete="current-password"
required
placeholder="当前密码"
/>
<button className="secondary" disabled={busy}>
保存策略
</button>
</form>
<h3>OAuth 授权与个人令牌</h3>
<p className="muted">
read 查询;draft 创建草稿;write 按写入策略执行普通写入;sensitive
发起敏感操作,仍须网页验证密码。令牌到期可新建并撤销旧令牌。
</p>
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget,
f = new FormData(form);
void act(async () => {
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
name: f.get('name'),
days: Number(f.get('days')),
password: f.get('password'),
scopes: f.getAll('scope'),
});
setToken(v.token);
form.reset();
});
}}
>
<input name="name" required maxLength={100} placeholder="连接名称" />
<input
name="days"
type="number"
min={1}
max={90}
defaultValue={30}
required
aria-label="有效天数"
/>
{['read', 'draft', 'write', 'sensitive'].map((s) => (
<label key={s}>
<input
type="checkbox"
name="scope"
value={s}
defaultChecked={s === 'read'}
required={s === 'read'}
/>
{s}
</label>
))}
<input
name="password"
type="password"
required
autoComplete="current-password"
placeholder="当前密码"
/>
<button className="secondary" disabled={busy}>
创建个人令牌
</button>
</form>
{token && (
<div role="status">
<p>完整令牌仅显示这一次,请妥善保存。</p>
<code className="agent-address">{token}</code>
<button
className="secondary"
type="button"
onClick={() => void navigator.clipboard.writeText(token)}
>
复制令牌
</button>
<button className="secondary" type="button" onClick={() => setToken('')}>
隐藏令牌
</button>
</div>
)}
<h3>已授权连接(最近 100 条)</h3>
{data.grants.map((g) => (
<div key={g.id} className="agent-row">
<span>
{g.name} · {g.clientId ? 'OAuth' : '个人令牌'} · {g.scopes.join(', ')} ·{' '}
{new Date(g.expiresAt).toLocaleString()} · {g.revokedAt ? '已撤销' : ''}
</span>
{!g.revokedAt && (
<button
className="secondary"
disabled={busy}
type="button"
onClick={() =>
void act(async () => {
await api('/agent/connections/' + g.id, 'DELETE');
setMessage('连接已撤销');
})
}
>
撤销
</button>
)}
</div>
))}
<h3>待确认及最近操作(最近 100 条)</h3>
<button
className="secondary"
disabled={busy}
type="button"
onClick={() => void act(load)}
>
刷新状态
</button>
{data.operations.map((o) => (
<div key={o.id} className="agent-row">
<span>
{o.tool} ·{' '}
{o.status === 'pending' && new Date(o.expiresAt) < new Date()
? 'expired'
: o.status}{' '}
· {new Date(o.createdAt).toLocaleString()}
</span>
<button
className="secondary"
type="button"
disabled={busy}
onClick={() => void act(() => show(o.id))}
>
查看影响和结果
</button>
</div>
))}
<h3>最近调用(不保存参数和敏感内容)</h3>
{data.calls.map((c) => (
<div key={c.id} className="agent-row">
{c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()}
</div>
))}
</>
)}
{consent && (
<div className="agent-confirm">
<h3>审核 OAuth 连接</h3>
<p>{consent.name}</p>
<p>权限:{consent.scopes.join(', ')}</p>
<p>资源:{consent.resource}</p>
<p>回调地址:{consent.redirectUri}</p>
<p className="muted">只批准你正在连接的客户端;请核对回调地址。</p>
{[true, false].map((approve) => (
<button
className="secondary"
disabled={busy}
type="button"
key={String(approve)}
onClick={() =>
void act(async () => {
const v = await api<{ redirect: string }>(
'/agent/authorizations/' + consent.id,
'POST',
{ approve },
);
location.assign(v.redirect);
})
}
>
{approve ? '授权此连接' : '拒绝授权'}
</button>
))}
</div>
)}
{preview && (
<div className="agent-confirm">
<h3>
{preview.tool} · {preview.status}
</h3>
<p>{preview.description}</p>
<pre>{JSON.stringify(preview.impact, null, 2)}</pre>
{preview.result !== undefined && <pre>{JSON.stringify(preview.result, null, 2)}</pre>}
{preview.status === 'pending' && (
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget),
form = e.currentTarget;
void act(async () => {
await api('/agent/operations/' + preview.operationId, 'POST', {
approve: true,
...(f.get('password') ? { password: f.get('password') } : {}),
...(f.get('username') ? { username: f.get('username') } : {}),
...(f.get('newPassword') ? { newPassword: f.get('newPassword') } : {}),
...(f.get('confirmation') ? { confirmation: f.get('confirmation') } : {}),
});
form.reset();
await show(preview.operationId);
setMessage('操作已提交,Agent 可以查询最终结果');
});
}}
>
{preview.web === 'clear' && (
<>
<a href="/api/backup" download>
先下载当前账号备份
</a>
<p>确认备份已保存后输入“确定清空”。</p>
<input name="confirmation" required placeholder="确定清空" />
</>
)}
{preview.web === 'credentials' && (
<>
<input name="username" placeholder="新账号(可选)" autoComplete="username" />
<input
name="newPassword"
type="password"
minLength={10}
maxLength={72}
placeholder="新密码(可选)"
autoComplete="new-password"
/>
</>
)}
{preview.sensitive && (
<input
name="password"
type="password"
required
placeholder="当前密码"
autoComplete="current-password"
/>
)}
<button className="secondary" disabled={busy}>
确认执行上述操作
</button>
<button
className="secondary"
disabled={busy}
type="button"
onClick={() =>
void act(async () => {
await api('/agent/operations/' + preview.operationId, 'POST', {
approve: false,
});
await show(preview.operationId);
})
}
>
取消
</button>
</form>
)}
</div>
)}
</section>
);
}
+20 -3
View File
@@ -53,6 +53,7 @@ import { GroupOrderList } from './GroupOrderList';
import { orderedGroups, mergeGroupOrder } from './group-order';
import { Calendar } from './Calendar';
import { SchedulePanel } from './SchedulePanel';
import { AgentConnections } from './AgentConnections';
import { IconPicker } from './IconPicker';
import { MetalPanel } from './MetalPanel';
import { QuickTransfer } from './QuickTransfer';
@@ -229,7 +230,12 @@ export default function App() {
const [user, setUser] = useState<User | null>(null),
[boot, setBoot] = useState(true),
[register, setRegister] = useState(false),
[page, setPage] = useState('overview'),
[page, setPage] = useState(
new URLSearchParams(location.search).has('agent_authorization') ||
new URLSearchParams(location.search).has('agent_operation')
? 'settings'
: 'overview',
),
[positions, setPositions] = useState<Position[]>([]),
[overview, setOverview] = useState<Overview | null>(null),
[rates, setRates] = useState<Rate[]>([]),
@@ -297,7 +303,12 @@ export default function App() {
[clearConfirmation, setClearConfirmation] = useState(0);
useToast(error, 'error');
useToast(success, 'success');
const [settingsSection, setSettingsSection] = useState('general'),
const [settingsSection, setSettingsSection] = useState(
new URLSearchParams(location.search).has('agent_authorization') ||
new URLSearchParams(location.search).has('agent_operation')
? 'agent'
: 'general',
),
[transfers, setTransfers] = useState<Transfer[]>([]);
const [historyRows, setHistoryRows] = useState<History[]>([]),
[historyCursor, setHistoryCursor] = useState<string | null>(null),
@@ -674,7 +685,11 @@ export default function App() {
password: f.get('password'),
});
if (session !== sessionGeneration.current) return;
setPage('overview');
const agentReturn =
new URLSearchParams(location.search).has('agent_authorization') ||
new URLSearchParams(location.search).has('agent_operation');
setPage(agentReturn ? 'settings' : 'overview');
if (agentReturn) setSettingsSection('agent');
setSelected(null);
setModal(null);
activityAt.current = Date.now();
@@ -1748,6 +1763,7 @@ export default function App() {
<nav className="settings-tabs" aria-label={tr('设置分类')}>
{Object.entries({
general: '常规设置',
agent: '连接 Agent',
display: '显示与菜单',
privacy: '登录与隐私',
security: '账号与密码',
@@ -1772,6 +1788,7 @@ export default function App() {
<IconLibrary />
</section>
)}
{settingsSection === 'agent' && <AgentConnections />}
{settingsSection === 'security' && (
<section className="panel">
<h2>{tr('账号与密码')}</h2>
+53
View File
@@ -1827,3 +1827,56 @@ textarea,
padding-bottom: 300px;
}
}
.agent-panel form {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0;
}
.agent-panel form h3 {
width: 100%;
}
.agent-panel input {
max-width: 100%;
}
.agent-address {
display: block;
overflow-wrap: anywhere;
user-select: text;
margin: 0.75rem 0;
}
.agent-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--line);
}
.agent-confirm {
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem;
margin: 1rem 0;
}
.agent-confirm pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 25rem;
overflow: auto;
user-select: text;
}
.agent-panel form > input,
.agent-panel form > select {
width: auto;
min-width: 12rem;
flex: 1 1 12rem;
}
.agent-panel form > label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.agent-panel form > label input[type='checkbox'] {
width: auto;
}