From b3747add6b341ac8dec704a2824bf6299d904fb4 Mon Sep 17 00:00:00 2001 From: oss-sync Date: Tue, 9 Jun 2026 14:21:52 +0000 Subject: [PATCH] sync: update from private repo (f811f38) --- src/bridge/mcp-api.test.ts | 35 ++ src/bridge/mcp-api.ts | 51 +- .../userfolder/McpConnectionsPanel.tsx | 129 ----- ui/src/components/userfolder/McpPanel.tsx | 29 +- .../components/userfolder/McpServersPanel.tsx | 534 ------------------ 5 files changed, 88 insertions(+), 690 deletions(-) delete mode 100644 ui/src/components/userfolder/McpConnectionsPanel.tsx delete mode 100644 ui/src/components/userfolder/McpServersPanel.tsx diff --git a/src/bridge/mcp-api.test.ts b/src/bridge/mcp-api.test.ts index 2180db0..f503e85 100644 --- a/src/bridge/mcp-api.test.ts +++ b/src/bridge/mcp-api.test.ts @@ -310,6 +310,41 @@ describe('mcp-api', () => { expect(post.status).toBe(409); }); + it('user can update their own server (edit); blank token preserves the existing one', async () => { + const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' }); + const create = await request(app).post('/api/mcp/user-servers').send({ + id: 'mine', name: 'Mine', url: 'http://127.0.0.1:9/mcp', + authKind: 'api_key', staticToken: 'sk-original', + }); + expect(create.status).toBe(200); + + // Edit: rename + add a custom header, leave the token blank (= keep existing). + const edit = await request(app).post('/api/mcp/user-servers').send({ + id: 'mine', name: 'Mine Renamed', url: 'http://127.0.0.1:9/mcp', + authKind: 'api_key', staticToken: '', authHeaderName: 'xc-mcp-token', + }); + expect(edit.status).toBe(200); + + const full = reg.getDecrypted('mine'); + expect(full?.staticToken).toBe('sk-original'); // preserved, not wiped + expect(full?.authHeaderName).toBe('xc-mcp-token'); // updated + expect(reg.listEnabledForUser('u1').find((s) => s.id === 'mine')?.name).toBe('Mine Renamed'); + }); + + it('admin edit of a global api_key server preserves the token when left blank', async () => { + const { app, reg } = makeApp({ currentRole: 'admin' }); + await request(app).post('/api/mcp/servers').send({ + id: 'g', name: 'G', url: 'http://127.0.0.1:1/mcp', + authKind: 'api_key', staticToken: 'sk-keep', + }); + const edit = await request(app).post('/api/mcp/servers').send({ + id: 'g', name: 'G2', url: 'http://127.0.0.1:1/mcp', + authKind: 'api_key', staticToken: '', + }); + expect(edit.status).toBe(200); + expect(reg.getDecrypted('g')?.staticToken).toBe('sk-keep'); + }); + it('DELETE /api/mcp/connections returns 400 for api_key global server', async () => { const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' }); reg.upsert({ diff --git a/src/bridge/mcp-api.ts b/src/bridge/mcp-api.ts index 8b31238..6a72c56 100644 --- a/src/bridge/mcp-api.ts +++ b/src/bridge/mcp-api.ts @@ -56,15 +56,22 @@ export function createAdminRouter(deps: McpApiDeps): Router { return; } - const authKind = (body.authKind ?? 'oauth') as 'oauth' | 'api_key'; + // Editing a global server re-POSTs the same id. Preserve write-only secrets + // when left blank, and keep the original authKind (the form locks it on edit). + const existing = deps.registry.getDecrypted(body.id); + const isEdit = existing !== null; + const authKind = (isEdit ? existing!.authKind : (body.authKind ?? 'oauth')) as 'oauth' | 'api_key'; + const staticToken = body.staticToken || (isEdit ? existing!.staticToken ?? undefined : undefined); + const oauthClientSecret = body.oauthClientSecret || (isEdit ? existing!.oauthClientSecret || undefined : undefined); + const oauthClientId = body.oauthClientId || (isEdit ? existing!.oauthClientId || undefined : undefined); if (authKind === 'oauth') { - if (!body.oauthClientId || !body.oauthClientSecret) { + if (!oauthClientId || !oauthClientSecret) { res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' }); return; } } else if (authKind === 'api_key') { - if (!body.staticToken) { + if (!staticToken) { res.status(400).json({ error: 'authKind api_key requires staticToken' }); return; } @@ -80,10 +87,10 @@ export function createAdminRouter(deps: McpApiDeps): Router { url: body.url, authKind, ownerId: null, - oauthClientId: body.oauthClientId, - oauthClientSecret: body.oauthClientSecret, + oauthClientId, + oauthClientSecret, oauthScopes: body.oauthScopes ?? null, - staticToken: body.staticToken, + staticToken, authHeaderName: body.authHeaderName ?? null, enabled: body.enabled !== false, createdBy: adminId, @@ -278,15 +285,28 @@ export function createUserServersRouter(deps: McpApiDeps): Router { return; } - const authKind = (body.authKind ?? 'oauth') as 'oauth' | 'api_key'; + // Editing re-POSTs the same id. Collide only with a server you do NOT own + // (a global or another user's server); your own server is an update. + const existing = deps.registry.getDecrypted(body.id); + if (existing && existing.ownerId !== userId) { + res.status(409).json({ error: `server id '${body.id}' already exists` }); + return; + } + const isEdit = existing !== null; + // authKind cannot change on edit (the form disables the selector). + const authKind = (isEdit ? existing!.authKind : (body.authKind ?? 'oauth')) as 'oauth' | 'api_key'; + // Secrets are write-only: a blank value on edit means "keep the existing one". + const staticToken = body.staticToken || (isEdit ? existing!.staticToken ?? undefined : undefined); + const oauthClientSecret = body.oauthClientSecret || (isEdit ? existing!.oauthClientSecret || undefined : undefined); + const oauthClientId = body.oauthClientId || (isEdit ? existing!.oauthClientId || undefined : undefined); if (authKind === 'api_key') { - if (!body.staticToken) { + if (!staticToken) { res.status(400).json({ error: 'authKind api_key requires staticToken' }); return; } } else if (authKind === 'oauth') { - if (!body.oauthClientId || !body.oauthClientSecret) { + if (!oauthClientId || !oauthClientSecret) { res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' }); return; } @@ -295,23 +315,16 @@ export function createUserServersRouter(deps: McpApiDeps): Router { return; } - // Check for id collision with any existing server (global or other-user-owned) - const existing = deps.registry.getDecrypted(body.id); - if (existing) { - res.status(409).json({ error: `server id '${body.id}' already exists` }); - return; - } - deps.registry.upsert({ id: body.id, name: body.name, url: body.url, authKind, ownerId: userId, - oauthClientId: body.oauthClientId, - oauthClientSecret: body.oauthClientSecret, + oauthClientId, + oauthClientSecret, oauthScopes: body.oauthScopes ?? null, - staticToken: body.staticToken, + staticToken, authHeaderName: body.authHeaderName ?? null, enabled: body.enabled !== false, createdBy: userId, diff --git a/ui/src/components/userfolder/McpConnectionsPanel.tsx b/ui/src/components/userfolder/McpConnectionsPanel.tsx deleted file mode 100644 index ead886b..0000000 --- a/ui/src/components/userfolder/McpConnectionsPanel.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; - -interface ConnectionRow { - serverId: string; - serverName: string; - connected: boolean; - authKind: 'oauth' | 'api_key'; - ownerId: string | null; -} - -interface ConnectionListResponse { - connections: ConnectionRow[]; -} - -async function fetchConnections(): Promise { - const res = await fetch('/api/mcp/connections', { credentials: 'include' }); - if (!res.ok) throw new Error(`${res.status}`); - const data: ConnectionListResponse = await res.json(); - return data.connections ?? []; -} - -async function disconnectMcp(serverId: string): Promise { - const res = await fetch(`/api/mcp/connections/${encodeURIComponent(serverId)}`, { - method: 'DELETE', - credentials: 'include', - }); - if (!res.ok) throw new Error(`${res.status}`); -} - -function OwnerBadge({ ownerId }: { ownerId: string | null }) { - if (ownerId === null) { - return ( - - global - - ); - } - return ( - - personal - - ); -} - -export function McpConnectionsPanel() { - const qc = useQueryClient(); - const { data, isLoading, error } = useQuery({ - queryKey: ['mcp-connections'], - queryFn: fetchConnections, - staleTime: 30_000, - }); - const disconnect = useMutation({ - mutationFn: disconnectMcp, - onSuccess: () => qc.invalidateQueries({ queryKey: ['mcp-connections'] }), - }); - - return ( -
-
-
-

MCP 接続

-

- 外部 MCP サーバーとの連携を管理します。OAuth サーバーは「連携する」を押すと - 外部サービスの認可ページに飛び、戻ってくると自動で連携が確立します。 - API key サーバーはサーバー登録と同時に接続済みになります。 -

-
- {isLoading &&
Loading…
} - {error &&
読み込みに失敗しました: {String(error)}
} - {!isLoading && !error && (data?.length ?? 0) === 0 && ( -
- 利用可能な MCP サーバーがありません。「mcp-servers/」でサーバーを登録してください。 -
- )} -
    - {(data ?? []).map((c) => ( -
  • -
    -
    - {c.serverName} - -
    -
    {c.serverId}
    -
    -
    - {c.authKind === 'oauth' ? ( - c.connected ? ( -
    - 連携済み - -
    - ) : ( - - 連携する - - ) - ) : ( - /* api_key */ - c.connected ? ( -
    - API key 接続済み - {c.ownerId !== null && ( - 削除は mcp-servers タブから - )} -
    - ) : ( - API key が未設定です - ) - )} -
    -
  • - ))} -
-
-
- ); -} diff --git a/ui/src/components/userfolder/McpPanel.tsx b/ui/src/components/userfolder/McpPanel.tsx index 9eb75c5..948dd8f 100644 --- a/ui/src/components/userfolder/McpPanel.tsx +++ b/ui/src/components/userfolder/McpPanel.tsx @@ -10,6 +10,7 @@ interface ServerPublic { ownerId: string | null; oauthClientId: string | null; oauthScopes: string | null; + authHeaderName: string | null; enabled: boolean; createdAt: string; updatedAt: string; @@ -33,6 +34,7 @@ interface ServerFormBody { oauthClientSecret?: string; oauthScopes?: string; staticToken?: string; + authHeaderName?: string; enabled?: boolean; } @@ -107,7 +109,7 @@ const INPUT_CLS = 'w-full border border-hairline rounded px-2 py-1 text-[13px]'; const emptyForm = (): ServerFormBody => ({ id: '', name: '', url: '', authKind: 'oauth', - oauthClientId: '', oauthClientSecret: '', oauthScopes: '', staticToken: '', + oauthClientId: '', oauthClientSecret: '', oauthScopes: '', staticToken: '', authHeaderName: '', }); function ServerForm({ @@ -204,12 +206,23 @@ function ServerForm({ )} {form.authKind === 'api_key' && ( - - setForm({ ...form, staticToken: e.target.value })} - placeholder="sk-..." required={!isEdit} - /> - + <> + + setForm({ ...form, staticToken: e.target.value })} + placeholder="sk-..." required={!isEdit} + /> + + + setForm({ ...form, authHeaderName: e.target.value })} + placeholder="xc-mcp-token" + /> + + 指定すると、トークンをこのヘッダ名でそのまま送ります(Bearer 前置なし)。例: NocoDB は xc-mcp-token。 + + + )} {formError &&
{formError}
} @@ -324,7 +337,7 @@ export function McpPanel({ showToast }: { showToast?: ShowToast }) { { await saveMut.mutateAsync({ body, isGlobal }); }} onCancel={() => setEditingId(null)} diff --git a/ui/src/components/userfolder/McpServersPanel.tsx b/ui/src/components/userfolder/McpServersPanel.tsx deleted file mode 100644 index a6076bc..0000000 --- a/ui/src/components/userfolder/McpServersPanel.tsx +++ /dev/null @@ -1,534 +0,0 @@ -import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useAuthState } from '../../App'; - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface ServerPublic { - id: string; - name: string; - url: string; - authKind: 'oauth' | 'api_key'; - ownerId: string | null; - oauthClientId: string | null; - oauthScopes: string | null; - enabled: boolean; - createdAt: string; - updatedAt: string; - authorizationEndpoint: string | null; - toolCount?: number; -} - -interface ServerListResponse { - servers: ServerPublic[]; -} - -interface UserServerListResponse { - servers: ServerPublic[]; -} - -// ── API helpers ─────────────────────────────────────────────────────────────── - -async function fetchAdminServers(): Promise { - const res = await fetch('/api/mcp/servers', { credentials: 'include' }); - if (!res.ok) throw new Error(`${res.status}`); - const data: ServerListResponse = await res.json(); - return data.servers ?? []; -} - -async function fetchUserServers(): Promise { - const res = await fetch('/api/mcp/user-servers', { credentials: 'include' }); - if (!res.ok) throw new Error(`${res.status}`); - const data: UserServerListResponse = await res.json(); - return data.servers ?? []; -} - -async function createGlobalServer(body: ServerFormBody): Promise { - const res = await fetch('/api/mcp/servers', { - method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`${res.status} ${text}`); - } -} - -async function createUserServer(body: ServerFormBody): Promise { - const res = await fetch('/api/mcp/user-servers', { - method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`${res.status} ${text}`); - } -} - -async function deleteServer(id: string, isGlobal: boolean): Promise { - const url = isGlobal - ? `/api/mcp/servers/${encodeURIComponent(id)}` - : `/api/mcp/user-servers/${encodeURIComponent(id)}`; - const res = await fetch(url, { method: 'DELETE', credentials: 'include' }); - if (!res.ok) throw new Error(`${res.status}`); -} - -async function refreshTools(id: string, isGlobal: boolean): Promise { - const url = isGlobal - ? `/api/mcp/servers/${encodeURIComponent(id)}/tools/refresh` - : `/api/mcp/user-servers/${encodeURIComponent(id)}/tools/refresh`; - const res = await fetch(url, { method: 'POST', credentials: 'include' }); - if (!res.ok) throw new Error(`${res.status}`); -} - -// ── Form types & helpers ────────────────────────────────────────────────────── - -interface ServerFormBody { - id: string; - name: string; - url: string; - authKind: 'oauth' | 'api_key'; - oauthClientId?: string; - oauthClientSecret?: string; - oauthScopes?: string; - staticToken?: string; - authHeaderName?: string; - enabled?: boolean; -} - -const emptyForm = (): ServerFormBody => ({ - id: '', - name: '', - url: '', - authKind: 'oauth', - oauthClientId: '', - oauthClientSecret: '', - oauthScopes: '', - staticToken: '', - authHeaderName: '', -}); - -function FormField({ label, children }: { label: string; children: React.ReactNode }) { - return ( - - ); -} - -// ── AddServerForm ───────────────────────────────────────────────────────────── - -interface AddServerFormProps { - sectionLabel: string; - onSubmit: (body: ServerFormBody) => Promise; - isPending: boolean; -} - -function AddServerForm({ sectionLabel, onSubmit, isPending }: AddServerFormProps) { - const [form, setForm] = useState(emptyForm()); - const [formError, setFormError] = useState(null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setFormError(null); - try { - await onSubmit(form); - setForm(emptyForm()); - } catch (err) { - setFormError(err instanceof Error ? err.message : String(err)); - } - }; - - return ( -
- - setForm({ ...form, id: e.target.value })} - placeholder="canva" - pattern="[a-z0-9_-]{1,64}" - required - /> - - - setForm({ ...form, name: e.target.value })} - placeholder="Canva" - required - /> - - - setForm({ ...form, url: e.target.value })} - placeholder="https://example.com/mcp" - type="url" - required - /> - - - {/* Auth kind radio */} -
- 認証方式 -
- - -
-
- - {/* OAuth-only fields */} - {form.authKind === 'oauth' && ( - <> - - setForm({ ...form, oauthClientId: e.target.value })} - required - /> - - - setForm({ ...form, oauthClientSecret: e.target.value })} - required - /> - - - setForm({ ...form, oauthScopes: e.target.value })} - placeholder="read write" - /> - - - )} - - {/* API key fields */} - {form.authKind === 'api_key' && ( - <> - - setForm({ ...form, staticToken: e.target.value })} - placeholder="sk-..." - required - /> - - - setForm({ ...form, authHeaderName: e.target.value })} - placeholder="xc-mcp-token" - /> - - 指定すると、トークンをこのヘッダ名でそのまま送ります(Bearer 前置なし)。例: NocoDB は{' '} - xc-mcp-token。 - - - - )} - - {formError && ( -
{formError}
- )} - -
- ); -} - -// ── ServerTable ─────────────────────────────────────────────────────────────── - -interface ServerTableProps { - servers: ServerPublic[]; - isGlobal: boolean; - canDelete: boolean; - onRefresh: (id: string, isGlobal: boolean) => void; - onDelete: (id: string, name: string, isGlobal: boolean) => void; - refreshPending: boolean; - deletePending: boolean; -} - -function ServerTable({ - servers, - isGlobal, - canDelete, - onRefresh, - onDelete, - refreshPending, - deletePending, -}: ServerTableProps) { - if (servers.length === 0) { - return ( -
- {isGlobal ? '登録された global サーバーがありません。' : 'あなたのサーバーがありません。'} -
- ); - } - - return ( - - - - - - - - - - - - - - {servers.map((s) => ( - - - - - - - - - - ))} - -
ID名前URL認証有効ツール数操作
{s.id}{s.name}{s.url} - - {s.authKind === 'oauth' ? 'OAuth' : 'API key'} - - {s.enabled ? '✓' : '—'} - {s.toolCount == null || s.toolCount === 0 ? ( - 未取得 — ツール更新を押してください - ) : ( - - {s.toolCount} ツール - - )} - - - {canDelete && ( - - )} -
- ); -} - -// ── McpServersPanel ─────────────────────────────────────────────────────────── - -type ShowToast = (message: string, variant?: 'success' | 'error') => void; - -interface McpServersPanelProps { - showToast?: ShowToast; -} - -export function McpServersPanel({ showToast }: McpServersPanelProps = {}) { - const qc = useQueryClient(); - const auth = useAuthState(); - const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true; - - // Admin fetches global servers from /api/mcp/servers - const { data: globalServers, isLoading: globalLoading, error: globalError } = useQuery({ - queryKey: ['mcp-servers-admin'], - queryFn: fetchAdminServers, - staleTime: 30_000, - enabled: isAdmin, - }); - - // All users fetch their own servers - const { data: userServers, isLoading: userLoading, error: userError } = useQuery({ - queryKey: ['mcp-user-servers'], - queryFn: fetchUserServers, - staleTime: 30_000, - }); - - const createGlobal = useMutation({ - mutationFn: createGlobalServer, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] }); - qc.invalidateQueries({ queryKey: ['mcp-connections'] }); - }, - }); - - const createUser = useMutation({ - mutationFn: createUserServer, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['mcp-user-servers'] }); - qc.invalidateQueries({ queryKey: ['mcp-connections'] }); - }, - }); - - const del = useMutation({ - mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) => - deleteServer(id, isGlobal), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] }); - qc.invalidateQueries({ queryKey: ['mcp-user-servers'] }); - qc.invalidateQueries({ queryKey: ['mcp-connections'] }); - }, - }); - - const refresh = useMutation({ - mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) => - refreshTools(id, isGlobal), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] }); - qc.invalidateQueries({ queryKey: ['mcp-user-servers'] }); - }, - onError: (err) => { - const msg = 'ツール更新に失敗: ' + (err instanceof Error ? err.message : String(err)); - if (showToast) showToast(msg, 'error'); - else console.error(msg); - }, - }); - - const handleDelete = (id: string, name: string, isGlobal: boolean) => { - const msg = isGlobal - ? `${id} を削除しますか? 全ユーザーのトークンも失効します。` - : `${name} を削除しますか?`; - if (window.confirm(msg)) { - del.mutate({ id, isGlobal }); - } - }; - - const handleRefresh = (id: string, isGlobal: boolean) => { - refresh.mutate({ id, isGlobal }); - }; - - return ( -
-
-
-

MCP サーバー管理

-

- MCP サーバーを登録すると、エージェントがそのサーバーのツールを利用できるようになります。 - {isAdmin - ? ' 管理者は全ユーザー共有の global サーバーと自分専用の personal サーバーを登録できます。' - : ' あなた専用の personal サーバーを登録できます。'} - OAuth credentials と API key は AES-256-GCM で暗号化されて保存されます。 -

-
- - {/* ── Global Servers (admin only) ──────────────────────────────── */} - {isAdmin && ( -
-
-

Global Servers (admin 管理)

- - 全ユーザー共有 - -
- {globalLoading &&
読み込み中…
} - {globalError && ( -
読み込みに失敗しました: {String(globalError)}
- )} - {!globalLoading && !globalError && ( - - )} -
-

Global サーバーを追加

- -
-
- )} - - {/* ── Your Servers (all users) ──────────────────────────────────── */} -
-
-

個人サーバー

- - personal - -
- {userLoading &&
読み込み中…
} - {userError && ( -
読み込みに失敗しました: {String(userError)}
- )} - {!userLoading && !userError && ( - - )} -
-

Personal サーバーを追加

- -
-
-
-
- ); -}