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 { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { LATEST_PROTOCOL_VERSION, ListToolsRequestSchema, isInitializeRequest, type Tool } from '@modelcontextprotocol/sdk/types.js' import type { KnowledgeService } from '../knowledge/knowledge-service' import { AssistantDatabase } from '../assistant/assistant-database' import { browserToolNames, KnowledgeMcpGateway, type MagicNotesDatabase } from './knowledge-mcp-gateway' import { browserTabIdSchema } from '../../shared/contracts' import { BrowserService } from '../browser/browser-service' import { createHash } from 'node:crypto' import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from '../desktop-diagnostics' import { ApplicationSettingsStore } from '../application-settings-store' import { CapabilityService } from '../capabilities/capability-service' import { GoodBuddyConfigService } from '../goodbuddy-config-service' const firstLibraryId = '11111111-1111-4111-8111-111111111111' const secondLibraryId = '22222222-2222-4222-8222-222222222222' function createService() { const retrieveMany = vi.fn( async (libraryIds: readonly string[]) => libraryIds.map((knowledgeBaseId, index) => ({ knowledgeBaseId, response: { diagnostics: {}, results: [{ knowledgeBaseId, documentId: `33333333-3333-4333-8333-33333333333${index}`, documentTitle: `文档 ${index}`, sourceDisplayName: `来源 ${index}`, chunkId: `44444444-4444-4444-8444-444444444440`, location: `第 ${index + 1} 段`, snippet: `匹配 ${index}`, rank: index + 1, channels: ['fts'] as const, scores: { fusedScore: 0.5, ftsRank: 1 } }] } })) ) const service = { database: { listKnowledgeBases: async () => [ { id: firstLibraryId, name: '一号知识库', description: '不应暴露' }, { id: secondLibraryId, name: '二号知识库', description: '已授权知识' } ] }, retrieveMany } as unknown as KnowledgeService return { service, retrieveMany } } function customMcpServer( url: string, id = '00000000-0000-4000-8000-000000000092' ) { return { id, name: 'Paged MCP', description: '', enabled: true, allowDynamicTools: true, assignments: ['opencode' as const], secretConfigured: false, transport: 'http' as const, url } } async function startToolUpstream( listTools: ( cursor: string | undefined ) => | { tools: Tool[]; nextCursor?: string } | Promise<{ tools: Tool[]; nextCursor?: string }> ): Promise<{ url: string notifyToolsChanged: () => Promise }> { const sessions = new Map< string, { protocol: McpProtocolServer transport: StreamableHTTPServerTransport } >() const server = createServer(async (request, response) => { let body: unknown if (request.method === 'POST') { const chunks: Buffer[] = [] for await (const chunk of request) { chunks.push(Buffer.from(chunk)) } body = JSON.parse(Buffer.concat(chunks).toString('utf8')) } const sessionId = request.headers['mcp-session-id'] let session = typeof sessionId === 'string' ? sessions.get(sessionId) : undefined if (!session) { if ( request.method !== 'POST' || !isInitializeRequest(body) ) { response.writeHead(404) response.end() return } const protocol = new McpProtocolServer( { name: 'tool-upstream', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } } ) protocol.setRequestHandler( ListToolsRequestSchema, async (requestValue) => listTools(requestValue.params?.cursor) ) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (idValue) => { sessions.set(idValue, { protocol, transport }) }, onsessionclosed: (idValue) => { sessions.delete(idValue) } }) session = { protocol, transport } await protocol.connect(transport) } await session.transport.handleRequest(request, response, body) }) httpServers.push(server) await new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '127.0.0.1', resolve) }) const address = server.address() if (!address || typeof address === 'string') { throw new Error('tool upstream did not bind') } return { url: `http://127.0.0.1:${address.port}/mcp`, notifyToolsChanged: async () => { await Promise.all( [...sessions.values()].map(({ protocol }) => protocol.sendToolListChanged() ) ) } } } function testTool(name: string): Tool { return { name, description: name, inputSchema: { type: 'object' } } } const gateways: KnowledgeMcpGateway[] = [] it('records safe HTTP rejection phases and isolates throwing observers', async () => { const observeFailure = vi.fn(() => { throw new Error('observer failed') }) const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure }) gateways.push(gateway) await gateway.start() const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal)! const correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}` const cases = [ { headers: {}, body: '{}', status: 401, phase: 'authentication' }, { headers: { authorization: `Bearer ${token}` }, body: 'private-invalid-json', status: 400, phase: 'request' }, { headers: { authorization: `Bearer ${token}`, 'mcp-session-id': 'private-session' }, body: '{}', status: 404, phase: 'session' }, { headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } }), status: 406, phase: 'initialize' } ] for (const entry of cases) { const response = await fetch(gateway.getEndpoint()!, { method: 'POST', headers: { 'content-type': 'application/json', ...entry.headers }, body: entry.body }) await response.text() expect(response.status).toBe(entry.status) expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({ code: 'runtime.mcp.failed', mcp: expect.objectContaining({ phase: entry.phase, category: 'http-rejection', status: entry.status, elapsedMs: expect.any(Number) }) })) } expect(observeFailure.mock.calls.at(-1)?.[0]).toMatchObject({ mcp: { correlationId } }) expect(JSON.stringify(observeFailure.mock.calls)).not.toContain(token) expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-') }) it('cleans up failed HTTP initialization and allows a subsequent connection', async () => { const observeFailure = vi.fn() const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure }) gateways.push(gateway) await gateway.start() const token = gateway.grant('request', [firstLibraryId], new AbortController().signal)! const connect = vi.spyOn(McpProtocolServer.prototype, 'connect').mockRejectedValue(new Error('private-connect-error')) const close = vi.spyOn(McpProtocolServer.prototype, 'close') for (let index = 0; index < 9; index += 1) { const response = await fetch(gateway.getEndpoint()!, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: index, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } }) }) await response.text() expect(response.status).toBe(500) } expect(close).toHaveBeenCalledTimes(9) expect(observeFailure).toHaveBeenCalledTimes(9) expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({ mcp: expect.objectContaining({ phase: 'initialize', category: 'handler-failure', status: 500 }) })) connect.mockRestore() const client = new Client({ name: 'test', version: '1' }) try { await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } })) await client.listTools() await client.callTool({ name: 'knowledge_list', arguments: {} }) expect(observeFailure).toHaveBeenCalledTimes(9) } finally { await client.close() } }) it('persists asynchronous HTTP tool discovery failures without private error details', async () => { const directory = await mkdtemp(join(tmpdir(), 'gateway-diagnostics-')) temporaryDirectories.push(directory) const diagnostics = new DesktopDiagnostics(directory) const pending: Promise[] = [] let clock = 100 vi.spyOn(performance, 'now').mockImplementation(() => clock) const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure: failure => { pending.push(diagnostics.recordFailure(failure)) }, storyGraphService: { available: async () => { clock = 145; throw new Error('private-url-token-body') }, read: () => ({}) } }) gateways.push(gateway) await gateway.start() const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, undefined, { runtimeTarget: 'opencode' })! const client = new Client({ name: 'test', version: '1' }) try { await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } })) await expect(client.listTools()).rejects.toThrow() await Promise.all(pending) const exported = (await diagnostics.exportRecent()).toString('utf8') expect(JSON.parse(exported)).toMatchObject({ code: 'runtime.mcp.failed', mcp: { phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 45 } }) expect(exported).not.toContain('private-') expect(exported).not.toContain(token) } finally { await client.close() await diagnostics.dispose() } }) it('honors a requested knowledge result count above eight', async () => { const { service, retrieveMany } = createService() const [sample] = await retrieveMany([firstLibraryId]) retrieveMany.mockClear() retrieveMany.mockResolvedValue([{ ...sample!, response: { diagnostics: {}, results: Array.from({ length: 12 }, (_, index) => ({ ...sample!.response.results[0]!, chunkId: crypto.randomUUID(), rank: index + 1 })) } }]) const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) const controller = new AbortController() const token = gateway.grant('large-search', [firstLibraryId], controller.signal)! const references = await gateway.search(token, { query: 'evidence', limit: 12 }) expect(references).toHaveLength(12) expect(retrieveMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', expect.any(AbortSignal)) await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow() }) const databases: AssistantDatabase[] = [] const temporaryDirectories: string[] = [] const httpServers: Server[] = [] afterEach(async () => { vi.restoreAllMocks() 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('grants reserved parent and inherited child targets without materializing a browser', async () => { const { service } = createService() const createSession = vi.fn(async (): Promise => { throw new Error('unused requests must not create a browser') }) const browserService = new BrowserService({ createSession }) const gateway = new KnowledgeMcpGateway(service, { browserService }) gateways.push(gateway) const parentController = new AbortController() const parent = browserService.reserveRequestTab('conversation', 'parent', 21) const child = browserService.acquireTabUsage('conversation', parent.tabId, 'child', 21) try { expect(browserService.listTabs('conversation')).toEqual([]) expect(() => gateway.grant('unleased', [], parentController.signal, 'none', undefined, 'conversation', parent.tabId)) .toThrow('不属于当前对话') const parentToken = gateway.grant('parent', [], parentController.signal, 'none', undefined, 'conversation', parent.tabId, parent)! const childToken = gateway.grant('child', [], new AbortController().signal, 'none', undefined, 'conversation', child.tabId, child)! expect(gateway.getAvailableToolNames(parentToken)).toEqual(browserToolNames) expect(gateway.getAvailableToolNames(childToken)).toEqual(browserToolNames) expect(createSession).not.toHaveBeenCalled() parentController.abort() expect(parent.signal.aborted).toBe(true) expect(child.signal.aborted).toBe(false) expect(browserService.getOwnerWindowId('conversation')).toBe(21) await gateway.start() const navigate = vi.spyOn(browserService, 'navigate').mockResolvedValue({ url: 'https://example.com/', origin: 'https://example.com' }) const client = new Client({ name: 'reserved-child-test', version: '1.0.0' }) try { await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${childToken}` } } })) await client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } }) expect(navigate).toHaveBeenCalledWith('conversation', 'https://example.com/', expect.any(AbortSignal), parent.tabId) expect(createSession).not.toHaveBeenCalled() } finally { await client.close() } gateway.revoke(childToken) expect(child.signal.aborted).toBe(true) expect(browserService.getOwnerWindowId('conversation')).toBeUndefined() } finally { await gateway.dispose() await browserService.dispose() } }) it('rejects mismatched or ended reservation leases and releases pre-canceled grants', async () => { const { service } = createService() const browserService = new BrowserService() const gateway = new KnowledgeMcpGateway(service, { browserService }) gateways.push(gateway) try { const mismatch = browserService.reserveRequestTab('conversation', 'owner', 21) expect(() => gateway.grant('other-owner', [], new AbortController().signal, 'none', undefined, 'conversation', mismatch.tabId, mismatch)) .toThrow('与当前请求不匹配') expect(mismatch.signal.aborted).toBe(true) const released = browserService.reserveRequestTab('conversation', 'released', 21) released.release() expect(() => gateway.grant('released', [], new AbortController().signal, 'none', undefined, 'conversation', released.tabId, released)) .toThrow('租约已终止') const canceled = browserService.reserveRequestTab('conversation', 'canceled', 21) expect(() => gateway.grant('canceled', [], AbortSignal.abort(new Error('request canceled')), 'none', undefined, 'conversation', canceled.tabId, canceled)) .toThrow('request canceled') expect(canceled.signal.aborted).toBe(true) expect(browserService.getOwnerWindowId('conversation')).toBeUndefined() expect(browserService.getSessionCount()).toBe(0) } finally { await browserService.dispose() } }) it('exposes the assigned browser reads and writes through a request-scoped conversation', async () => { const { service } = createService() const browserTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000301' ) const primaryBrowserTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000302' ) const releaseBrowserUsage = vi.fn() const acquireTabUsage = vi.fn( (conversationId: string, tabId: typeof browserTabId, owner: string) => ({ conversationId, tabId, owner, signal: new AbortController().signal, release: releaseBrowserUsage }) ) const browserService = { createTab: vi.fn(), getOrigin: vi.fn(() => undefined), navigate: vi.fn(async () => ({ url: 'https://example.com/', origin: 'https://example.com' })), snapshot: vi.fn(async () => ({ url: 'https://example.com/', title: 'Example', nodes: [], truncated: false })), click: vi.fn(), type: vi.fn(), select: vi.fn(), back: vi.fn(), screenshot: vi.fn(async () => ({ type: 'image' as const, mimeType: 'image/jpeg' as const, data: '/9j/' })), releaseConversation: vi.fn(async () => undefined), acquireTabUsage, listTabs: vi.fn(() => [ { conversationId: 'browser-conversation', tabId: primaryBrowserTabId, primary: true, status: 'ready' as const, isLoading: false, canGoBack: false, createdAt: 1, updatedAt: 1 }, { conversationId: 'browser-conversation', tabId: browserTabId, primary: false, status: 'ready' as const, isLoading: false, canGoBack: false, createdAt: 1, updatedAt: 1 } ]) } const gateway = new KnowledgeMcpGateway(service, { browserService }) gateways.push(gateway) await gateway.start() expect(() => gateway.grant( 'wrong-browser-tab', [], new AbortController().signal, 'none', undefined, 'browser-conversation', browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000399' ) ) ).toThrow('浏览器标签页不存在或不属于当前对话') const token = gateway.grant( 'browser-request', [], new AbortController().signal, 'none', undefined, 'browser-conversation', browserTabId )! expect(acquireTabUsage).toHaveBeenCalledWith( 'browser-conversation', browserTabId, 'browser-request' ) const expectedTools = browserToolNames expect(gateway.getAvailableToolNames(token)).toEqual(expectedTools) const client = new Client({ name: 'browser-loopback-test', 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.map((tool) => tool.name)).toEqual( expectedTools ) expect(listed.tools.filter((tool) => tool.annotations?.readOnlyHint).map((tool) => tool.name)) .toEqual(['browser_snapshot', 'browser_screenshot']) await client.callTool({ name: 'browser_snapshot', arguments: {} }) await client.callTool({ name: 'browser_screenshot', arguments: {} }) expect(browserService.snapshot).toHaveBeenCalledWith('browser-conversation', expect.any(AbortSignal), browserTabId) expect(browserService.screenshot).toHaveBeenCalledWith('browser-conversation', expect.any(AbortSignal), browserTabId) await expect( client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com' } }) ).resolves.toMatchObject({ content: [ { type: 'text', text: JSON.stringify({ url: 'https://example.com/', origin: 'https://example.com' }) } ] }) expect(browserService.navigate).toHaveBeenCalledWith( 'browser-conversation', 'https://example.com', expect.any(AbortSignal), browserTabId ) expect(browserService.listTabs).toHaveBeenCalledTimes(2) vi.mocked(browserService.click).mockRejectedValueOnce( new Error('浏览器标签页不存在或不属于当前对话') ) await expect( client.callTool({ name: 'browser_click', arguments: { ref: 'b_boundTabReference' } }) ).resolves.toMatchObject({ isError: true }) expect(browserService.click).toHaveBeenCalledWith( 'browser-conversation', 'b_boundTabReference', expect.any(AbortSignal), browserTabId ) expect(browserService.listTabs).toHaveBeenCalledTimes(2) gateway.revoke(token) gateway.revoke(token) expect(releaseBrowserUsage).toHaveBeenCalledOnce() await expect( client.callTool({ name: 'browser_snapshot', arguments: {} }) ).rejects.toThrow() } finally { await client.close() } }) it('keeps shared tools and the MCP session alive after tab closure and navigates a dedicated replacement', async () => { const { service } = createService() const browserService = new BrowserService() const controller = new AbortController() const lease = browserService.reserveRequestTab('conversation', 'request', 21) const gateway = new KnowledgeMcpGateway(service, { browserService }) gateways.push(gateway) const token = gateway.grant('request', [firstLibraryId], controller.signal, 'none', undefined, 'conversation', lease.tabId, lease)! const navigate = vi.spyOn(browserService, 'navigate').mockImplementation( async (_conversationId, _url, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }) }) ) const snapshot = vi.spyOn(browserService, 'snapshot').mockResolvedValue({} as never) const replacementController = new AbortController() const replacementTabId = browserTabIdSchema.parse(crypto.randomUUID()) const replacementLease = { conversationId: 'conversation', tabId: replacementTabId, owner: 'request', signal: replacementController.signal, release: vi.fn() } const createTab = vi.spyOn(browserService, 'createTab').mockResolvedValue({ tabId: replacementTabId } as never) const acquire = vi.spyOn(browserService, 'acquireTabUsage').mockReturnValue(replacementLease) await gateway.start() const client = new Client({ name: 'closed-tab-test', version: '1.0.0' }) try { await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } })) const pending = client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } }) await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce()) lease.release() await expect(pending).resolves.toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringContaining('标签页已关闭') }] }) expect(navigate.mock.calls[0]![2].aborted).toBe(true) expect(controller.signal.aborted).toBe(false) await expect(client.callTool({ name: 'knowledge_search', arguments: { query: 'evidence' } })) .resolves.toMatchObject({ content: [{ type: 'text', text: expect.stringContaining(firstLibraryId) }] }) expect((await client.listTools()).tools.map(tool => tool.name)).toContain('browser_navigate') for (const name of browserToolNames.filter(name => name !== 'browser_navigate')) { await expect(client.callTool({ name, arguments: {} })).resolves.toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringContaining('标签页已关闭') }] }) } expect(snapshot).not.toHaveBeenCalled() expect(createTab).not.toHaveBeenCalled() await expect(client.callTool({ name: 'browser_navigate', arguments: {} })).resolves.toMatchObject({ isError: true }) expect(createTab).not.toHaveBeenCalled() navigate.mockResolvedValue({ url: 'https://example.com/', origin: 'https://example.com' }) await client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } }) expect(createTab).toHaveBeenCalledWith('conversation', undefined, expect.any(AbortSignal), `browser-recovery:request:${lease.tabId}`) expect(acquire).toHaveBeenCalledExactlyOnceWith('conversation', replacementTabId, 'request') expect(navigate).toHaveBeenLastCalledWith('conversation', 'https://example.com/', expect.any(AbortSignal), replacementTabId) await client.callTool({ name: 'browser_snapshot', arguments: {} }) expect(snapshot).toHaveBeenCalledWith('conversation', expect.any(AbortSignal), replacementTabId) expect(createTab).toHaveBeenCalledOnce() snapshot.mockImplementationOnce(async (_conversationId, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }) })) const callController = new AbortController() const canceled = client.callTool({ name: 'browser_snapshot', arguments: {} }, undefined, { signal: callController.signal }) const cancellation = expect(canceled).rejects.toThrow('cancel this call') await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(2)) callController.abort(new Error('cancel this call')) await cancellation await vi.waitFor(() => expect(snapshot.mock.calls[1]![1].aborted).toBe(true)) expect(replacementController.signal.aborted).toBe(false) expect(controller.signal.aborted).toBe(false) await expect(client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } })) .resolves.not.toHaveProperty('isError', true) expect(createTab).toHaveBeenCalledOnce() gateway.revoke(token) expect(replacementLease.release).toHaveBeenCalledOnce() } finally { await client.close() await gateway.dispose() await browserService.dispose() } }) it('exposes GoodBuddy config tools according to the scoped grant', 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 writeToken = gateway.grant( 'config-write', [], new AbortController().signal, 'none', { access: 'write', workspacePath: process.cwd() } )! 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) ) gateway.revoke(writeToken) expect(configService.revokeRequest).toHaveBeenCalledWith('config-write') }) it('applies a real configuration plan over MCP without an authorizer', async () => { const directory = await mkdtemp(join(tmpdir(), 'config-gateway-')) temporaryDirectories.push(directory) const application = new ApplicationSettingsStore(join(directory, 'application.json')) const capabilities = new CapabilityService( join(directory, 'capabilities.json'), join(directory, 'builtin'), join(directory, 'imported'), { isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString() } ) const configService = new GoodBuddyConfigService(application, capabilities) const gateway = new KnowledgeMcpGateway(createService().service, { configService }) gateways.push(gateway) const token = gateway.grant('config-mcp', [], new AbortController().signal, 'none', { access: 'write', workspacePath: directory })! await gateway.start() const client = new Client({ name: 'config-apply-test', version: '1.0.0' }) try { await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } })) const planned = await client.callTool({ name: 'goodbuddy_config_plan', arguments: { operations: [{ operation: 'application.update', updates: { checkUpdatesOnStartup: false } }] } }) expect(planned.isError).not.toBe(true) const content = planned.content as Array<{ type: string; text: string }> const { plan } = JSON.parse(content[0]!.text) expect(plan).not.toHaveProperty('requiresApproval') expect((await application.get()).checkUpdatesOnStartup).toBe(true) const applied = await client.callTool({ name: 'goodbuddy_config_apply', arguments: { planId: plan.planId } }) expect(applied.isError).not.toBe(true) expect((await application.get()).checkUpdatesOnStartup).toBe(false) expect(configService.takePendingReload('config-mcp')).toBe('after-current-request') await expect(client.callTool({ name: 'goodbuddy_config_apply', arguments: { planId: plan.planId } })).rejects.toThrow('不存在') } finally { await client.close() } }) it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => { const { service, retrieveMany } = 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(await gateway.listLibraries(token!)).toEqual([ { id: secondLibraryId, name: '二号知识库', description: '已授权知识' } ]) await expect( gateway.listLibraries(token!, { libraryIds: [firstLibraryId] }) ).rejects.toThrow() const references = await gateway.search(token!, { query: ' 要找什么 ', limit: 1 }) expect(retrieveMany).toHaveBeenCalledWith( [secondLibraryId], '要找什么', 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('keeps long-running request capabilities valid until revoked or aborted', async () => { const { service } = createService() const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000) const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) expect( gateway.grant('empty', [], new AbortController().signal) ).toBeUndefined() const revoked = gateway.grant( 'revoked', [firstLibraryId], new AbortController().signal )! clock.mockReturnValue(1_000 + 24 * 60 * 60_000) await expect(gateway.search(revoked, { query: 'x' })).resolves.toHaveLength(1) gateway.revoke(revoked) await expect( gateway.search(revoked, { query: 'x' }) ).rejects.toThrow('Tool authorization is unavailable') const abortController = new AbortController() const aborted = gateway.grant( 'aborted', [firstLibraryId], abortController.signal )! abortController.abort() await expect( gateway.search(aborted, { query: 'x' }) ).rejects.toThrow('Tool authorization is unavailable') const nextRequest = gateway.grant( 'next-request', [firstLibraryId], new AbortController().signal )! expect(nextRequest).not.toBe(revoked) await expect( gateway.search(nextRequest, { query: 'x' }) ).resolves.toHaveLength(1) clock.mockRestore() }) it('keeps Magic Notes available during long requests without a knowledge scope', async () => { const { service } = createService() const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000) const searchMagicNotes = vi.fn(async () => [ { 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' )! clock.mockReturnValue(1_000 + 24 * 60 * 60_000) expect(gateway.getAvailableToolNames(token)).toEqual([ 'note_list', 'note_get', 'note_search' ]) expect( await gateway.searchMagicNotes(token, { query: ' 发布 ', limit: 3 }) ).toEqual([ expect.objectContaining({ noteTitle: '发布计划', content: '核对构建产物' }) ]) expect(searchMagicNotes).toHaveBeenCalledWith('发布', 3) await expect( gateway.searchMagicNotes(token, { query: '发布', noteIds: ['not-allowed'] }) ).rejects.toThrow() gateway.revoke(token) expect(() => gateway.getAvailableToolNames(token)).toThrow('Tool authorization is unavailable') clock.mockRestore() }) it('preserves scoped grants and supports revision-safe Magic Notes CRUD', async () => { const { service } = createService() const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-mcp-')) temporaryDirectories.push(directory) const onMagicNotesChanged = vi.fn() const database = new AssistantDatabase( join(directory, 'assistant.sqlite'), { onMagicNotesChanged } ) databases.push(database) database.initialize('C:\\Workspace') const gateway = new KnowledgeMcpGateway(service, { magicNotesDatabase: { listMagicNotes: async (...args) => database.listMagicNotes(...args), getMagicNote: async (...args) => database.getMagicNote(...args), getMagicNoteEntry: async (...args) => database.getMagicNoteEntry(...args), searchMagicNotes: async (...args) => database.searchMagicNotes(...args), createMagicNote: async (...args) => { await Promise.resolve(); return database.createMagicNote(...args) }, updateMagicNote: async (...args) => { await Promise.resolve(); return database.updateMagicNote(...args) }, deleteMagicNote: async (...args) => { await Promise.resolve(); database.deleteMagicNote(...args) }, createMagicNoteEntry: async (...args) => { await Promise.resolve(); return database.createMagicNoteEntry(...args) }, updateMagicNoteEntry: async (...args) => { await Promise.resolve(); return database.updateMagicNoteEntry(...args) }, deleteMagicNoteEntry: async (...args) => { await Promise.resolve(); return database.deleteMagicNoteEntry(...args) } } }) 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' ]) await expect( gateway.createMagicNote(readToken, { title: '不允许创建' }) ).rejects.toThrow('unavailable') const created = await gateway.createMagicNote(writeToken, { title: '发布计划', content: '核对构建产物' }) expect(await gateway.listMagicNotes(readToken)).toEqual([ expect.objectContaining({ id: created.id, title: '发布计划', revision: 1, entryCount: 1 }) ]) expect(created.entries[0]?.content).toBe('核对构建产物') expect(onMagicNotesChanged).toHaveBeenCalledTimes(1) await gateway.updateMagicNote(writeToken, { noteId: created.id, title: 'Updated plan', expectedRevision: created.revision }) expect(onMagicNotesChanged).toHaveBeenCalledTimes(2) const withEntry = await gateway.createMagicNoteEntry(writeToken, { noteId: created.id, content: '通知发布负责人' }) const entry = withEntry.entries[1]! expect(entry.content).toBe('通知发布负责人') expect(onMagicNotesChanged).toHaveBeenCalledTimes(3) const updatedEntry = await gateway.updateMagicNoteEntry(writeToken, { entryId: entry.id, content: '核对六个平台构建产物', expectedRevision: entry.revision }) expect(updatedEntry.entries[1]?.content).toBe( '核对六个平台构建产物' ) await expect( gateway.deleteMagicNoteEntry(writeToken, { entryId: entry.id, expectedRevision: entry.revision }) ).rejects.toThrow('已被更新') expect(onMagicNotesChanged).toHaveBeenCalledTimes(4) const withoutEntry = await gateway.deleteMagicNoteEntry(writeToken, { entryId: entry.id, expectedRevision: updatedEntry.entries[1]!.revision }) expect(withoutEntry.entries).toEqual([ expect.objectContaining({ content: '核对构建产物' }) ]) expect( await gateway.deleteMagicNote(writeToken, { noteId: created.id, expectedRevision: withoutEntry.revision }) ).toEqual({ deleted: true, noteId: created.id }) expect(onMagicNotesChanged).toHaveBeenCalledTimes(6) await expect( gateway.getMagicNote(readToken, { noteId: created.id }) ).rejects.toThrow('笔记不存在') const canvas = database.getMagicNote(database.createMagicNote({ title: 'Canvas', content: { version: 2, kind: 'paged-canvas', assets: [], pages: [{ id: 'page', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [{ type: 'IText', text: 'Canvas text' }] }] } }).id) const canvasEntry = (await gateway.getMagicNote(readToken, { noteId: canvas.id })).entries[0]! expect(canvasEntry).toMatchObject({ content: 'Canvas text', contentKind: 'paged-canvas', contentVersion: 2, plainTextEditable: false }) await expect(gateway.updateMagicNoteEntry(writeToken, { entryId: canvasEntry.id, expectedRevision: canvasEntry.revision, content: 'overwrite' })).rejects.toThrow('画布记录不能') expect(database.getMagicNote(canvas.id)).toEqual(canvas) // Tags: normalized on write, AND-filtered on list, replaced as a whole on update. // Clearing a note removes its links but keeps the reusable global tag definitions. const tagged = await gateway.createMagicNote(writeToken, { title: 'Tagged', tags: [' Work ', 'work', 'Q4'] }) expect(tagged.tags).toEqual(['Work', 'Q4']) expect(await gateway.listMagicNotes(readToken, { tags: ['WORK', 'q4'] })).toEqual([ expect.objectContaining({ id: tagged.id, tags: ['Work', 'Q4'] }) ]) expect(await gateway.listMagicNotes(readToken, { tags: ['Work', 'missing'] })).toEqual([]) await expect(gateway.createMagicNote(writeToken, { title: 'Bad', tags: ['a,b'] })).rejects.toThrow() const cleared = await gateway.updateMagicNote(writeToken, { noteId: tagged.id, tags: [], expectedRevision: tagged.revision }) expect(cleared.tags).toEqual([]) expect(database.listMagicNoteTags()).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'Work', noteCount: 0 }), expect.objectContaining({ name: 'Q4', noteCount: 0 }) ])) }) it('searches more than ten notes over MCP and returns recoverable argument errors', async () => { const { service } = createService() const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-search-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 token = gateway.grant('notes-search-http', [], new AbortController().signal, 'write')! for (let index = 0; index < 12; index += 1) { await gateway.createMagicNote(token, { title: `Search regression ${index}`, content: 'Matching entry' }) } await gateway.start() const client = new Client({ name: 'note-search-test', version: '1.0.0' }) try { await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } })) const listed = await client.listTools() expect(listed.tools.find((tool) => tool.name === 'note_search')?.inputSchema.properties?.limit) .toMatchObject({ type: 'integer', minimum: 1, maximum: 100, default: 8 }) for (const limit of [11, 12, 100]) { const result = await client.callTool({ name: 'note_search', arguments: { query: 'Search regression', limit } }) expect(result.isError).not.toBe(true) expect(result.content).toEqual([{ type: 'text', text: expect.any(String) }]) const content = result.content as Array<{ type: 'text'; text: string }> expect(JSON.parse(content[0]!.text).notes).toHaveLength(Math.min(limit, 12)) } for (const limit of [101, 0, 1.5, '12']) { await expect(client.callTool({ name: 'note_search', arguments: { query: 'Search regression', limit } })) .resolves.toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringContaining('Invalid arguments for note_search: limit:') }] }) } await expect(client.callTool({ name: 'note_create', arguments: { title: '' } })) .resolves.toMatchObject({ isError: true }) expect(database.listMagicNotes()).toHaveLength(12) const retried = await client.callTool({ name: 'note_search', arguments: { query: 'Search regression' } }) const content = retried.content as Array<{ type: 'text'; text: string }> expect(JSON.parse(content[0]!.text).notes).toHaveLength(8) } finally { await client.close() } }) it('binds an authenticated MCP 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(401) 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 clock = vi.spyOn(Date, 'now').mockReturnValue(1_000) 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( /^[a-f0-9]{4}_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}` ) clock.mockReturnValue(1_000 + 24 * 60 * 60_000) 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() clock.mockRestore() } }) it('loads every tools/list page before exposing custom MCP tools', async () => { const listTools = vi.fn((cursor: string | undefined) => cursor !== undefined ? { tools: [testTool('second')] } : { tools: [testTool('first')], nextCursor: 'page-2' } ) const upstream = await startToolUpstream(listTools) const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) const token = gateway.grantCustomMcp( 'paged-tools', [customMcpServer(upstream.url)], new AbortController().signal )! const tools = await gateway.prepareCustomMcpTools(token) expect(tools.map((tool) => tool.name)).toEqual([ expect.stringMatching(/_first$/u), expect.stringMatching(/_second$/u) ]) expect(listTools).toHaveBeenNthCalledWith(1, undefined) expect(listTools).toHaveBeenNthCalledWith(2, 'page-2') }) it('continues tools/list pagination with an empty cursor', async () => { const listTools = vi.fn((cursor: string | undefined) => cursor === undefined ? { tools: [testTool('first')], nextCursor: '' } : { tools: [testTool('second')] } ) const upstream = await startToolUpstream(listTools) const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) const token = gateway.grantCustomMcp( 'empty-cursor-tools', [customMcpServer(upstream.url)], new AbortController().signal )! const tools = await gateway.prepareCustomMcpTools(token) expect(tools.map((tool) => tool.name)).toEqual([ expect.stringMatching(/_first$/u), expect.stringMatching(/_second$/u) ]) expect(listTools).toHaveBeenNthCalledWith(2, '') }) it.each(['disabled', 'unassigned'] as const)('rejects grants for %s custom MCP servers', (state) => { const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) const server = customMcpServer('http://127.0.0.1:1/mcp') if (state === 'disabled') server.enabled = false else server.assignments = [] expect(() => gateway.grantCustomMcp('unavailable', [server], new AbortController().signal)).toThrow('无效 Server') }) it('explicitly requests task execution for required tools from earlier pages', async () => { const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) const server = customMcpServer('http://127.0.0.1:1/mcp') const token = gateway.grantCustomMcp( 'required-task-tool', [server], new AbortController().signal )! const callToolStream = vi.fn( async function* () { yield { type: 'result' as const, result: { content: [{ type: 'text' as const, text: 'done' }], structuredContent: { value: 'done' } } } } ) const outputValidator = vi.fn(() => ({ valid: true as const, data: { value: 'done' }, errorMessage: undefined })) const callRequiredTool = ( gateway as unknown as { callCustomMcpTool( capabilityToken: string, binding: unknown, input: Record, signal: AbortSignal ): Promise } ).callCustomMcpTool.bind(gateway) await expect( callRequiredTool( token, { client: { experimental: { tasks: { callToolStream, cancelTask: vi.fn() } } }, server, originalName: 'required-first-page', taskSupport: 'required', outputValidator, exposedTool: testTool('required-first-page') }, {}, new AbortController().signal ) ).resolves.toMatchObject({ content: [{ type: 'text', text: 'done' }] }) expect(callToolStream).toHaveBeenCalledWith( { name: 'required-first-page', arguments: {} }, undefined, expect.objectContaining({ task: {} }) ) expect(outputValidator).toHaveBeenCalledWith({ value: 'done' }) }) it('omits servers with cyclic cursors or custom MCP tool counts over 100', async () => { const cyclicUpstream = await startToolUpstream( (cursor: string | undefined) => ({ tools: [testTool(cursor ? 'second' : 'first')], nextCursor: 'cycle' }) ) const excessiveUpstream = await startToolUpstream(() => ({ tools: Array.from({ length: 101 }, (_, index) => testTool(`tool-${index}`) ) })) const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) const cycleToken = gateway.grantCustomMcp( 'cursor-cycle', [customMcpServer(cyclicUpstream.url)], new AbortController().signal )! const excessiveToken = gateway.grantCustomMcp( 'excessive-tools', [ customMcpServer( excessiveUpstream.url, '00000000-0000-4000-8000-000000000093' ) ], new AbortController().signal )! await expect( gateway.prepareCustomMcpTools(cycleToken) ).resolves.toEqual([]) await expect( gateway.prepareCustomMcpTools(excessiveToken) ).resolves.toEqual([]) }) it('keeps healthy tools available when another upstream fails discovery', async () => { const healthyList = vi.fn(() => ({ tools: [testTool('healthy')] })) const healthy = await startToolUpstream(healthyList) const failedList = vi.fn(() => { throw new Error('private-upstream-failure') }) const failed = await startToolUpstream(failedList) const observeFailure = vi.fn() const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure }) gateways.push(gateway) await gateway.start() const controller = new AbortController() const token = gateway.grantCustomMcp('partial-discovery', [ customMcpServer(healthy.url), customMcpServer(failed.url, '00000000-0000-4000-8000-000000000093') ], controller.signal)! await expect(gateway.prepareCustomMcpTools(token)).resolves.toEqual([ expect.objectContaining({ name: expect.stringMatching(/_healthy$/u) }) ]) const client = new Client({ name: 'partial-discovery', version: '1.0.0' }) try { await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } })) const listed = await client.listTools() expect(listed.tools.map(tool => tool.name)).toEqual([ expect.stringMatching(/_healthy$/u) ]) expect(healthyList).toHaveBeenCalledTimes(1) expect(failedList).toHaveBeenCalledTimes(1) expect(observeFailure).toHaveBeenCalledWith(expect.objectContaining({ mcp: expect.objectContaining({ phase: 'tool-discovery' }) })) expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-upstream-failure') } finally { await client.close() } }) it.each(['abort', 'revoke'] as const)('does not swallow %s during discovery', async action => { const controller = new AbortController() const reason = new Error('request cancelled') const gateway = new KnowledgeMcpGateway(createService().service) gateways.push(gateway) const upstream = await startToolUpstream(() => { if (action === 'abort') controller.abort(reason) else gateway.revoke(token) return { tools: [testTool('late')] } }) const token = gateway.grantCustomMcp('cancel-discovery', [customMcpServer(upstream.url)], controller.signal)! const preparation = gateway.prepareCustomMcpTools(token) if (action === 'abort') await expect(preparation).rejects.toBe(reason) else await expect(preparation).rejects.toThrow('MCP capability was revoked') }) it('releases rejected initialize attempts before enforcing the session limit', async () => { const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) await gateway.start() const endpoint = gateway.getEndpoint()! const token = gateway.grant( 'initialize-retry', [firstLibraryId], new AbortController().signal )! const initialize = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'initialize-retry-fixture', version: '1.0.0' } } } for (let attempt = 0; attempt < 8; attempt += 1) { const rejected = await fetch(endpoint, { method: 'POST', headers: { accept: 'application/json', authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(initialize) }) expect(rejected.status).toBe(406) } const accepted = await fetch(endpoint, { method: 'POST', headers: { accept: 'application/json, text/event-stream', authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(initialize) }) expect(accepted.status).toBe(200) expect(accepted.headers.get('mcp-session-id')).toEqual( expect.any(String) ) }) it('publishes upstream tool changes downstream after a successful refresh', async () => { let tools = [testTool('before')] const listTools = vi.fn(async () => ({ tools })) const upstream = await startToolUpstream(listTools) const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) await gateway.start() const token = gateway.grantCustomMcp( 'dynamic-tools', [customMcpServer(upstream.url)], new AbortController().signal )! const listChanged = vi.fn() const client = new Client( { name: 'dynamic-client', version: '1.0.0' }, { listChanged: { tools: { autoRefresh: false, debounceMs: 0, onChanged: listChanged } } } ) await client.connect( new StreamableHTTPClientTransport( new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } } ) ) try { const initial = await client.listTools() expect(initial.tools[0]?.name).toMatch(/_before$/u) await new Promise((resolve) => setTimeout(resolve, 25)) tools = [testTool('after')] await upstream.notifyToolsChanged() await vi.waitFor(() => { expect(listChanged).toHaveBeenCalledWith(null, null) }) const updated = await client.listTools() expect(updated.tools.map((tool) => tool.name)).toEqual([ expect.stringMatching(/_after$/u) ]) } finally { await client.close() } }) it('does not publish a downstream change when dynamic refresh fails', async () => { let failRefresh = false const listTools = vi.fn(async () => { if (failRefresh) { throw new Error('refresh failed') } return { tools: [testTool('stable')] } }) const upstream = await startToolUpstream(listTools) const { service } = createService() const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) await gateway.start() const token = gateway.grantCustomMcp( 'failed-refresh', [customMcpServer(upstream.url)], new AbortController().signal )! const listChanged = vi.fn() const client = new Client( { name: 'failed-refresh-client', version: '1.0.0' }, { listChanged: { tools: { autoRefresh: false, debounceMs: 0, onChanged: listChanged } } } ) await client.connect( new StreamableHTTPClientTransport( new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } } ) ) try { await client.listTools() await new Promise((resolve) => setTimeout(resolve, 25)) failRefresh = true await upstream.notifyToolsChanged() await vi.waitFor(() => { expect(listTools).toHaveBeenCalledTimes(2) }) await new Promise((resolve) => setTimeout(resolve, 25)) expect(listChanged).not.toHaveBeenCalled() await expect(client.listTools()).resolves.toMatchObject({ tools: [] }) failRefresh = false const recovered = await client.listTools() expect(recovered.tools.map(tool => tool.name)).toEqual([ expect.stringMatching(/_stable$/u) ]) } finally { await client.close() } }) })