import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuthState } from '../../App'; 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; toolCount?: number; } interface ConnectionRow { serverId: string; serverName: string; connected: boolean; authKind: 'oauth' | 'api_key'; ownerId: string | null; } interface ServerFormBody { id: string; name: string; url: string; authKind: 'oauth' | 'api_key'; oauthClientId?: string; oauthClientSecret?: string; oauthScopes?: string; staticToken?: string; enabled?: boolean; } // ── API helpers ─────────────────────────────────────────────────────────── async function fetchAdminServers(): Promise { const res = await fetch('/api/mcp/servers', { credentials: 'include' }); if (!res.ok) throw new Error(`${res.status}`); return ((await res.json()) as { servers: ServerPublic[] }).servers ?? []; } async function fetchUserServers(): Promise { const res = await fetch('/api/mcp/user-servers', { credentials: 'include' }); if (!res.ok) throw new Error(`${res.status}`); return ((await res.json()) as { servers: ServerPublic[] }).servers ?? []; } async function fetchConnections(): Promise { const res = await fetch('/api/mcp/connections', { credentials: 'include' }); if (!res.ok) throw new Error(`${res.status}`); return ((await res.json()) as { connections: ConnectionRow[] }).connections ?? []; } async function upsertServer(body: ServerFormBody, isGlobal: boolean): Promise { const url = isGlobal ? '/api/mcp/servers' : '/api/mcp/user-servers'; const res = await fetch(url, { 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}`); } 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}`); } // ── Sub-components ────────────────────────────────────────────────────── function FormField({ label, children }: { label: string; children: React.ReactNode }) { return ( ); } 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: '', }); function ServerForm({ initial, isEdit, sectionLabel, onSubmit, onCancel, isPending, }: { initial: ServerFormBody; isEdit: boolean; sectionLabel: string; onSubmit: (body: ServerFormBody) => Promise; onCancel?: () => void; isPending: boolean; }) { const [form, setForm] = useState(initial); const [formError, setFormError] = useState(null); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setFormError(null); try { await onSubmit(form); if (!isEdit) 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 disabled={isEdit} /> setForm({ ...form, name: e.target.value })} placeholder="Canva" required /> setForm({ ...form, url: e.target.value })} placeholder="https://example.com/mcp" type="url" required />
認証方式
{form.authKind === 'oauth' && ( <> setForm({ ...form, oauthClientId: e.target.value })} required={!isEdit} /> setForm({ ...form, oauthClientSecret: e.target.value })} required={!isEdit} /> setForm({ ...form, oauthScopes: e.target.value })} placeholder="read write" /> )} {form.authKind === 'api_key' && ( setForm({ ...form, staticToken: e.target.value })} placeholder="sk-..." required={!isEdit} /> )} {formError &&
{formError}
}
{onCancel && ( )}
); } function ScopeBadge({ ownerId }: { ownerId: string | null }) { return ownerId === null ? ( global ) : ( personal ); } function ConnectionBadge({ connection, serverId }: { connection?: ConnectionRow; serverId: string }) { if (!connection) return ; if (connection.authKind === 'api_key') { return connection.connected ? API key 接続済み : API key 未設定; } if (connection.connected) { return OAuth 連携済み; } return ( 連携する ); } // ── Main component ────────────────────────────────────────────────────── type ShowToast = (message: string, variant?: 'success' | 'error') => void; export function McpPanel({ showToast }: { showToast?: ShowToast }) { const qc = useQueryClient(); const auth = useAuthState(); const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true; const [editingId, setEditingId] = useState(null); const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null); const invalidateAll = () => { qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] }); qc.invalidateQueries({ queryKey: ['mcp-user-servers'] }); qc.invalidateQueries({ queryKey: ['mcp-connections'] }); }; const { data: globalServers, isLoading: globalLoading } = useQuery({ queryKey: ['mcp-servers-admin'], queryFn: fetchAdminServers, staleTime: 30_000, enabled: isAdmin, }); const { data: userServers, isLoading: userLoading } = useQuery({ queryKey: ['mcp-user-servers'], queryFn: fetchUserServers, staleTime: 30_000, }); const { data: connections } = useQuery({ queryKey: ['mcp-connections'], queryFn: fetchConnections, staleTime: 30_000, }); const connMap = new Map((connections ?? []).map(c => [c.serverId, c])); const saveMut = useMutation({ mutationFn: ({ body, isGlobal }: { body: ServerFormBody; isGlobal: boolean }) => upsertServer(body, isGlobal), onSuccess: () => { invalidateAll(); setEditingId(null); setAddingSection(null); }, onError: (err) => showToast?.('保存に失敗: ' + (err instanceof Error ? err.message : String(err)), 'error'), }); const delMut = useMutation({ mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) => deleteServer(id, isGlobal), onSuccess: invalidateAll, }); const refreshMut = useMutation({ mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) => refreshTools(id, isGlobal), onSuccess: invalidateAll, onError: (err) => showToast?.('ツール更新に失敗: ' + (err instanceof Error ? err.message : String(err)), 'error'), }); const disconnectMut = useMutation({ mutationFn: disconnectMcp, onSuccess: invalidateAll, }); const handleDelete = (id: string, name: string, isGlobal: boolean) => { const msg = isGlobal ? `${id} を削除しますか? 全ユーザーのトークンも失効します。` : `${name} を削除しますか?`; if (window.confirm(msg)) delMut.mutate({ id, isGlobal }); }; const renderServerRow = (s: ServerPublic, isGlobal: boolean) => { const conn = connMap.get(s.id); const isEditing = editingId === s.id; if (isEditing) { return (
{ await saveMut.mutateAsync({ body, isGlobal }); }} onCancel={() => setEditingId(null)} isPending={saveMut.isPending} />
); } return (
{s.name} {s.authKind === 'oauth' ? 'OAuth' : 'API key'} {s.toolCount != null && s.toolCount > 0 && ( {s.toolCount} ツール )}
{s.url}
{conn?.connected && conn.authKind === 'oauth' && ( )}
); }; const isLoading = globalLoading || userLoading; return (

MCP サーバー

MCP サーバーの登録・接続管理・設定変更をまとめて行えます。 OAuth credentials と API key は AES-256-GCM で暗号化されて保存されます。

{isLoading &&
読み込み中…
} {/* Global Servers (admin) */} {isAdmin && (globalServers ?? []).length > 0 && (

Global サーバー

全ユーザー共有
{(globalServers ?? []).map(s => renderServerRow(s, true))}
)} {/* Personal Servers */} {(userServers ?? []).length > 0 && (

個人サーバー

{(userServers ?? []).map(s => renderServerRow(s, false))}
)} {/* Empty state */} {!isLoading && (globalServers ?? []).length === 0 && (userServers ?? []).length === 0 && (
MCP サーバーがまだ登録されていません。下のボタンから追加してください。
)} {/* Add buttons / forms */}
{addingSection ? (

{addingSection === 'global' ? 'Global サーバーを追加' : 'Personal サーバーを追加'}

{ await saveMut.mutateAsync({ body, isGlobal: addingSection === 'global' }); }} onCancel={() => setAddingSection(null)} isPending={saveMut.isPending} />
) : (
{isAdmin && ( )}
)}
); }