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>
);
}