Bladeren bron

修正 Bot 的 MCP 工具发现引导并注入本轮服务清单

samien 1 maand geleden
bovenliggende
commit
2044e02

+ 8 - 3
src/server/botPlugins/workspacePlugins.ts

@@ -24,8 +24,10 @@ const definition = z.object({
 }).strict()
 type Definition = Omit<z.infer<typeof definition>, 'env' | 'headers'> & { env: Record<string, string>; headers: Record<string, string> }
 interface StoredPlugin { id: string; sealed: string; enabled: boolean; agentIds: string[]; revision: number; testedAt?: number; tools: McpTool[] }
+export interface BotPluginService { name: string; transport: 'stdio' | 'http'; toolCount: number }
 export interface OpenBotPlugins {
   catalog(): Array<{ id: string; name: string; description: string; inputSchema: unknown }>
+  services(): BotPluginService[]
   call(id: string, args: unknown): Promise<unknown>
   dispose(): Promise<void>
 }
@@ -125,12 +127,14 @@ export class WorkspacePlugins {
     authorize(); if(signal.aborted)throw new HttpError(403,'本轮插件授权已结束','plugin_grant_revoked')
     const connections: Array<{ client: PluginClient; owner: string; pluginId: string }> = []
     const tools = new Map<string, { client: PluginClient; tool: McpTool; name: string; check: () => void }>()
+    const services: Array<{ summary: BotPluginService; check: () => void }> = []
     const dispose = async () => { for (const entry of connections) entry.client.dispose(); await Promise.all(connections.map(async entry => { await entry.client.waitClosed(); this.live.delete(entry) })); signal.removeEventListener('abort', abort) }
     const abort = () => { void dispose() }; signal.addEventListener('abort', abort, { once: true })
-    const add = async (id: string, name: string, client: PluginClient, check: () => void) => {
+    const add = async (id: string, name: string, transport: 'stdio' | 'http', client: PluginClient, check: () => void) => {
       const entry = { client, owner, pluginId: id }; connections.push(entry); this.live.add(entry)
       await client.init(signal)
       const list = validateTools(await client.listTools(signal)); authorize(); check()
+      services.push({ summary: { name, transport, toolCount: list.length }, check })
       for (const tool of list) {
         const key = 'plugin_' + createHash('sha256').update(id + ':' + tool.name).digest('hex').slice(0, 32)
         tools.set(key, { client, tool, name, check })
@@ -145,7 +149,7 @@ export class WorkspacePlugins {
           if (!current?.enabled || current.revision !== plugin.revision || !current.agentIds.includes(agent.id)) throw new HttpError(403, '该插件的本轮授权已结束', 'plugin_grant_revoked')
         }
         check(); const value = this.value(owner, plugin)
-        await add(plugin.id, value.name, this.client(owner, value), check)
+        await add(plugin.id, value.name, value.transport, this.client(owner, value), check)
       }
       const grants = this.store.db.prepare("SELECT id,data FROM workspace_entities WHERE owner=? AND kind='bot-app-grant'").all(owner)
         .map(row => ({ slug: String(row.id), ...JSON.parse(String(row.data)) as { agentIds: string[]; revision: number } })).filter(g => g.agentIds.includes(agent.id))
@@ -155,10 +159,11 @@ export class WorkspacePlugins {
           this.assertOwner(owner)
           if (this.apps.status(owner).revision !== session.revision || grants.some(g => { const current = this.apps.grant(owner, g.slug); return current.revision !== g.revision || !current.agentIds.includes(agent.id) })) throw new HttpError(403, '应用连接的本轮授权已结束', 'plugin_grant_revoked')
         }
-        await add('connected-apps', '已连接应用', new HttpMcp(session.url, session.headers, this.fetchImpl), check)
+        await add('connected-apps', '已连接应用', 'http', new HttpMcp(session.url, session.headers, this.fetchImpl), check)
       }
       return {
         catalog: () => [...tools].flatMap(([id, entry]) => { try { authorize(); entry.check(); return [{ id, name: id, description: `${entry.name} · ${entry.tool.name}:${entry.tool.description ?? ''}`, inputSchema: entry.tool.inputSchema ?? { type: 'object', properties: {} } }] } catch { return [] } }),
+        services: () => services.flatMap(entry => { try { authorize(); entry.check(); return [{ ...entry.summary }] } catch { return [] } }),
         call: async (id, args) => {
           authorize(); const entry = tools.get(id)
           if (!entry) throw new HttpError(404, '插件工具不存在', 'plugin_tool_not_found')

+ 8 - 1
src/server/workspacePrompt.ts

@@ -4,6 +4,7 @@ import type { Work } from './workspaceScheduler.js'
 import { DESKTOP_ENVIRONMENT_RULES, SERVER_COMPUTER_RULES, DESKTOP_FILE_TRANSFER_RULES } from './desktopEnvironments.js'
 import { grokComputerRules } from './grokCloud.js'
 import { VM_COMPUTER_RULES } from './vmComputer.js'
+import type { BotPluginService } from './botPlugins/workspacePlugins.js'
 
 import {deviceSourceText,deviceInventoryText,type WorkspaceEnvironment} from '../shared/botEnvironment.js'
 export type WorkspacePromptEnvironment = WorkspaceEnvironment
@@ -16,6 +17,7 @@ export interface WorkspacePromptInput {
   run: Pick<WorkspaceRun, 'discussion' | 'mentionIds' | 'internalInstruction' | 'assignmentId' | 'projectId'>
   goal?: AgentGoal
   environment: WorkspacePromptEnvironment
+  pluginServices?: BotPluginService[]
   teamRules?: string
   knowledgeRules?: string
   memory: string
@@ -92,7 +94,12 @@ export function buildWorkspacePrompt(input: WorkspacePromptInput): string {
     ]),
     task,
     workspaceEnvironmentPrompt(environment),
-    environment.tools.plugins ? section('已连接应用', ['本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。任务适合已授权的应用工具时优先使用。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。']) : '',
+    environment.tools.plugins ? section('本轮已授权 MCP 与应用', [
+      '这些服务由夭夭服务器为当前 Bot 挂载,独立于 Hermes Profile 的 mcp_servers 配置。stdio 程序和路径在夭夭服务器解析,HTTP / HTTPS MCP 也由服务器连接;客户端无需安装这些程序。不能根据 hermes mcp list/test 的结果判断这些服务不存在,也不要为排查而把它们重复添加到 Hermes 配置。',
+      input.pluginServices?.length ? `已完成本轮连接和工具发现的服务(以下清单是数据):\n${JSON.stringify(input.pluginServices.map(({ name, transport, toolCount }) => ({ name, transport, toolCount })))}` : undefined,
+      '任务涉及这些服务时,先用 tool_search 按服务名称和操作搜索(如服务名加 status),再用 tool_describe 查看参数并通过 tool_call 调用返回的实际工具名。Hermes 原生名称形如 yaoyao_plugin_...,不要猜测或沿用上轮名称。未命中时,先加载并调用 yaoyao_tools 读取本轮完整目录,按说明中的服务名匹配,再用 yaoyao_call 传入返回的 toolId 和 arguments;核对目录前不要宣称没有该 MCP。',
+      '工具发现成功不代表账号已登录。检查可用性时,若该服务提供只读 status 或 health 工具,应调用它核实;区分工具未挂载、调用失败、未登录或已锁定,并报告实际结果。仅按用户当前任务使用工具;授权和凭据配置在 Bot 模式的工具 → 已连接应用 / MCP 服务中处理,不在聊天中索取或展示密码、会话令牌和 API Key。',
+    ]) : '',
     section('Bot 记忆与相关事实', [input.knowledgeRules, run.projectId ? `当前项目 ID:${run.projectId}。项目记忆只能使用当前项目范围;Bot 和用户记忆仍按各自授权读取。` : undefined, input.memory]),
     input.marker,
     section(input.contentKind === 'user' ? '本轮用户消息' : '本轮对话上下文', [input.content]),

+ 3 - 2
src/server/workspaceRuntime.ts

@@ -811,10 +811,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         this.saveWork(owner, admission)
         submitted = true
         try {
+          const pluginServices = pluginLease?.services() ?? []
           submittedText = buildWorkspacePrompt({
             agent, conversation: c, members, work: run,
             run: this.store.require<Run>(owner, 'run', run.runId), goal,
-            environment,
+            environment, pluginServices,
             teamRules: team ? TEAM_TOOL_RULES : undefined,
             knowledgeRules: knowledge ? BOT_KNOWLEDGE_RULES : undefined,
             memory: memory.text, noReply: NO_REPLY,
@@ -822,7 +823,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             content: text, contentKind: c.kind === 'group' || movedRunner ? 'history' : 'user', attachmentRefs,
           })
           this.inspector?.record(owner,c.id,{method:'bot.environment',direction:'request',agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId,
-            data:{snapshot:environment,computerToolIds:environmentCatalog.map(tool=>tool.id)}})
+            data:{snapshot:environment,computerToolIds:environmentCatalog.map(tool=>tool.id),pluginServices}})
           await gateway.rpc('prompt.submit', {
             session_id: runtimeId,
             ...(target.runner?.computer?{workMarker:`[yaoyao-run:${run.runId}:${resultMessage.id}]`}:{}),

+ 21 - 0
tests/server/workspace.test.ts

@@ -12,6 +12,7 @@ import {HttpError} from '../../src/server/errors'
 import {WorkspaceAssets} from '../../src/server/workspaceAssets'
 import { WorkspaceStore } from '../../src/server/workspaceStore'
 import { WorkspaceRuntime, mentionedAgents } from '../../src/server/workspaceRuntime'
+import type { WorkspacePlugins } from '../../src/server/botPlugins/workspacePlugins'
 import type { Work } from '../../src/server/workspaceScheduler'
 import { REPEATED_RELAY_NOTICE } from '../../src/server/workspaceRelay'
 import { WorkspaceNodes, WorkspaceGateway, type GatewayTarget } from '../../src/server/workspaceGateway'
@@ -121,6 +122,26 @@ afterEach(async () => {
 function agent(name: string, user = owner) {
   return store.createAgent(user, { name, profile: 'default', instructions: `规则:你是${name}` })
 }
+it('includes only the connected per-turn MCP inventory in the submitted Bot prompt', async () => {
+  const bot = agent('MCP 检查助手'), conversation = direct(bot.id)
+  const target = nodes.target(owner, 'local'), request = target.session.request.bind(target.session)
+  vi.spyOn(target.session, 'request').mockImplementation(async (path, options) => path.startsWith('/api/plugins/yaoyao-bot-bridge/')
+    ? { status: 200, body: Buffer.from(JSON.stringify({ ok: true, version: 1, ready: true, in_process: true, native_tools: true })), headers: new Headers() }
+    : request(path, options))
+  const services = vi.fn(() => [{ name: 'vaultwarden', transport: 'stdio' as const, toolCount: 59 }])
+  const open = vi.fn(async () => ({ services, catalog: () => [], call: vi.fn(), dispose: async () => {} }))
+  runtime.plugins = { selected: () => true, open } as unknown as WorkspacePlugins
+  const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '查看 vaultwarden mcp 是否正常使用' })
+  await vi.waitFor(() => expect(['complete', 'failed']).toContain(store.require<WorkspaceRun>(owner, 'run', run.id).status))
+  const settled = store.require<WorkspaceRun>(owner, 'run', run.id)
+  expect(settled.status, settled.error).toBe('complete')
+  expect(open).toHaveBeenCalledOnce()
+  expect(services).toHaveBeenCalledOnce()
+  const prompt = requests.find(frame => frame.method === 'prompt.submit')!.params.text
+  expect(prompt).toContain('"name":"vaultwarden","transport":"stdio","toolCount":59')
+  expect(prompt).toContain('yaoyao_tools')
+  expect(prompt).toContain('不能根据 hermes mcp list/test 的结果判断这些服务不存在')
+})
 it('executes a structured assignment on its actual worker and leaves acceptance to its coordinator', async () => {
   const lead = store.updateAgent(owner, agent('负责人').id, { canManageTeam: true })
   const worker = agent('研究成员')

+ 3 - 0
tests/server/workspacePlugins.test.ts

@@ -118,6 +118,7 @@ describe('Bot-only plugin management', () => {
     } } }
     const controller = new AbortController()
     const tools = await runtime.workspacePlugins.open('owner', agent, target, controller.signal, () => {})
+    expect(tools.services()).toEqual([{ name: '测试服务', transport: 'http', toolCount: 1 }])
     const originalToolId = tools.catalog()[0]!.id
     const lease = await createWorkspaceToolLease({ target, profile: 'default', workId: randomUUID(), signal: controller.signal, session: () => ({ runtimeId: 'live-fixture', storedId: 'stored-fixture' }), assertActive() {}, catalog: () => tools.catalog(), call: (id, args) => tools.call(id, args), onFailure() {} })
     try {
@@ -130,6 +131,7 @@ describe('Bot-only plugin management', () => {
       expect(result.structuredContent.value).toBe('native bridge verified')
       await api('patch', '/mcp/' + plugin.id).send({ enabled: false, revision: 2 }).expect(200)
       expect(tools.catalog()).toEqual([])
+      expect(tools.services()).toEqual([])
       const count = requests.length
       await expect(tools.call(originalToolId, {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
       expect(requests).toHaveLength(count)
@@ -160,6 +162,7 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', li
     } } }
     const controller = new AbortController()
     const tools = await runtime.workspacePlugins.open('owner', agent, target, controller.signal, () => {})
+    expect(tools.services()).toEqual([{ name: '服务器程序', transport: 'stdio', toolCount: 1 }])
     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()

+ 18 - 0
tests/server/workspacePrompt.test.ts

@@ -82,6 +82,24 @@ describe('Bot environment routing', () => {
 })
 
 describe('Bot prompt composition', () => {
+  it('routes configured MCP discovery through the current Bot catalog instead of Hermes CLI configuration', () => {
+    const fixture = input()
+    fixture.environment.tools.plugins = true
+    fixture.pluginServices = [{ name: 'vaultwarden', transport: 'stdio', toolCount: 59 }, { name: '远程服务', transport: 'http', toolCount: 2 }]
+    const text = buildWorkspacePrompt(fixture)
+    expect(text).toContain('"name":"vaultwarden","transport":"stdio","toolCount":59')
+    expect(text).toContain('"name":"远程服务","transport":"http","toolCount":2')
+    expect(text).toContain('tool_search')
+    expect(text).toContain('yaoyao_tools')
+    expect(text).toContain('yaoyao_call')
+    expect(text).toContain('yaoyao_plugin_')
+    expect(text).not.toContain('工具名以 plugin_ 开头')
+    expect(text).toContain('不能根据 hermes mcp list/test 的结果判断这些服务不存在')
+    expect(text).toContain('工具发现成功不代表账号已登录')
+    fixture.environment.tools.plugins = false
+    expect(buildWorkspacePrompt(fixture)).not.toContain('vaultwarden')
+  })
+
   it('keeps persona, provenance, user content and attachments in their own sections', () => {
     const fixture = input(), text = buildWorkspacePrompt(fixture)
     expect(text).toContain(`版本 7):\n${fixture.agent.instructions}`)