mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
1289 lines
39 KiB
Vue
1289 lines
39 KiB
Vue
<template>
|
|
<MasterDetailSidebarLayout>
|
|
<template #sidebar-header>
|
|
<InputGroup class="shadow-none">
|
|
<InputGroupInput
|
|
v-model="searchText"
|
|
:placeholder="$t('mcp.searchPlaceholder')"
|
|
aria-label="Search MCP servers"
|
|
/>
|
|
<InputGroupAddon align="inline-end">
|
|
<InputGroupButton
|
|
type="button"
|
|
size="icon-xs"
|
|
aria-label="Search"
|
|
>
|
|
<FontAwesomeIcon :icon="['fas', 'magnifying-glass']" />
|
|
</InputGroupButton>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</template>
|
|
|
|
<template #sidebar-content>
|
|
<div
|
|
v-if="loading && items.length === 0"
|
|
class="flex items-center gap-2 text-sm text-muted-foreground p-4"
|
|
>
|
|
<Spinner />
|
|
<span>{{ $t('common.loading') }}</span>
|
|
</div>
|
|
<SidebarMenu
|
|
v-for="item in filteredItems"
|
|
v-else
|
|
:key="item.id || '_draft'"
|
|
>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
as-child
|
|
class="justify-start py-5! px-4"
|
|
>
|
|
<Toggle
|
|
:class="['py-4 border w-full text-left', selectedItem?.id === item.id ? 'border-border' : 'border-transparent']"
|
|
:model-value="selectedItem?.id === item.id"
|
|
@update:model-value="(v) => { if (v) selectItem(item) }"
|
|
>
|
|
<div class="flex items-center gap-2 w-full min-w-0">
|
|
<span
|
|
class="size-2 rounded-full shrink-0"
|
|
:class="statusDotClass(item)"
|
|
/>
|
|
<span class="truncate flex-1">
|
|
{{ item.name }}
|
|
<span
|
|
v-if="!item.id"
|
|
class="text-muted-foreground text-xs"
|
|
>*</span>
|
|
</span>
|
|
<Badge
|
|
v-if="item.id"
|
|
variant="outline"
|
|
class="shrink-0 text-[10px]"
|
|
>
|
|
{{ item.type }}
|
|
</Badge>
|
|
<Badge
|
|
v-else
|
|
variant="secondary"
|
|
class="shrink-0 text-[10px]"
|
|
>
|
|
{{ $t('mcp.draft') }}
|
|
</Badge>
|
|
</div>
|
|
</Toggle>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</template>
|
|
|
|
<template #sidebar-footer>
|
|
<div class="flex gap-2 p-2">
|
|
<Button
|
|
class="flex-1"
|
|
size="sm"
|
|
@click="openCreateDialog"
|
|
>
|
|
<FontAwesomeIcon
|
|
:icon="['fas', 'plus']"
|
|
class="mr-1.5"
|
|
/>
|
|
{{ $t('common.add') }}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
@click="openImportDialog"
|
|
>
|
|
{{ $t('common.import') }}
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
|
|
<template #detail>
|
|
<ScrollArea
|
|
v-if="selectedItem"
|
|
class="max-h-full h-full"
|
|
>
|
|
<div class="p-6 space-y-6">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold">
|
|
{{ selectedItem.name }}
|
|
</h3>
|
|
<div class="flex items-center gap-2">
|
|
<Button
|
|
v-if="selectedItem.id"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="handleExportSingle"
|
|
>
|
|
{{ $t('common.export') }}
|
|
</Button>
|
|
<ConfirmPopover
|
|
:message="$t('mcp.deleteConfirm')"
|
|
@confirm="handleDelete(selectedItem!)"
|
|
>
|
|
<template #trigger>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
>
|
|
{{ $t('common.delete') }}
|
|
</Button>
|
|
</template>
|
|
</ConfirmPopover>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
class="flex flex-col gap-4"
|
|
@submit.prevent="handleSubmit"
|
|
>
|
|
<div class="space-y-1.5">
|
|
<Label>{{ $t('common.name') }}</Label>
|
|
<Input
|
|
v-model="formData.name"
|
|
:placeholder="$t('common.namePlaceholder')"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-1.5">
|
|
<Label>{{ $t('common.type') }}</Label>
|
|
<Select v-model="connectionType">
|
|
<SelectTrigger class="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="stdio">
|
|
{{ $t('mcp.types.stdio') }}
|
|
</SelectItem>
|
|
<SelectItem value="remote">
|
|
{{ $t('mcp.types.remote') }}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<template v-if="connectionType === 'stdio'">
|
|
<div class="space-y-1.5">
|
|
<Label>{{ $t('mcp.command') }}</Label>
|
|
<Input
|
|
v-model="formData.command"
|
|
:placeholder="$t('mcp.commandPlaceholder')"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<Label>{{ $t('mcp.arguments') }}</Label>
|
|
<TagsInput
|
|
v-model="argsTags"
|
|
:add-on-blur="true"
|
|
:duplicate="true"
|
|
>
|
|
<TagsInputItem
|
|
v-for="tag in argsTags"
|
|
:key="tag"
|
|
:value="tag"
|
|
>
|
|
<TagsInputItemText />
|
|
<TagsInputItemDelete />
|
|
</TagsInputItem>
|
|
<TagsInputInput
|
|
:placeholder="$t('mcp.argumentsPlaceholder')"
|
|
class="w-full py-1"
|
|
/>
|
|
</TagsInput>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<Label>{{ $t('mcp.env') }}</Label>
|
|
<KeyValueEditor
|
|
v-model="envPairs"
|
|
key-placeholder="KEY"
|
|
value-placeholder="VALUE"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<Label>{{ $t('mcp.cwd') }}</Label>
|
|
<Input
|
|
v-model="formData.cwd"
|
|
:placeholder="$t('mcp.cwdPlaceholder')"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="space-y-1.5">
|
|
<Label>URL</Label>
|
|
<Input
|
|
v-model="formData.url"
|
|
placeholder="https://example.com/mcp"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<Label>Headers</Label>
|
|
<KeyValueEditor
|
|
v-model="headerPairs"
|
|
key-placeholder="Header-Name"
|
|
value-placeholder="Header-Value"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<Label>Transport</Label>
|
|
<Select v-model="formData.transport">
|
|
<SelectTrigger class="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="http">
|
|
HTTP (Streamable)
|
|
</SelectItem>
|
|
<SelectItem value="sse">
|
|
SSE
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="flex items-center justify-between pt-2 border-t">
|
|
<div class="flex items-center gap-2">
|
|
<Label class="text-sm font-normal">{{ $t('mcp.active') }}</Label>
|
|
<Switch
|
|
:model-value="formData.active"
|
|
@update:model-value="(val) => (formData.active = !!val)"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
:disabled="submitting || !formData.name.trim()"
|
|
>
|
|
<Spinner
|
|
v-if="submitting"
|
|
class="mr-1.5"
|
|
/>
|
|
{{ $t('common.save') }}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Probe status & tools -->
|
|
<div
|
|
v-if="selectedItem.id"
|
|
class="border-t pt-4 space-y-4"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<h4 class="text-sm font-medium">
|
|
{{ $t('mcp.tools') }}
|
|
</h4>
|
|
<Badge
|
|
v-if="selectedItem.status === 'connected'"
|
|
variant="outline"
|
|
class="text-[10px] text-green-600"
|
|
>
|
|
{{ $t('mcp.statusConnected') }}
|
|
</Badge>
|
|
<Badge
|
|
v-else-if="selectedItem.status === 'error'"
|
|
variant="outline"
|
|
class="text-[10px] text-destructive"
|
|
>
|
|
{{ $t('mcp.statusError') }}
|
|
</Badge>
|
|
<Badge
|
|
v-else
|
|
variant="outline"
|
|
class="text-[10px] text-muted-foreground"
|
|
>
|
|
{{ $t('mcp.statusUnknown') }}
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="probing"
|
|
@click="handleProbe(selectedItem!)"
|
|
>
|
|
<Spinner
|
|
v-if="probing"
|
|
class="mr-1.5"
|
|
/>
|
|
<FontAwesomeIcon
|
|
v-else
|
|
:icon="['fas', 'rotate']"
|
|
class="mr-1.5"
|
|
/>
|
|
{{ probing ? $t('mcp.probing') : $t('mcp.probe') }}
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
v-if="selectedItem.status_message && selectedItem.status === 'error'"
|
|
class="text-sm text-destructive bg-destructive/5 rounded-md p-3"
|
|
>
|
|
{{ selectedItem.status_message }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="probeAuthRequired"
|
|
class="text-sm text-amber-600 bg-amber-50 dark:bg-amber-900/20 rounded-md p-3 flex items-center gap-2"
|
|
>
|
|
<FontAwesomeIcon :icon="['fas', 'lock']" />
|
|
{{ $t('mcp.authRequired') }}
|
|
</div>
|
|
|
|
<!-- OAuth section (remote connections only) -->
|
|
<div
|
|
v-if="selectedItem.type !== 'stdio' && (probeAuthRequired || selectedItem.auth_type === 'oauth' || oauthStatus)"
|
|
class="border rounded-md p-4 space-y-3"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-medium">
|
|
{{ $t('mcp.oauth.title') }}
|
|
</h4>
|
|
<Badge
|
|
v-if="oauthStatus?.has_token && !oauthStatus?.expired"
|
|
variant="outline"
|
|
class="text-[10px] text-green-600"
|
|
>
|
|
{{ $t('mcp.oauth.authorized') }}
|
|
</Badge>
|
|
<Badge
|
|
v-else-if="oauthStatus?.has_token && oauthStatus?.expired"
|
|
variant="outline"
|
|
class="text-[10px] text-amber-600"
|
|
>
|
|
{{ $t('mcp.oauth.expired') }}
|
|
</Badge>
|
|
<Badge
|
|
v-else-if="oauthStatus?.configured"
|
|
variant="outline"
|
|
class="text-[10px] text-muted-foreground"
|
|
>
|
|
{{ $t('mcp.oauth.notConfigured') }}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div
|
|
v-if="oauthStatus?.auth_server"
|
|
class="text-xs text-muted-foreground"
|
|
>
|
|
{{ $t('mcp.oauth.authServer') }}: {{ oauthStatus.auth_server }}
|
|
</div>
|
|
|
|
<!-- Client ID input (shown when server doesn't support DCR) -->
|
|
<div
|
|
v-if="oauthNeedsClientId && (!oauthStatus?.has_token || oauthStatus?.expired)"
|
|
class="space-y-2"
|
|
>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ $t('mcp.oauth.clientIdHint') }}
|
|
</p>
|
|
<div class="space-y-1.5">
|
|
<Label class="text-xs">
|
|
{{ $t('mcp.oauth.clientId') }}
|
|
</Label>
|
|
<Input
|
|
v-model="oauthClientId"
|
|
:placeholder="$t('mcp.oauth.clientIdPlaceholder')"
|
|
class="font-mono text-xs"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<Label class="text-xs">
|
|
{{ $t('mcp.oauth.clientSecret') }}
|
|
</Label>
|
|
<Input
|
|
v-model="oauthClientSecret"
|
|
type="password"
|
|
:placeholder="$t('mcp.oauth.clientSecretPlaceholder')"
|
|
class="font-mono text-xs"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="oauthCallbackUrl"
|
|
class="space-y-1"
|
|
>
|
|
<Label class="text-xs">
|
|
{{ $t('mcp.oauth.callbackUrl') }}
|
|
</Label>
|
|
<div class="flex items-center gap-1.5">
|
|
<code class="text-xs bg-muted px-2 py-1 rounded flex-1 break-all select-all">{{ oauthCallbackUrl }}</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
@click="copyText(oauthCallbackUrl); toast.success($t('common.copied'))"
|
|
>
|
|
<FontAwesomeIcon :icon="['far', 'copy']" />
|
|
</Button>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ $t('mcp.oauth.callbackUrlHint') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<Button
|
|
v-if="!oauthStatus?.has_token || oauthStatus?.expired"
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="oauthDiscovering || oauthAuthorizing || (oauthNeedsClientId && !oauthClientId.trim())"
|
|
@click="handleOAuthFlow"
|
|
>
|
|
<Spinner
|
|
v-if="oauthDiscovering || oauthAuthorizing"
|
|
class="mr-1.5"
|
|
/>
|
|
<FontAwesomeIcon
|
|
v-else
|
|
:icon="['fas', 'key']"
|
|
class="mr-1.5"
|
|
/>
|
|
{{ oauthDiscovering ? $t('mcp.oauth.discovering') : oauthAuthorizing ? $t('mcp.oauth.authorizing') : $t('mcp.oauth.authorize') }}
|
|
</Button>
|
|
<Button
|
|
v-if="oauthStatus?.has_token"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="handleOAuthRevoke"
|
|
>
|
|
{{ $t('mcp.oauth.revoke') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="displayTools.length > 0"
|
|
class="space-y-1"
|
|
>
|
|
<p class="text-xs text-muted-foreground mb-2">
|
|
{{ $t('mcp.toolsCount', { count: displayTools.length }) }}
|
|
</p>
|
|
<div
|
|
v-for="tool in displayTools"
|
|
:key="tool.name"
|
|
class="flex items-start gap-2 py-1.5 px-2 rounded text-sm hover:bg-accent/50"
|
|
>
|
|
<FontAwesomeIcon
|
|
:icon="['fas', 'wrench']"
|
|
class="mt-1 text-muted-foreground shrink-0 text-xs"
|
|
/>
|
|
<div class="min-w-0">
|
|
<span class="font-mono text-xs font-medium">{{ tool.name }}</span>
|
|
<p
|
|
v-if="tool.description"
|
|
class="text-xs text-muted-foreground truncate"
|
|
>
|
|
{{ tool.description }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p
|
|
v-else-if="selectedItem.status === 'connected'"
|
|
class="text-sm text-muted-foreground"
|
|
>
|
|
{{ $t('mcp.toolsEmpty') }}
|
|
</p>
|
|
|
|
<p
|
|
v-if="selectedItem.last_probed_at"
|
|
class="text-xs text-muted-foreground"
|
|
>
|
|
{{ $t('mcp.lastProbed') }}: {{ formatDate(selectedItem.last_probed_at) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<Empty
|
|
v-else
|
|
class="h-full flex justify-center items-center"
|
|
>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<FontAwesomeIcon :icon="['fas', 'plug']" />
|
|
</EmptyMedia>
|
|
</EmptyHeader>
|
|
<EmptyTitle>{{ $t('mcp.emptyTitle') }}</EmptyTitle>
|
|
<EmptyDescription>{{ $t('mcp.emptyDescription') }}</EmptyDescription>
|
|
<EmptyContent>
|
|
<Button
|
|
variant="outline"
|
|
@click="openCreateDialog"
|
|
>
|
|
{{ $t('common.add') }}
|
|
</Button>
|
|
</EmptyContent>
|
|
</Empty>
|
|
</template>
|
|
</MasterDetailSidebarLayout>
|
|
|
|
<!-- Create dialog -->
|
|
<Dialog v-model:open="createDialogOpen">
|
|
<DialogContent class="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{{ $t('mcp.addTitle') }}</DialogTitle>
|
|
</DialogHeader>
|
|
<form
|
|
class="mt-4 flex flex-col gap-4"
|
|
@submit.prevent="handleCreateDraft"
|
|
>
|
|
<div class="space-y-1.5">
|
|
<Label>{{ $t('common.name') }}</Label>
|
|
<Input
|
|
v-model="createName"
|
|
:placeholder="$t('common.namePlaceholder')"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<Label>{{ $t('common.type') }}</Label>
|
|
<Select v-model="createType">
|
|
<SelectTrigger class="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="stdio">
|
|
{{ $t('mcp.types.stdio') }}
|
|
</SelectItem>
|
|
<SelectItem value="remote">
|
|
{{ $t('mcp.types.remote') }}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<DialogFooter>
|
|
<DialogClose as-child>
|
|
<Button variant="outline">
|
|
{{ $t('common.cancel') }}
|
|
</Button>
|
|
</DialogClose>
|
|
<Button
|
|
type="submit"
|
|
:disabled="!createName.trim()"
|
|
>
|
|
{{ $t('common.confirm') }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<!-- Import dialog -->
|
|
<Dialog v-model:open="importDialogOpen">
|
|
<DialogContent class="sm:max-w-lg w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{{ $t('common.import') }} MCP Servers</DialogTitle>
|
|
</DialogHeader>
|
|
<p class="text-sm text-muted-foreground mt-2">
|
|
{{ $t('mcp.importHint') }}
|
|
</p>
|
|
<div class="h-[350px] rounded-md border overflow-hidden mt-3">
|
|
<MonacoEditor
|
|
v-model="importJson"
|
|
language="json"
|
|
/>
|
|
</div>
|
|
<DialogFooter class="mt-4">
|
|
<DialogClose as-child>
|
|
<Button variant="outline">
|
|
{{ $t('common.cancel') }}
|
|
</Button>
|
|
</DialogClose>
|
|
<Button
|
|
:disabled="importSubmitting || !importJson.trim()"
|
|
@click="handleImportFromDialog"
|
|
>
|
|
<Spinner
|
|
v-if="importSubmitting"
|
|
class="mr-1.5"
|
|
/>
|
|
{{ $t('common.import') }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<!-- Export dialog -->
|
|
<Dialog v-model:open="exportDialogOpen">
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{{ $t('common.export') }} mcpServers</DialogTitle>
|
|
</DialogHeader>
|
|
<div class="h-[350px] rounded-md border overflow-hidden mt-4">
|
|
<MonacoEditor
|
|
:model-value="exportJson"
|
|
language="json"
|
|
:readonly="true"
|
|
/>
|
|
</div>
|
|
<DialogFooter class="mt-4">
|
|
<Button
|
|
variant="outline"
|
|
@click="handleCopyExport"
|
|
>
|
|
{{ $t('common.copy') }}
|
|
</Button>
|
|
<DialogClose as-child>
|
|
<Button>
|
|
{{ $t('common.confirm') }}
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { toast } from 'vue-sonner'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
Empty,
|
|
EmptyContent,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
Input,
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupInput,
|
|
Label,
|
|
ScrollArea,
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
Spinner,
|
|
Switch,
|
|
TagsInput,
|
|
TagsInputInput,
|
|
TagsInputItem,
|
|
TagsInputItemDelete,
|
|
TagsInputItemText,
|
|
Toggle,
|
|
} from '@memoh/ui'
|
|
import MasterDetailSidebarLayout from '@/components/master-detail-sidebar-layout/index.vue'
|
|
import MonacoEditor from '@/components/monaco-editor/index.vue'
|
|
import KeyValueEditor from '@/components/key-value-editor/index.vue'
|
|
import type { KeyValuePair } from '@/components/key-value-editor/index.vue'
|
|
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
|
import {
|
|
getBotsByBotIdMcp,
|
|
postBotsByBotIdMcp,
|
|
putBotsByBotIdMcpById,
|
|
deleteBotsByBotIdMcpById,
|
|
} from '@memoh/sdk'
|
|
import type { McpUpsertRequest, McpImportRequest } from '@memoh/sdk'
|
|
import { client } from '@memoh/sdk/client'
|
|
import { resolveApiErrorMessage } from '@/utils/api-error'
|
|
import { useClipboard } from '@/composables/useClipboard'
|
|
|
|
interface ToolDescriptor {
|
|
name: string
|
|
description: string
|
|
}
|
|
|
|
interface McpItem {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
config: Record<string, unknown>
|
|
is_active: boolean
|
|
status: string
|
|
tools_cache: ToolDescriptor[]
|
|
last_probed_at: string | null
|
|
status_message: string
|
|
auth_type: string
|
|
}
|
|
|
|
interface McpServerEntry {
|
|
command?: string
|
|
args?: string[]
|
|
env?: Record<string, string>
|
|
cwd?: string
|
|
url?: string
|
|
headers?: Record<string, string>
|
|
transport?: string
|
|
}
|
|
|
|
interface ProbeResponse {
|
|
status: string
|
|
tools: ToolDescriptor[]
|
|
error?: string
|
|
auth_required?: boolean
|
|
}
|
|
|
|
const DRAFT_ID = ''
|
|
|
|
const IMPORT_EXAMPLE = JSON.stringify({
|
|
mcpServers: {
|
|
'example-stdio': {
|
|
command: 'npx',
|
|
args: ['-y', '@example/mcp-server'],
|
|
env: { API_KEY: 'your-api-key' },
|
|
},
|
|
'example-remote': {
|
|
url: 'https://example.com/mcp',
|
|
headers: { Authorization: 'Bearer your-token' },
|
|
transport: 'sse',
|
|
},
|
|
},
|
|
}, null, 2)
|
|
|
|
const props = defineProps<{ botId: string }>()
|
|
const { t } = useI18n()
|
|
const { copyText } = useClipboard()
|
|
|
|
const loading = ref(false)
|
|
const items = ref<McpItem[]>([])
|
|
const selectedItem = ref<McpItem | null>(null)
|
|
const searchText = ref('')
|
|
const submitting = ref(false)
|
|
const probing = ref(false)
|
|
const probeAuthRequired = ref(false)
|
|
const oauthDiscovering = ref(false)
|
|
const oauthAuthorizing = ref(false)
|
|
|
|
interface OAuthStatusResponse {
|
|
configured: boolean
|
|
has_token: boolean
|
|
expired: boolean
|
|
scopes?: string
|
|
expires_at?: string
|
|
auth_server?: string
|
|
callback_url?: string
|
|
}
|
|
|
|
interface OAuthDiscoveryResponse {
|
|
registration_endpoint?: string
|
|
authorization_endpoint?: string
|
|
token_endpoint?: string
|
|
}
|
|
|
|
const oauthStatus = ref<OAuthStatusResponse | null>(null)
|
|
const oauthClientId = ref('')
|
|
const oauthClientSecret = ref('')
|
|
const oauthNeedsClientId = ref(false)
|
|
const oauthCallbackUrl = ref('')
|
|
const oauthDiscovered = ref(false)
|
|
|
|
const createDialogOpen = ref(false)
|
|
const createName = ref('')
|
|
const importDialogOpen = ref(false)
|
|
const exportDialogOpen = ref(false)
|
|
const importJson = ref('')
|
|
const importSubmitting = ref(false)
|
|
const exportJson = ref('')
|
|
|
|
const createType = ref<'stdio' | 'remote'>('stdio')
|
|
const connectionType = ref<'stdio' | 'remote'>('stdio')
|
|
const formData = ref({
|
|
name: '',
|
|
command: '',
|
|
url: '',
|
|
cwd: '',
|
|
transport: 'http' as 'http' | 'sse',
|
|
active: true,
|
|
})
|
|
const argsTags = ref<string[]>([])
|
|
const envPairs = ref<KeyValuePair[]>([])
|
|
const headerPairs = ref<KeyValuePair[]>([])
|
|
|
|
const isDraft = computed(() => selectedItem.value?.id === DRAFT_ID)
|
|
|
|
const filteredItems = computed(() => {
|
|
if (!searchText.value) return items.value
|
|
const kw = searchText.value.toLowerCase()
|
|
return items.value.filter((i) => i.id === DRAFT_ID || i.name.toLowerCase().includes(kw))
|
|
})
|
|
|
|
const displayTools = computed<ToolDescriptor[]>(() => {
|
|
if (!selectedItem.value) return []
|
|
return selectedItem.value.tools_cache ?? []
|
|
})
|
|
|
|
function statusDotClass(item: McpItem): string {
|
|
if (!item.id) return 'bg-muted-foreground/40'
|
|
if (!item.is_active) return 'bg-muted-foreground/40'
|
|
switch (item.status) {
|
|
case 'connected': return 'bg-green-500'
|
|
case 'error': return 'bg-destructive'
|
|
default: return 'bg-amber-400'
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr: string | null): string {
|
|
if (!dateStr) return ''
|
|
try {
|
|
return new Date(dateStr).toLocaleString()
|
|
} catch {
|
|
return dateStr
|
|
}
|
|
}
|
|
|
|
function configValue(config: Record<string, unknown>, key: string): string {
|
|
const val = config?.[key]
|
|
return typeof val === 'string' ? val : ''
|
|
}
|
|
|
|
function configArray(config: Record<string, unknown>, key: string): string[] {
|
|
const val = config?.[key]
|
|
if (Array.isArray(val)) return val.map(String)
|
|
return []
|
|
}
|
|
|
|
function configMap(config: Record<string, unknown>, key: string): Record<string, string> {
|
|
const val = config?.[key]
|
|
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
const out: Record<string, string> = {}
|
|
for (const [k, v] of Object.entries(val)) {
|
|
out[k] = String(v)
|
|
}
|
|
return out
|
|
}
|
|
return {}
|
|
}
|
|
|
|
function recordToPairs(record: Record<string, string>): KeyValuePair[] {
|
|
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
|
}
|
|
|
|
function pairsToRecord(pairs: KeyValuePair[]): Record<string, string> {
|
|
const out: Record<string, string> = {}
|
|
for (const p of pairs) {
|
|
if (p.key.trim()) out[p.key.trim()] = p.value
|
|
}
|
|
return out
|
|
}
|
|
|
|
function selectItem(item: McpItem) {
|
|
selectedItem.value = item
|
|
probeAuthRequired.value = false
|
|
oauthStatus.value = null
|
|
oauthClientId.value = ''
|
|
oauthClientSecret.value = ''
|
|
oauthNeedsClientId.value = false
|
|
oauthCallbackUrl.value = ''
|
|
oauthDiscovered.value = false
|
|
if (item.id && item.type !== 'stdio') {
|
|
loadOAuthStatus(item)
|
|
}
|
|
const cfg = item.config ?? {}
|
|
connectionType.value = item.type === 'stdio' ? 'stdio' : 'remote'
|
|
formData.value = {
|
|
name: item.name,
|
|
command: configValue(cfg, 'command'),
|
|
url: configValue(cfg, 'url'),
|
|
cwd: configValue(cfg, 'cwd'),
|
|
transport: item.type === 'sse' ? 'sse' : 'http',
|
|
active: !!item.is_active,
|
|
}
|
|
argsTags.value = configArray(cfg, 'args')
|
|
envPairs.value = recordToPairs(configMap(cfg, 'env'))
|
|
headerPairs.value = recordToPairs(configMap(cfg, 'headers'))
|
|
}
|
|
|
|
function removeDraft() {
|
|
items.value = items.value.filter((i) => i.id !== DRAFT_ID)
|
|
}
|
|
|
|
function openCreateDialog() {
|
|
createName.value = ''
|
|
createType.value = 'stdio'
|
|
createDialogOpen.value = true
|
|
}
|
|
|
|
function openImportDialog() {
|
|
importJson.value = IMPORT_EXAMPLE
|
|
importDialogOpen.value = true
|
|
}
|
|
|
|
function handleCreateDraft() {
|
|
const name = createName.value.trim()
|
|
if (!name) return
|
|
removeDraft()
|
|
const draft: McpItem = {
|
|
id: DRAFT_ID,
|
|
name,
|
|
type: createType.value === 'stdio' ? 'stdio' : 'http',
|
|
config: {},
|
|
is_active: true,
|
|
status: 'unknown',
|
|
tools_cache: [],
|
|
last_probed_at: null,
|
|
status_message: '',
|
|
auth_type: 'none',
|
|
}
|
|
items.value = [draft, ...items.value]
|
|
selectItem(draft)
|
|
createDialogOpen.value = false
|
|
}
|
|
|
|
function itemToExportEntry(item: McpItem): McpServerEntry {
|
|
const cfg = item.config ?? {}
|
|
if (item.type === 'stdio') {
|
|
return {
|
|
command: configValue(cfg, 'command') || undefined,
|
|
args: configArray(cfg, 'args').length ? configArray(cfg, 'args') : undefined,
|
|
cwd: configValue(cfg, 'cwd') || undefined,
|
|
env: Object.keys(configMap(cfg, 'env')).length ? configMap(cfg, 'env') : undefined,
|
|
}
|
|
}
|
|
return {
|
|
url: configValue(cfg, 'url') || undefined,
|
|
headers: Object.keys(configMap(cfg, 'headers')).length ? configMap(cfg, 'headers') : undefined,
|
|
transport: item.type === 'sse' ? 'sse' : undefined,
|
|
}
|
|
}
|
|
|
|
function buildRequestBody(
|
|
fd: typeof formData.value,
|
|
mode: 'stdio' | 'remote',
|
|
args: string[],
|
|
env: KeyValuePair[],
|
|
headers: KeyValuePair[],
|
|
): McpUpsertRequest {
|
|
const body: McpUpsertRequest = {
|
|
name: fd.name.trim(),
|
|
is_active: fd.active,
|
|
}
|
|
if (mode === 'stdio') {
|
|
body.command = fd.command.trim()
|
|
if (args.length > 0) body.args = args
|
|
const envRecord = pairsToRecord(env)
|
|
if (Object.keys(envRecord).length > 0) body.env = envRecord
|
|
if (fd.cwd.trim()) body.cwd = fd.cwd.trim()
|
|
} else {
|
|
body.url = fd.url.trim()
|
|
const headerRecord = pairsToRecord(headers)
|
|
if (Object.keys(headerRecord).length > 0) body.headers = headerRecord
|
|
if (fd.transport === 'sse') body.transport = 'sse'
|
|
}
|
|
return body
|
|
}
|
|
|
|
async function loadList() {
|
|
loading.value = true
|
|
try {
|
|
const { data } = await getBotsByBotIdMcp({
|
|
path: { bot_id: props.botId },
|
|
throwOnError: true,
|
|
})
|
|
const serverItems: McpItem[] = (data.items ?? []).map((item: Record<string, unknown>) => ({
|
|
...item,
|
|
status: item.status ?? 'unknown',
|
|
tools_cache: item.tools_cache ?? [],
|
|
last_probed_at: item.last_probed_at ?? null,
|
|
status_message: item.status_message ?? '',
|
|
auth_type: item.auth_type ?? 'none',
|
|
}))
|
|
const draft = items.value.find((i) => i.id === DRAFT_ID)
|
|
items.value = draft ? [draft, ...serverItems] : serverItems
|
|
|
|
if (selectedItem.value && selectedItem.value.id !== DRAFT_ID) {
|
|
const still = serverItems.find((i) => i.id === selectedItem.value!.id)
|
|
if (still) selectItem(still)
|
|
else selectedItem.value = null
|
|
}
|
|
if (!selectedItem.value && items.value.length > 0) {
|
|
selectItem(items.value[0])
|
|
}
|
|
} catch (error) {
|
|
toast.error(resolveApiErrorMessage(error, t('mcp.loadFailed')))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function handleProbe(item: McpItem) {
|
|
if (!item.id) return
|
|
probing.value = true
|
|
probeAuthRequired.value = false
|
|
try {
|
|
const { data } = await client.post<ProbeResponse>({
|
|
url: '/bots/{bot_id}/mcp/{id}/probe',
|
|
path: { bot_id: props.botId, id: item.id },
|
|
throwOnError: true,
|
|
})
|
|
if (data) {
|
|
item.status = data.status
|
|
item.tools_cache = data.tools ?? []
|
|
item.status_message = data.error ?? ''
|
|
item.last_probed_at = new Date().toISOString()
|
|
probeAuthRequired.value = !!data.auth_required
|
|
if (data.status === 'connected') {
|
|
toast.success(t('mcp.probeSuccess'))
|
|
} else if (data.auth_required) {
|
|
toast.warning(t('mcp.authRequired'))
|
|
} else {
|
|
toast.error(data.error || t('mcp.probeFailed'))
|
|
}
|
|
}
|
|
} catch (error) {
|
|
toast.error(resolveApiErrorMessage(error, t('mcp.probeFailed')))
|
|
} finally {
|
|
probing.value = false
|
|
}
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
if (!selectedItem.value) return
|
|
submitting.value = true
|
|
try {
|
|
const body = buildRequestBody(formData.value, connectionType.value, argsTags.value, envPairs.value, headerPairs.value)
|
|
let savedId: string | undefined
|
|
if (isDraft.value) {
|
|
const { data } = await postBotsByBotIdMcp({
|
|
path: { bot_id: props.botId },
|
|
body,
|
|
throwOnError: true,
|
|
})
|
|
savedId = data?.id
|
|
removeDraft()
|
|
await loadList()
|
|
const created = items.value.find((i) => i.id === savedId) ?? items.value.find((i) => i.name === body.name)
|
|
if (created) selectItem(created)
|
|
toast.success(t('mcp.createSuccess'))
|
|
} else {
|
|
savedId = selectedItem.value.id
|
|
await putBotsByBotIdMcpById({
|
|
path: { bot_id: props.botId, id: selectedItem.value.id },
|
|
body,
|
|
throwOnError: true,
|
|
})
|
|
await loadList()
|
|
toast.success(t('mcp.updateSuccess'))
|
|
}
|
|
if (savedId && selectedItem.value) {
|
|
handleProbe(selectedItem.value)
|
|
}
|
|
} catch (error) {
|
|
toast.error(resolveApiErrorMessage(error, t('common.saveFailed')))
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
async function handleDelete(item: McpItem) {
|
|
if (item.id === DRAFT_ID) {
|
|
removeDraft()
|
|
selectedItem.value = items.value.length > 0 ? items.value[0] : null
|
|
if (selectedItem.value) selectItem(selectedItem.value)
|
|
return
|
|
}
|
|
try {
|
|
await deleteBotsByBotIdMcpById({
|
|
path: { bot_id: props.botId, id: item.id },
|
|
throwOnError: true,
|
|
})
|
|
if (selectedItem.value?.id === item.id) selectedItem.value = null
|
|
await loadList()
|
|
toast.success(t('mcp.deleteSuccess'))
|
|
} catch (error) {
|
|
toast.error(resolveApiErrorMessage(error, t('mcp.deleteFailed')))
|
|
}
|
|
}
|
|
|
|
async function handleImportFromDialog() {
|
|
importSubmitting.value = true
|
|
try {
|
|
let parsed: McpImportRequest = JSON.parse(importJson.value)
|
|
if (!parsed.mcpServers && typeof parsed === 'object') {
|
|
parsed = { mcpServers: parsed as McpImportRequest['mcpServers'] }
|
|
}
|
|
await client.put({
|
|
url: '/bots/{bot_id}/mcp-ops/import',
|
|
path: { bot_id: props.botId },
|
|
body: parsed,
|
|
throwOnError: true,
|
|
})
|
|
importDialogOpen.value = false
|
|
importJson.value = ''
|
|
await loadList()
|
|
toast.success(t('mcp.importSuccess'))
|
|
} catch (error) {
|
|
toast.error(resolveApiErrorMessage(error, t('mcp.importFailed')))
|
|
} finally {
|
|
importSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
function handleExportSingle() {
|
|
if (!selectedItem.value || !selectedItem.value.id) return
|
|
const mcpServers: Record<string, McpServerEntry> = {
|
|
[selectedItem.value.name]: itemToExportEntry(selectedItem.value),
|
|
}
|
|
exportJson.value = JSON.stringify({ mcpServers }, null, 2)
|
|
exportDialogOpen.value = true
|
|
}
|
|
|
|
function handleCopyExport() {
|
|
void copyText(exportJson.value)
|
|
toast.success(t('common.copied'))
|
|
}
|
|
|
|
async function loadOAuthStatus(item: McpItem) {
|
|
if (!item.id || item.type === 'stdio') {
|
|
oauthStatus.value = null
|
|
return
|
|
}
|
|
try {
|
|
const { data } = await client.get<OAuthStatusResponse>({
|
|
url: '/bots/{bot_id}/mcp/{id}/oauth/status',
|
|
path: { bot_id: props.botId, id: item.id },
|
|
throwOnError: true,
|
|
})
|
|
oauthStatus.value = data ?? null
|
|
oauthCallbackUrl.value = `${window.location.origin}/oauth/mcp/callback`
|
|
} catch {
|
|
oauthStatus.value = null
|
|
}
|
|
}
|
|
|
|
async function handleOAuthDiscover() {
|
|
if (!selectedItem.value?.id) return
|
|
const item = selectedItem.value
|
|
|
|
oauthDiscovering.value = true
|
|
oauthNeedsClientId.value = false
|
|
try {
|
|
const { data } = await client.post<OAuthDiscoveryResponse>({
|
|
url: '/bots/{bot_id}/mcp/{id}/oauth/discover',
|
|
path: { bot_id: props.botId, id: item.id },
|
|
throwOnError: true,
|
|
})
|
|
toast.success(t('mcp.oauth.discoverSuccess'))
|
|
if (!data?.registration_endpoint) {
|
|
oauthNeedsClientId.value = true
|
|
}
|
|
} catch (error) {
|
|
toast.error(resolveApiErrorMessage(error, t('mcp.oauth.discoverFailed')))
|
|
oauthDiscovering.value = false
|
|
return false
|
|
}
|
|
oauthDiscovering.value = false
|
|
return true
|
|
}
|
|
|
|
async function handleOAuthFlow() {
|
|
if (!selectedItem.value?.id) return
|
|
const item = selectedItem.value
|
|
|
|
if (!oauthDiscovered.value) {
|
|
const discovered = await handleOAuthDiscover()
|
|
if (!discovered) return
|
|
oauthDiscovered.value = true
|
|
if (oauthNeedsClientId.value && !oauthClientId.value.trim()) {
|
|
return
|
|
}
|
|
}
|
|
|
|
oauthAuthorizing.value = true
|
|
try {
|
|
const { data } = await client.post<{ authorization_url: string }>({
|
|
url: '/bots/{bot_id}/mcp/{id}/oauth/authorize',
|
|
path: { bot_id: props.botId, id: item.id },
|
|
body: {
|
|
client_id: oauthClientId.value.trim() || undefined,
|
|
client_secret: oauthClientSecret.value.trim() || undefined,
|
|
callback_url: `${window.location.origin}/oauth/mcp/callback`,
|
|
},
|
|
throwOnError: true,
|
|
})
|
|
if (!data?.authorization_url) {
|
|
toast.error(t('mcp.oauth.authFailed'))
|
|
oauthAuthorizing.value = false
|
|
return
|
|
}
|
|
|
|
const popup = window.open(data.authorization_url, 'mcp-oauth', 'width=600,height=700')
|
|
|
|
const onMessage = async (event: MessageEvent) => {
|
|
if (event.data?.type === 'mcp-oauth-callback') {
|
|
window.removeEventListener('message', onMessage)
|
|
oauthAuthorizing.value = false
|
|
if (event.data.status === 'success') {
|
|
toast.success(t('mcp.oauth.authSuccess'))
|
|
await loadOAuthStatus(item)
|
|
handleProbe(item)
|
|
} else {
|
|
toast.error(event.data.error || t('mcp.oauth.authFailed'))
|
|
}
|
|
}
|
|
}
|
|
window.addEventListener('message', onMessage)
|
|
|
|
const pollTimer = setInterval(() => {
|
|
if (popup && popup.closed) {
|
|
clearInterval(pollTimer)
|
|
window.removeEventListener('message', onMessage)
|
|
oauthAuthorizing.value = false
|
|
loadOAuthStatus(item)
|
|
}
|
|
}, 500)
|
|
} catch (error) {
|
|
toast.error(resolveApiErrorMessage(error, t('mcp.oauth.authFailed')))
|
|
oauthAuthorizing.value = false
|
|
}
|
|
}
|
|
|
|
async function handleOAuthRevoke() {
|
|
if (!selectedItem.value?.id) return
|
|
try {
|
|
await client.delete({
|
|
url: '/bots/{bot_id}/mcp/{id}/oauth/token',
|
|
path: { bot_id: props.botId, id: selectedItem.value.id },
|
|
throwOnError: true,
|
|
})
|
|
toast.success(t('mcp.oauth.revokeSuccess'))
|
|
oauthDiscovered.value = false
|
|
oauthNeedsClientId.value = false
|
|
oauthClientId.value = ''
|
|
oauthClientSecret.value = ''
|
|
await loadOAuthStatus(selectedItem.value)
|
|
} catch (error) {
|
|
toast.error(resolveApiErrorMessage(error, t('mcp.oauth.revokeFailed')))
|
|
}
|
|
}
|
|
|
|
watch(connectionType, (mode) => {
|
|
if (mode === 'stdio') {
|
|
formData.value.url = ''
|
|
formData.value.transport = 'http'
|
|
headerPairs.value = []
|
|
} else {
|
|
formData.value.command = ''
|
|
formData.value.cwd = ''
|
|
argsTags.value = []
|
|
envPairs.value = []
|
|
}
|
|
})
|
|
|
|
watch(() => props.botId, () => {
|
|
if (props.botId) {
|
|
selectedItem.value = null
|
|
loadList()
|
|
}
|
|
}, { immediate: true })
|
|
</script>
|