添加MCP客户端超时配置和连接管理改进
添加了MCP客户端的超时配置功能,包括启动超时和工具调用超时参数。 改进了HTTP客户端和标准IO客户端的超时处理机制,确保请求在指定时间内完成或取消。 增加了对MCP服务器数量的限制,防止配置过多服务器导致系统不稳定。 增强了错误处理机制,当连接异常时能够正确清理资源并移除桥接主机。 添加了对大型MCP消息的大小验证,防止过大的请求导致系统问题。 优化了密钥更改后的处理流程,确保在修改密钥时停用服务器并要求重新测试。
This commit is contained in:
@@ -81,4 +81,15 @@ describe('McpServersView', () => {
|
||||
expect(confirm).toHaveBeenCalled()
|
||||
expect(service.deleteMcpServer).toHaveBeenCalledWith('server-1')
|
||||
})
|
||||
|
||||
it('confirms permission changes before updating an existing server', async () => {
|
||||
const wrapper = await render([server])
|
||||
vi.mocked(service.updateMcpServer).mockResolvedValue(server)
|
||||
await wrapper.findAll('button').find(button => button.text().includes('编辑'))!.trigger('click')
|
||||
await wrapper.get('input[placeholder="network.request, notes.read"]').setValue('notes.read')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效'))
|
||||
expect(service.updateMcpServer).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +142,20 @@ async function save() {
|
||||
}
|
||||
|
||||
function executionChanged(server: McpServer, input: McpServerInput) {
|
||||
return JSON.stringify([server.transport, server.command, server.args, server.url, server.headers, Object.keys(server.secret_headers)]) !== JSON.stringify([input.transport, input.command, input.args, input.url, input.headers, input.secret_header_keys])
|
||||
const sortedEntries = (value: Record<string, string>) => Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
|
||||
const current = [
|
||||
server.transport, server.command, server.args, server.url,
|
||||
sortedEntries(server.headers), sortedEntries(server.environment),
|
||||
Object.keys(server.secret_headers).sort(), Object.keys(server.secret_environment).sort(),
|
||||
[...server.permissions].sort(), server.startup_timeout_seconds, server.tool_timeout_seconds,
|
||||
]
|
||||
const next = [
|
||||
input.transport, input.command, input.args, input.url,
|
||||
sortedEntries(input.headers), sortedEntries(input.environment),
|
||||
[...input.secret_header_keys].sort(), [...input.secret_environment_keys].sort(),
|
||||
[...input.permissions].sort(), input.startup_timeout_seconds, input.tool_timeout_seconds,
|
||||
]
|
||||
return JSON.stringify(current) !== JSON.stringify(next)
|
||||
}
|
||||
|
||||
async function approve(server: McpServer): Promise<McpServer | null> {
|
||||
|
||||
@@ -74,4 +74,33 @@ describe('FileTreePanel file switching', () => {
|
||||
expect(editorStore.content).toContain('# 二叉搜索树')
|
||||
expect(editorStore.currentNoteId).toBe('note-bst')
|
||||
})
|
||||
|
||||
it('creates a Markdown note inside the selected folder', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/workspace', component: { template: '<div />' } }],
|
||||
})
|
||||
await router.push('/workspace')
|
||||
await router.isReady()
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
await workspaceStore.openVault('C:/vault')
|
||||
const createFile = vi.spyOn(workspaceService, 'createFile').mockResolvedValue({
|
||||
id: 'note-new', note_id: 'note-new', name: '新笔记.md',
|
||||
path: '/数据结构/新笔记.md', type: 'file',
|
||||
})
|
||||
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
|
||||
|
||||
await wrapper.findAll('.tree-node').find((node) => node.text().includes('数据结构'))!.trigger('click')
|
||||
await wrapper.get('button[aria-label="新建笔记"]').trigger('click')
|
||||
await wrapper.get('.new-item input').setValue('新笔记')
|
||||
await wrapper.get('.new-item').trigger('submit')
|
||||
await waitForPath('/数据结构/新笔记.md')
|
||||
await vi.waitFor(() => {
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/新笔记.md')
|
||||
})
|
||||
|
||||
expect(createFile).toHaveBeenCalledWith('/数据结构', '新笔记.md', '# 新笔记\n\n')
|
||||
expect(wrapper.findAll('.tree-node').some((node) => node.classes().includes('active') && node.text().includes('新笔记.md'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { FileNode } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
@@ -15,9 +15,19 @@ const router = useRouter()
|
||||
const newItemType = ref<'file' | 'folder' | null>(null)
|
||||
const newItemName = ref('')
|
||||
const parentPath = ref('/')
|
||||
const selectedTreePath = ref(workspaceStore.activeFilePath ?? '/')
|
||||
const selectedFolderPath = ref(
|
||||
workspaceStore.activeFilePath ? containingFolder(workspaceStore.activeFilePath) : '/',
|
||||
)
|
||||
const contextTarget = ref<FileNode | null>(null)
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
watch(() => workspaceStore.activeFilePath, (path) => {
|
||||
if (!path) return
|
||||
selectedTreePath.value = path
|
||||
selectedFolderPath.value = containingFolder(path)
|
||||
})
|
||||
|
||||
function beginCreate(type: 'file' | 'folder', parent = '/') {
|
||||
newItemType.value = type
|
||||
newItemName.value = ''
|
||||
@@ -31,19 +41,28 @@ async function createItem() {
|
||||
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
||||
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
|
||||
workspaceStore.addFileToTree(parentPath.value, file)
|
||||
selectedTreePath.value = file.path
|
||||
selectedFolderPath.value = parentPath.value
|
||||
await editorStore.loadFile(file.path)
|
||||
workspaceStore.openFile(file.path)
|
||||
await router.push('/workspace')
|
||||
} else {
|
||||
const folder = await workspaceService.createFolder(parentPath.value, rawName)
|
||||
workspaceStore.addFileToTree(parentPath.value, folder)
|
||||
selectedTreePath.value = folder.path
|
||||
selectedFolderPath.value = folder.path
|
||||
}
|
||||
newItemType.value = null
|
||||
newItemName.value = ''
|
||||
}
|
||||
|
||||
async function openNode(node: FileNode) {
|
||||
if (node.type === 'folder') return workspaceStore.toggleFolder(node.path)
|
||||
selectedTreePath.value = node.path
|
||||
if (node.type === 'folder') {
|
||||
selectedFolderPath.value = node.path
|
||||
return workspaceStore.toggleFolder(node.path)
|
||||
}
|
||||
selectedFolderPath.value = containingFolder(node.path)
|
||||
// 先同步活动文件,让真实点击立即生效;内容加载失败时再恢复原状态。
|
||||
const previousPath = workspaceStore.activeFilePath
|
||||
const wasOpen = workspaceStore.openFiles.includes(node.path)
|
||||
@@ -61,6 +80,8 @@ async function openNode(node: FileNode) {
|
||||
function openContextMenu(event: MouseEvent, node: FileNode) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
selectedTreePath.value = node.path
|
||||
selectedFolderPath.value = node.type === 'folder' ? node.path : containingFolder(node.path)
|
||||
contextTarget.value = node
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
@@ -79,6 +100,12 @@ async function renameTarget() {
|
||||
await workspaceService.renameFile(oldPath, normalizedName)
|
||||
workspaceStore.renamePath(oldPath, newPath, normalizedName)
|
||||
editorStore.renameFilePath(oldPath, newPath)
|
||||
if (selectedTreePath.value === oldPath || selectedTreePath.value.startsWith(`${oldPath}/`)) {
|
||||
selectedTreePath.value = `${newPath}${selectedTreePath.value.slice(oldPath.length)}`
|
||||
}
|
||||
if (selectedFolderPath.value === oldPath || selectedFolderPath.value.startsWith(`${oldPath}/`)) {
|
||||
selectedFolderPath.value = `${newPath}${selectedFolderPath.value.slice(oldPath.length)}`
|
||||
}
|
||||
}
|
||||
closeContextMenu()
|
||||
}
|
||||
@@ -90,19 +117,28 @@ async function deleteTarget() {
|
||||
await workspaceService.deleteFile(node.path)
|
||||
const activeWasRemoved = workspaceStore.closePath(node.path)
|
||||
workspaceStore.removeFromTree(node.path)
|
||||
if (selectedTreePath.value === node.path || selectedTreePath.value.startsWith(`${node.path}/`)) {
|
||||
selectedTreePath.value = containingFolder(node.path)
|
||||
selectedFolderPath.value = selectedTreePath.value
|
||||
}
|
||||
if (activeWasRemoved) {
|
||||
editorStore.closeFile()
|
||||
if (workspaceStore.activeFilePath) await editorStore.loadFile(workspaceStore.activeFilePath)
|
||||
}
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function containingFolder(path: string): string {
|
||||
const separator = path.lastIndexOf('/')
|
||||
return separator > 0 ? path.slice(0, separator) : '/'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="file-tree-panel" @click="closeContextMenu">
|
||||
<div class="toolbar">
|
||||
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file')"><AppIcon :icon="DocumentAdd" /></button>
|
||||
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder')"><AppIcon :icon="FolderAdd" /></button>
|
||||
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button>
|
||||
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button>
|
||||
</div>
|
||||
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
|
||||
<input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus />
|
||||
@@ -111,7 +147,7 @@ async function deleteTarget() {
|
||||
</form>
|
||||
<div class="tree">
|
||||
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
|
||||
:active-path="workspaceStore.activeFilePath" @open="openNode" @context-menu="openContextMenu" />
|
||||
:active-path="selectedTreePath" @open="openNode" @context-menu="openContextMenu" />
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="contextTarget" class="context-menu"
|
||||
|
||||
Reference in New Issue
Block a user