feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user