sync: update from private repo (f811f38)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-09 14:21:52 +00:00
parent 4601e8d5c3
commit b3747add6b
5 changed files with 88 additions and 690 deletions

View File

@ -310,6 +310,41 @@ describe('mcp-api', () => {
expect(post.status).toBe(409); 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 () => { it('DELETE /api/mcp/connections returns 400 for api_key global server', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' }); const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
reg.upsert({ reg.upsert({

View File

@ -56,15 +56,22 @@ export function createAdminRouter(deps: McpApiDeps): Router {
return; 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 (authKind === 'oauth') {
if (!body.oauthClientId || !body.oauthClientSecret) { if (!oauthClientId || !oauthClientSecret) {
res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' }); res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' });
return; return;
} }
} else if (authKind === 'api_key') { } else if (authKind === 'api_key') {
if (!body.staticToken) { if (!staticToken) {
res.status(400).json({ error: 'authKind api_key requires staticToken' }); res.status(400).json({ error: 'authKind api_key requires staticToken' });
return; return;
} }
@ -80,10 +87,10 @@ export function createAdminRouter(deps: McpApiDeps): Router {
url: body.url, url: body.url,
authKind, authKind,
ownerId: null, ownerId: null,
oauthClientId: body.oauthClientId, oauthClientId,
oauthClientSecret: body.oauthClientSecret, oauthClientSecret,
oauthScopes: body.oauthScopes ?? null, oauthScopes: body.oauthScopes ?? null,
staticToken: body.staticToken, staticToken,
authHeaderName: body.authHeaderName ?? null, authHeaderName: body.authHeaderName ?? null,
enabled: body.enabled !== false, enabled: body.enabled !== false,
createdBy: adminId, createdBy: adminId,
@ -278,15 +285,28 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
return; 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 (authKind === 'api_key') {
if (!body.staticToken) { if (!staticToken) {
res.status(400).json({ error: 'authKind api_key requires staticToken' }); res.status(400).json({ error: 'authKind api_key requires staticToken' });
return; return;
} }
} else if (authKind === 'oauth') { } else if (authKind === 'oauth') {
if (!body.oauthClientId || !body.oauthClientSecret) { if (!oauthClientId || !oauthClientSecret) {
res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' }); res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' });
return; return;
} }
@ -295,23 +315,16 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
return; 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({ deps.registry.upsert({
id: body.id, id: body.id,
name: body.name, name: body.name,
url: body.url, url: body.url,
authKind, authKind,
ownerId: userId, ownerId: userId,
oauthClientId: body.oauthClientId, oauthClientId,
oauthClientSecret: body.oauthClientSecret, oauthClientSecret,
oauthScopes: body.oauthScopes ?? null, oauthScopes: body.oauthScopes ?? null,
staticToken: body.staticToken, staticToken,
authHeaderName: body.authHeaderName ?? null, authHeaderName: body.authHeaderName ?? null,
enabled: body.enabled !== false, enabled: body.enabled !== false,
createdBy: userId, createdBy: userId,

View File

@ -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<ConnectionRow[]> {
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<void> {
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 (
<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>
);
}
return (
<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>
);
}
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 (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-900 mb-1">MCP </h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
MCP OAuth
API key
</p>
</div>
{isLoading && <div className="text-[13px] text-slate-400">Loading</div>}
{error && <div className="text-[13px] text-red-500">: {String(error)}</div>}
{!isLoading && !error && (data?.length ?? 0) === 0 && (
<div className="text-[13px] text-slate-400">
MCP mcp-servers/
</div>
)}
<ul className="divide-y divide-hairline">
{(data ?? []).map((c) => (
<li key={c.serverId} className="py-3 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[13px] font-medium text-slate-900 truncate">{c.serverName}</span>
<OwnerBadge ownerId={c.ownerId} />
</div>
<div className="text-2xs text-slate-500 font-mono truncate">{c.serverId}</div>
</div>
<div className="shrink-0">
{c.authKind === 'oauth' ? (
c.connected ? (
<div className="flex items-center gap-3">
<span className="text-xs text-emerald-600 font-medium"></span>
<button
type="button"
className="text-xs text-slate-500 hover:text-slate-700 underline"
onClick={() => {
if (window.confirm(`${c.serverName} の連携を解除しますか?`)) {
disconnect.mutate(c.serverId);
}
}}
disabled={disconnect.isPending}
></button>
</div>
) : (
<a
className="px-3 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
href={`/auth/mcp/${encodeURIComponent(c.serverId)}/start`}
>
</a>
)
) : (
/* api_key */
c.connected ? (
<div className="flex items-center gap-1.5">
<span className="text-xs text-emerald-600 font-medium">API key </span>
{c.ownerId !== null && (
<span className="text-2xs text-slate-400"> mcp-servers </span>
)}
</div>
) : (
<span className="text-xs text-amber-600">API key </span>
)
)}
</div>
</li>
))}
</ul>
</div>
</div>
);
}

View File

@ -10,6 +10,7 @@ interface ServerPublic {
ownerId: string | null; ownerId: string | null;
oauthClientId: string | null; oauthClientId: string | null;
oauthScopes: string | null; oauthScopes: string | null;
authHeaderName: string | null;
enabled: boolean; enabled: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@ -33,6 +34,7 @@ interface ServerFormBody {
oauthClientSecret?: string; oauthClientSecret?: string;
oauthScopes?: string; oauthScopes?: string;
staticToken?: string; staticToken?: string;
authHeaderName?: string;
enabled?: boolean; enabled?: boolean;
} }
@ -107,7 +109,7 @@ const INPUT_CLS = 'w-full border border-hairline rounded px-2 py-1 text-[13px]';
const emptyForm = (): ServerFormBody => ({ const emptyForm = (): ServerFormBody => ({
id: '', name: '', url: '', authKind: 'oauth', id: '', name: '', url: '', authKind: 'oauth',
oauthClientId: '', oauthClientSecret: '', oauthScopes: '', staticToken: '', oauthClientId: '', oauthClientSecret: '', oauthScopes: '', staticToken: '', authHeaderName: '',
}); });
function ServerForm({ function ServerForm({
@ -204,12 +206,23 @@ function ServerForm({
)} )}
{form.authKind === 'api_key' && ( {form.authKind === 'api_key' && (
<FormField label={isEdit ? 'API key (空欄なら変更なし)' : 'API key / Bearer token'}> <>
<input className={INPUT_CLS} type="password" value={form.staticToken ?? ''} <FormField label={isEdit ? 'API key (空欄なら変更なし)' : 'API key / トークン'}>
onChange={(e) => setForm({ ...form, staticToken: e.target.value })} <input className={INPUT_CLS} type="password" value={form.staticToken ?? ''}
placeholder="sk-..." required={!isEdit} onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
/> placeholder="sk-..." required={!isEdit}
</FormField> />
</FormField>
<FormField label="認証ヘッダ名 (任意 — 空なら Authorization: Bearer)">
<input className={`${INPUT_CLS} font-mono`} value={form.authHeaderName ?? ''}
onChange={(e) => setForm({ ...form, authHeaderName: e.target.value })}
placeholder="xc-mcp-token"
/>
<span className="block text-[11px] text-slate-400 mt-1">
Bearer : NocoDB <code className="font-mono">xc-mcp-token</code>
</span>
</FormField>
</>
)} )}
{formError && <div className="text-xs text-red-600">{formError}</div>} {formError && <div className="text-xs text-red-600">{formError}</div>}
@ -324,7 +337,7 @@ export function McpPanel({ showToast }: { showToast?: ShowToast }) {
<ServerForm <ServerForm
initial={{ id: s.id, name: s.name, url: s.url, authKind: s.authKind, initial={{ id: s.id, name: s.name, url: s.url, authKind: s.authKind,
oauthClientId: s.oauthClientId ?? '', oauthClientSecret: '', oauthScopes: s.oauthScopes ?? '', oauthClientId: s.oauthClientId ?? '', oauthClientSecret: '', oauthScopes: s.oauthScopes ?? '',
staticToken: '', enabled: s.enabled }} staticToken: '', authHeaderName: s.authHeaderName ?? '', enabled: s.enabled }}
isEdit sectionLabel={`edit-${s.id}`} isEdit sectionLabel={`edit-${s.id}`}
onSubmit={async (body) => { await saveMut.mutateAsync({ body, isGlobal }); }} onSubmit={async (body) => { await saveMut.mutateAsync({ body, isGlobal }); }}
onCancel={() => setEditingId(null)} onCancel={() => setEditingId(null)}

View File

@ -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<ServerPublic[]> {
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<ServerPublic[]> {
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<void> {
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<void> {
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<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}`);
}
// ── 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 (
<label className="block">
<span className="block text-2xs text-slate-600 mb-1">{label}</span>
{children}
</label>
);
}
// ── AddServerForm ─────────────────────────────────────────────────────────────
interface AddServerFormProps {
sectionLabel: string;
onSubmit: (body: ServerFormBody) => Promise<void>;
isPending: boolean;
}
function AddServerForm({ sectionLabel, onSubmit, isPending }: AddServerFormProps) {
const [form, setForm] = useState<ServerFormBody>(emptyForm());
const [formError, setFormError] = useState<string | null>(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 (
<form className="space-y-3 max-w-xl" onSubmit={handleSubmit}>
<FormField label="ID (slug, 例: canva)">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.id}
onChange={(e) => setForm({ ...form, id: e.target.value })}
placeholder="canva"
pattern="[a-z0-9_-]{1,64}"
required
/>
</FormField>
<FormField label="表示名">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Canva"
required
/>
</FormField>
<FormField label="MCP URL (https://...)">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://example.com/mcp"
type="url"
required
/>
</FormField>
{/* Auth kind radio */}
<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' })}
/>
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' })}
/>
API key
</label>
</div>
</fieldset>
{/* OAuth-only fields */}
{form.authKind === 'oauth' && (
<>
<FormField label="OAuth client_id">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.oauthClientId ?? ''}
onChange={(e) => setForm({ ...form, oauthClientId: e.target.value })}
required
/>
</FormField>
<FormField label="OAuth client_secret">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
type="password"
value={form.oauthClientSecret ?? ''}
onChange={(e) => setForm({ ...form, oauthClientSecret: e.target.value })}
required
/>
</FormField>
<FormField label="scopes (space-separated, 任意)">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.oauthScopes ?? ''}
onChange={(e) => setForm({ ...form, oauthScopes: e.target.value })}
placeholder="read write"
/>
</FormField>
</>
)}
{/* API key fields */}
{form.authKind === 'api_key' && (
<>
<FormField label="API key / トークン">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
type="password"
value={form.staticToken ?? ''}
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
placeholder="sk-..."
required
/>
</FormField>
<FormField label="認証ヘッダ名 (任意 — 空なら Authorization: Bearer)">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px] font-mono"
value={form.authHeaderName ?? ''}
onChange={(e) => setForm({ ...form, authHeaderName: e.target.value })}
placeholder="xc-mcp-token"
/>
<span className="block text-[11px] text-slate-400 mt-1">
Bearer : NocoDB {' '}
<code className="font-mono">xc-mcp-token</code>
</span>
</FormField>
</>
)}
{formError && (
<div className="text-xs text-red-600">{formError}</div>
)}
<button
type="submit"
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"
disabled={isPending}
>
{isPending ? '保存中…' : '追加'}
</button>
</form>
);
}
// ── 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 (
<div className="text-[13px] text-slate-400">
{isGlobal ? '登録された global サーバーがありません。' : 'あなたのサーバーがありません。'}
</div>
);
}
return (
<table className="w-full text-[13px]">
<thead className="text-left text-2xs uppercase tracking-wide text-slate-500">
<tr className="border-b border-hairline">
<th className="py-2 pr-2">ID</th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2">URL</th>
<th className="py-2 pr-2 w-20"></th>
<th className="py-2 pr-2 w-16"></th>
<th className="py-2 pr-2 w-24"></th>
<th className="py-2 pr-2 w-40"></th>
</tr>
</thead>
<tbody className="divide-y divide-hairline">
{servers.map((s) => (
<tr key={s.id}>
<td className="py-2 pr-2 font-mono">{s.id}</td>
<td className="py-2 pr-2">{s.name}</td>
<td className="py-2 pr-2 font-mono text-2xs truncate max-w-xs" title={s.url}>{s.url}</td>
<td className="py-2 pr-2">
<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>
</td>
<td className="py-2 pr-2">{s.enabled ? '✓' : '—'}</td>
<td className="py-2 pr-2">
{s.toolCount == null || s.toolCount === 0 ? (
<span className="text-[10px] text-slate-400 italic"> </span>
) : (
<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>
)}
</td>
<td className="py-2 pr-2 space-x-2">
<button
type="button"
className="text-2xs text-slate-600 hover:text-slate-800 underline"
onClick={() => onRefresh(s.id, isGlobal)}
disabled={refreshPending}
></button>
{canDelete && (
<button
type="button"
className="text-2xs text-red-600 hover:text-red-800 dark:hover:text-red-300 underline"
onClick={() => onDelete(s.id, s.name, isGlobal)}
disabled={deletePending}
></button>
)}
</td>
</tr>
))}
</tbody>
</table>
);
}
// ── 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 (
<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
{isAdmin
? ' 管理者は全ユーザー共有の global サーバーと自分専用の personal サーバーを登録できます。'
: ' あなた専用の personal サーバーを登録できます。'}
OAuth credentials API key AES-256-GCM
</p>
</div>
{/* ── Global Servers (admin only) ──────────────────────────────── */}
{isAdmin && (
<section className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-semibold text-slate-900">Global Servers (admin )</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>
{globalLoading && <div className="text-[13px] text-slate-400"></div>}
{globalError && (
<div className="text-[13px] text-red-500">: {String(globalError)}</div>
)}
{!globalLoading && !globalError && (
<ServerTable
servers={globalServers ?? []}
isGlobal={true}
canDelete={true}
onRefresh={handleRefresh}
onDelete={handleDelete}
refreshPending={refresh.isPending}
deletePending={del.isPending}
/>
)}
<div>
<h4 className="text-xs font-semibold text-slate-700 mb-2">Global </h4>
<AddServerForm
sectionLabel="global"
onSubmit={createGlobal.mutateAsync}
isPending={createGlobal.isPending}
/>
</div>
</section>
)}
{/* ── Your Servers (all users) ──────────────────────────────────── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-semibold text-slate-900"></h3>
<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>
</div>
{userLoading && <div className="text-[13px] text-slate-400"></div>}
{userError && (
<div className="text-[13px] text-red-500">: {String(userError)}</div>
)}
{!userLoading && !userError && (
<ServerTable
servers={userServers ?? []}
isGlobal={false}
canDelete={true}
onRefresh={handleRefresh}
onDelete={handleDelete}
refreshPending={refresh.isPending}
deletePending={del.isPending}
/>
)}
<div>
<h4 className="text-xs font-semibold text-slate-700 mb-2">Personal </h4>
<AddServerForm
sectionLabel="personal"
onSubmit={createUser.mutateAsync}
isPending={createUser.isPending}
/>
</div>
</section>
</div>
</div>
);
}