Browse Source

feat: MCP 工具策略增加"仅后台测试"——只在后台 Playground / Bot Playground 的会话中提供(每次审批、不能在脚本中调用、标注测试中),外部 API、Bot API、定时任务的会话中不可见也不可调用

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 17 hours ago
parent
commit
19c4151234

+ 3 - 2
apps/admin/src/views/McpView.vue

@@ -11,7 +11,7 @@ const state = ref<McpStateDto | null>(null)
 const busy = ref<string | null>(null)
 const expanded = reactive<Record<string, boolean>>({})
 
-const POLICY_LABELS: Record<McpToolPolicy, string> = { auto: '直接调用', ask: '跟随权限模式', confirm: '总是审批', off: '关闭' }
+const POLICY_LABELS: Record<McpToolPolicy, string> = { auto: '直接调用', ask: '跟随权限模式', confirm: '总是审批', test: '仅后台测试', off: '关闭' }
 const HINT_LABELS: Record<keyof McpToolHints, string> = { readOnly: '只读', destructive: '破坏性', idempotent: '幂等' }
 const HINT_TIPS: Record<keyof McpToolHints, string> = {
   readOnly: '只读:不修改任何东西。默认直接调用;只读模式下只提供只读工具',
@@ -314,7 +314,7 @@ onBeforeUnmount(() => window.removeEventListener('message', onOAuthMessage))
           <span class="codicon codicon-info text-link text-[18px] mt-0.5" />
           <div class="flex flex-col gap-1.5 text-[12.5px]">
             <div>所有工具都经由 AgentServer 的 MCP Gateway 提供给 Claude 和 Codex:配置一次,两个引擎共用;密钥只保存在服务端。</div>
-            <div>第三方服务需要<b>在 Profile 中勾选</b>才会出现在对话中;工具名为 <code class="font-mono">服务名__工具名</code>。每个工具可以设为:直接调用、跟随权限模式("需审批"时弹出审批卡片,"自动"时直接调用,"只读"时只提供只读工具)、总是审批(自动模式下也要确认,每次都问)、关闭。</div>
+            <div>第三方服务需要<b>在 Profile 中勾选</b>才会出现在对话中;工具名为 <code class="font-mono">服务名__工具名</code>。每个工具可以设为:直接调用、跟随权限模式("需审批"时弹出审批卡片,"自动"时直接调用,"只读"时只提供只读工具)、总是审批(自动模式下也要确认,每次都问)、仅后台测试(还没测完的工具:只在后台的 Playground / Bot Playground 中提供,每次审批;外部 API、机器人、定时任务看不到)、关闭。</div>
             <div>工具的提示(只读、破坏性、幂等)由服务声明,决定默认策略和出错时能否自动重试;声明不准确时可以在"提示"中覆盖。</div>
             <div class="hint">本地进程类服务在独立的沙箱中运行:系统目录只读、独立的数据目录、默认不能联网,只能访问你开放的路径。</div>
           </div>
@@ -391,6 +391,7 @@ onBeforeUnmount(() => window.removeEventListener('message', onOAuthMessage))
                     <span v-if="t.hasOutputSchema" class="badge bg-code text-fg-muted" title="声明了返回结构(outputSchema):结果会按结构校验">结构化输出</span>
                     <span v-if="t.taskSupport" class="badge bg-code text-link" :title="t.taskSupport === 'required' ? '只能以长任务方式调用' : '支持以长任务方式调用'">长任务</span>
                     <span v-if="Object.keys(t.overrides).length" class="badge bg-code text-warning" title="提示已被管理员覆盖">已覆盖</span>
+                    <span v-if="t.policy === 'test'" class="badge bg-code text-warning" title="只在后台的 Playground / Bot Playground 中提供,每次审批;外部 API、机器人、定时任务看不到">测试中</span>
                   </div>
                   <div class="hint line-clamp-2">{{ t.description }}</div>
                   <div v-if="hintsOpen[`${s.id}:${t.name}`]" class="flex flex-wrap gap-3 mt-1.5">

+ 1 - 1
apps/server/src/http/routes/admin.ts

@@ -189,7 +189,7 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     g.post<IdParams>('/mcp/servers/:id/refresh', async req => app.mcpServers.refresh(req.params.id))
     g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool', async req => {
-      const policy = parse(z.object({ policy: z.enum(['auto', 'ask', 'confirm', 'off']) }), req.body).policy
+      const policy = parse(z.object({ policy: z.enum(['auto', 'ask', 'confirm', 'test', 'off']) }), req.body).policy
       return app.mcpServers.setToolPolicy(req.params.id, req.params.tool, policy)
     })
     g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool/hints', async req => {

