Jelajahi Sumber

完善服务器 MCP 调用说明并修复 Bot 授权管理

samien 1 bulan lalu
induk
melakukan
1ea6954e69

+ 34 - 11
src/client/components/workspace/BotPluginsPanel.vue

@@ -11,8 +11,10 @@ const base = '/api/app/bot-tools'
 const surface = ref<'apps' | 'mcp'>('apps'), view = ref('marketplace')
 const settings = ref<BotPluginSettings>({ configured: false, revision: 0 }), canManage = ref(false)
 const plugins = ref<BotMcpPlugin[]>([]), cards = ref<BotAppCard[]>([]), connections = ref<BotAppConnection[]>([]), agents = ref<WorkspaceAgent[]>([])
+const agentsLoaded = ref(false)
 const loading = ref(true), busy = ref(false), error = ref(''), notice = ref(''), search = ref(''), apiKey = ref(''), authConfigs = ref('{}')
 const editing = ref<string | null>(null), editorOpen = ref(false), grantSlug = ref(''), grantIds = ref<string[]>([]), aliasSlug = ref(''), alias = ref('')
+const mcpGrantId = ref(''), mcpGrantIds = ref<string[]>([]), mcpGrantRevision = ref(0)
 const form = reactive({ name: '', transport: 'stdio' as 'stdio' | 'http', command: 'npx', args: '[]', url: '', env: '{}', headers: '{}', agentIds: [] as string[], revision: 0 })
 const pendingUrls = ref<Record<string, string>>({})
 const visibleCount = ref(40)
@@ -20,6 +22,7 @@ watch([search, view], () => { visibleCount.value = 40 })
 let closed = false, generation = 0, timer: ReturnType<typeof setTimeout> | undefined
 const call = <T,>(path: string, method: string, body?: unknown) => apiRequest<T>(base + path, { method, ...(body === undefined ? {} : { body: body as JsonValue }) })
 const selectable = computed(() => agents.value.filter(a => !a.archived && !a.remoteAgentId && !a.temporaryGoalId))
