From c74bb0cd6a72f3fd0fa1fcadc3df23eed28ef382 Mon Sep 17 00:00:00 2001 From: dongsk Date: Wed, 2 Sep 2026 14:43:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=9C=8D=E5=8A=A1=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mcp-auth/backend/app/routers/services.py | 18 +++- mcp-auth/backend/app/routers/tokens.py | 29 +++--- mcp-auth/frontend/src/api/index.ts | 3 + .../frontend/src/pages/Services/index.tsx | 56 ++++++++--- mcp-auth/frontend/src/pages/Tokens/index.tsx | 98 +++++++++++++++++-- mcp-auth/frontend/vite.config.ts | 2 +- mcp-auth/sql/init.sql | 4 +- mcp-auth/sql/upgrade_service_url.sql | 33 +++++++ mcp-auth/sql/upgrade_token_service_url.sql | 16 +++ 9 files changed, 219 insertions(+), 40 deletions(-) create mode 100644 mcp-auth/sql/upgrade_service_url.sql create mode 100644 mcp-auth/sql/upgrade_token_service_url.sql diff --git a/mcp-auth/backend/app/routers/services.py b/mcp-auth/backend/app/routers/services.py index 3ef4d70..03a3e25 100644 --- a/mcp-auth/backend/app/routers/services.py +++ b/mcp-auth/backend/app/routers/services.py @@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/services", tags=["services"]) class ServiceCreate(BaseModel): service_name: str # erp / crm / ... + service_url: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp) description: str | None = None @@ -22,6 +23,7 @@ def _row_to_dict(row) -> dict: return { "service_id": row["service_id"], "service_name": row["service_name"], + "service_url": row["service_url"], "api_key": row["api_key"], "description": row["description"], "status": row["status"], @@ -60,15 +62,23 @@ async def register_service(req: ServiceCreate, admin: dict = Depends(current_adm if existing: raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态") + # MCP 服务地址唯一性校验 + url_existing = await pool.fetchval( + "SELECT 1 FROM mcp_service WHERE service_url = $1", + req.service_url.strip(), + ) + if url_existing: + raise HTTPException(400, f"MCP 服务地址 {req.service_url} 已被其他服务使用") + # 生成 API Key plain = secrets.token_urlsafe(32) api_key_hash = hashlib.sha256(plain.encode()).hexdigest() row = await pool.fetchrow( - """INSERT INTO mcp_service (service_name, api_key, api_key_hash, description, created_by) - VALUES ($1, $2, $3, $4, $5) - RETURNING service_id, service_name, api_key, description, created_at, created_by""", - req.service_name, plain, api_key_hash, req.description, admin.get("username", "admin"), + """INSERT INTO mcp_service (service_name, service_url, api_key, api_key_hash, description, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING service_id, service_name, service_url, api_key, description, created_at, created_by""", + req.service_name, req.service_url.strip(), plain, api_key_hash, req.description, admin.get("username", "admin"), ) return { diff --git a/mcp-auth/backend/app/routers/tokens.py b/mcp-auth/backend/app/routers/tokens.py index 7155b13..7490e11 100644 --- a/mcp-auth/backend/app/routers/tokens.py +++ b/mcp-auth/backend/app/routers/tokens.py @@ -17,7 +17,7 @@ router = APIRouter(prefix="/api/tokens", tags=["tokens"]) class TokenCreate(BaseModel): client_id: str - service_scope: str + service_scope: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp),后端反查服务名入库 description: str | None = None expires_at: datetime | None = None # null = 永不过期 @@ -38,6 +38,7 @@ def _row_to_dict(row) -> dict: "token_plain": row["token_plain"], "client_id": row["client_id"], "service_scope": row["service_scope"], + "service_url": row["service_url"], "status": row["status"], "expires_at": row["expires_at"].isoformat() if row["expires_at"] else None, "description": row["description"], @@ -78,21 +79,22 @@ async def list_tokens( async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)): pool = await get_pool() - # 动态校验 service_scope:必须是已注册的 active 服务 - exists = await pool.fetchval( - "SELECT 1 FROM mcp_service WHERE service_name = $1 AND status = 'active'", - req.service_scope, + # 动态校验 service_scope:传入 MCP 服务地址,反查服务名(必须是已注册的 active 服务) + svc_row = await pool.fetchrow( + "SELECT service_name, service_url FROM mcp_service WHERE service_url = $1 AND status = 'active'", + req.service_scope.strip(), ) - if not exists: + if svc_row is None: raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务") + service_name = svc_row["service_name"] # client_id + service_scope 唯一校验:同一客户端同一服务不允许重复签发 existing = await pool.fetchval( "SELECT 1 FROM mcp_token WHERE client_id = $1 AND service_scope = $2 AND status = 'active'", - req.client_id, req.service_scope, + req.client_id, service_name, ) if existing: - raise HTTPException(400, f"client_id '{req.client_id}' 已存在服务于 '{req.service_scope}' 的活跃 Token,请先吊销旧 Token") + raise HTTPException(400, f"client_id '{req.client_id}' 已存在服务于 '{service_name}' 的活跃 Token,请先吊销旧 Token") # 生成明文 token:仅此一次返回 plain = secrets.token_urlsafe(32) @@ -100,12 +102,12 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)): token_prefix = plain[:12] + "…" row = await pool.fetchrow( - """INSERT INTO mcp_token (token_hash, token_plain, token_prefix, client_id, service_scope, status, + """INSERT INTO mcp_token (token_hash, token_plain, token_prefix, client_id, service_scope, service_url, status, expires_at, description, created_by) - VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8) - RETURNING token_id, token_prefix, client_id, service_scope, status, + VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9) + RETURNING token_id, token_prefix, client_id, service_scope, service_url, status, expires_at, description, created_at, created_by""", - token_hash, plain, token_prefix, req.client_id, req.service_scope, + token_hash, plain, token_prefix, req.client_id, service_name, svc_row["service_url"], req.expires_at, req.description, admin.get("username", "admin"), ) @@ -113,7 +115,7 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)): await pool.execute( "INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'issued', $2)", row["token_id"], - json.dumps({"client_id": req.client_id, "service_scope": req.service_scope}), + json.dumps({"client_id": req.client_id, "service_scope": service_name, "service_url": svc_row["service_url"]}), ) return { @@ -122,6 +124,7 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)): "token_prefix": row["token_prefix"], "client_id": row["client_id"], "service_scope": row["service_scope"], + "service_url": row["service_url"], "message": "请立即保存此 token,之后无法再次查看", } diff --git a/mcp-auth/frontend/src/api/index.ts b/mcp-auth/frontend/src/api/index.ts index 323fa32..93c4cd5 100644 --- a/mcp-auth/frontend/src/api/index.ts +++ b/mcp-auth/frontend/src/api/index.ts @@ -6,6 +6,7 @@ export interface TokenRow { token_plain: string | null; client_id: string; service_scope: string; + service_url: string | null; status: string; expires_at: string | null; description: string | null; @@ -108,6 +109,7 @@ export async function getStats() { export interface ServiceRow { service_id: number; service_name: string; + service_url: string; api_key: string; description: string | null; status: string; @@ -119,6 +121,7 @@ export interface ServiceRow { export interface ServiceCreate { service_name: string; + service_url: string; description?: string; } diff --git a/mcp-auth/frontend/src/pages/Services/index.tsx b/mcp-auth/frontend/src/pages/Services/index.tsx index ee02183..8203877 100644 --- a/mcp-auth/frontend/src/pages/Services/index.tsx +++ b/mcp-auth/frontend/src/pages/Services/index.tsx @@ -96,15 +96,24 @@ export default function Services() { }; const onCreate = async () => { - const values = await form.validateFields(); - const res = await registerService({ - service_name: values.service_name, - description: values.description, - }); - setCreated(res); - setCreateOpen(false); - form.resetFields(); - load(); + try { + const values = await form.validateFields(); + const res = await registerService({ + service_name: values.service_name, + service_url: values.service_url, + description: values.description, + }); + setCreated(res); + setCreateOpen(false); + form.resetFields(); + load(); + } catch (e: any) { + if (e?.response?.data?.detail) { + message.error(e.response.data.detail); + } else if (!e?.errorFields) { + message.error('注册失败'); + } + } }; const onCopy = (text: string) => { @@ -114,12 +123,11 @@ export default function Services() { }; const columns: ColumnsType = [ - { title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 240 }, { title: 'API Key', dataIndex: 'api_key', key: 'api_key', - width: 300, + width: 400, render: (key: string) => ( @@ -129,7 +137,20 @@ export default function Services() { ), }, - { title: '说明', dataIndex: 'description', key: 'description' }, + { title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 150, ellipsis: true }, + { + title: 'MCP服务地址', + dataIndex: 'service_url', + key: 'service_url', + width: 280, + ellipsis: true, + render: (u: string) => ( + + {u} + + ), + }, + { title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true }, { title: '状态', dataIndex: 'status', @@ -149,12 +170,14 @@ export default function Services() { dataIndex: 'last_used_at', key: 'last_used_at', width: 160, + ellipsis: true, render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'), }, { title: '操作', key: 'action', width: 180, + fixed: 'right', render: (_, row: ServiceRow) => row.status === 'active' ? ( @@ -197,6 +220,8 @@ export default function Services() { rowKey="service_id" loading={loading} pagination={{ pageSize: 15 }} + tableLayout="fixed" + scroll={{ x: 1540 }} /> {/* 注册服务表单 */} @@ -216,6 +241,13 @@ export default function Services() { > + + + diff --git a/mcp-auth/frontend/src/pages/Tokens/index.tsx b/mcp-auth/frontend/src/pages/Tokens/index.tsx index a456183..d09345a 100644 --- a/mcp-auth/frontend/src/pages/Tokens/index.tsx +++ b/mcp-auth/frontend/src/pages/Tokens/index.tsx @@ -38,8 +38,25 @@ export default function Tokens() { const [createOpen, setCreateOpen] = useState(false); const [created, setCreated] = useState(null); const [services, setServices] = useState([]); + const [jsonRow, setJsonRow] = useState(null); const [form] = Form.useForm(); + const buildRegisterJson = (row: TokenRow): string => { + const json = { + mcpServers: { + [row.service_scope]: { + type: 'streamable-http', + url: row.service_url || '', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${row.token_plain || ''}`, + }, + }, + }, + }; + return JSON.stringify(json, null, 2); + }; + const load = async () => { setLoading(true); try { @@ -110,10 +127,26 @@ export default function Tokens() { }); }; - const onCopy = (text: string) => { - navigator.clipboard.writeText(text).then(() => { + const onCopy = async (text: string) => { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + // 非 HTTPS 环境降级方案:临时 textarea + execCommand + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + if (!ok) throw new Error('copy failed'); + } message.success('已复制'); - }); + } catch { + message.error('复制失败,请手动选择文本复制'); + } }; const onCreate = async () => { @@ -141,14 +174,26 @@ export default function Tokens() { }; const columns: ColumnsType = [ - { title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 240 }, + { title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 150, ellipsis: true }, { - title: '范围', + title: '服务标识', dataIndex: 'service_scope', key: 'service_scope', - width: 80, + width: 180, render: (s: string) => {s}, }, + { + title: 'MCP服务地址', + dataIndex: 'service_url', + key: 'service_url', + width: 280, + ellipsis: true, + render: (u: string | null) => ( + + {u || '-'} + + ), + }, { title: 'Token', dataIndex: 'token_plain', @@ -181,19 +226,21 @@ export default function Tokens() { width: 160, render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '永不过期'), }, - { title: '说明', dataIndex: 'description', key: 'description' }, + { title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true }, { title: '最近调用', dataIndex: 'last_used_at', key: 'last_used_at', width: 160, + ellipsis: true, render: (t: string, row: TokenRow) => t ? `${dayjs(t).format('MM-DD HH:mm')} (${row.last_used_svc || '-'})` : '-', }, { title: '操作', key: 'action', - width: 180, + width: 240, + fixed: 'right', render: (_, row: TokenRow) => row.status === 'active' ? ( @@ -203,6 +250,9 @@ export default function Tokens() { + ) : row.status === 'revoked' ? ( @@ -236,6 +286,8 @@ export default function Tokens() { rowKey="token_id" loading={loading} pagination={{ pageSize: 15 }} + tableLayout="fixed" + scroll={{ x: 1810 }} /> {/* 签发表单 */} @@ -263,7 +315,7 @@ export default function Tokens() {