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()
}
})
})