feat: add authenticated remote MCP access and complete business workflows
This commit is contained in:
1 parent
f40f4da781
commit
027a8c1b6a
35 files changed
+4430
-183
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user