+ 15 - 7
apps/server/src/mcp-gateway/server.ts

@@ -65,10 +65,13 @@ interface ExternalTool {
   tool: McpCachedTool
   exposed: string
   hints: EffectiveHints
+  /** 生效的策略:"仅后台测试"的工具在测试会话中按"总是审批"处理 */
   policy: ReturnType<typeof toolState>['policy']
+  /** 仅后台测试的工具 */
+  testing: boolean
 }
 
-/** 这次对话可用的第三方工具(只读模式下只提供只读工具) */
+/** 这次对话可用的第三方工具(只读模式下只提供只读工具;"仅后台测试"的工具只在后台的测试会话中提供) */
 async function externalTools(ctx: GatewayRunContext, deps: GatewayDeps): Promise<ExternalTool[]> {
   const out: ExternalTool[] = []
   for (const row of deps.mcp.rows(ctx.mcpServerIds)) {
@@ -81,8 +84,9 @@ async function externalTools(ctx: GatewayRunContext, deps: GatewayDeps): Promise
     }
     for (const tool of tools) {
       const { hints, policy } = toolState(row, tool)
-      if (policy === 'off' || (ctx.permissionMode === 'readonly' && !hints.readOnly)) continue
-      out.push({ row, tool, hints, policy, exposed: `${row.name}${MCP_SEP}${tool.name}`.slice(0, 64) })
+      if (policy === 'off' || (policy === 'test' && !ctx.adminTest) || (ctx.permissionMode === 'readonly' && !hints.readOnly)) continue
+      const testing = policy === 'test'
+      out.push({ row, tool, hints, policy: testing ? 'confirm' : policy, testing, exposed: `${row.name}${MCP_SEP}${tool.name}`.slice(0, 64) })
     }
   }
   return out
@@ -109,10 +113,10 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
       ...builtinTools(ctx, deps),
       // 有第三方工具时,提供脚本批量调用(docs/design/17-run-script.md)
       ...(external.length ? [{ name: RUN_SCRIPT, description: RUN_SCRIPT_DESCRIPTION, inputSchema: z.toJSONSchema(runScriptSchema) as Tool['inputSchema'] }] : []),
