feat(web): implement bot MCP management page and remove global MCP page

- Add bot-mcp.vue component with table, create/edit dialog, import/export
- Wire MCP tab into bot detail page
- Remove dead global MCP page, composable, and form component (/mcp route)
- Add i18n keys for import/export/copy and MCP CRUD messages
- Fix store/User.ts case sensitivity for Linux builds
This commit is contained in:
BBQ
2026-02-13 00:40:53 +08:00
parent 9dd7135820
commit 7942df6a32
9 changed files with 659 additions and 503 deletions
@@ -1,313 +0,0 @@
<template>
<section class="flex">
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button
variant="default"
class="ml-auto my-4"
>
{{ $t('mcp.addTitle') }}
</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-106.25">
<form @submit="createMCP">
<DialogHeader>
<DialogTitle>{{ $t('mcp.addTitle') }}</DialogTitle>
<DialogDescription class="mb-4">
{{ $t('mcp.addDescription') }}
</DialogDescription>
</DialogHeader>
<div class="flex flex-col gap-3">
<!-- Name -->
<FormField
v-slot="{ componentField }"
name="name"
>
<FormItem>
<FormLabel class="mb-2">
{{ $t('mcp.name') }}
</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="$t('mcp.namePlaceholder')"
v-bind="componentField"
autocomplete="name"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<!-- Type -->
<FormField
v-slot="{ componentField }"
name="config.type"
>
<FormItem>
<FormLabel class="mb-2">
{{ $t('mcp.type') }}
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('mcp.typePlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="stdio">
{{ $t('mcp.types.stdio') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<!-- Cwd -->
<FormField
v-slot="{ componentField }"
name="config.cwd"
>
<FormItem>
<FormLabel class="mb-2">
{{ $t('mcp.cwd') }}
</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="$t('mcp.cwdPlaceholder')"
v-bind="componentField"
autocomplete="cwd"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<!-- Command -->
<FormField
v-slot="{ componentField }"
name="config.command"
>
<FormItem>
<FormLabel class="mb-2">
{{ $t('mcp.command') }}
</FormLabel>
<FormControl>
<Input
:placeholder="$t('mcp.commandPlaceholder')"
v-bind="componentField"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<!-- Arguments -->
<FormField
v-slot="{ componentField }"
name="config.args"
>
<FormItem>
<FormLabel class="mb-2">
{{ $t('mcp.arguments') }}
</FormLabel>
<FormControl>
<TagsInput
v-model="componentField.modelValue"
:add-on-blur="true"
:duplicate="true"
@update:model-value="componentField['onUpdate:modelValue']"
>
<TagsInputItem
v-for="item in componentField.modelValue"
:key="item"
:value="item"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:placeholder="$t('mcp.argumentsPlaceholder')"
class="w-full py-1"
/>
</TagsInput>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<!-- Env (key:value tags) -->
<FormField
v-slot="{ componentField }"
name="config.env"
>
<FormItem>
<FormLabel class="mb-2">
{{ $t('mcp.env') }}
</FormLabel>
<FormControl>
<TagsInput
:add-on-blur="true"
:model-value="envTags.tagList.value"
:convert-value="envTags.convertValue"
@update:model-value="(tags) => envTags.handleUpdate(tags.map(String), componentField['onUpdate:modelValue'])"
>
<TagsInputItem
v-for="(value, index) in envTags.tagList.value"
:key="index"
:value="value"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:placeholder="$t('mcp.envPlaceholder')"
class="w-full py-1"
/>
</TagsInput>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<!-- Active -->
<FormField
v-slot="{ componentField }"
name="active"
>
<FormItem>
<FormControl>
<section class="flex gap-4">
<Label>{{ $t('mcp.active') }}</Label>
<Switch
:model-value="componentField.modelValue"
@update:model-value="componentField['onUpdate:modelValue']"
/>
</section>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-4">
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button type="submit">
{{ $t('mcp.addTitle') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
</template>
<script setup lang="ts">
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
FormField,
FormControl,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
FormItem,
FormLabel,
FormMessage,
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText,
Switch,
Label,
} from '@memoh/ui'
import z from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { ref, inject, watch } from 'vue'
import { type MCPListItem as MCPType } from '@/composables/api/useMcp'
import { useKeyValueTags } from '@/composables/useKeyValueTags'
import { useCreateOrUpdateMcp } from '@/composables/api/useMcp'
const envTags = useKeyValueTags()
const validateSchema = toTypedSchema(z.object({
name: z.string().min(1),
config: z.object({
type: z.string().min(1),
command: z.string().min(1),
args: z.array(z.coerce.string().check(z.minLength(1))).min(1),
env: z.looseObject({}),
cwd: z.string().min(1),
}),
active: z.coerce.boolean(),
}))
const form = useForm({
validationSchema: validateSchema,
})
const { mutate: fetchMCP } = useCreateOrUpdateMcp()
const open = inject('open', ref(false))
const mcpEditData = inject('mcpEditData', ref<{
name: string
config: MCPType['config']
active: boolean
id: string
} | null>(null))
watch(open, () => {
if (open.value && mcpEditData.value) {
form.setValues(mcpEditData.value)
envTags.initFromObject(mcpEditData.value.config?.env as Record<string, string>)
}
if (!open.value) {
mcpEditData.value = null
}
}, { immediate: true })
const createMCP = form.handleSubmit(async (value) => {
try {
await fetchMCP({ ...value, id: mcpEditData.value?.id })
open.value = false
} catch {
return
}
})
</script>
@@ -1,70 +0,0 @@
import { client } from '@memoh/sdk/client'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
// ---- Types ----
export interface MCPListItem {
id: string
type: string
name: string
config: {
cwd: string
env: Record<string, string>
args: string[]
type: string
command: string
}
active: boolean
user: string
createdAt: string
updatedAt: string
}
export interface CreateMcpRequest {
name: string
config: Record<string, unknown>
active: boolean
}
export interface UpdateMcpRequest extends CreateMcpRequest {
id?: string
}
// ---- Query: MCP list ----
export function useMcpList() {
return useQuery({
key: ['mcp'],
query: async () => {
const { data } = await client.get({
url: '/mcp/',
throwOnError: true,
}) as { data: { items: MCPListItem[] } }
return data.items
},
})
}
// ---- Mutations ----
export function useCreateOrUpdateMcp() {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: UpdateMcpRequest) => {
const isEdit = !!data.id
return isEdit
? client.put({ url: `/mcp/${data.id}`, body: data, throwOnError: true })
: client.post({ url: '/mcp/', body: data, throwOnError: true })
},
onSettled: () => queryCache.invalidateQueries({ key: ['mcp'] }),
})
}
export function useDeleteMcp() {
const queryCache = useQueryCache()
return useMutation({
mutation: (id: string) =>
client.delete({ url: `/mcp/${id}`, throwOnError: true }),
onSettled: () => queryCache.invalidateQueries({ key: ['mcp'] }),
})
}
@@ -1,7 +1,7 @@
import { ref } from 'vue'
/**
* TagsInput key:value two-way conversion for add-platform config and create-mcp env.
* TagsInput key:value two-way conversion for platform config and MCP env/headers.
* Input: string[] of "key:value"; output: Record<string, string> via callback.
*/
export function useKeyValueTags() {
+18 -2
View File
@@ -11,7 +11,11 @@
"loading": "Loading...",
"operation": "Actions",
"enable": "Enable",
"optional": "optional"
"optional": "optional",
"import": "Import",
"export": "Export",
"copy": "Copy",
"copied": "Copied to clipboard"
},
"auth": {
"welcome": "Welcome Back",
@@ -167,7 +171,19 @@
"cwd": "Working Directory",
"arguments": "Arguments",
"env": "Environment"
}
},
"empty": "No MCP servers configured yet.",
"deleteConfirm": "Are you sure you want to delete this MCP server?",
"loadFailed": "Failed to load MCP servers",
"saveFailed": "Failed to save MCP server",
"createSuccess": "MCP server created",
"updateSuccess": "MCP server updated",
"deleteSuccess": "MCP server deleted",
"deleteFailed": "Failed to delete MCP server",
"importHint": "Paste a standard mcpServers JSON configuration. Existing servers with the same name will be updated.",
"importSuccess": "MCP servers imported",
"importFailed": "Failed to import MCP servers",
"exportFailed": "Failed to export MCP servers"
},
"home": {
"title": "Home"
+18 -2
View File
@@ -11,7 +11,11 @@
"loading": "加载中...",
"operation": "操作",
"enable": "启用",
"optional": "可选"
"optional": "可选",
"import": "导入",
"export": "导出",
"copy": "复制",
"copied": "已复制到剪贴板"
},
"auth": {
"welcome": "欢迎回来",
@@ -167,7 +171,19 @@
"cwd": "工作目录",
"arguments": "参数",
"env": "环境变量"
}
},
"empty": "暂未配置 MCP 服务器。",
"deleteConfirm": "确定要删除这个 MCP 服务器吗?",
"loadFailed": "加载 MCP 服务器列表失败",
"saveFailed": "保存 MCP 服务器失败",
"createSuccess": "MCP 服务器已创建",
"updateSuccess": "MCP 服务器已更新",
"deleteSuccess": "MCP 服务器已删除",
"deleteFailed": "删除 MCP 服务器失败",
"importHint": "粘贴标准 mcpServers JSON 配置。同名的服务器会被更新配置。",
"importSuccess": "MCP 服务器已导入",
"importFailed": "导入 MCP 服务器失败",
"exportFailed": "导出 MCP 服务器失败"
},
"home": {
"title": "首页"
@@ -0,0 +1,620 @@
<template>
<div class="max-w-4xl mx-auto space-y-5">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1 min-w-0">
<h3 class="text-lg font-semibold">
{{ $t('mcp.addTitle') }}
</h3>
<p class="text-sm text-muted-foreground">
{{ $t('mcp.addDescription') }}
</p>
</div>
<div class="flex flex-wrap gap-2 shrink-0 justify-end">
<Button
variant="outline"
size="sm"
:disabled="loading"
@click="loadList"
>
<Spinner
v-if="loading"
class="mr-1.5"
/>
{{ $t('bots.container.actions.refresh') }}
</Button>
<Button
variant="outline"
size="sm"
@click="handleExport"
>
{{ $t('common.export') }}
</Button>
<Button
variant="outline"
size="sm"
@click="importDialogOpen = true"
>
{{ $t('common.import') }}
</Button>
<Button
size="sm"
@click="openCreateDialog"
>
{{ $t('common.add') }}
</Button>
</div>
</div>
<!-- Loading -->
<div
v-if="loading && items.length === 0"
class="flex items-center gap-2 text-sm text-muted-foreground"
>
<Spinner />
<span>{{ $t('common.loading') }}</span>
</div>
<!-- Empty -->
<div
v-else-if="items.length === 0"
class="rounded-md border p-4"
>
<p class="text-sm text-muted-foreground">
{{ $t('mcp.empty') }}
</p>
</div>
<!-- Table -->
<DataTable
v-else
:columns="columns"
:data="items"
/>
<!-- Create/Edit dialog -->
<Dialog v-model:open="formDialogOpen">
<DialogContent class="sm:max-w-lg">
<form @submit.prevent="handleSubmit">
<DialogHeader>
<DialogTitle>{{ editingItem ? $t('common.edit') : $t('common.add') }} MCP Server</DialogTitle>
</DialogHeader>
<div class="mt-4 flex flex-col gap-3">
<div class="space-y-1.5">
<Label>{{ $t('mcp.name') }}</Label>
<Input
v-model="formData.name"
:placeholder="$t('mcp.namePlaceholder')"
/>
</div>
<div class="space-y-1.5">
<Label>{{ $t('mcp.command') }} <span class="text-muted-foreground text-xs">({{ $t('common.optional') }})</span></Label>
<Input
v-model="formData.command"
:placeholder="$t('mcp.commandPlaceholder')"
:disabled="!!formData.url"
/>
</div>
<div class="space-y-1.5">
<Label>URL <span class="text-muted-foreground text-xs">({{ $t('common.optional') }})</span></Label>
<Input
v-model="formData.url"
placeholder="https://example.com/mcp"
:disabled="!!formData.command"
/>
</div>
<div
v-if="formData.command"
class="space-y-1.5"
>
<Label>{{ $t('mcp.arguments') }}</Label>
<TagsInput
v-model="argsTags"
:add-on-blur="true"
:duplicate="true"
>
<TagsInputItem
v-for="item in argsTags"
:key="item"
:value="item"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:placeholder="$t('mcp.argumentsPlaceholder')"
class="w-full py-1"
/>
</TagsInput>
</div>
<div
v-if="formData.command"
class="space-y-1.5"
>
<Label>{{ $t('mcp.env') }}</Label>
<TagsInput
:model-value="envTags.tagList.value"
:add-on-blur="true"
:convert-value="envTags.convertValue"
@update:model-value="(tags) => envTags.handleUpdate(tags.map(String))"
>
<TagsInputItem
v-for="(value, index) in envTags.tagList.value"
:key="index"
:value="value"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:placeholder="$t('mcp.envPlaceholder')"
class="w-full py-1"
/>
</TagsInput>
</div>
<div
v-if="formData.command"
class="space-y-1.5"
>
<Label>{{ $t('mcp.cwd') }}</Label>
<Input
v-model="formData.cwd"
:placeholder="$t('mcp.cwdPlaceholder')"
/>
</div>
<div
v-if="formData.url"
class="space-y-1.5"
>
<Label>Headers</Label>
<TagsInput
:model-value="headerTags.tagList.value"
:add-on-blur="true"
:convert-value="headerTags.convertValue"
@update:model-value="(tags) => headerTags.handleUpdate(tags.map(String))"
>
<TagsInputItem
v-for="(value, index) in headerTags.tagList.value"
:key="index"
:value="value"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
placeholder="Key:Value"
class="w-full py-1"
/>
</TagsInput>
</div>
<div
v-if="formData.url && !formData.command"
class="space-y-1.5"
>
<Label>Transport</Label>
<Select v-model="formData.transport">
<SelectTrigger class="w-full">
<SelectValue placeholder="http" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="http">
HTTP (Streamable)
</SelectItem>
<SelectItem value="sse">
SSE
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-3">
<Label>{{ $t('mcp.active') }}</Label>
<Switch v-model:checked="formData.active" />
</div>
</div>
<DialogFooter class="mt-6">
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
type="submit"
:disabled="submitting || !formData.name.trim() || (!formData.command.trim() && !formData.url.trim())"
>
<Spinner
v-if="submitting"
class="mr-1.5"
/>
{{ $t('common.confirm') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Import dialog -->
<Dialog v-model:open="importDialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ $t('common.import') }} mcpServers</DialogTitle>
</DialogHeader>
<div class="mt-4 space-y-3">
<p class="text-sm text-muted-foreground">
{{ $t('mcp.importHint') }}
</p>
<Textarea
v-model="importJson"
rows="10"
class="font-mono text-xs"
placeholder='{ "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"] } } }'
/>
</div>
<DialogFooter class="mt-4">
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
:disabled="importSubmitting || !importJson.trim()"
@click="handleImport"
>
<Spinner
v-if="importSubmitting"
class="mr-1.5"
/>
{{ $t('common.import') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Export dialog -->
<Dialog v-model:open="exportDialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ $t('common.export') }} mcpServers</DialogTitle>
</DialogHeader>
<div class="mt-4">
<Textarea
:model-value="exportJson"
rows="10"
class="font-mono text-xs"
readonly
/>
</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>
</div>
</template>
<script setup lang="ts">
import { h, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { type ColumnDef } from '@tanstack/vue-table'
import {
Badge,
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Spinner,
Switch,
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText,
Textarea,
} from '@memoh/ui'
import DataTable from '@/components/data-table/index.vue'
import { useKeyValueTags } from '@/composables/useKeyValueTags'
import { client } from '@memoh/sdk/client'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
interface McpItem {
id: string
name: string
type: string
config: Record<string, unknown>
is_active: boolean
}
const props = defineProps<{ botId: string }>()
const { t } = useI18n()
const loading = ref(false)
const items = ref<McpItem[]>([])
const formDialogOpen = ref(false)
const editingItem = ref<McpItem | null>(null)
const submitting = ref(false)
const importDialogOpen = ref(false)
const importJson = ref('')
const importSubmitting = ref(false)
const exportDialogOpen = ref(false)
const exportJson = ref('')
const formData = ref({
name: '',
command: '',
url: '',
cwd: '',
transport: 'http',
active: true,
})
const argsTags = ref<string[]>([])
const envTags = useKeyValueTags()
const headerTags = useKeyValueTags()
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 {}
}
const columns: ColumnDef<McpItem>[] = [
{
accessorKey: 'name',
header: () => h('div', { class: 'text-left py-4' }, t('mcp.table.name')),
},
{
accessorKey: 'type',
header: () => h('div', { class: 'text-left' }, t('mcp.table.type')),
cell: ({ row }) => h(Badge, { variant: 'outline' }, () => row.original.type),
},
{
id: 'target',
header: () => h('div', { class: 'text-left' }, 'Command / URL'),
cell: ({ row }) => {
const cfg = row.original.config ?? {}
return h('span', { class: 'font-mono text-xs' },
configValue(cfg, 'command') || configValue(cfg, 'url') || '-',
)
},
},
{
id: 'status',
header: () => h('div', { class: 'text-center' }, t('mcp.active')),
cell: ({ row }) => h('div', { class: 'text-center' },
h(Badge, { variant: row.original.is_active ? 'default' : 'secondary' },
() => row.original.is_active ? 'ON' : 'OFF'),
),
},
{
id: 'actions',
header: () => h('div', { class: 'text-center' }, t('common.operation')),
cell: ({ row }) => h('div', { class: 'flex gap-2 justify-center' }, [
h(Button, {
size: 'sm',
variant: 'outline',
onClick: () => openEditDialog(row.original),
}, () => t('common.edit')),
h(ConfirmPopover, {
message: t('mcp.deleteConfirm'),
onConfirm: () => handleDelete(row.original.id),
}, {
trigger: () => h(Button, {
size: 'sm',
variant: 'destructive',
}, () => t('common.delete')),
}),
]),
},
]
async function loadList() {
loading.value = true
try {
const { data } = await client.get({
url: `/bots/${props.botId}/mcp`,
throwOnError: true,
}) as { data: { items: McpItem[] } }
items.value = data.items ?? []
} catch (error) {
toast.error(resolveError(error, t('mcp.loadFailed')))
} finally {
loading.value = false
}
}
function openCreateDialog() {
editingItem.value = null
formData.value = { name: '', command: '', url: '', cwd: '', transport: 'http', active: true }
argsTags.value = []
envTags.initFromObject(null)
headerTags.initFromObject(null)
formDialogOpen.value = true
}
function openEditDialog(item: McpItem) {
editingItem.value = item
const cfg = item.config ?? {}
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')
envTags.initFromObject(configMap(cfg, 'env'))
headerTags.initFromObject(configMap(cfg, 'headers'))
formDialogOpen.value = true
}
function buildRequestBody() {
const body: Record<string, unknown> = {
name: formData.value.name.trim(),
is_active: formData.value.active,
}
if (formData.value.command.trim()) {
body.command = formData.value.command.trim()
if (argsTags.value.length > 0) body.args = argsTags.value
const env: Record<string, string> = {}
envTags.tagList.value.forEach((tag) => {
const [k, v] = tag.split(':')
if (k && v) env[k] = v
})
if (Object.keys(env).length > 0) body.env = env
if (formData.value.cwd.trim()) body.cwd = formData.value.cwd.trim()
} else if (formData.value.url.trim()) {
body.url = formData.value.url.trim()
const headers: Record<string, string> = {}
headerTags.tagList.value.forEach((tag) => {
const [k, v] = tag.split(':')
if (k && v) headers[k] = v
})
if (Object.keys(headers).length > 0) body.headers = headers
if (formData.value.transport === 'sse') body.transport = 'sse'
}
return body
}
async function handleSubmit() {
submitting.value = true
try {
const body = buildRequestBody()
if (editingItem.value) {
await client.put({
url: `/bots/${props.botId}/mcp/${editingItem.value.id}`,
body,
throwOnError: true,
})
} else {
await client.post({
url: `/bots/${props.botId}/mcp`,
body,
throwOnError: true,
})
}
formDialogOpen.value = false
await loadList()
toast.success(editingItem.value ? t('mcp.updateSuccess') : t('mcp.createSuccess'))
} catch (error) {
toast.error(resolveError(error, t('mcp.saveFailed')))
} finally {
submitting.value = false
}
}
async function handleDelete(id: string) {
try {
await client.delete({
url: `/bots/${props.botId}/mcp/${id}`,
throwOnError: true,
})
await loadList()
toast.success(t('mcp.deleteSuccess'))
} catch (error) {
toast.error(resolveError(error, t('mcp.deleteFailed')))
}
}
async function handleImport() {
importSubmitting.value = true
try {
let parsed = JSON.parse(importJson.value)
if (!parsed.mcpServers && typeof parsed === 'object') {
parsed = { mcpServers: parsed }
}
await client.put({
url: `/bots/${props.botId}/mcp/import`,
body: parsed,
throwOnError: true,
})
importDialogOpen.value = false
importJson.value = ''
await loadList()
toast.success(t('mcp.importSuccess'))
} catch (error) {
toast.error(resolveError(error, t('mcp.importFailed')))
} finally {
importSubmitting.value = false
}
}
async function handleExport() {
try {
const { data } = await client.get({
url: `/bots/${props.botId}/mcp/export`,
throwOnError: true,
}) as { data: { mcpServers: Record<string, unknown> } }
exportJson.value = JSON.stringify(data, null, 2)
exportDialogOpen.value = true
} catch (error) {
toast.error(resolveError(error, t('mcp.exportFailed')))
}
}
function handleCopyExport() {
navigator.clipboard.writeText(exportJson.value)
toast.success(t('common.copied'))
}
function resolveError(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim()) return error.message
if (error && typeof error === 'object' && 'message' in error) {
const msg = (error as { message?: string }).message
if (msg?.trim()) return msg
}
return fallback
}
watch(() => props.botId, () => {
if (props.botId) loadList()
}, { immediate: true })
</script>
+2 -1
View File
@@ -434,7 +434,7 @@
value="mcp"
class="mt-6"
>
<!-- TODO: MCP content -->
<BotMcp :bot-id="botId" />
</TabsContent>
<TabsContent
value="subagents"
@@ -550,6 +550,7 @@ import type {
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import BotSettings from './components/bot-settings.vue'
import BotChannels from './components/bot-channels.vue'
import BotMcp from './components/bot-mcp.vue'
type BotCheck = BotsBotCheck
type BotContainerInfo = HandlersGetContainerResponse
-106
View File
@@ -1,106 +0,0 @@
<template>
<section class="[&_td:last-child]:w-40">
<CreateMCP />
<DataTable
:columns="columns"
:data="mcpFormatData"
/>
</section>
</template>
<script setup lang="ts">
import { h, provide, ref, computed } from 'vue'
import DataTable from '@/components/data-table/index.vue'
import CreateMCP from '@/components/create-mcp/index.vue'
import { type ColumnDef } from '@tanstack/vue-table'
import {
Badge,
Button
} from '@memoh/ui'
import { type MCPListItem as MCPType } from '@/composables/api/useMcp'
import { useMcpList, useDeleteMcp } from '@/composables/api/useMcp'
import { useI18n } from 'vue-i18n'
const open = ref(false)
const editMCPData = ref<{
name: string
config: MCPType['config']
active: boolean
id: string
} | null>(null)
provide('open', open)
provide('mcpEditData', editMCPData)
const { mutate: DeleteMCP } = useDeleteMcp()
const { t } = useI18n()
const columns:ColumnDef<MCPType>[] = [
{
accessorKey: 'name',
header: () => h('div', { class: 'text-left py-4' }, t('mcp.table.name')),
},
{
accessorKey: 'type',
header: () => h('div', { class: 'text-left' }, t('mcp.table.type')),
},
{
accessorKey: 'config.command',
header: () => h('div', { class: 'text-left' }, t('mcp.table.command')),
},
{
accessorKey: 'config.cwd',
header: () => h('div', { class: 'text-left' }, t('mcp.table.cwd')),
},
{
accessorKey: 'config.args',
header: () => h('div', { class: 'text-left' }, t('mcp.table.arguments')),
cell: ({ row }) => h('div', {class:'flex gap-4'}, row.original.config.args.map((argTxt) => {
return h(Badge, {
variant:'default'
},()=>argTxt)
}))
},
{
accessorKey: 'config.env',
header: () => h('div', { class: 'text-left' }, t('mcp.table.env')),
cell: ({ row }) => h('div', { class: 'flex gap-4' }, Object.entries(row.original.config.env).map(([key,value]) => {
return h(Badge, {
variant: 'outline'
}, ()=>`${key}:${value}`)
}))
},
{
accessorKey: 'control',
header: () => h('div', { class: 'text-center' }, t('common.operation')),
cell: ({ row }) => h('div', {class:'flex gap-2'}, [
h(Button, {
onClick() {
editMCPData.value = {
name: row.original.name,
config: {...row.original.config},
active: row.original.active,
id:row.original.id
}
open.value=true
}
}, ()=>t('common.edit')),
h(Button, {
variant: 'destructive',
async onClick() {
try {
await DeleteMCP(row.original.id)
} catch {
return
}
}
},()=>t('common.delete'))
])
}
]
const { data: mcpData } = useMcpList()
const mcpFormatData = computed(() => mcpData.value ?? [])
</script>
-8
View File
@@ -75,14 +75,6 @@ const routes = [
breadcrumb: i18nRef('settings.user'),
},
},
{
name: 'mcp',
path: '/mcp',
component: () => import('@/pages/mcp/index.vue'),
meta: {
breadcrumb: i18nRef('sidebar.mcp'),
},
},
{
name: 'platform',
path: '/platform',