+const validAgentIds = (ids: string[]) => ids.filter(id => selectable.value.some(agent => agent.id === id))
 const filtered = computed(() => {
   const all = new Map(cards.value.map(c => [c.slug, c]))
   for (const c of connections.value) if (!all.has(c.slug)) all.set(c.slug, { slug: c.slug, name: c.slug, description: '' })
@@ -28,7 +31,7 @@ const filtered = computed(() => {
 })
 const connected = (slug: string) => connections.value.find(c => c.slug === slug)
 const stateLabel = (status: string) => ({ ACTIVE: '已连接', INITIATED: '待授权', INITIALIZING: '连接中', PENDING: '待授权', EXPIRED: '已过期', FAILED: '连接失败', INACTIVE: '未启用' }[status.toUpperCase()] ?? status)
-const grantNames = (ids: string[]) => ids.map(id => agents.value.find(a => a.id === id)?.name).filter(Boolean).join('、') || '尚未授权 Bot'
+const grantNames = (ids: string[]) => !agentsLoaded.value ? 'Bot 授权暂不可读' : ids.map(id => selectable.value.find(a => a.id === id)?.name).filter(Boolean).join('、') || '尚未授权 Bot'
 async function load() {
   const version = ++generation
   const results = await Promise.allSettled([
@@ -44,6 +47,7 @@ async function load() {
   if (m.status === 'fulfilled') plugins.value = m.value.plugins
   if (c.status === 'fulfilled') cards.value = c.value.cards
   if (a.status === 'fulfilled' && a.value.authoritative) connections.value = a.value.connections
+  agentsLoaded.value = bots.status === 'fulfilled'
   if (bots.status === 'fulfilled') agents.value = bots.value.agents
   error.value = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason instanceof Error ? r.reason.message : '读取失败,请重试').join(';')
   loading.value = false
@@ -55,8 +59,9 @@ async function action(fn: () => Promise<void>) {
   finally { busy.value = false }
 }
 function edit(plugin?: BotMcpPlugin) {
+  mcpGrantId.value = ''
   editing.value = plugin?.id ?? null
-  Object.assign(form, { name: plugin?.name ?? '', transport: plugin?.transport ?? 'stdio', command: plugin?.command ?? 'npx', args: JSON.stringify(plugin?.args ?? [], null, 2), url: plugin?.url ?? '', env: JSON.stringify(Object.fromEntries((plugin?.envKeys ?? []).map(k => [k, true])), null, 2), headers: JSON.stringify(Object.fromEntries((plugin?.headerKeys ?? []).map(k => [k, true])), null, 2), agentIds: [...(plugin?.agentIds ?? [])], revision: plugin?.revision ?? 0 })
+  Object.assign(form, { name: plugin?.name ?? '', transport: plugin?.transport ?? 'stdio', command: plugin?.command ?? 'npx', args: JSON.stringify(plugin?.args ?? [], null, 2), url: plugin?.url ?? '', env: JSON.stringify(Object.fromEntries((plugin?.envKeys ?? []).map(k => [k, true])), null, 2), headers: JSON.stringify(Object.fromEntries((plugin?.headerKeys ?? []).map(k => [k, true])), null, 2), agentIds: validAgentIds(plugin?.agentIds ?? []), revision: plugin?.revision ?? 0 })
   editorOpen.value = true; emit('dirty-change', false)
 }
 function closeEditor() { editorOpen.value = false; emit('dirty-change', false) }
@@ -69,9 +74,20 @@ async function save() {
     closeEditor(); notice.value = '已保存。测试连接成功后即可启用。'
   })
 }
-async function test(plugin: BotMcpPlugin) { await action(async () => { const result = await call<{ plugin: BotMcpPlugin }>('/mcp/' + plugin.id + '/test', 'POST', {}); notice.value = `连接成功,发现 ${result.plugin.toolCount} 个工具。` }) }
+async function test(plugin: BotMcpPlugin) { await action(async () => { const result = await call<{ plugin: BotMcpPlugin }>('/mcp/' + plugin.id + '/test', 'POST', {}); notice.value = `服务器连接成功,发现 ${result.plugin.toolCount} 个工具。服务登录状态需另行确认。` }) }
 async function toggle(plugin: BotMcpPlugin) { await action(async () => { await call('/mcp/' + plugin.id, 'PATCH', { enabled: !plugin.enabled, revision: plugin.revision }) }) }
-async function remove(plugin: BotMcpPlugin) { if (window.confirm(`移除插件“${plugin.name}”?此插件将不再提供给 Bot。`)) await action(async () => { await call('/mcp/' + plugin.id, 'DELETE') }) }
+async function remove(plugin: BotMcpPlugin) { if (window.confirm(`移除插件“${plugin.name}”?此插件将不再提供给 Bot。`)) await action(async () => { await call('/mcp/' + plugin.id, 'DELETE'); if (mcpGrantId.value === plugin.id) closeMcpGrant() }) }
+function openMcpGrant(plugin: BotMcpPlugin) {
+  closeEditor()
+  mcpGrantId.value = plugin.id; mcpGrantIds.value = validAgentIds(plugin.agentIds); mcpGrantRevision.value = plugin.revision
+}
+function closeMcpGrant() { mcpGrantId.value = ''; emit('dirty-change', false) }
+async function saveMcpGrant() {
+  await action(async () => {
+    await call('/mcp/' + mcpGrantId.value, 'PATCH', { agentIds: mcpGrantIds.value, revision: mcpGrantRevision.value })
+    closeMcpGrant(); notice.value = 'Bot 授权已保存,将用于下一轮对话。'
+  })
+}
 async function configure(clear = false) {
   if (clear && !window.confirm('清除当前账号的应用连接服务配置?Bot 将暂时无法使用这些应用。')) return
   await action(async () => {
@@ -123,25 +139,32 @@ onBeforeUnmount(() => { closed = true; generation++; clearTimeout(timer) })
     <p v-if="notice" class="plugin-notice" role="status">{{ notice }}</p>
     <p v-if="loading" role="status">正在读取插件…</p>
     <template v-else-if="surface === 'mcp'">
-      <div class="plugin-toolbar"><p>自定义 MCP 服务</p><button :disabled="!canManage || busy" @click="edit()"><AppIcon name="plus" :size="16" />添加服务</button></div>
-      <p class="hint">本机程序在 Web 服务所在主机运行。新增或修改后需重新测试并启用,仅向所选 Bot 提供工具。</p>
+      <div class="plugin-toolbar"><p>自定义 MCP 服务</p><button :disabled="!canManage || busy || !agentsLoaded" @click="edit()"><AppIcon name="plus" :size="16" />添加服务</button></div>
+      <p class="hint">stdio 程序及其路径在当前连接的夭夭服务器上运行和读取;HTTP / HTTPS 地址也由服务器连接。客户端通过服务器使用已授权的工具,无需安装 MCP 程序。</p>
       <p v-if="!canManage" class="hint">自定义 MCP 服务由管理员管理。</p>
       <form v-if="editorOpen" class="plugin-editor" aria-label="MCP 服务配置" @submit.prevent="save" @input="emit('dirty-change', true)" @change="emit('dirty-change', true)">
         <h4>{{ editing ? '编辑 MCP 服务' : '添加 MCP 服务' }}</h4>
         <label>名称<input v-model="form.name" required maxlength="80" /></label>
-        <label>连接方式<select v-model="form.transport"><option value="stdio">本机程序(stdio)</option><option value="http">远程地址(HTTP)</option></select></label>
-        <template v-if="form.transport === 'stdio'"><label>程序<input v-model="form.command" required placeholder="npx 或程序的完整路径" /></label><label>参数(JSON 数组)<textarea v-model="form.args" rows="3" spellcheck="false" /></label><label>环境变量(JSON)<textarea v-model="form.env" rows="3" spellcheck="false" autocomplete="off" /></label></template>
+        <label>连接方式<select v-model="form.transport"><option value="stdio">服务器程序(stdio)</option><option value="http">远程地址(HTTP / HTTPS)</option></select></label>
+        <template v-if="form.transport === 'stdio'"><label>服务器程序<input v-model="form.command" required placeholder="服务器上的 npx 或程序完整路径" /></label><label>参数(JSON 数组)<textarea v-model="form.args" rows="3" spellcheck="false" /></label><label>环境变量(JSON)<textarea v-model="form.env" rows="3" spellcheck="false" autocomplete="off" /></label><p class="hint">程序和参数中的文件路径须在服务器上可用。所需账号配置和环境变量也须提供给服务器上的 MCP 进程。</p></template>
         <template v-else><label>MCP 地址<input v-model="form.url" type="url" required placeholder="https://example.com/mcp" /></label><label>请求头(JSON)<textarea v-model="form.headers" rows="3" spellcheck="false" autocomplete="off" /></label></template>
         <p class="hint">凭据请放在环境变量或请求头中。已保存的值用 true 表示保留,不会回显。</p>
         <fieldset><legend>允许使用的 Bot</legend><label v-for="agent in selectable" :key="agent.id" class="check-row"><input v-model="form.agentIds" type="checkbox" :value="agent.id" />{{ agent.name }}</label><p v-if="!selectable.length" class="hint">创建 Bot 后可在这里授权。</p></fieldset>
-        <div class="editor-actions"><button type="button" :disabled="busy" @click="closeEditor">取消</button><button type="submit" class="primary" :disabled="busy">{{ busy ? '保存中…' : '保存服务' }}</button></div>
+        <div class="editor-actions"><button type="button" :disabled="busy" @click="closeEditor">取消</button><button type="submit" class="primary" :disabled="busy || !agentsLoaded">{{ busy ? '保存中…' : '保存服务' }}</button></div>
       </form>
       <p v-if="!plugins.length && !editorOpen" class="plugin-empty">还没有 MCP 服务。添加服务并测试连接后,即可授权 Bot 使用。</p>
       <article v-for="plugin in plugins" :key="plugin.id" class="mcp-card">
         <div class="card-heading"><AppIcon name="tools" :size="20" /><h4>{{ plugin.name }}</h4><span class="badge" :class="{ enabled: plugin.enabled }">{{ plugin.enabled ? '已启用' : '未启用' }}</span></div>
         <p class="hint endpoint">{{ plugin.transport === 'stdio' ? plugin.command : plugin.url }}</p>
-        <p class="hint">{{ plugin.testedAt ? `已验证 · ${plugin.toolCount} 个工具` : '尚未测试连接' }} · {{ grantNames(plugin.agentIds) }}</p>
-        <div class="card-actions"><button :disabled="busy || !canManage" @click="test(plugin)">测试连接</button><button :disabled="busy || !canManage || (!plugin.enabled && !plugin.testedAt)" @click="toggle(plugin)">{{ plugin.enabled ? '停用' : '启用' }}</button><button :disabled="busy || !canManage" @click="edit(plugin)">编辑</button><button class="danger" :disabled="busy || !canManage" @click="remove(plugin)">移除</button></div>
+        <p class="hint">{{ plugin.transport === 'stdio' ? '运行于夭夭服务器' : '由夭夭服务器连接' }} · {{ plugin.testedAt ? `已发现 ${plugin.toolCount} 个工具` : '尚未测试连接' }} · {{ grantNames(plugin.agentIds) }}</p>
+        <p v-if="agentsLoaded && validAgentIds(plugin.agentIds).length !== plugin.agentIds.length" class="hint">部分 Bot 授权已失效,请重新选择可使用此服务的 Bot。</p>
+        <p v-if="agentsLoaded && plugin.enabled && !validAgentIds(plugin.agentIds).length" class="hint">服务已启用,尚无可使用它的 Bot。请先授权 Bot。</p>
+        <div class="card-actions"><button :disabled="busy || !canManage" @click="test(plugin)">测试连接</button><button :disabled="busy || !canManage || (!plugin.enabled && !plugin.testedAt)" @click="toggle(plugin)">{{ plugin.enabled ? '停用' : '启用' }}</button><button :disabled="busy || !canManage || !agentsLoaded" @click="openMcpGrant(plugin)">授权 Bot</button><button :disabled="busy || !canManage || !agentsLoaded" @click="edit(plugin)">编辑</button><button class="danger" :disabled="busy || !canManage" @click="remove(plugin)">移除</button></div>
+        <form v-if="mcpGrantId === plugin.id" class="grant-editor" aria-label="MCP Bot 授权" @submit.prevent="saveMcpGrant" @change="emit('dirty-change', true)">
+          <fieldset><legend>允许使用的 Bot</legend><label v-for="agent in selectable" :key="agent.id" class="check-row"><input v-model="mcpGrantIds" type="checkbox" :value="agent.id" :disabled="busy" />{{ agent.name }}</label><p v-if="!selectable.length" class="hint">创建 Bot 后可在这里授权。</p></fieldset>
+          <p class="hint">授权按 Bot 保存,同一账号在各客户端均可使用。更改授权后,连接验证结果和服务启用状态会保留。</p>
+          <div class="card-actions"><button type="button" :disabled="busy" @click="closeMcpGrant">取消</button><button type="submit" :disabled="busy || !canManage || !agentsLoaded">保存授权</button></div>
+        </form>
       </article>
     </template>
     <template v-else-if="!loading">

+ 4 - 0
src/server/workspaceStore.ts

@@ -638,6 +638,10 @@ export class WorkspaceStore {
       for (const conversation of conversations.filter(c => c.kind === 'direct' && c.memberIds[0] === id))
         this.deleteConversation(owner, conversation.id, true)
       for(const kind of ['routine','routine-run'])for(const item of this.list<{id:string;agentId:string}>(owner,kind).filter(item=>item.agentId===id))this.remove(owner,kind,item.id)
+      for (const plugin of this.list<{ id: string; agentIds: string[]; revision: number }>(owner, 'bot-mcp-plugin')) {
+        if (!plugin.agentIds.includes(id)) continue
+        this.put(owner, 'bot-mcp-plugin', plugin.id, { ...plugin, agentIds: plugin.agentIds.filter(agentId => agentId !== id), revision: plugin.revision + 1 })
+      }
       this.remove(owner, 'agent', id)
       this.event(owner, 'agent.deleted', { id })
     })

+ 43 - 4
tests/client/botPluginsPanel.test.ts

@@ -4,20 +4,22 @@ import BotPluginsPanel from '@/components/workspace/BotPluginsPanel.vue'
 import { apiRequest } from '@/api/client'
 
 vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
-let failInventory = false, canManage = true
+let failInventory = false, failAgents = false, canManage = true
+let agentIds: string[] = []
 const plugin = { id: 'plugin-1', name: '现有插件', transport: 'http', url: 'https://mcp.example.test', envKeys: [], headerKeys: ['Authorization'], agentIds: [], enabled: false, revision: 2, toolCount: 1, testedAt: 1 }
 beforeEach(() => {
-  failInventory = false; canManage = true
+  failInventory = false; failAgents = false; canManage = true; agentIds = []
   vi.mocked(apiRequest).mockReset().mockImplementation(async (path, options) => {
     if (options?.method && options.method !== 'GET') return {} as never
     if (path.endsWith('/settings')) return { configured: true, revision: 1, canManageMcp: canManage } as never
-    if (path.endsWith('/mcp')) return { plugins: [plugin], canManage } as never
+    if (path.endsWith('/mcp')) return { plugins: [{ ...plugin, agentIds }], canManage } as never
     if (path.endsWith('/apps/catalog')) return { cards: [{ slug: 'gmail', name: 'Gmail', description: '邮件' }] } as never
     if (path.endsWith('/apps')) {
       if (failInventory) throw new Error('暂时无法读取连接状态')
       return { authoritative: true, connections: [{ slug: 'gmail', accounts: [{ id: 'account-1', alias: '工作账号', status: 'ACTIVE' }], agentIds: [], revision: 0 }] } as never
     }
-    return { agents: [] } as never
+    if (failAgents) throw new Error('暂时无法读取 Bot 列表')
+    return { agents: [{ id: 'current-bot', name: '小夭', archived: false }] } as never
   })
 })
 afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks() })
@@ -54,5 +56,42 @@ it('disables host MCP management for non-admin accounts', async () => {
   await button(wrapper, 'MCP 服务').trigger('click')
   expect(button(wrapper, '添加服务').attributes('disabled')).toBeDefined()
   expect(button(wrapper, '测试连接').attributes('disabled')).toBeDefined()
+  expect(button(wrapper, '授权 Bot').attributes('disabled')).toBeDefined()
+  wrapper.unmount()
+})
+it('rebinds stale MCP grants without resaving credentials or resetting the connection test', async () => {
+  agentIds = ['deleted-bot']
+  const wrapper = render(); await flushPromises()
+  await button(wrapper, 'MCP 服务').trigger('click')
+  expect(wrapper.text()).toContain('服务器')
+  expect(wrapper.text()).toContain('授权已失效')
+  await button(wrapper, '授权 Bot').trigger('click')
+  const form = wrapper.get('form[aria-label="MCP Bot 授权"]')
+  await form.get('input[type="checkbox"]').setValue(true)
+  await form.trigger('submit'); await flushPromises()
+  expect(vi.mocked(apiRequest)).toHaveBeenCalledWith('/api/app/bot-tools/mcp/plugin-1', {
+    method: 'PATCH', body: { agentIds: ['current-bot'], revision: 2 },
+  })
+  expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
+  expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([false])
+  wrapper.unmount()
+})
+it('drops missing Bot ids when editing an old service while keeping its HTTP URL', async () => {
+  agentIds = ['deleted-bot', 'current-bot']
+  const wrapper = render(); await flushPromises()
+  await button(wrapper, 'MCP 服务').trigger('click')
+  await button(wrapper, '编辑').trigger('click')
+  await wrapper.get('form[aria-label="MCP 服务配置"]').trigger('submit'); await flushPromises()
+  const call = vi.mocked(apiRequest).mock.calls.find(([, options]) => options?.method === 'PUT')!
+  expect(call[1]?.body).toMatchObject({ definition: { url: plugin.url, transport: 'http', agentIds: ['current-bot'], headers: { Authorization: true } } })
+  wrapper.unmount()
+})
+it('does not treat a failed Bot inventory as deleted grants', async () => {
+  agentIds = ['current-bot']; failAgents = true
+  const wrapper = render(); await flushPromises()
+  await button(wrapper, 'MCP 服务').trigger('click')
+  expect(wrapper.text()).not.toContain('授权已失效')
+  expect(button(wrapper, '授权 Bot').attributes('disabled')).toBeDefined()
+  expect(button(wrapper, '编辑').attributes('disabled')).toBeDefined()
   wrapper.unmount()
 })

+ 57 - 1
tests/server/workspacePlugins.test.ts

@@ -1,6 +1,6 @@
 // @vitest-environment node
 import { beforeEach, afterEach, describe, it, expect } from 'vitest'
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { randomUUID } from 'node:crypto'
@@ -137,6 +137,62 @@ describe('Bot-only plugin management', () => {
     const notGranted = runtime.workspace.createAgent('owner', { name: '未授权 Bot', profile: 'default' })
     expect(runtime.workspacePlugins.selected('owner', notGranted)).toBe(false)
   })
+  it('runs a server stdio program through the tool bridge without a client filesystem', async () => {
+    const agent = runtime.workspace.createAgent('owner', { name: '远端客户端使用的 Bot', profile: 'default' })
+    const script = join(home, 'server-mcp.cjs'), data = join(home, 'server-only.txt')
+    writeFileSync(data, '服务器文件内容')
+    writeFileSync(script, `const fs = require('node:fs');
+require('node:readline').createInterface({ input: process.stdin }).on('line', line => {
+  const r = JSON.parse(line); if (!r.id) return;
+  const result = r.method === 'initialize' ? { protocolVersion: '2025-06-18', capabilities: { tools: {} } }
+    : r.method === 'tools/list' ? { tools: [{ name: 'read_server_file', inputSchema: { type: 'object' } }] }
+    : { content: [{ type: 'text', text: fs.readFileSync(process.argv[2], 'utf8') }] };
+  process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: r.id, result }) + '\\n');
+});`)
+    const added = await api('post', '/mcp').send({ name: '服务器程序', transport: 'stdio', command: process.execPath, args: [script, data], agentIds: [agent.id] }).expect(201)
+    const id = added.body.plugin.id
+    await api('post', '/mcp/' + id + '/test').send({}).expect(200)
+    await api('patch', '/mcp/' + id).send({ enabled: true, revision: 1 }).expect(200)
+    let binding: any
+    const target: any = { url: new URL('http://127.0.0.1:19119'), session: { request: async (path: string, options: any) => {
+      if (path.endsWith('/bind')) binding = options.body
+      return { status: 200, body: Buffer.from(JSON.stringify(path.endsWith('/capabilities') ? { version: 1, ready: true, native_tools: true, in_process: true } : { ok: true, native_tools: true })) }
+    } } }
+    const controller = new AbortController()
+    const tools = await runtime.workspacePlugins.open('owner', agent, target, controller.signal, () => {})
+    const lease = await createWorkspaceToolLease({ target, profile: 'default', workId: randomUUID(), signal: controller.signal, session: () => ({ runtimeId: 'server-stdio', storedId: 'server-stdio-stored' }), assertActive() {}, catalog: () => tools.catalog(), call: (id, args) => tools.call(id, args), onFailure() {} })
+    try {
+      await lease.bind()
+      const bridge = async (path: string, body: unknown) => (await fetch(binding.bridge_url + path, { method: 'POST', headers: { Authorization: 'Bearer ' + binding.token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json() as Promise<any>
+      const catalog = await bridge('/tools/list', {})
+      expect(catalog.tools).toHaveLength(1)
+      expect(JSON.stringify(catalog)).not.toContain(home)
+      const result = await bridge('/tools/call', { toolId: catalog.tools[0].id, arguments: {}, callId: randomUUID() })
+      expect(result.content).toEqual([{ type: 'text', text: '服务器文件内容' }])
+    } finally { controller.abort(); await tools.dispose(); await lease.dispose() }
+  })
+  it('removes deleted Bot grants without changing the service or granting a replacement by name', async () => {
+    const old = runtime.workspace.createAgent('owner', { name: '小夭', profile: 'default' })
+    const other = runtime.workspace.createAgent('owner', { name: '保留的 Bot', profile: 'default' })
+    const plugin = await addPlugin([old.id, other.id])
+    await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
+    await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 }).expect(200)
+    const unrelated = await api('post', '/mcp').send({ name: '其他服务', transport: 'http', url: 'https://other.example.test/mcp', agentIds: [other.id] }).expect(201)
+    const unrelatedBefore = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', unrelated.body.plugin.id)
+    const before = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
+    runtime.workspace.updateAgent('owner', old.id, { archived: true })
+    runtime.workspace.deleteAgent('owner', old.id)
+    const after = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
+    expect(after).toEqual({ ...before, agentIds: [other.id], revision: before.revision + 1 })
+    expect(runtime.workspace.require('owner', 'bot-mcp-plugin', unrelated.body.plugin.id)).toEqual(unrelatedBefore)
+    const replacement = runtime.workspace.createAgent('owner', { name: '小夭', profile: 'default' })
+    expect(runtime.workspacePlugins.selected('owner', replacement)).toBe(false)
+    expect(runtime.workspacePlugins.selected('owner', other)).toBe(true)
+    await api('patch', '/mcp/' + plugin.id).send({ agentIds: [replacement.id, other.id], revision: after.revision }).expect(200)
+    const rebound = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
+    expect(rebound).toMatchObject({ enabled: true, testedAt: before.testedAt, sealed: before.sealed, tools: before.tools })
+    expect(runtime.workspacePlugins.selected('owner', replacement)).toBe(true)
+  })
 })
 
 describe('account-owned connected applications', () => {