sync: update from private repo (f811f38)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
4601e8d5c3
commit
b3747add6b
@ -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({
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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'}>
|
<>
|
||||||
|
<FormField label={isEdit ? 'API key (空欄なら変更なし)' : 'API key / トークン'}>
|
||||||
<input className={INPUT_CLS} type="password" value={form.staticToken ?? ''}
|
<input className={INPUT_CLS} type="password" value={form.staticToken ?? ''}
|
||||||
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
|
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
|
||||||
placeholder="sk-..." required={!isEdit}
|
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)}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user