import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Server } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' 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 type { KnowledgeService } from '../knowledge/knowledge-service' import { AssistantDatabase } from '../assistant/assistant-database' import { KnowledgeMcpGateway, type MagicNotesDatabase } from './knowledge-mcp-gateway' const firstLibraryId = '11111111-1111-4111-8111-111111111111' const secondLibraryId = '22222222-2222-4222-8222-222222222222' function createService() { const searchHybridMany = vi.fn( async (libraryIds: readonly string[]) => libraryIds.map((knowledgeBaseId, index) => ({ knowledgeBaseId, result: { document: { id: `33333333-3333-4333-8333-33333333333${index}`, title: `文档 ${index}` }, source: { displayName: `来源 ${index}`, location: `/private/${index}` }, chunk: { id: `44444444-4444-4444-8444-44444444444${index}`, location: `第 ${index + 1} 段` }, snippet: `匹配 ${index}`, rank: index + 1, retrieval: { score: 0.5, channels: ['fts'] as const, lexicalRank: 1, evidenceIds: [] } } })) ) const service = { database: { listKnowledgeBases: () => [ { id: firstLibraryId, name: '一号知识库', description: '不应暴露' }, { id: secondLibraryId, name: '二号知识库', description: '已授权知识' } ] }, searchHybridMany } as unknown as KnowledgeService return { service, searchHybridMany } } const gateways: KnowledgeMcpGateway[] = [] const databases: AssistantDatabase[] = [] const temporaryDirectories: string[] = [] const httpServers: Server[] = [] afterEach(async () => { await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose())) for (const database of databases.splice(0)) { database.close() } await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { recursive: true, force: true })) ) await Promise.all( httpServers.splice(0).map( (server) => new Promise((resolve) => server.close(() => resolve())) ) ) }) describe('KnowledgeMcpGateway', () => { it('exposes GoodBuddy config reads in Ask and apply only in Execute', async () => { const { service } = createService() const configService = { getCapabilities: vi.fn(() => ({ server: 'goodbuddy_config' })), getSnapshot: vi.fn(async () => ({ application: {}, skills: [], mcpServers: [] })), plan: vi.fn(async () => ({ planId: 'plan' })), apply: vi.fn(async () => ({ status: 'applied' })), revokeRequest: vi.fn() } const gateway = new KnowledgeMcpGateway(service, { configService: configService as never }) gateways.push(gateway) const readToken = gateway.grant( 'config-read', [], new AbortController().signal, 'none', { access: 'read', workspacePath: process.cwd() } )! const authorizeApply = vi.fn(async () => true) const writeToken = gateway.grant( 'config-write', [], new AbortController().signal, 'none', { access: 'write', workspacePath: process.cwd(), authorizeApply } )! expect(gateway.getAvailableToolNames(readToken)).toEqual([ 'goodbuddy_config_capabilities', 'goodbuddy_config_get', 'goodbuddy_config_plan' ]) expect(gateway.getAvailableToolNames(writeToken)).toEqual([ 'goodbuddy_config_capabilities', 'goodbuddy_config_get', 'goodbuddy_config_plan', 'goodbuddy_config_apply' ]) await gateway.callGoodBuddyConfigTool( readToken, 'goodbuddy_config_capabilities', {} ) expect(configService.getCapabilities).toHaveBeenCalledWith({}) await expect( gateway.callGoodBuddyConfigTool( readToken, 'goodbuddy_config_apply', { planId: crypto.randomUUID() } ) ).rejects.toThrow('unavailable') await gateway.callGoodBuddyConfigTool( writeToken, 'goodbuddy_config_apply', { planId: crypto.randomUUID() } ) expect(configService.apply).toHaveBeenCalledWith( 'config-write', expect.any(Object), expect.any(AbortSignal), authorizeApply ) gateway.revoke(writeToken) expect(configService.revokeRequest).toHaveBeenCalledWith('config-write') }) it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => { const { service, searchHybridMany } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) const token = gateway.grant( 'request-1', [secondLibraryId], new AbortController().signal ) expect(token).toMatch(/^[A-Za-z0-9_-]{40,}$/u) expect(gateway.getAvailableToolNames(token!)).toEqual([ 'knowledge_list', 'knowledge_search' ]) expect(gateway.listLibraries(token!)).toEqual([ { id: secondLibraryId, name: '二号知识库', description: '已授权知识' } ]) expect(() => gateway.listLibraries(token!, { libraryIds: [firstLibraryId] }) ).toThrow() const references = await gateway.search(token!, { query: ' 要找什么 ', limit: 1 }) expect(searchHybridMany).toHaveBeenCalledWith( [secondLibraryId], '要找什么', 1, expect.any(AbortSignal) ) expect(references).toEqual([ expect.objectContaining({ libraryId: secondLibraryId, libraryName: '二号知识库', chunkId: '44444444-4444-4444-8444-444444444440', score: 0.5, snippet: '匹配 0' }) ]) expect(references[0]?.sourceLocation).toBeUndefined() expect(gateway.drainReferences(token)).toEqual(references) expect(gateway.drainReferences(token)).toEqual([]) await expect( gateway.search(token!, { query: 'x', limit: 9, libraryIds: [firstLibraryId] }) ).rejects.toThrow() }) it('creates no capability for empty scope and rejects revoked, aborted, and expired capabilities', async () => { const { service } = createService() let now = 1_000 const gateway = new KnowledgeMcpGateway(service, { capabilityTtlMs: 10, now: () => now }) gateways.push(gateway) expect( gateway.grant('empty', [], new AbortController().signal) ).toBeUndefined() const revoked = gateway.grant( 'revoked', [firstLibraryId], new AbortController().signal )! gateway.revoke(revoked) await expect( gateway.search(revoked, { query: 'x' }) ).rejects.toThrow('unavailable or expired') const abortController = new AbortController() const aborted = gateway.grant( 'aborted', [firstLibraryId], abortController.signal )! abortController.abort() await expect( gateway.search(aborted, { query: 'x' }) ).rejects.toThrow('unavailable or expired') const expired = gateway.grant( 'expired', [firstLibraryId], new AbortController().signal )! now += 11 await expect( gateway.search(expired, { query: 'x' }) ).rejects.toThrow('unavailable or expired') }) it('grants bounded global Magic Notes search without a knowledge scope', () => { const { service } = createService() const searchMagicNotes = vi.fn(() => [ { noteId: '00000000-0000-4000-8000-000000000701', noteTitle: '发布计划', entryId: '00000000-0000-4000-8000-000000000702', content: '核对构建产物', updatedAt: '2026-08-10T00:00:00.000Z' } ]) const gateway = new KnowledgeMcpGateway(service, { magicNotesDatabase: { listMagicNotes: vi.fn(() => []), getMagicNote: vi.fn(() => { throw new Error('not used') }), getMagicNoteEntry: vi.fn(() => { throw new Error('not used') }), searchMagicNotes, createMagicNote: vi.fn(() => { throw new Error('not used') }), updateMagicNote: vi.fn(() => { throw new Error('not used') }), deleteMagicNote: vi.fn(), createMagicNoteEntry: vi.fn(() => { throw new Error('not used') }), updateMagicNoteEntry: vi.fn(() => { throw new Error('not used') }), deleteMagicNoteEntry: vi.fn(() => { throw new Error('not used') }) } satisfies MagicNotesDatabase }) gateways.push(gateway) const token = gateway.grant( 'notes', [], new AbortController().signal, 'read' )! expect(gateway.getAvailableToolNames(token)).toEqual([ 'note_list', 'note_get', 'note_search' ]) expect( gateway.searchMagicNotes(token, { query: ' 发布 ', limit: 3 }) ).toEqual([ expect.objectContaining({ noteTitle: '发布计划', content: '核对构建产物' }) ]) expect(searchMagicNotes).toHaveBeenCalledWith('发布', 3) expect(() => gateway.searchMagicNotes(token, { query: '发布', noteIds: ['not-allowed'] }) ).toThrow() }) it('keeps Ask read-only and supports revision-safe Magic Notes CRUD in Execute', async () => { const { service } = createService() const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-mcp-')) temporaryDirectories.push(directory) const database = new AssistantDatabase( join(directory, 'assistant.sqlite') ) databases.push(database) database.initialize('C:\\Workspace') const gateway = new KnowledgeMcpGateway(service, { magicNotesDatabase: database }) gateways.push(gateway) const readToken = gateway.grant( 'notes-read', [], new AbortController().signal, 'read' )! const writeToken = gateway.grant( 'notes-write', [], new AbortController().signal, 'write' )! expect(gateway.getAvailableToolNames(readToken)).toEqual([ 'note_list', 'note_get', 'note_search' ]) expect(gateway.getAvailableToolNames(writeToken)).toEqual([ 'note_list', 'note_get', 'note_search', 'note_create', 'note_update', 'note_entry_create', 'note_entry_update', 'note_entry_delete', 'note_delete' ]) expect(() => gateway.createMagicNote(readToken, { title: '不允许创建' }) ).toThrow('unavailable') const created = gateway.createMagicNote(writeToken, { title: '发布计划', content: '核对构建产物' }) expect(gateway.listMagicNotes(readToken)).toEqual([ expect.objectContaining({ id: created.id, title: '发布计划', revision: 1, entryCount: 1 }) ]) expect(created.entries[0]?.content).toBe('核对构建产物') const withEntry = gateway.createMagicNoteEntry(writeToken, { noteId: created.id, content: '通知发布负责人' }) const entry = withEntry.entries[1]! expect(entry.content).toBe('通知发布负责人') const updatedEntry = gateway.updateMagicNoteEntry(writeToken, { entryId: entry.id, content: '核对六个平台构建产物', expectedRevision: entry.revision }) expect(updatedEntry.entries[1]?.content).toBe( '核对六个平台构建产物' ) expect(() => gateway.deleteMagicNoteEntry(writeToken, { entryId: entry.id, expectedRevision: entry.revision }) ).toThrow('已被更新') const withoutEntry = gateway.deleteMagicNoteEntry(writeToken, { entryId: entry.id, expectedRevision: updatedEntry.entries[1]!.revision }) expect(withoutEntry.entries).toEqual([ expect.objectContaining({ content: '核对构建产物' }) ]) expect( gateway.deleteMagicNote(writeToken, { noteId: created.id, expectedRevision: withoutEntry.revision }) ).toEqual({ deleted: true, noteId: created.id }) expect(() => gateway.getMagicNote(readToken, { noteId: created.id }) ).toThrow('笔记不存在') }) it('binds a POST-only authenticated endpoint and rejects oversized bodies', async () => { const { service } = createService() const gateway = new KnowledgeMcpGateway(service, { maximumBodyBytes: 32 }) gateways.push(gateway) await gateway.start() const endpoint = gateway.getEndpoint()! const token = gateway.grant( 'http', [firstLibraryId], new AbortController().signal )! const getResponse = await fetch(endpoint) expect(getResponse.status).toBe(405) expect(getResponse.headers.get('access-control-allow-origin')).toBeNull() const unauthorized = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}x` }, body: '{}' }) expect(unauthorized.status).toBe(401) const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ value: 'x'.repeat(100) }) }) expect(oversized.status).toBe(413) }) it('proxies custom MCP through a request-scoped loopback token without exposing the upstream credential', async () => { const upstreamAuthorizations: Array = [] const upstream = createServer(async (request, response) => { upstreamAuthorizations.push(request.headers.authorization) if (request.method !== 'POST') { response.writeHead(405) response.end() return } const chunks: Buffer[] = [] for await (const chunk of request) { chunks.push(Buffer.from(chunk)) } const body = JSON.parse( Buffer.concat(chunks).toString('utf8') ) as unknown const mcp = new McpServer({ name: 'private-upstream', version: '1.0.0' }) mcp.registerTool( 'echo_private', { description: 'Echo through the private server', inputSchema: { value: z.string().max(100) } }, async ({ value }) => ({ content: [{ type: 'text', text: `upstream:${value}` }] }) ) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }) await mcp.connect(transport) await transport.handleRequest(request, response, body) await Promise.allSettled([transport.close(), mcp.close()]) }) httpServers.push(upstream) await new Promise((resolve, reject) => { upstream.once('error', reject) upstream.listen(0, '127.0.0.1', resolve) }) const address = upstream.address() if (!address || typeof address === 'string') { throw new Error('upstream did not bind') } const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) await gateway.start() const controller = new AbortController() const token = gateway.grantCustomMcp( 'custom-request', [ { id: '00000000-0000-4000-8000-000000000091', name: 'Private MCP', description: '', enabled: true, allowDynamicTools: true, assignments: ['opencode'], secretConfigured: true, secret: 'upstream-secret', transport: 'http', url: `http://127.0.0.1:${address.port}/mcp` } ], controller.signal )! const client = new Client({ name: 'loopback-test-client', version: '1.0.0' }) await client.connect( new StreamableHTTPClientTransport( new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } } ) ) try { const listed = await client.listTools() expect(listed.tools).toEqual([ expect.objectContaining({ name: expect.stringMatching( /^mcp_[a-f0-9]{8}_[a-f0-9]{8}_echo_private$/u ), description: expect.stringContaining('Private MCP') }) ]) expect(JSON.stringify(listed)).not.toContain('upstream-secret') expect(JSON.stringify(listed)).not.toContain( `127.0.0.1:${address.port}` ) const result = await client.callTool({ name: listed.tools[0]!.name, arguments: { value: 'hello' } }) expect(result).toMatchObject({ content: [{ type: 'text', text: 'upstream:hello' }] }) expect(upstreamAuthorizations).toContain( 'Bearer upstream-secret' ) } finally { gateway.revoke(token) await client.close() } }) })