462 lines
18 KiB
TypeScript
462 lines
18 KiB
TypeScript
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<ServerPublic[]> {
|
|
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<ServerPublic[]> {
|
|
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<ConnectionRow[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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 (
|
|
<label className="block">
|
|
<span className="block text-2xs text-slate-600 mb-1">{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
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<void>;
|
|
onCancel?: () => void;
|
|
isPending: boolean;
|
|
}) {
|
|
const [form, setForm] = useState<ServerFormBody>(initial);
|
|
const [formError, setFormError] = useState<string | null>(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 (
|
|
<form className="space-y-3 max-w-xl" onSubmit={handleSubmit}>
|
|
<FormField label="ID (slug, 例: canva)">
|
|
<input className={INPUT_CLS} value={form.id}
|
|
onChange={(e) => setForm({ ...form, id: e.target.value })}
|
|
placeholder="canva" pattern="[a-z0-9_-]{1,64}" required disabled={isEdit}
|
|
/>
|
|
</FormField>
|
|
<FormField label="表示名">
|
|
<input className={INPUT_CLS} value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
placeholder="Canva" required
|
|
/>
|
|
</FormField>
|
|
<FormField label="MCP URL (https://...)">
|
|
<input className={INPUT_CLS} value={form.url}
|
|
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
|
placeholder="https://example.com/mcp" type="url" required
|
|
/>
|
|
</FormField>
|
|
|
|
<fieldset>
|
|
<legend className="block text-2xs text-slate-600 mb-1">認証方式</legend>
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
|
|
<input type="radio" name={`authKind-${sectionLabel}`} value="oauth"
|
|
checked={form.authKind === 'oauth'}
|
|
onChange={() => setForm({ ...form, authKind: 'oauth' })}
|
|
disabled={isEdit}
|
|
/> OAuth
|
|
</label>
|
|
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
|
|
<input type="radio" name={`authKind-${sectionLabel}`} value="api_key"
|
|
checked={form.authKind === 'api_key'}
|
|
onChange={() => setForm({ ...form, authKind: 'api_key' })}
|
|
disabled={isEdit}
|
|
/> API key
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{form.authKind === 'oauth' && (
|
|
<>
|
|
<FormField label="OAuth client_id">
|
|
<input className={INPUT_CLS} value={form.oauthClientId ?? ''}
|
|
onChange={(e) => setForm({ ...form, oauthClientId: e.target.value })}
|
|
required={!isEdit}
|
|
/>
|
|
</FormField>
|
|
<FormField label={isEdit ? 'OAuth client_secret (空欄なら変更なし)' : 'OAuth client_secret'}>
|
|
<input className={INPUT_CLS} type="password" value={form.oauthClientSecret ?? ''}
|
|
onChange={(e) => setForm({ ...form, oauthClientSecret: e.target.value })}
|
|
required={!isEdit}
|
|
/>
|
|
</FormField>
|
|
<FormField label="scopes (space-separated, 任意)">
|
|
<input className={INPUT_CLS} value={form.oauthScopes ?? ''}
|
|
onChange={(e) => setForm({ ...form, oauthScopes: e.target.value })}
|
|
placeholder="read write"
|
|
/>
|
|
</FormField>
|
|
</>
|
|
)}
|
|
|
|
{form.authKind === 'api_key' && (
|
|
<FormField label={isEdit ? 'API key (空欄なら変更なし)' : 'API key / Bearer token'}>
|
|
<input className={INPUT_CLS} type="password" value={form.staticToken ?? ''}
|
|
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
|
|
placeholder="sk-..." required={!isEdit}
|
|
/>
|
|
</FormField>
|
|
)}
|
|
|
|
{formError && <div className="text-xs text-red-600">{formError}</div>}
|
|
<div className="flex gap-2">
|
|
<button type="submit" disabled={isPending}
|
|
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50">
|
|
{isPending ? '保存中…' : isEdit ? '更新' : '追加'}
|
|
</button>
|
|
{onCancel && (
|
|
<button type="button" onClick={onCancel}
|
|
className="px-4 py-1.5 rounded-md text-xs text-slate-700 border border-hairline hover:bg-surface transition-colors">
|
|
キャンセル
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function ScopeBadge({ ownerId }: { ownerId: string | null }) {
|
|
return ownerId === null ? (
|
|
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">global</span>
|
|
) : (
|
|
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 text-blue-600 leading-none">personal</span>
|
|
);
|
|
}
|
|
|
|
function ConnectionBadge({ connection, serverId }: { connection?: ConnectionRow; serverId: string }) {
|
|
if (!connection) return <span className="text-2xs text-slate-400">—</span>;
|
|
if (connection.authKind === 'api_key') {
|
|
return connection.connected
|
|
? <span className="text-2xs text-emerald-600 font-medium">API key 接続済み</span>
|
|
: <span className="text-2xs text-amber-600">API key 未設定</span>;
|
|
}
|
|
if (connection.connected) {
|
|
return <span className="text-2xs text-emerald-600 font-medium">OAuth 連携済み</span>;
|
|
}
|
|
return (
|
|
<a className="px-2 py-0.5 rounded text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
|
|
href={`/auth/mcp/${encodeURIComponent(serverId)}/start`}>
|
|
連携する
|
|
</a>
|
|
);
|
|
}
|
|
|
|
// ── 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<string | null>(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 (
|
|
<div key={s.id} className="p-3 bg-surface/50 border border-hairline rounded-md">
|
|
<ServerForm
|
|
initial={{ id: s.id, name: s.name, url: s.url, authKind: s.authKind,
|
|
oauthClientId: s.oauthClientId ?? '', oauthClientSecret: '', oauthScopes: s.oauthScopes ?? '',
|
|
staticToken: '', enabled: s.enabled }}
|
|
isEdit sectionLabel={`edit-${s.id}`}
|
|
onSubmit={async (body) => { await saveMut.mutateAsync({ body, isGlobal }); }}
|
|
onCancel={() => setEditingId(null)}
|
|
isPending={saveMut.isPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={s.id} className="flex items-center gap-3 py-2.5 border-b border-hairline last:border-b-0">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[13px] font-medium text-slate-900">{s.name}</span>
|
|
<ScopeBadge ownerId={s.ownerId} />
|
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${
|
|
s.authKind === 'oauth' ? 'bg-purple-50 text-purple-600' : 'bg-amber-50 text-amber-600'
|
|
}`}>{s.authKind === 'oauth' ? 'OAuth' : 'API key'}</span>
|
|
{s.toolCount != null && s.toolCount > 0 && (
|
|
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-50 text-green-700 leading-none">
|
|
{s.toolCount} ツール
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-2xs text-slate-500 font-mono truncate mt-0.5">{s.url}</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<ConnectionBadge connection={conn} serverId={s.id} />
|
|
|
|
{conn?.connected && conn.authKind === 'oauth' && (
|
|
<button type="button" className="text-2xs text-slate-500 hover:text-slate-700 underline"
|
|
onClick={() => { if (window.confirm(`${s.name} の連携を解除しますか?`)) disconnectMut.mutate(s.id); }}
|
|
disabled={disconnectMut.isPending}>
|
|
解除
|
|
</button>
|
|
)}
|
|
|
|
<button type="button" className="text-2xs text-slate-600 hover:text-slate-800 underline"
|
|
onClick={() => refreshMut.mutate({ id: s.id, isGlobal })}
|
|
disabled={refreshMut.isPending}>
|
|
ツール更新
|
|
</button>
|
|
<button type="button" className="text-2xs text-slate-600 hover:text-slate-800 underline"
|
|
onClick={() => setEditingId(s.id)}>
|
|
編集
|
|
</button>
|
|
<button type="button" className="text-2xs text-red-600 hover:text-red-800 underline"
|
|
onClick={() => handleDelete(s.id, s.name, isGlobal)}
|
|
disabled={delMut.isPending}>
|
|
削除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const isLoading = globalLoading || userLoading;
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto">
|
|
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-slate-900 mb-1">MCP サーバー</h2>
|
|
<p className="text-[13px] text-slate-500 leading-relaxed">
|
|
MCP サーバーの登録・接続管理・設定変更をまとめて行えます。
|
|
OAuth credentials と API key は AES-256-GCM で暗号化されて保存されます。
|
|
</p>
|
|
</div>
|
|
|
|
{isLoading && <div className="text-[13px] text-slate-400">読み込み中…</div>}
|
|
|
|
{/* Global Servers (admin) */}
|
|
{isAdmin && (globalServers ?? []).length > 0 && (
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<h3 className="text-[13px] font-semibold text-slate-900">Global サーバー</h3>
|
|
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">全ユーザー共有</span>
|
|
</div>
|
|
<div>{(globalServers ?? []).map(s => renderServerRow(s, true))}</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Personal Servers */}
|
|
{(userServers ?? []).length > 0 && (
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<h3 className="text-[13px] font-semibold text-slate-900">個人サーバー</h3>
|
|
</div>
|
|
<div>{(userServers ?? []).map(s => renderServerRow(s, false))}</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!isLoading && (globalServers ?? []).length === 0 && (userServers ?? []).length === 0 && (
|
|
<div className="text-[13px] text-slate-400 text-center py-8">
|
|
MCP サーバーがまだ登録されていません。下のボタンから追加してください。
|
|
</div>
|
|
)}
|
|
|
|
{/* Add buttons / forms */}
|
|
<div className="space-y-4">
|
|
{addingSection ? (
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-slate-700 mb-2">
|
|
{addingSection === 'global' ? 'Global サーバーを追加' : 'Personal サーバーを追加'}
|
|
</h4>
|
|
<ServerForm
|
|
initial={emptyForm()} isEdit={false}
|
|
sectionLabel={addingSection}
|
|
onSubmit={async (body) => { await saveMut.mutateAsync({ body, isGlobal: addingSection === 'global' }); }}
|
|
onCancel={() => setAddingSection(null)}
|
|
isPending={saveMut.isPending}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => setAddingSection('personal')}
|
|
className="px-3 py-1.5 rounded-md text-xs font-semibold text-accent border border-accent/30 hover:bg-accent-soft transition-colors">
|
|
+ Personal サーバーを追加
|
|
</button>
|
|
{isAdmin && (
|
|
<button type="button" onClick={() => setAddingSection('global')}
|
|
className="px-3 py-1.5 rounded-md text-xs font-semibold text-slate-600 border border-hairline hover:bg-surface transition-colors">
|
|
+ Global サーバーを追加
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|