Add an opt-in persistent frosted glass setting for the top bar and runtime checklist, with chat scrolling behind the header while preserving scroll anchors and window controls. Retry transient local MCP initialization failures once after cleanup, include redacted connection diagnostics in feedback, and index lifecycle events to reduce execution statistics scans. Release note: Add optional frosted glass appearance with chat visible behind the title bar; improve local tool connection recovery and diagnostic feedback.
1550 lines
55 KiB
TypeScript
1550 lines
55 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'
|
|
import { createHash } from 'node:crypto'
|
|
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
|
|
|
|
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('records safe HTTP rejection phases and isolates throwing observers', async () => {
|
|
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { 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<void>[] = []
|
|
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<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('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', () => {
|
|
const { service } = createService()
|
|
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
|
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'
|
|
)!
|
|
|
|
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
|
|
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()
|
|
gateway.revoke(token)
|
|
expect(() => gateway.getAvailableToolNames(token)).toThrow('Tool authorization is unavailable')
|
|
clock.mockRestore()
|
|
})
|
|
|
|
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 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: 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('核对构建产物')
|
|
expect(onMagicNotesChanged).toHaveBeenCalledTimes(1)
|
|
gateway.updateMagicNote(writeToken, {
|
|
noteId: created.id,
|
|
title: 'Updated plan',
|
|
expectedRevision: created.revision
|
|
})
|
|
expect(onMagicNotesChanged).toHaveBeenCalledTimes(2)
|
|
const withEntry = gateway.createMagicNoteEntry(writeToken, {
|
|
noteId: created.id,
|
|
content: '通知发布负责人'
|
|
})
|
|
const entry = withEntry.entries[1]!
|
|
expect(entry.content).toBe('通知发布负责人')
|
|
expect(onMagicNotesChanged).toHaveBeenCalledTimes(3)
|
|
|
|
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('已被更新')
|
|
expect(onMagicNotesChanged).toHaveBeenCalledTimes(4)
|
|
|
|
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(onMagicNotesChanged).toHaveBeenCalledTimes(6)
|
|
expect(() =>
|
|
gateway.getMagicNote(readToken, { noteId: created.id })
|
|
).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 = gateway.getMagicNote(readToken, { noteId: canvas.id }).entries[0]!
|
|
expect(canvasEntry).toMatchObject({ content: 'Canvas text', contentKind: 'paged-canvas', contentVersion: 2, plainTextEditable: false })
|
|
expect(() => gateway.updateMagicNoteEntry(writeToken, { entryId: canvasEntry.id, expectedRevision: canvasEntry.revision, content: 'overwrite' })).toThrow('画布记录不能')
|
|
expect(database.getMagicNote(canvas.id)).toEqual(canvas)
|
|
|
|
// Tags: normalized on write, AND-filtered on list, replaced as a whole on update.
|
|
const tagged = gateway.createMagicNote(writeToken, { title: 'Tagged', tags: [' Work ', 'work', 'Q4'] })
|
|
expect(tagged.tags).toEqual(['Work', 'Q4'])
|
|
expect(gateway.listMagicNotes(readToken, { tags: ['WORK', 'q4'] })).toEqual([
|
|
expect.objectContaining({ id: tagged.id, tags: ['Work', 'Q4'] })
|
|
])
|
|
expect(gateway.listMagicNotes(readToken, { tags: ['Work', 'missing'] })).toEqual([])
|
|
expect(() => gateway.createMagicNote(writeToken, { title: 'Bad', tags: ['a,b'] })).toThrow()
|
|
const cleared = gateway.updateMagicNote(writeToken, { noteId: tagged.id, tags: [], expectedRevision: tagged.revision })
|
|
expect(cleared.tags).toEqual([])
|
|
expect(database.listMagicNoteTags()).toEqual([])
|
|
})
|
|
|
|
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) {
|
|
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<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(
|
|
/^[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('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()
|
|
}
|
|
})
|
|
})
|