oss-sync caa0d03900
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (fd190dc)
2026-06-06 00:39:26 +00:00

462 lines
19 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 dark:bg-blue-500/15 text-blue-600 dark:text-blue-300 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 dark:bg-purple-500/15 text-purple-600 dark:text-purple-300' : 'bg-amber-50 dark:bg-amber-500/15 text-amber-600 dark:text-amber-300'
}`}>{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 dark:bg-green-500/15 text-green-700 dark:text-green-300 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 dark:hover:text-red-300 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>
);
}