-      ...external.map(({ row, tool, exposed, hints }) => ({
+      ...external.map(({ row, tool, exposed, hints, testing }) => ({
         name: exposed,
         ...(tool.title ? { title: tool.title } : {}),
-        description: `[${row.title}] ${tool.description.slice(0, 2000)}${schemaHint(tool.outputSchema)}`,
+        description: `[${row.title}]${testing ? '[测试中]' : ''} ${tool.description.slice(0, 2000)}${schemaHint(tool.outputSchema)}`,
         inputSchema: (tool.inputSchema?.type === 'object' ? tool.inputSchema : { type: 'object', properties: {} }) as Tool['inputSchema'],
         annotations: { readOnlyHint: hints.readOnly, destructiveHint: hints.destructive, idempotentHint: hints.idempotent, openWorldHint: hints.openWorld },
       })),
@@ -144,11 +148,12 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
 
     const found = (await externalTools(ctx, deps)).find(t => t.exposed === name)
     if (!found) return fail(`工具不存在或当前不可用:${name}`)
-    const { row, tool, hints, policy } = found
+    const { row, tool, hints, policy, testing } = found
     // 需要审批:ask 跟随权限模式(需审批时询问;自动模式直接调用;只读模式已在列表中排除非只读工具);confirm 总是询问且每次都问
     const confirm = policy === 'confirm'
     if (confirm || (policy === 'ask' && ctx.permissionMode === 'ask' && !sessionAllowed.get(ctx.sessionId)?.has(name))) {
-      const reason = confirm ? `这是会造成破坏性修改的操作,每次都需要确认。${tool.description.slice(0, 260)}` : tool.description.slice(0, 300)
+      const reason = testing ? `这个工具还在测试中(仅后台测试会话可用),每次都需要确认。${tool.description.slice(0, 260)}`
+        : confirm ? `这是会造成破坏性修改的操作,每次都需要确认。${tool.description.slice(0, 260)}` : tool.description.slice(0, 300)
       const outcome = await ctx.interact({
         kind: 'approval', tool: `mcp__${GATEWAY_SERVER}__${name}`, title: `${row.title} · ${tool.title ?? tool.name}`,
         input: args, detail: { reason: reason || undefined }, options: confirm ? ['allow_once', 'deny'] : ['allow_once', 'allow_session', 'deny'],
@@ -212,6 +217,8 @@ async function runScriptTool(ctx: GatewayRunContext, deps: GatewayDeps, rawArgs:
   const writes = [...new Set(parsed.data.tools)].filter(n => !byName.get(n)?.hints.readOnly)
   const unknown = writes.filter(n => !byName.has(n))
   if (unknown.length) return fail(`这些工具不存在或当前不可用:${unknown.join('、')}`)
+  const testing = [...new Set(parsed.data.tools)].filter(n => byName.get(n)?.testing)
+  if (testing.length) return fail(`这些工具还在测试中,不能在脚本中调用,请直接调用:${testing.join('、')}`)
   const blocked = writes.filter(n => byName.get(n)!.policy === 'confirm' || byName.get(n)!.tool.taskSupport === 'required')
   if (blocked.length) return fail(`这些工具不能在脚本中调用(破坏性工具每次都要确认,或只能以长任务方式调用),请直接调用:${blocked.join('、')}`)
   // 写工具:需要审批的统一审批一次
@@ -229,6 +236,7 @@ async function runScriptTool(ctx: GatewayRunContext, deps: GatewayDeps, rawArgs:
 
   const tools = new Map<string, ScriptTool>()
   for (const t of external) {
+    if (t.testing) continue
     if (!t.hints.readOnly && !writes.includes(t.exposed)) continue
     if (t.tool.taskSupport === 'required') continue
     tools.set(t.exposed, {

+ 2 - 0
apps/server/src/mcp-gateway/tokens.ts

@@ -18,6 +18,8 @@ export interface GatewayRunContext {
   knowledgeRoots: KnowledgeRootDto[]
   /** Profile 勾选的第三方 MCP 服务 */
   mcpServerIds: string[]
+  /** 后台的测试会话(Playground、Bot Playground,不包括定时任务触发的):提供"仅后台测试"的工具 */
+  adminTest?: boolean
   audit: (kind: 'gateway_call' | 'network_denied' | 'file_access_denied' | 'approval', summary: string, detail?: unknown) => void
   /** 发起审批或表单询问(与引擎侧的审批相同:显示交互卡片并等待回复) */
   interact: (req: NewApproval | NewForm) => Promise<InteractionOutcome>

+ 1 - 1
apps/server/src/schedules/service.ts

@@ -132,7 +132,7 @@ export class ScheduleService {
         return
       }
       try {
-        runId = this.deps.runs.start(session, text, []).id
+        runId = this.deps.runs.start(session, text, [], { unattended: true }).id
         outcome = 'running'
       } catch (err) {
         const busy = (err as { code?: string }).code === 'session_busy'

+ 1 - 1
apps/server/src/services/mcp-servers.ts

@@ -163,7 +163,7 @@ export class McpServerService {
   setToolPolicy(id: string, tool: string, policy: McpToolPolicy): McpServerDto {
     const r = this.row(id)
     this.db.update(mcpServers).set({ toolPolicies: { ...r.toolPolicies, [tool]: policy }, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
-    this.audit(`MCP 工具 ${r.name}__${tool} 设为 ${({ auto: '直接调用', ask: '跟随权限模式', confirm: '总是审批', off: '关闭' } as const)[policy]}`)
+    this.audit(`MCP 工具 ${r.name}__${tool} 设为 ${({ auto: '直接调用', ask: '跟随权限模式', confirm: '总是审批', test: '仅后台测试', off: '关闭' } as const)[policy]}`)
     return this.get(id)
   }
 

+ 6 - 4
apps/server/src/services/runs.ts

@@ -118,8 +118,8 @@ export class RunManager {
     return this.start(session, data.text, this.deps.files.getMany(session.id, data.attachments ?? []))
   }
 
-  /** 登记 Run 并排队(工作组直接调用:附件是工作组目录中的文件) */
-  start(session: SessionRow, text: string, attachments: RunAttachment[]): RunDto {
+  /** 登记 Run 并排队(工作组直接调用:附件是工作组目录中的文件)。unattended:无人值守(定时任务),不提供"仅后台测试"的工具 */
+  start(session: SessionRow, text: string, attachments: RunAttachment[], opts: { unattended?: boolean } = {}): RunDto {
     if (this.activeForSession(session.id)) throw conflict('session_busy', '会话正在运行,请等待完成或先中断')
     const provider = this.deps.providers[session.provider]
     if (!provider || !this.deps.settings.get().providers[session.provider].enabled) throw conflict('provider_unavailable', `引擎 ${session.provider} 当前不可用`)
@@ -138,7 +138,7 @@ export class RunManager {
     const queue = this.perProvider[session.provider]
     this.deps.events.append(session.id, runId, 'run.queued', { position: queue.size + queue.pending })
 
-    void queue.add(() => this.global.add(() => this.execute(runId, session.id, provider, text, attachments)))
+    void queue.add(() => this.global.add(() => this.execute(runId, session.id, provider, text, attachments, !!opts.unattended)))
       .catch(err => this.deps.log.error({ err, runId }, 'run task crashed'))
     return this.get(runId)
   }
@@ -161,7 +161,7 @@ export class RunManager {
     return toRunDto(r)
   }
 
-  private async execute(runId: string, sessionId: string, provider: AgentProvider, text: string, attachments: RunAttachment[]) {
+  private async execute(runId: string, sessionId: string, provider: AgentProvider, text: string, attachments: RunAttachment[], unattended: boolean) {
     const a = this.active.get(runId)
     if (!a || a.controller.signal.aborted) return
     const { deps } = this
@@ -213,6 +213,8 @@ export class RunManager {
         sessionId, runId, provider: session.provider, workspaceDir: session.workspaceDir, access, permissionMode, settings,
         knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
         mcpServerIds: profile.mcpServerIds,
+        // 后台发起的会话(工作组按其来源)为测试会话;外部 API、Bot API 与定时任务不是
+        adminTest: !unattended && this.usageSourceOf(sessionRow) === 'admin',
         audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${sessionRow.provider}`, sessionId, kind, summary, detail }),
         interact,
         progress: p => deps.events.append(sessionId, runId, 'script.progress', p),

+ 151 - 0
apps/server/test/mcp-test-policy.test.ts

@@ -0,0 +1,151 @@
+// MCP 工具策略"仅后台测试":只在后台的测试会话中提供(每次审批、不能在脚本中调用);外部 API、Bot API、定时任务的会话中看不到也调不了
+import fs from 'node:fs'
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { z } from 'zod'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+import { buildServer } from '../src/http/server'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import type { AgentProvider, RunContext } from '../src/providers/types'
+import { resolveAccess } from '../src/services/access-policy'
+import type { NewApproval } from '../src/services/interactions'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let serverId: string
+const approvals: NewApproval[] = []
+
+/** 假引擎:记录每次运行时网关上下文中的 adminTest */
+const seen: (boolean | undefined)[] = []
+const recorder: AgentProvider = {
+  id: 'claude',
+  async run(c: RunContext) {
+    seen.push(app.gatewayTokens.get(c.gateway.token)?.adminTest)
+    return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0 } }
+  },
+  async status() {
+    return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
+  },
+}
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: { claude: recorder } })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  upstream = http.createServer(async (req, res) => {
+    const mcp = new McpServer({ name: 'fixture', version: '1.0.0' })
+    mcp.registerTool('lookup', { description: '查询(已测完)', inputSchema: { q: z.string() }, annotations: { readOnlyHint: true } }, async ({ q }) => ({ content: [{ type: 'text', text: `found:${q}` }] }))
+    mcp.registerTool('new_report', { description: '新报表(测试中)', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [{ type: 'text', text: 'report' }] }))
+    const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
+    res.on('close', () => { void t.close(); void mcp.close() })
+    await mcp.connect(t)
+    let body = ''
+    req.on('data', c => (body += c))
+    req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
+  })
+  const url = await new Promise<string>(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
+  const s = app.mcpServers.create({
+    name: 'biz', title: '业务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [],
+  })
+  serverId = s.id
+  await app.mcpServers.refresh(s.id)
+})
+afterAll(async () => {
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.close()
+})
+
+function ctx(over: Partial<GatewayRunContext>): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_tp_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(ws, { recursive: true })
+  const settings = app.settings.get()
+  return {
+    sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
+    access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
+    knowledgeRoots: [], mcpServerIds: [serverId], audit: () => {},
+    interact: async req => {
+      approvals.push(req as NewApproval)
+      return { resolution: 'allowed', scope: 'session' }
+    },
+    ...over,
+  }
+}
+async function gateway(c: GatewayRunContext) {
+  const client = new Client({ name: 'test', version: '1' })
+  await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(c)}` } } }))
+  return client
+}
+const text = (r: unknown) => (r as { content: { text: string }[] }).content[0]!.text
+
+describe('仅后台测试', () => {
+  it('外部会话看不到也调不了;后台测试会话中提供、标注测试中,每次都要审批;不能在脚本中调用', async () => {
+    const s = app.mcpServers.setToolPolicy(serverId, 'new_report', 'test')
+    expect(s.tools.find(t => t.name === 'new_report')!.policy).toBe('test')
+
+    // 外部 API / Bot API / 定时任务:adminTest 为 false
+    const ext = await gateway(ctx({ adminTest: false }))
+    const extNames = (await ext.listTools()).tools.map(t => t.name)
+    expect(extNames).toContain('biz__lookup')
+    expect(extNames).not.toContain('biz__new_report')
+    const denied = await ext.callTool({ name: 'biz__new_report', arguments: {} })
+    expect(denied.isError).toBe(true)
+    expect(text(denied)).toMatch(/不存在或当前不可用/)
+    expect(text(await ext.callTool({ name: 'biz__lookup', arguments: { q: 'x' } }))).toBe('found:x')
+    await ext.close()
+
+    // 后台测试会话:提供,并标注测试中;即使在自动模式、即使选择"本会话允许",每次都要审批
+    const admin = await gateway(ctx({ adminTest: true }))
+    const tool = (await admin.listTools()).tools.find(t => t.name === 'biz__new_report')!
+    expect(tool.description).toContain('[测试中]')
+    approvals.length = 0
+    expect(text(await admin.callTool({ name: 'biz__new_report', arguments: {} }))).toBe('report')
+    expect(text(await admin.callTool({ name: 'biz__new_report', arguments: {} }))).toBe('report')
+    expect(approvals).toHaveLength(2)
+    expect(approvals[0]!.options).toEqual(['allow_once', 'deny'])
+    expect(String(approvals[0]!.detail?.reason)).toContain('测试中')
+    // 已测完的工具不受影响(只读、自动模式直接调用)
+    expect(text(await admin.callTool({ name: 'biz__lookup', arguments: { q: 'y' } }))).toBe('found:y')
+    expect(approvals).toHaveLength(2)
+
+    // 脚本中不能调用测试中的工具(在执行脚本前拒绝)
+    const script = await admin.callTool({ name: 'run_script', arguments: { code: 'print(1)', tools: ['biz__new_report'], description: '试' } })
+    expect(script.isError).toBe(true)
+    expect(text(script)).toMatch(/还在测试中/)
+    await admin.close()
+
+    // 测完后改回其他策略:外部会话可用
+    app.mcpServers.setToolPolicy(serverId, 'new_report', 'auto')
+    const ext2 = await gateway(ctx({ adminTest: false }))
+    expect((await ext2.listTools()).tools.map(t => t.name)).toContain('biz__new_report')
+    await ext2.close()
+  })
+})
+
+describe('哪些会话是后台测试会话', () => {
+  it('后台发起的会话是;外部 API Key 的会话、定时任务触发的运行不是', async () => {
+    const builtin = app.profiles.getBuiltin().id
+    const key = app.apiKeys.create('平台A', { allowedProfileIds: [builtin], defaultProfileId: builtin }).apiKey
+    const run = async (source: string, unattended = false) => {
+      const s = await app.sessions.create(source, {})
+      const n = seen.length
+      app.runs.start(app.sessions.getRow(s.id), '你好', [], { unattended })
+      for (let i = 0; i < 1000 && seen.length === n; i++) await new Promise(r => setTimeout(r, 10))
+      return seen[n]
+    }
+    expect(await run('admin')).toBe(true)
+    expect(await run(key.id)).toBe(false)
+    expect(await run('admin', true)).toBe(false)
+  })
+})

+ 5 - 0
docs/design/16-mcp-support.md

@@ -34,6 +34,11 @@
 
 - 默认值为 true 的 `destructiveHint` 不能直接用来强制审批,否则所有没写提示的工具在自动模式下都要审批;所以只认**明确声明**的 true。
 - 工具策略增加一档"总是审批"(`confirm`):不论权限模式都询问(只读模式下非只读工具仍然不提供)。明确声明破坏性的工具默认为这一档,管理员可以改。
+- 工具策略"仅后台测试"(`test`,2026-11-04):用于还没测完的工具。
+  - 只在后台的测试会话中提供:会话来源为后台(Playground;工作组按其来源,即 Bot Playground),且不是定时任务触发的运行(`runs.start(..., { unattended: true })`)。网关上下文中为 `adminTest`。
+  - 在测试会话中按"总是审批"处理(每次都问,不能"本会话允许"),工具说明前标注"[测试中]",不能在 `run_script` 中调用。
+  - 其他会话(外部 API、Bot API、定时任务)中不出现在工具列表中,直接调用也返回"不存在或当前不可用"。
+  - 测完后改回其他策略即可,工具名与 Profile 不变。测试 `test/mcp-test-policy.test.ts`。
 - 外部服务的提示不一定可信:管理员可以在"MCP 服务"页面逐个工具覆盖只读、破坏性、幂等三个提示(`toolHints`),覆盖值优先。
 - 网关提供给引擎的工具定义带上生效的提示(覆盖后的值)。
 

+ 2 - 1
packages/shared/src/api.ts

@@ -506,7 +506,8 @@ export interface ApiReferenceDto {
 
 /** 工具策略:auto 直接调用;ask 跟随权限模式(需审批时询问);off 不提供 */
 /** auto 直接调用;ask 跟随权限模式;confirm 总是审批(自动模式下也审批);off 关闭 */
-export type McpToolPolicy = 'auto' | 'ask' | 'confirm' | 'off'
+/** auto 直接调用;ask 跟随权限模式;confirm 总是审批;test 仅后台测试(只在后台的 Playground / Bot Playground 会话中提供,每次审批);off 关闭 */
+export type McpToolPolicy = 'auto' | 'ask' | 'confirm' | 'test' | 'off'
 
 /** 工具提示(docs/design/16-mcp-support.md §2.1):管理员可以覆盖服务的声明 */
 export interface McpToolHints {