Connect Dify, FastGPT and RAGFlow through read-only bindings, encrypted local credentials, shared retrieval and persistent citations. Configuration and removal affect GoodBuddy only. Show active conversations across projects, retain older live work, and improve project dialog scrolling. Queue concurrent questions and route managed SSH OpenCode questions through the Agent. Preserve recoverable read/search errors and partial search results. Document the Electron UI automation standard, real model and external knowledge validation, and remaining acceptance limits. Validation: earlier full regression passed 4081 tests with 66 skipped; subsequent focused remote-question checks and real Linux evidence are recorded in feature progress. Precommit typecheck and lint passed; latest project dialog and shared UI suites passed 58 tests. The full suite was not rerun for the final dialog changes. Release note: Use external knowledge in conversations, find active work across projects, and answer concurrent local or managed SSH OpenCode questions. Managed SSH questions require the updated Agent adapter.
1375 lines
44 KiB
TypeScript
1375 lines
44 KiB
TypeScript
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'
|
|
|
|
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: `<mark>匹配</mark> ${index}`,
|
|
rank: index + 1,
|
|
channels: ['fts'] as const,
|
|
scores: { fusedScore: 0.5, ftsRank: 1 }
|
|
}] }
|
|
}))
|
|
)
|
|
const service = {
|
|
database: {
|
|
listKnowledgeBases: () => [
|
|
{
|
|
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<void>
|
|
}> {
|
|
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<void>((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('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 () => {
|
|
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<void>((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<never> => {
|
|
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 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(),
|
|
click: vi.fn(),
|
|
type: vi.fn(),
|
|
select: vi.fn(),
|
|
back: vi.fn(),
|
|
screenshot: vi.fn(),
|
|
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'
|
|
)
|
|
expect(gateway.getAvailableToolNames(token)).toEqual(browserToolNames)
|
|
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(
|
|
browserToolNames
|
|
)
|
|
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<never>((_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<never>((_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 reads in Ask and apply only in Execute', async () => {
|
|
const { service } = createService()
|
|
const configService = {
|
|
getCapabilities: vi.fn(() => ({ server: 'goodbuddy_config' })),
|
|
getSnapshot: vi.fn(async () => ({ application: {}, skills: [], mcpServers: [] })),
|
|
plan: vi.fn(async () => ({ planId: 'plan' })),
|
|
apply: vi.fn(async () => ({ status: 'applied' })),
|
|
revokeRequest: vi.fn()
|
|
}
|
|
const gateway = new KnowledgeMcpGateway(service, {
|
|
configService: configService as never
|
|
})
|
|
gateways.push(gateway)
|
|
const readToken = gateway.grant(
|
|
'config-read',
|
|
[],
|
|
new AbortController().signal,
|
|
'none',
|
|
{ access: 'read', workspacePath: process.cwd() }
|
|
)!
|
|
const authorizeApply = vi.fn(async () => true)
|
|
const writeToken = gateway.grant(
|
|
'config-write',
|
|
[],
|
|
new AbortController().signal,
|
|
'none',
|
|
{
|
|
access: 'write',
|
|
workspacePath: process.cwd(),
|
|
authorizeApply
|
|
}
|
|
)!
|
|
|
|
expect(gateway.getAvailableToolNames(readToken)).toEqual([
|
|
'goodbuddy_config_capabilities',
|
|
'goodbuddy_config_get',
|
|
'goodbuddy_config_plan'
|
|
])
|
|
expect(gateway.getAvailableToolNames(writeToken)).toEqual([
|
|
'goodbuddy_config_capabilities',
|
|
'goodbuddy_config_get',
|
|
'goodbuddy_config_plan',
|
|
'goodbuddy_config_apply'
|
|
])
|
|
await gateway.callGoodBuddyConfigTool(
|
|
readToken,
|
|
'goodbuddy_config_capabilities',
|
|
{}
|
|
)
|
|
expect(configService.getCapabilities).toHaveBeenCalledWith({})
|
|
await expect(
|
|
gateway.callGoodBuddyConfigTool(
|
|
readToken,
|
|
'goodbuddy_config_apply',
|
|
{ planId: crypto.randomUUID() }
|
|
)
|
|
).rejects.toThrow('unavailable')
|
|
await gateway.callGoodBuddyConfigTool(
|
|
writeToken,
|
|
'goodbuddy_config_apply',
|
|
{ planId: crypto.randomUUID() }
|
|
)
|
|
expect(configService.apply).toHaveBeenCalledWith(
|
|
'config-write',
|
|
expect.any(Object),
|
|
expect.any(AbortSignal),
|
|
authorizeApply
|
|
)
|
|
gateway.revoke(writeToken)
|
|
expect(configService.revokeRequest).toHaveBeenCalledWith('config-write')
|
|
})
|
|
|
|
it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => {
|
|
const { service, 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(gateway.listLibraries(token!)).toEqual([
|
|
{
|
|
id: secondLibraryId,
|
|
name: '二号知识库',
|
|
description: '已授权知识'
|
|
}
|
|
])
|
|
expect(() =>
|
|
gateway.listLibraries(token!, {
|
|
libraryIds: [firstLibraryId]
|
|
})
|
|
).toThrow()
|
|
const references = await gateway.search(token!, {
|
|
query: ' 要找什么 ',
|
|
limit: 1
|
|
})
|
|
|
|
expect(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('creates no capability for empty scope and rejects revoked, aborted, and expired capabilities', async () => {
|
|
const { service } = createService()
|
|
let now = 1_000
|
|
const gateway = new KnowledgeMcpGateway(service, {
|
|
capabilityTtlMs: 10,
|
|
now: () => now
|
|
})
|
|
gateways.push(gateway)
|
|
expect(
|
|
gateway.grant('empty', [], new AbortController().signal)
|
|
).toBeUndefined()
|
|
|
|
const revoked = gateway.grant(
|
|
'revoked',
|
|
[firstLibraryId],
|
|
new AbortController().signal
|
|
)!
|
|
gateway.revoke(revoked)
|
|
await expect(
|
|
gateway.search(revoked, { query: 'x' })
|
|
).rejects.toThrow('unavailable or expired')
|
|
|
|
const abortController = new AbortController()
|
|
const aborted = gateway.grant(
|
|
'aborted',
|
|
[firstLibraryId],
|
|
abortController.signal
|
|
)!
|
|
abortController.abort()
|
|
await expect(
|
|
gateway.search(aborted, { query: 'x' })
|
|
).rejects.toThrow('unavailable or expired')
|
|
|
|
const expired = gateway.grant(
|
|
'expired',
|
|
[firstLibraryId],
|
|
new AbortController().signal
|
|
)!
|
|
now += 11
|
|
await expect(
|
|
gateway.search(expired, { query: 'x' })
|
|
).rejects.toThrow('unavailable or expired')
|
|
})
|
|
|
|
it('grants bounded global Magic Notes search without a knowledge scope', () => {
|
|
const { service } = createService()
|
|
const searchMagicNotes = vi.fn(() => [
|
|
{
|
|
noteId: '00000000-0000-4000-8000-000000000701',
|
|
noteTitle: '发布计划',
|
|
entryId: '00000000-0000-4000-8000-000000000702',
|
|
content: '核对构建产物',
|
|
updatedAt: '2026-08-10T00:00:00.000Z'
|
|
}
|
|
])
|
|
const gateway = new KnowledgeMcpGateway(service, {
|
|
magicNotesDatabase: {
|
|
listMagicNotes: vi.fn(() => []),
|
|
getMagicNote: vi.fn(() => {
|
|
throw new Error('not used')
|
|
}),
|
|
getMagicNoteEntry: vi.fn(() => {
|
|
throw new Error('not used')
|
|
}),
|
|
searchMagicNotes,
|
|
createMagicNote: vi.fn(() => {
|
|
throw new Error('not used')
|
|
}),
|
|
updateMagicNote: vi.fn(() => {
|
|
throw new Error('not used')
|
|
}),
|
|
deleteMagicNote: vi.fn(),
|
|
createMagicNoteEntry: vi.fn(() => {
|
|
throw new Error('not used')
|
|
}),
|
|
updateMagicNoteEntry: vi.fn(() => {
|
|
throw new Error('not used')
|
|
}),
|
|
deleteMagicNoteEntry: vi.fn(() => {
|
|
throw new Error('not used')
|
|
})
|
|
} satisfies MagicNotesDatabase
|
|
})
|
|
gateways.push(gateway)
|
|
const token = gateway.grant(
|
|
'notes',
|
|
[],
|
|
new AbortController().signal,
|
|
'read'
|
|
)!
|
|
|
|
expect(gateway.getAvailableToolNames(token)).toEqual([
|
|
'note_list',
|
|
'note_get',
|
|
'note_search'
|
|
])
|
|
expect(
|
|
gateway.searchMagicNotes(token, {
|
|
query: ' 发布 ',
|
|
limit: 3
|
|
})
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
noteTitle: '发布计划',
|
|
content: '核对构建产物'
|
|
})
|
|
])
|
|
expect(searchMagicNotes).toHaveBeenCalledWith('发布', 3)
|
|
expect(() =>
|
|
gateway.searchMagicNotes(token, {
|
|
query: '发布',
|
|
noteIds: ['not-allowed']
|
|
})
|
|
).toThrow()
|
|
})
|
|
|
|
it('keeps Ask read-only and supports revision-safe Magic Notes CRUD in Execute', async () => {
|
|
const { service } = createService()
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-mcp-'))
|
|
temporaryDirectories.push(directory)
|
|
const database = new AssistantDatabase(
|
|
join(directory, 'assistant.sqlite')
|
|
)
|
|
databases.push(database)
|
|
database.initialize('C:\\Workspace')
|
|
const gateway = new KnowledgeMcpGateway(service, {
|
|
magicNotesDatabase: database
|
|
})
|
|
gateways.push(gateway)
|
|
const readToken = gateway.grant(
|
|
'notes-read',
|
|
[],
|
|
new AbortController().signal,
|
|
'read'
|
|
)!
|
|
const writeToken = gateway.grant(
|
|
'notes-write',
|
|
[],
|
|
new AbortController().signal,
|
|
'write'
|
|
)!
|
|
|
|
expect(gateway.getAvailableToolNames(readToken)).toEqual([
|
|
'note_list',
|
|
'note_get',
|
|
'note_search'
|
|
])
|
|
expect(gateway.getAvailableToolNames(writeToken)).toEqual([
|
|
'note_list',
|
|
'note_get',
|
|
'note_search',
|
|
'note_create',
|
|
'note_update',
|
|
'note_entry_create',
|
|
'note_entry_update',
|
|
'note_entry_delete',
|
|
'note_delete'
|
|
])
|
|
expect(() =>
|
|
gateway.createMagicNote(readToken, { title: '不允许创建' })
|
|
).toThrow('unavailable')
|
|
|
|
const created = gateway.createMagicNote(writeToken, {
|
|
title: '发布计划',
|
|
content: '核对构建产物'
|
|
})
|
|
expect(gateway.listMagicNotes(readToken)).toEqual([
|
|
expect.objectContaining({
|
|
id: created.id,
|
|
title: '发布计划',
|
|
revision: 1,
|
|
entryCount: 1
|
|
})
|
|
])
|
|
expect(created.entries[0]?.content).toBe('核对构建产物')
|
|
const withEntry = gateway.createMagicNoteEntry(writeToken, {
|
|
noteId: created.id,
|
|
content: '通知发布负责人'
|
|
})
|
|
const entry = withEntry.entries[1]!
|
|
expect(entry.content).toBe('通知发布负责人')
|
|
|
|
const updatedEntry = gateway.updateMagicNoteEntry(writeToken, {
|
|
entryId: entry.id,
|
|
content: '核对六个平台构建产物',
|
|
expectedRevision: entry.revision
|
|
})
|
|
expect(updatedEntry.entries[1]?.content).toBe(
|
|
'核对六个平台构建产物'
|
|
)
|
|
expect(() =>
|
|
gateway.deleteMagicNoteEntry(writeToken, {
|
|
entryId: entry.id,
|
|
expectedRevision: entry.revision
|
|
})
|
|
).toThrow('已被更新')
|
|
|
|
const withoutEntry = gateway.deleteMagicNoteEntry(writeToken, {
|
|
entryId: entry.id,
|
|
expectedRevision: updatedEntry.entries[1]!.revision
|
|
})
|
|
expect(withoutEntry.entries).toEqual([
|
|
expect.objectContaining({ content: '核对构建产物' })
|
|
])
|
|
expect(
|
|
gateway.deleteMagicNote(writeToken, {
|
|
noteId: created.id,
|
|
expectedRevision: withoutEntry.revision
|
|
})
|
|
).toEqual({ deleted: true, noteId: created.id })
|
|
expect(() =>
|
|
gateway.getMagicNote(readToken, { noteId: created.id })
|
|
).toThrow('笔记不存在')
|
|
})
|
|
|
|
it('binds 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 upstreamAuthorizations: Array<string | undefined> = []
|
|
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<void>((resolve, reject) => {
|
|
upstream.once('error', reject)
|
|
upstream.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
const address = upstream.address()
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('upstream did not bind')
|
|
}
|
|
|
|
const { service } = createService()
|
|
const gateway = new KnowledgeMcpGateway(service)
|
|
gateways.push(gateway)
|
|
await gateway.start()
|
|
const controller = new AbortController()
|
|
const token = gateway.grantCustomMcp(
|
|
'custom-request',
|
|
[
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000091',
|
|
name: 'Private MCP',
|
|
description: '',
|
|
enabled: true,
|
|
allowDynamicTools: true,
|
|
assignments: ['opencode'],
|
|
secretConfigured: true,
|
|
secret: 'upstream-secret',
|
|
transport: 'http',
|
|
url: `http://127.0.0.1:${address.port}/mcp`
|
|
}
|
|
],
|
|
controller.signal
|
|
)!
|
|
const client = new Client({
|
|
name: 'loopback-test-client',
|
|
version: '1.0.0'
|
|
})
|
|
await client.connect(
|
|
new StreamableHTTPClientTransport(
|
|
new URL(gateway.getEndpoint()!),
|
|
{
|
|
requestInit: {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
}
|
|
}
|
|
)
|
|
)
|
|
try {
|
|
const listed = await client.listTools()
|
|
expect(listed.tools).toEqual([
|
|
expect.objectContaining({
|
|
name: expect.stringMatching(
|
|
/^mcp_[a-f0-9]{8}_[a-f0-9]{8}_echo_private$/u
|
|
),
|
|
description: expect.stringContaining('Private MCP')
|
|
})
|
|
])
|
|
expect(JSON.stringify(listed)).not.toContain('upstream-secret')
|
|
expect(JSON.stringify(listed)).not.toContain(
|
|
`127.0.0.1:${address.port}`
|
|
)
|
|
const result = await client.callTool({
|
|
name: listed.tools[0]!.name,
|
|
arguments: { value: 'hello' }
|
|
})
|
|
expect(result).toMatchObject({
|
|
content: [{ type: 'text', text: 'upstream:hello' }]
|
|
})
|
|
expect(upstreamAuthorizations).toContain(
|
|
'Bearer upstream-secret'
|
|
)
|
|
} finally {
|
|
gateway.revoke(token)
|
|
await client.close()
|
|
}
|
|
})
|
|
|
|
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('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<string, unknown>,
|
|
signal: AbortSignal
|
|
): Promise<unknown>
|
|
}
|
|
).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('rejects cyclic cursors and 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)
|
|
).rejects.toMatchObject({
|
|
cause: expect.objectContaining({
|
|
message: expect.stringContaining('分页游标发生循环')
|
|
})
|
|
})
|
|
await expect(
|
|
gateway.prepareCustomMcpTools(excessiveToken)
|
|
).rejects.toMatchObject({
|
|
cause: expect.objectContaining({
|
|
message: expect.stringContaining('工具数量超过安全限制')
|
|
})
|
|
})
|
|
})
|
|
|
|
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()
|
|
} finally {
|
|
await client.close()
|
|
}
|
|
})
|
|
})
|