import fs from 'node:fs' import http from 'node:http' import type { AddressInfo } from 'node:net' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' 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 type { McpServerInput } from '@agent-server/shared' 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 { BWRAP } from '../src/mcp-gateway/upstream' import { resolveAccess } from '../src/services/access-policy' import type { InteractionOutcome, NewApproval } from '../src/services/interactions' const here = path.dirname(fileURLToPath(import.meta.url)) let app: App let server: Awaited> let base: string let upstream: http.Server let upstreamUrl: string const seenAuth: (string | undefined)[] = [] /** 测试用的远程 MCP 服务(Streamable HTTP,无状态) */ function startUpstream(): Promise { upstream = http.createServer(async (req, res) => { seenAuth.push(req.headers.authorization) if (req.headers.authorization !== 'Bearer up-secret') return res.writeHead(401).end() const mcp = new McpServer({ name: 'fixture-http', version: '1.0.0' }) mcp.registerTool('echo', { description: '原样返回', inputSchema: { text: z.string() }, annotations: { readOnlyHint: true } }, async ({ text }) => ({ content: [{ type: 'text', text: `echo:${text}` }] })) mcp.registerTool('write_note', { description: '写一条笔记', inputSchema: { note: z.string() } }, async ({ note }) => ({ content: [{ type: 'text', text: `saved:${note}` }] })) mcp.registerTool('big', { description: '很长的结果', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [{ type: 'text', text: 'x'.repeat(150_000) }] })) mcp.registerTool('export', { description: '导出文件', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [ { type: 'text', text: '共 3 行' }, { type: 'resource', resource: { uri: 'fwh://export/power flow/SN1_2026.csv', mimeType: 'text/csv', text: 'ts,pv\n1,2\n3,4\n' } }, { type: 'resource', resource: { uri: 'fwh://export/../../wave.bin?x=1', mimeType: 'application/octet-stream', blob: Buffer.from([0, 1, 2, 255]).toString('base64') } }, { type: 'resource', resource: { uri: 'fwh://export/power flow/SN1_2026.csv', mimeType: 'text/csv', text: 'again' } }, ], })) 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)) }) return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`))) } const httpInput = (over: Partial = {}): McpServerInput => ({ name: 'demo', title: '演示服务', description: '', transport: 'http', enabled: true, url: upstreamUrl, headers: [{ name: 'Authorization', value: 'Bearer up-secret' }], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [], ...over, }) const approvals: NewApproval[] = [] let answer: InteractionOutcome = { resolution: 'allowed', scope: 'once' } function runCtx(over: Partial = {}): GatewayRunContext { const ws = path.join(config.dataDir, 'workspaces', `ses_mcp_${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: 'ask', settings, access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }), knowledgeRoots: [], mcpServerIds: [], audit: () => {}, interact: async req => { approvals.push(req); return answer }, ...over, } } async function gateway(ctx: 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(ctx)}` } } })) return client } const text = (r: unknown) => ((r as { content: { text: string }[] }).content[0]!.text) beforeAll(async () => { const log = pino({ level: 'silent' }) app = createApp(log, { providers: {} }) server = await buildServer(app, { logger: log }) base = await server.listen({ port: 0, host: '127.0.0.1' }) upstreamUrl = await startUpstream() }) afterAll(async () => { await app.mcpUpstream.closeAll() await server.close() upstream.close() }) describe('第三方 MCP(远程 HTTP)', () => { it('测试连接、登记;密钥不返回,更新时可沿用', async () => { const t = await app.mcpServers.test(httpInput(), null) expect(t).toMatchObject({ ok: true }) expect(t.tools.map(x => [x.name, x.readOnly, x.policy])).toEqual([['echo', true, 'auto'], ['write_note', false, 'ask'], ['big', true, 'auto'], ['export', true, 'auto']]) const bad = await app.mcpServers.test(httpInput({ headers: [{ name: 'Authorization', value: 'Bearer wrong' }] }), null) expect(bad.ok).toBe(false) const s = app.mcpServers.create(httpInput()) expect(s.headers).toEqual([{ name: 'Authorization', value: null, isSet: true }]) expect(JSON.stringify(s)).not.toContain('up-secret') const updated = app.mcpServers.update(s.id, { ...httpInput({ title: '演示服务2' }), headers: [{ name: 'Authorization', value: null }] }) expect(updated.title).toBe('演示服务2') expect((await app.mcpServers.refresh(s.id)).tools).toHaveLength(4) expect(() => app.mcpServers.create(httpInput())).toThrow(/已被使用/) expect(() => app.mcpServers.create(httpInput({ name: 'Bad Name' }))).toThrow(/名称/) expect(() => app.mcpServers.create(httpInput({ name: 'x2', headers: [{ name: 'Authorization', value: null }] }))).toThrow(/请填写/) }) it('经网关转接:只提供 Profile 勾选的服务;只读工具直接调用,其余审批;只读模式只列只读工具', async () => { const id = app.mcpServers.list().find(s => s.name === 'demo')!.id const none = await gateway(runCtx()) expect((await none.listTools()).tools.map(t => t.name)).not.toContain('demo__echo') await none.close() const ctx = runCtx({ mcpServerIds: [id] }) const c = await gateway(ctx) const names = (await c.listTools()).tools.map(t => t.name) expect(names).toEqual(expect.arrayContaining(['convert_document', 'web_fetch', 'demo__echo', 'demo__write_note', 'demo__big'])) expect(text(await c.callTool({ name: 'demo__echo', arguments: { text: '你好' } }))).toBe('echo:你好') expect(approvals).toHaveLength(0) answer = { resolution: 'denied', reason: '不需要' } const denied = await c.callTool({ name: 'demo__write_note', arguments: { note: 'a' } }) expect(denied.isError).toBe(true) expect(text(denied)).toMatch(/拒绝.*不需要/) expect(approvals[0]).toMatchObject({ kind: 'approval', tool: 'mcp__gateway__demo__write_note', title: '演示服务2 · write_note', input: { note: 'a' } }) answer = { resolution: 'allowed', scope: 'session' } expect(text(await c.callTool({ name: 'demo__write_note', arguments: { note: 'b' } }))).toBe('saved:b') expect(text(await c.callTool({ name: 'demo__write_note', arguments: { note: 'c' } }))).toBe('saved:c') expect(approvals).toHaveLength(2) // 本会话允许后不再询问 const big = text(await c.callTool({ name: 'demo__big', arguments: {} })) expect(big.length).toBeLessThan(101_000) const saved = /保存到 (\S+))/.exec(big)![1]! expect(fs.readFileSync(path.join(ctx.workspaceDir, saved), 'utf8')).toHaveLength(150_000) // 嵌入资源一律存入工作区,只返回路径;文件名取 URI 最后一段,重名加序号,不会越出 .agent/mcp const exported = (await c.callTool({ name: 'demo__export', arguments: {} })).content as { type: string; text: string }[] expect(exported.map(x => x.type)).toEqual(['text', 'text', 'text', 'text']) expect(exported[0]!.text).toBe('共 3 行') const paths = exported.slice(1).map(x => /^已保存到 (\S+?)(/.exec(x.text)![1]!) expect(paths).toEqual([path.join('.agent', 'mcp', 'SN1_2026.csv'), path.join('.agent', 'mcp', 'wave.bin'), path.join('.agent', 'mcp', 'SN1_2026-2.csv')]) expect(exported[1]!.text).toContain('text/csv') expect(exported.map(x => x.text).join('')).not.toContain('ts,pv') expect(fs.readFileSync(path.join(ctx.workspaceDir, paths[0]!), 'utf8')).toBe('ts,pv\n1,2\n3,4\n') expect([...fs.readFileSync(path.join(ctx.workspaceDir, paths[1]!))]).toEqual([0, 1, 2, 255]) expect(fs.readFileSync(path.join(ctx.workspaceDir, paths[2]!), 'utf8')).toBe('again') await c.close() const ro = await gateway(runCtx({ mcpServerIds: [id], permissionMode: 'readonly' })) const roNames = (await ro.listTools()).tools.map(t => t.name) expect(roNames).toContain('demo__echo') expect(roNames).not.toContain('demo__write_note') expect((await ro.callTool({ name: 'demo__write_note', arguments: { note: 'x' } })).isError).toBe(true) await ro.close() // 自动模式不询问;关闭的工具不提供 const before = approvals.length const auto = await gateway(runCtx({ mcpServerIds: [id], permissionMode: 'auto' })) expect(text(await auto.callTool({ name: 'demo__write_note', arguments: { note: 'd' } }))).toBe('saved:d') expect(approvals).toHaveLength(before) app.mcpServers.setToolPolicy(id, 'echo', 'off') expect((await auto.listTools()).tools.map(t => t.name)).not.toContain('demo__echo') await auto.close() expect(seenAuth.every(a => a === 'Bearer up-secret' || a === 'Bearer wrong')).toBe(true) }) it('Profile 引用 MCP 服务;删除服务时从 Profile 中移除', async () => { const id = app.mcpServers.list().find(s => s.name === 'demo')!.id const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = app.profiles.getBuiltin() const p = app.profiles.create({ ...input, name: 'MCP 测试', isDefault: false, mcpServerIds: [id] }) expect(app.mcpServers.get(id).profileNames).toEqual(['MCP 测试']) expect(() => app.profiles.create({ ...input, name: 'x', isDefault: false, mcpServerIds: ['mcp_none'] })).toThrow(/不存在/) await app.mcpServers.delete(id) expect(app.profiles.get(p.id).mcpServerIds).toEqual([]) }) }) describe.skipIf(!BWRAP)('第三方 MCP(本地进程,沙箱)', () => { it('在沙箱中运行:密钥经环境变量传入、独立 home、只能读开放的路径', async () => { const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-outside-')) fs.writeFileSync(path.join(outside, 'secret.txt'), 'TOP-SECRET') const input: McpServerInput = { name: 'local', title: '本地服务', description: '', transport: 'stdio', enabled: true, url: '', headers: [], command: process.execPath, args: [path.join(here, 'fixtures-mcp-stdio.mjs')], env: [{ name: 'FIXTURE_SECRET', value: 's3cr3t' }], allowNetwork: false, // 测试脚本与依赖所在目录(只读) readPaths: [here, path.resolve(here, '..', 'node_modules'), path.resolve(here, '../../../node_modules'), path.dirname(process.execPath)], writePaths: [], } const s = app.mcpServers.create(input) expect(s.env).toEqual([{ name: 'FIXTURE_SECRET', value: null, isSet: true }]) const refreshed = await app.mcpServers.refresh(s.id) expect(refreshed.lastError).toBeNull() expect(refreshed.tools.map(t => t.name)).toEqual(['whoami', 'read_file']) const c = await gateway(runCtx({ mcpServerIds: [s.id] })) const who = JSON.parse(text(await c.callTool({ name: 'local__whoami', arguments: {} }))) expect(who.secret).toBe('s3cr3t') expect(who.home).toMatch(/data\/mcp\/mcp_/) const denied = await c.callTool({ name: 'local__read_file', arguments: { path: path.join(outside, 'secret.txt') } }) expect(text(denied)).toMatch(/ERR ENOENT/) expect(text(await c.callTool({ name: 'local__read_file', arguments: { path: path.join(here, 'setup.ts') } }))).toContain('AGENT_SERVER_DATA_DIR') await c.close() expect(() => app.mcpServers.create({ ...input, name: 'bad1', readPaths: [config.dataDir] })).toThrow(/服务数据目录/) expect(() => app.mcpServers.create({ ...input, name: 'bad2', readPaths: ['relative/path'] })).toThrow(/绝对路径/) }, 60_000) })