feat: connect local Obsidian vaults to conversations
Users can now read, search, and update existing local Obsidian notes through built-in tools without configuring an external MCP server or Obsidian plugin. Add vault discovery, an optional folder scope, and independent test, save, enablement, and runtime assignment controls in MCP settings. Bundle MCPVault 0.16.0 and its runtime dependencies, exposing 19 shared tools to direct models and managed local OpenCode and Continue conversations. Obsidian is disabled by default. Ask remains read-only; Execute permits writes. Scope and assignment changes revoke existing Obsidian grants. DeepSeek Harness and remote execution remain unsupported. Validate the isolated staged snapshot with its own dependency installation: typecheck, lint, production build, and 540 tests across 14 files passed. No paid model calls were repeated. Physical-offline, installer, and other platform acceptance remain pending as documented in the feature record. Release note: Connect local Obsidian vaults in MCP settings to find notes and write conversation results back to them. Test all registered vaults or a chosen folder before enabling access; no Obsidian plugin is required.
This commit is contained in:
@@ -12,6 +12,7 @@ import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { scopedReadToolNames } from '../../shared/scoped-data-tools'
|
||||
import {
|
||||
ContinueHostAdapter,
|
||||
inspectContinueNativeConfiguration,
|
||||
@@ -777,22 +778,7 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(args).toEqual([
|
||||
'--config',
|
||||
expect.stringContaining('knowledge-config-'),
|
||||
'--allow',
|
||||
'knowledge_list',
|
||||
'--allow',
|
||||
'knowledge_search',
|
||||
'--allow',
|
||||
'note_list',
|
||||
'--allow',
|
||||
'note_get',
|
||||
'--allow',
|
||||
'note_search',
|
||||
'--allow',
|
||||
'goodbuddy_config_capabilities',
|
||||
'--allow',
|
||||
'goodbuddy_config_get',
|
||||
'--allow',
|
||||
'goodbuddy_config_plan',
|
||||
...scopedReadToolNames.flatMap((name) => ['--allow', name]),
|
||||
'--exclude',
|
||||
'*',
|
||||
'serve',
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join, resolve } from 'node:path'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { CallToolResultSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { obsidianReadToolNames, obsidianScopedDataTools, obsidianWriteToolNames } from '../../shared/obsidian-tools'
|
||||
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
import { ObsidianService, type ObsidianSettings } from '../obsidian/obsidian-service'
|
||||
import { packageObsidianMcpVault } from '../obsidian/package-mcpvault'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
|
||||
let root: string
|
||||
let vaultPath: string
|
||||
let service: ObsidianService
|
||||
const gateways: KnowledgeMcpGateway[] = []
|
||||
const clients: Client[] = []
|
||||
const provider = vi.fn<() => NodeJS.ProcessEnv>()
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-gateway-'))
|
||||
vaultPath = join(root, 'First Vault')
|
||||
const second = join(root, 'Second Vault')
|
||||
const config = join(root, 'config')
|
||||
const bin = join(root, 'managed-bin')
|
||||
await Promise.all([vaultPath, second, bin, join(config, 'obsidian')].map((path) => mkdir(path, { recursive: true })))
|
||||
await writeFile(join(config, 'obsidian', 'obsidian.json'), JSON.stringify({ vaults: {
|
||||
first: { path: vaultPath }, second: { path: second }
|
||||
} }))
|
||||
await writeFile(join(bin, process.platform === 'win32' ? 'node.cmd' : 'node'),
|
||||
process.platform === 'win32'
|
||||
? `@echo off\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${process.execPath}" %*\r\n`
|
||||
: `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec '${process.execPath.replaceAll("'", "'\\''")}' "$@"\n`,
|
||||
{ mode: 0o755 })
|
||||
provider.mockImplementation(() => ({ PATH: `${bin}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` }))
|
||||
const appPath = join(root, 'app')
|
||||
await packageObsidianMcpVault(resolve('.'), join(appPath, 'out', 'main', 'obsidian-mcpvault'))
|
||||
service = new ObsidianService({ appPath, launchEnvironmentProvider: provider,
|
||||
platform: 'linux', environment: { ...process.env, XDG_CONFIG_HOME: config } })
|
||||
}, 60_000)
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.splice(0).map((client) => client.close()))
|
||||
await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose()))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
afterAll(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
|
||||
function grant(access: 'read' | 'write', settings: ObsidianSettings = {}, signal = new AbortController().signal) {
|
||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service })
|
||||
gateways.push(gateway)
|
||||
const token = gateway.grant('obsidian-test', [], signal, 'none', undefined, undefined, undefined, undefined, { settings, access })!
|
||||
return { gateway, token }
|
||||
}
|
||||
|
||||
async function connect(gateway: KnowledgeMcpGateway, token: string) {
|
||||
await gateway.start()
|
||||
const client = new Client({ name: 'obsidian-gateway-test', version: '1.0.0' })
|
||||
clients.push(client)
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
||||
}))
|
||||
return client
|
||||
}
|
||||
|
||||
function text(result: CallToolResult) {
|
||||
return result.content.map((item) => item.type === 'text' ? item.text : '').join('\n')
|
||||
}
|
||||
|
||||
describe('Obsidian gateway', () => {
|
||||
it('uses the shared Ask classification and exposes every tool for Execute', async () => {
|
||||
const read = grant('read')
|
||||
const write = grant('write')
|
||||
expect(read.gateway.getAvailableToolNames(read.token)).toEqual(obsidianReadToolNames)
|
||||
expect(write.gateway.getAvailableToolNames(write.token)).toEqual([...obsidianReadToolNames, ...obsidianWriteToolNames])
|
||||
const client = await connect(write.gateway, write.token)
|
||||
const tools = (await client.listTools()).tools
|
||||
expect(tools).toHaveLength(19)
|
||||
expect(tools.map((tool) => tool.name).sort()).toEqual(obsidianScopedDataTools.map((tool) => tool.name).sort())
|
||||
for (const definition of obsidianScopedDataTools) {
|
||||
expect(tools.find((tool) => tool.name === definition.name)?.annotations?.readOnlyHint).toBe(definition.access === 'read')
|
||||
}
|
||||
await expect(read.gateway.callObsidianTool(read.token, 'obsidian_write_note', { path: 'blocked.md', content: '' })).rejects.toThrow('capability is unavailable')
|
||||
const absent = new KnowledgeMcpGateway({} as KnowledgeService)
|
||||
gateways.push(absent)
|
||||
expect(absent.grant('absent', [], new AbortController().signal, 'none', undefined, undefined, undefined, undefined,
|
||||
{ settings: {}, access: 'write' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes, reads, lists and deletes through HTTP using the real service and explicit vault targets', async () => {
|
||||
const { gateway, token } = grant('write')
|
||||
const client = await connect(gateway, token)
|
||||
const call = async (name: string, args: Record<string, unknown> = {}) =>
|
||||
CallToolResultSchema.parse(await client.callTool({ name, arguments: args }, CallToolResultSchema))
|
||||
const vaults = JSON.parse(text(await call('obsidian_list_vaults'))).vaults
|
||||
expect(vaults.map((vault: { id: string }) => vault.id)).toEqual(['first', 'second'])
|
||||
await expect(call('obsidian_write_note', { path: 'gateway.md', content: 'ambiguous' })).rejects.toThrow('vaultId is required')
|
||||
await expect(call('obsidian_read_note', { vaultId: 'missing', path: 'gateway.md' })).rejects.toThrow('Unknown Obsidian vaultId')
|
||||
for (const vaultId of ['first', 'second']) {
|
||||
expect((await call('obsidian_write_note', { vaultId, path: 'gateway.md', content: `# ${vaultId}` })).isError).not.toBe(true)
|
||||
expect(text(await call('obsidian_read_note', { vaultId, path: 'gateway.md' }))).toContain(`# ${vaultId}`)
|
||||
expect(text(await call('obsidian_list_directory', { vaultId }))).toContain('gateway.md')
|
||||
}
|
||||
expect(await readFile(join(vaultPath, 'gateway.md'), 'utf8')).toContain('# first')
|
||||
const failedDelete = await call('obsidian_delete_note', { vaultId: 'first', path: 'gateway.md', confirmPath: 'wrong.md' })
|
||||
expect(failedDelete.isError).toBe(true)
|
||||
expect(text(failedDelete)).toContain('confirmPath')
|
||||
expect(text(failedDelete)).not.toContain('"content":[')
|
||||
for (const vaultId of ['first', 'second']) {
|
||||
expect((await call('obsidian_delete_note', { vaultId, path: 'gateway.md', confirmPath: 'gateway.md' })).isError).not.toBe(true)
|
||||
expect(text(await call('obsidian_list_directory', { vaultId }))).not.toContain('gateway.md')
|
||||
}
|
||||
expect(provider).toHaveBeenCalled()
|
||||
}, 30_000)
|
||||
|
||||
it('allows an omitted vaultId for an explicit single vault and returns native errors directly', async () => {
|
||||
const { gateway, token } = grant('write', { vaultPath })
|
||||
const result = await gateway.callObsidianTool(token, 'obsidian_read_note', { path: 'absent.md' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('absent.md')
|
||||
expect(JSON.parse(text(await gateway.callObsidianTool(token, 'obsidian_list_vaults', {}))).vaults).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves native content and enforces the existing MCP result bound in HTTP and direct calls', async () => {
|
||||
const { gateway, token } = grant('read')
|
||||
const native: CallToolResult = { isError: true, content: [
|
||||
{ type: 'text', text: 'upstream detail' },
|
||||
{ type: 'image', data: 'aGVsbG8=', mimeType: 'image/png' }
|
||||
], structuredContent: { detail: 'upstream detail' } }
|
||||
const call = vi.spyOn(service, 'callTool').mockResolvedValue(native)
|
||||
const client = await connect(gateway, token)
|
||||
expect(await gateway.callObsidianTool(token, 'obsidian_read_note', { vaultId: 'first', path: 'test.md' })).toEqual(native)
|
||||
expect(call).toHaveBeenLastCalledWith({}, { vaultId: 'first', name: 'read_note', arguments: { path: 'test.md', prettyPrint: false } }, expect.any(AbortSignal))
|
||||
expect(await client.callTool({ name: 'obsidian_read_note', arguments: { path: 'test.md' } }, CallToolResultSchema)).toEqual(native)
|
||||
call.mockResolvedValue({ content: [{ type: 'text', text: 'x'.repeat(256 * 1024) }] })
|
||||
await expect(gateway.callObsidianTool(token, 'obsidian_read_note', { path: 'large.md' })).rejects.toThrow('256KB')
|
||||
await expect(client.callTool({ name: 'obsidian_read_note', arguments: { path: 'large.md' } })).rejects.toThrow('256KB')
|
||||
})
|
||||
|
||||
it('revokes all Obsidian grants while retaining unrelated capabilities and allowing new grants', () => {
|
||||
const { gateway, token } = grant('read')
|
||||
const signal = new AbortController().signal
|
||||
const writeToken = gateway.grant('write', [], signal, 'none', undefined, undefined, undefined, undefined,
|
||||
{ settings: { vaultPath }, access: 'write' })!
|
||||
const mixedToken = gateway.grant('mixed', ['library'], signal, 'none', undefined, undefined, undefined, undefined,
|
||||
{ settings: {}, access: 'write' })!
|
||||
const knowledgeToken = gateway.grant('knowledge', ['library'], signal)!
|
||||
gateway.revokeObsidianCapabilities()
|
||||
gateway.revokeObsidianCapabilities()
|
||||
for (const revoked of [token, writeToken, mixedToken]) {
|
||||
expect(() => gateway.getAvailableToolNames(revoked)).toThrow('unavailable or expired')
|
||||
}
|
||||
expect(gateway.getAvailableToolNames(knowledgeToken)).toEqual(['knowledge_list', 'knowledge_search'])
|
||||
const replacement = gateway.grant('replacement', [], signal, 'none', undefined, undefined, undefined, undefined,
|
||||
{ settings: { vaultPath }, access: 'write' })!
|
||||
expect(gateway.getAvailableToolNames(replacement)).toEqual([...obsidianReadToolNames, ...obsidianWriteToolNames])
|
||||
})
|
||||
|
||||
it.each([false, true])('awaits transport close during disposal, including previously revoked calls (%s)', async (revokeFirst) => {
|
||||
const { gateway, token } = grant('read', { vaultPath })
|
||||
let markStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
let markClosing!: () => void
|
||||
const closing = new Promise<void>((resolve) => { markClosing = resolve })
|
||||
let releaseClose!: () => void
|
||||
const closeGate = new Promise<void>((resolve) => { releaseClose = resolve })
|
||||
let closed = false
|
||||
const close = StdioClientTransport.prototype.close
|
||||
vi.spyOn(StdioClientTransport.prototype, 'close').mockImplementation(async function (this: StdioClientTransport) {
|
||||
markClosing()
|
||||
await closeGate
|
||||
await close.call(this)
|
||||
closed = true
|
||||
})
|
||||
const send = StdioClientTransport.prototype.send
|
||||
vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) {
|
||||
const result = send.call(this, message)
|
||||
if ('method' in message && message.method === 'tools/call') markStarted()
|
||||
return result
|
||||
})
|
||||
const pending = gateway.callObsidianTool(token, 'obsidian_list_directory', {})
|
||||
.then(() => undefined, (error: unknown) => error)
|
||||
await Promise.race([started, pending.then(() => { throw new Error('Call ended before upstream dispatch') })])
|
||||
if (revokeFirst) gateway.revokeObsidianCapabilities()
|
||||
let disposed = false
|
||||
const disposal = gateway.dispose().then(() => { disposed = true })
|
||||
try {
|
||||
await closing
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(disposed).toBe(false)
|
||||
expect(closed).toBe(false)
|
||||
} finally {
|
||||
releaseClose()
|
||||
await disposal
|
||||
await pending
|
||||
}
|
||||
expect(closed).toBe(true)
|
||||
expect(await pending).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it.each(['revoke', 'settings', 'request', 'call', 'dispose'] as const)('cancels and closes the actual upstream child on %s', async (cancellation) => {
|
||||
const controller = new AbortController()
|
||||
const callController = new AbortController()
|
||||
const { gateway, token } = grant('read', { vaultPath }, controller.signal)
|
||||
const transports = new Set<StdioClientTransport>()
|
||||
const send = StdioClientTransport.prototype.send
|
||||
vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) {
|
||||
transports.add(this)
|
||||
const result = send.call(this, message)
|
||||
if ('method' in message && message.method === 'tools/call') {
|
||||
if (cancellation === 'revoke') gateway.revoke(token)
|
||||
else if (cancellation === 'settings') gateway.revokeObsidianCapabilities()
|
||||
else if (cancellation === 'request') controller.abort(new Error('Request cancelled'))
|
||||
else if (cancellation === 'call') callController.abort(new Error('Call cancelled'))
|
||||
else void gateway.dispose()
|
||||
}
|
||||
return result
|
||||
})
|
||||
await expect(gateway.callObsidianTool(token, 'obsidian_list_directory', {}, callController.signal)).rejects.toThrow(/revoked|cancelled/)
|
||||
expect(transports.size).toBe(1)
|
||||
for (const transport of transports) expect(transport.pid).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
type ScopedDataToolName
|
||||
} from '../../shared/scoped-data-tools'
|
||||
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
import type { ObsidianService, ObsidianSettings } from '../obsidian/obsidian-service'
|
||||
import { obsidianReadToolNames, obsidianWriteToolNames } from '../../shared/obsidian-tools'
|
||||
import type {
|
||||
MagicNoteDetail,
|
||||
MagicNoteEntry,
|
||||
@@ -199,6 +201,7 @@ type Capability = {
|
||||
browserConversationId?: string
|
||||
browserTabId?: BrowserTabId
|
||||
browserUsageLease?: BrowserTabUsageLease
|
||||
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
|
||||
expiresAt: number
|
||||
signal: AbortSignal
|
||||
brokerController: AbortController
|
||||
@@ -244,6 +247,7 @@ export type KnowledgeMcpGatewayOptions = {
|
||||
now?: () => number
|
||||
magicNotesDatabase?: MagicNotesDatabase
|
||||
configService?: GoodBuddyConfigService
|
||||
obsidianService?: ObsidianService
|
||||
browserService?: BrowserToolService & {
|
||||
createTab: BrowserService['createTab']
|
||||
listTabs(conversationId: string): BrowserTabSummary[]
|
||||
@@ -367,11 +371,13 @@ export class KnowledgeMcpGateway {
|
||||
>()
|
||||
private readonly downstreamMcpCleanups = new Set<Promise<void>>()
|
||||
private readonly customMcpCleanups = new Set<Promise<void>>()
|
||||
private readonly activeObsidianCalls = new Set<Promise<CallToolResult>>()
|
||||
private readonly now: () => number
|
||||
private readonly capabilityTtlMs: number
|
||||
private readonly maximumBodyBytes: number
|
||||
private readonly magicNotesDatabase?: MagicNotesDatabase
|
||||
private readonly configService?: GoodBuddyConfigService
|
||||
private readonly obsidianService?: ObsidianService
|
||||
private readonly browserService?: BrowserToolService & {
|
||||
createTab: BrowserService['createTab']
|
||||
listTabs(conversationId: string): BrowserTabSummary[]
|
||||
@@ -403,6 +409,7 @@ export class KnowledgeMcpGateway {
|
||||
this.now = options.now ?? Date.now
|
||||
this.magicNotesDatabase = options.magicNotesDatabase
|
||||
this.configService = options.configService
|
||||
this.obsidianService = options.obsidianService
|
||||
this.browserService = options.browserService
|
||||
this.launchEnvironmentProvider = options.launchEnvironmentProvider
|
||||
}
|
||||
@@ -458,7 +465,8 @@ export class KnowledgeMcpGateway {
|
||||
},
|
||||
browserConversationId?: string,
|
||||
browserTabId?: BrowserTabId,
|
||||
browserUsageLease?: BrowserTabUsageLease
|
||||
browserUsageLease?: BrowserTabUsageLease,
|
||||
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
|
||||
): string | undefined {
|
||||
const effectiveMagicNotesAccess = this.magicNotesDatabase
|
||||
? magicNotesAccess
|
||||
@@ -466,6 +474,7 @@ export class KnowledgeMcpGateway {
|
||||
const effectiveConfigAccess = this.configService
|
||||
? config?.access ?? 'none'
|
||||
: 'none'
|
||||
const effectiveObsidian = this.obsidianService ? obsidian : undefined
|
||||
const effectiveBrowserConversationId = this.browserService
|
||||
? browserConversationId
|
||||
: undefined
|
||||
@@ -506,7 +515,8 @@ export class KnowledgeMcpGateway {
|
||||
authorizedLibraryIds.length === 0 &&
|
||||
effectiveMagicNotesAccess === 'none' &&
|
||||
effectiveConfigAccess === 'none' &&
|
||||
!effectiveBrowserConversationId
|
||||
!effectiveBrowserConversationId &&
|
||||
!effectiveObsidian
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
@@ -518,6 +528,7 @@ export class KnowledgeMcpGateway {
|
||||
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
|
||||
magicNotesAccess: effectiveMagicNotesAccess,
|
||||
configAccess: effectiveConfigAccess,
|
||||
obsidian: effectiveObsidian,
|
||||
browserConversationId: effectiveBrowserConversationId,
|
||||
browserTabId: effectiveBrowserTabId,
|
||||
browserUsageLease: effectiveBrowserUsageLease,
|
||||
@@ -640,6 +651,14 @@ export class KnowledgeMcpGateway {
|
||||
})
|
||||
}
|
||||
|
||||
revokeObsidianCapabilities(): void {
|
||||
for (const [token, capability] of this.capabilities) {
|
||||
if (capability.obsidian) {
|
||||
this.revoke(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private closeDownstreamMcpSession(
|
||||
session: DownstreamMcpSession
|
||||
): Promise<void> {
|
||||
@@ -796,6 +815,8 @@ export class KnowledgeMcpGateway {
|
||||
...(capability.configAccess === 'write'
|
||||
? goodbuddyConfigWriteToolNames
|
||||
: []),
|
||||
...(capability.obsidian ? obsidianReadToolNames : []),
|
||||
...(capability.obsidian?.access === 'write' ? obsidianWriteToolNames : []),
|
||||
...(capability.browserConversationId && capability.browserTabId
|
||||
? browserToolNames
|
||||
: [])
|
||||
@@ -1228,6 +1249,50 @@ export class KnowledgeMcpGateway {
|
||||
)
|
||||
}
|
||||
|
||||
async callObsidianTool(
|
||||
token: string,
|
||||
name: Extract<ScopedDataToolName, `obsidian_${string}`>,
|
||||
input: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<CallToolResult> {
|
||||
const capability = this.getCapability(token)
|
||||
const definition = scopedDataToolByName.get(name)
|
||||
if (
|
||||
!this.obsidianService ||
|
||||
!capability.obsidian ||
|
||||
!definition ||
|
||||
!name.startsWith('obsidian_') ||
|
||||
(definition.access === 'write' && capability.obsidian.access !== 'write')
|
||||
) {
|
||||
throw new Error('Obsidian capability is unavailable')
|
||||
}
|
||||
const effectiveSignal = AbortSignal.any([
|
||||
capability.signal,
|
||||
capability.brokerController.signal,
|
||||
...(signal ? [signal] : [])
|
||||
])
|
||||
effectiveSignal.throwIfAborted()
|
||||
const parsed = definition.inputSchema.parse(input) as Record<string, unknown>
|
||||
const { vaultId, ...argumentsValue } = parsed
|
||||
const pending = name === 'obsidian_list_vaults'
|
||||
? this.obsidianService.listVaults(capability.obsidian.settings).then((vaults) => ({
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ vaults }) }]
|
||||
}))
|
||||
: this.obsidianService.callTool(capability.obsidian.settings, {
|
||||
vaultId: vaultId as string | undefined,
|
||||
name: name.slice('obsidian_'.length),
|
||||
arguments: argumentsValue
|
||||
}, effectiveSignal)
|
||||
this.activeObsidianCalls.add(pending)
|
||||
try {
|
||||
const result = await pending
|
||||
effectiveSignal.throwIfAborted()
|
||||
return ensureBoundedCustomMcpResult(result)
|
||||
} finally {
|
||||
this.activeObsidianCalls.delete(pending)
|
||||
}
|
||||
}
|
||||
|
||||
private requireConfig(
|
||||
token: string,
|
||||
requiredAccess: Exclude<MagicNotesCapabilityAccess, 'none'>
|
||||
@@ -1513,6 +1578,26 @@ export class KnowledgeMcpGateway {
|
||||
case 'goodbuddy_config_plan':
|
||||
case 'goodbuddy_config_apply':
|
||||
return this.callGoodBuddyConfigTool(token, name, input)
|
||||
case 'obsidian_list_vaults':
|
||||
case 'obsidian_read_note':
|
||||
case 'obsidian_write_note':
|
||||
case 'obsidian_patch_note':
|
||||
case 'obsidian_list_directory':
|
||||
case 'obsidian_delete_note':
|
||||
case 'obsidian_search_notes':
|
||||
case 'obsidian_move_note':
|
||||
case 'obsidian_move_file':
|
||||
case 'obsidian_read_multiple_notes':
|
||||
case 'obsidian_update_frontmatter':
|
||||
case 'obsidian_get_notes_info':
|
||||
case 'obsidian_get_frontmatter':
|
||||
case 'obsidian_manage_tags':
|
||||
case 'obsidian_get_vault_stats':
|
||||
case 'obsidian_list_all_tags':
|
||||
case 'obsidian_wiki_link':
|
||||
case 'obsidian_get_note_outline':
|
||||
case 'obsidian_read_note_lines':
|
||||
return this.callObsidianTool(token, name, input, signal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1662,18 +1747,20 @@ export class KnowledgeMcpGateway {
|
||||
}]
|
||||
}
|
||||
}
|
||||
const result = await this.callScopedTool(
|
||||
token,
|
||||
name as ScopedDataToolName,
|
||||
parsedInput.data,
|
||||
extra.signal
|
||||
)
|
||||
if (name.startsWith('obsidian_')) {
|
||||
return result as CallToolResult
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(
|
||||
await this.callScopedTool(
|
||||
token,
|
||||
name as ScopedDataToolName,
|
||||
parsedInput.data,
|
||||
extra.signal
|
||||
)
|
||||
)
|
||||
text: JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1913,6 +2000,7 @@ export class KnowledgeMcpGateway {
|
||||
for (const token of [...this.capabilities.keys()]) {
|
||||
this.revoke(token)
|
||||
}
|
||||
await Promise.allSettled([...this.activeObsidianCalls])
|
||||
await Promise.allSettled([...this.downstreamMcpCleanups])
|
||||
await Promise.allSettled([...this.customMcpCleanups])
|
||||
const server = this.server
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join, resolve } from 'node:path'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { z } from 'zod'
|
||||
import { obsidianReadToolNames, obsidianScopedDataTools } from '../../shared/obsidian-tools'
|
||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||
import type { KnowledgeService } from '../knowledge/knowledge-service'
|
||||
import { ObsidianService } from '../obsidian/obsidian-service'
|
||||
import { packageObsidianMcpVault } from '../obsidian/package-mcpvault'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { ModelToolProvider, type ModelToolCallContext, type ModelToolResult } from './model-tool-provider'
|
||||
|
||||
let root: string
|
||||
let vaultPath: string
|
||||
let secondVaultPath: string
|
||||
let service: ObsidianService
|
||||
const fixtures: { provider: ModelToolProvider; gateway: KnowledgeMcpGateway }[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-model-'))
|
||||
vaultPath = join(root, 'First Vault')
|
||||
secondVaultPath = join(root, 'Second Vault')
|
||||
const config = join(root, 'config')
|
||||
const bin = join(root, 'managed-bin')
|
||||
await Promise.all([vaultPath, secondVaultPath, bin, join(config, 'obsidian')]
|
||||
.map((path) => mkdir(path, { recursive: true })))
|
||||
await writeFile(join(config, 'obsidian', 'obsidian.json'), JSON.stringify({ vaults: {
|
||||
first: { path: vaultPath }, second: { path: secondVaultPath }
|
||||
} }))
|
||||
await writeFile(join(bin, process.platform === 'win32' ? 'node.cmd' : 'node'),
|
||||
process.platform === 'win32'
|
||||
? `@echo off\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${process.execPath}" %*\r\n`
|
||||
: `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec '${process.execPath.replaceAll("'", "'\\''")}' "$@"\n`,
|
||||
{ mode: 0o755 })
|
||||
const appPath = join(root, 'app')
|
||||
await packageObsidianMcpVault(resolve('.'), join(appPath, 'out', 'main', 'obsidian-mcpvault'))
|
||||
service = new ObsidianService({
|
||||
appPath,
|
||||
launchEnvironmentProvider: () => ({ PATH: `${bin}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` }),
|
||||
platform: 'linux', environment: { ...process.env, XDG_CONFIG_HOME: config }
|
||||
})
|
||||
}, 60_000)
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(fixtures.splice(0).map(async ({ provider, gateway }) => {
|
||||
await provider.dispose()
|
||||
await gateway.dispose()
|
||||
}))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
afterAll(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
|
||||
function fixture(access: 'read' | 'write' = 'write') {
|
||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service })
|
||||
const signal = new AbortController().signal
|
||||
const token = gateway.grant('obsidian-model-test', [], signal, 'none', undefined,
|
||||
undefined, undefined, undefined, { settings: {}, access })!
|
||||
const provider = new ModelToolProvider(root, [], undefined, gateway)
|
||||
fixtures.push({ provider, gateway })
|
||||
const context: ModelToolCallContext = {
|
||||
conversationId: 'obsidian-model-test', runtimeTarget: 'model',
|
||||
workMode: 'execute', knowledgeCapabilityToken: token
|
||||
}
|
||||
return { gateway, provider, token, signal, context,
|
||||
call: (name: string, args: Record<string, unknown> = {}) => provider.callTool(name, args, signal, context) }
|
||||
}
|
||||
|
||||
function text(result: ModelToolResult): string {
|
||||
expect(result.contextBytes).toBe(result.parts.reduce((sum, part) =>
|
||||
sum + Buffer.byteLength(part.type === 'text' ? part.text : part.data), 0))
|
||||
return result.parts.map((part) => part.type === 'text' ? part.text : '').join('\n')
|
||||
}
|
||||
|
||||
describe('ModelToolProvider with bundled Obsidian service', () => {
|
||||
it('discovers and executes all 19 tools through a scoped token, with real filesystem effects', async () => {
|
||||
const { provider, context, signal, call } = fixture()
|
||||
const tools = (await provider.listTools(context, signal)).filter((tool) => tool.name.startsWith('obsidian_'))
|
||||
expect(tools).toHaveLength(19)
|
||||
expect(tools.map((tool) => tool.name).sort()).toEqual(obsidianScopedDataTools.map((tool) => tool.name).sort())
|
||||
const executed = new Set<string>()
|
||||
const run = async (name: string, args: Record<string, unknown> = {}) => {
|
||||
const result = text(await call(name, args))
|
||||
executed.add(name)
|
||||
return result
|
||||
}
|
||||
expect(JSON.parse(await run('obsidian_list_vaults')).vaults).toEqual([
|
||||
{ id: 'first', name: 'First Vault', path: vaultPath },
|
||||
{ id: 'second', name: 'Second Vault', path: secondVaultPath }
|
||||
])
|
||||
await expect(call('obsidian_write_note', { path: 'model.md', content: 'ambiguous' }))
|
||||
.rejects.toThrow('vaultId is required')
|
||||
await expect(call('obsidian_read_note', { vaultId: 'unknown', path: 'model.md' }))
|
||||
.rejects.toThrow('Unknown Obsidian vaultId')
|
||||
const target = { vaultId: 'first', path: 'model.md' }
|
||||
await run('obsidian_write_note', { ...target, content: '# Original\nBody', frontmatter: { title: 'Model' } })
|
||||
expect(await readFile(join(vaultPath, 'model.md'), 'utf8')).toContain('# Original')
|
||||
await expect(readFile(join(secondVaultPath, 'model.md'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(JSON.parse(await run('obsidian_read_note', target)).content).toContain('# Original')
|
||||
expect(await run('obsidian_list_directory', { vaultId: 'first' })).toContain('model.md')
|
||||
await run('obsidian_patch_note', { ...target, oldString: 'Original', newString: 'Patched' })
|
||||
await run('obsidian_update_frontmatter', { ...target, frontmatter: { status: 'verified' } })
|
||||
await run('obsidian_manage_tags', { ...target, operation: 'add', tags: ['integration'] })
|
||||
expect(await run('obsidian_get_frontmatter', target)).toContain('verified')
|
||||
expect(await run('obsidian_search_notes', { vaultId: 'first', query: 'Patched' })).toContain('model.md')
|
||||
expect(await run('obsidian_read_multiple_notes', { vaultId: 'first', paths: ['model.md'] })).toContain('Patched')
|
||||
expect(await run('obsidian_get_notes_info', { vaultId: 'first', paths: ['model.md'] })).toContain('model.md')
|
||||
expect(await run('obsidian_get_vault_stats', { vaultId: 'first' })).toContain('model.md')
|
||||
expect(await run('obsidian_list_all_tags', { vaultId: 'first' })).toContain('integration')
|
||||
expect(await run('obsidian_wiki_link', { vaultId: 'first', document: '[[model]]' })).toContain('Patched')
|
||||
expect(await run('obsidian_get_note_outline', target)).toContain('Patched')
|
||||
expect(await run('obsidian_read_note_lines', { ...target, startLine: 1, endLine: 20 })).toContain('Patched')
|
||||
await run('obsidian_move_note', { vaultId: 'first', oldPath: 'model.md', newPath: 'renamed.md' })
|
||||
expect(await readFile(join(vaultPath, 'renamed.md'), 'utf8')).toContain('# Patched')
|
||||
await writeFile(join(vaultPath, 'attachment.bin'), Buffer.from([0, 1, 255]))
|
||||
await run('obsidian_move_file', { vaultId: 'first', oldPath: 'attachment.bin', newPath: 'moved.bin',
|
||||
confirmOldPath: 'attachment.bin', confirmNewPath: 'moved.bin' })
|
||||
expect(await readFile(join(vaultPath, 'moved.bin'))).toEqual(Buffer.from([0, 1, 255]))
|
||||
await run('obsidian_delete_note', { vaultId: 'first', path: 'renamed.md', confirmPath: 'renamed.md' })
|
||||
await expect(readFile(join(vaultPath, 'renamed.md'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await run('obsidian_list_directory', { vaultId: 'first' })).not.toContain('renamed.md')
|
||||
expect([...executed].sort()).toEqual(tools.map((tool) => tool.name).sort())
|
||||
}, 60_000)
|
||||
|
||||
it('enforces Ask, read-only grants, missing tokens and revoked tokens at the provider boundary', async () => {
|
||||
const { provider, gateway, context, signal, token, call } = fixture()
|
||||
const ask = { ...context, workMode: 'ask' as const }
|
||||
expect((await provider.listTools(ask, signal)).filter((tool) => tool.name.startsWith('obsidian_'))
|
||||
.map((tool) => tool.name).sort()).toEqual([...obsidianReadToolNames].sort())
|
||||
const args = { vaultId: 'first', path: 'blocked.md', content: 'blocked' }
|
||||
await expect(provider.callTool('obsidian_write_note', args, signal, ask)).rejects.toThrow('Ask')
|
||||
await expect(fixture('read').call('obsidian_write_note', args)).rejects.toThrow('capability is unavailable')
|
||||
const unscoped = { ...context, knowledgeCapabilityToken: undefined }
|
||||
expect((await provider.listTools(unscoped, signal)).some((tool) => tool.name.startsWith('obsidian_'))).toBe(false)
|
||||
await expect(provider.callTool('obsidian_list_vaults', {}, signal, unscoped)).rejects.toThrow()
|
||||
gateway.revoke(token)
|
||||
await expect(call('obsidian_list_vaults')).rejects.toThrow()
|
||||
await expect(readFile(join(vaultPath, 'blocked.md'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('awaits the active bundled child cleanup when the gateway is disposed', async () => {
|
||||
const { gateway, call } = fixture()
|
||||
let markStarted!: (transport: StdioClientTransport) => void
|
||||
const started = new Promise<StdioClientTransport>((resolve) => { markStarted = resolve })
|
||||
const closed = new Set<StdioClientTransport>()
|
||||
const close = StdioClientTransport.prototype.close
|
||||
vi.spyOn(StdioClientTransport.prototype, 'close').mockImplementation(async function (this: StdioClientTransport) {
|
||||
await close.call(this)
|
||||
closed.add(this)
|
||||
})
|
||||
const send = StdioClientTransport.prototype.send
|
||||
vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) {
|
||||
const result = send.call(this, message)
|
||||
if ('method' in message && message.method === 'tools/call') markStarted(this)
|
||||
return result
|
||||
})
|
||||
const pending = call('obsidian_list_directory', { vaultId: 'first' })
|
||||
.then(() => undefined, (error: unknown) => error)
|
||||
const transport = await Promise.race([
|
||||
started,
|
||||
pending.then(() => { throw new Error('Tool call ended before upstream dispatch') })
|
||||
])
|
||||
expect(transport.pid).not.toBeNull()
|
||||
try {
|
||||
await gateway.dispose()
|
||||
// The SDK clears pid at the start of close(), before awaiting process exit.
|
||||
expect(closed.has(transport), 'dispose must await the actual transport close promise').toBe(true)
|
||||
} finally {
|
||||
expect(await pending).toBeInstanceOf(Error)
|
||||
expect(closed.has(transport)).toBe(true)
|
||||
expect(transport.pid).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps upstream defaulted arguments optional in model discovery', async () => {
|
||||
const { provider, context, signal } = fixture()
|
||||
const tools = await provider.listTools(context, signal)
|
||||
for (const definition of obsidianScopedDataTools) {
|
||||
const expected = z.toJSONSchema(definition.inputSchema, { io: 'input' })
|
||||
expect.soft(tools.find((tool) => tool.name === definition.name)?.inputSchema.required ?? [], definition.name)
|
||||
.toEqual(expected.required ?? [])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not spend the custom MCP budget on disabled Obsidian tools', async () => {
|
||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service })
|
||||
const servers: ResolvedMcpServer[] = Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-00000000000${index}`, name: `Vault ${index}`,
|
||||
description: '', enabled: true, allowDynamicTools: false, assignments: ['model'],
|
||||
secretConfigured: false, transport: 'stdio', command: 'node',
|
||||
args: [join(root, 'app', 'out', 'main', 'obsidian-mcpvault', 'node_modules',
|
||||
'@bitbonsai', 'mcpvault', 'dist', 'server.js'), vaultPath]
|
||||
}))
|
||||
const provider = new ModelToolProvider(root, servers, undefined, gateway, false, {}, () => ({
|
||||
PATH: `${join(root, 'managed-bin')}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}`
|
||||
}))
|
||||
fixtures.push({ provider, gateway })
|
||||
// No scoped grant: 72 real upstream tools plus two workspace tools fit below 100.
|
||||
const tools = await provider.listTools({ conversationId: 'disabled-obsidian', workMode: 'execute' },
|
||||
new AbortController().signal)
|
||||
expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(72)
|
||||
expect(tools.some((tool) => tool.name.startsWith('obsidian_'))).toBe(false)
|
||||
expect(tools).toHaveLength(74)
|
||||
}, 30_000)
|
||||
|
||||
it.each(['read_note', 'delete_note'] as const)('preserves the native %s error detail for the model', async (name) => {
|
||||
const { call } = fixture()
|
||||
const args = name === 'read_note' ? { path: 'missing.md' }
|
||||
: { path: 'protected.md', confirmPath: 'wrong.md' }
|
||||
if (name === 'delete_note') await call('obsidian_write_note', { vaultId: 'first', path: args.path, content: 'keep me' })
|
||||
// Establish the actual bundled server error, without injecting a service or gateway double.
|
||||
const native = await service.callTool({}, { vaultId: 'first', name, arguments: args })
|
||||
expect(native.isError).toBe(true)
|
||||
const detail = native.content.map((part) => part.type === 'text' ? part.text : '').join('\n')
|
||||
expect(detail).toContain(name === 'read_note' ? 'missing.md' : 'confirmPath')
|
||||
const failure = await call(`obsidian_${name}`, { vaultId: 'first', ...args }).catch((error: unknown) => error)
|
||||
if (name === 'delete_note') expect(await readFile(join(vaultPath, args.path), 'utf8')).toContain('keep me')
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
expect((failure as Error).message).toContain(detail)
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type WorkspaceAccess
|
||||
} from '../workspace'
|
||||
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { scopedDataToolByName } from '../../shared/scoped-data-tools'
|
||||
import {
|
||||
LocalDirectModelProcessService,
|
||||
type DirectModelProcessService,
|
||||
@@ -1008,29 +1009,13 @@ describe('ModelToolProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reserves all scoped data tool slots for Execute', async () => {
|
||||
it('reserves the granted scoped data tool slots for Execute', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
const gateway = {
|
||||
listLibraries: vi.fn(() => []),
|
||||
search: vi.fn(async () => []),
|
||||
searchMagicNotes: vi.fn(() => []),
|
||||
getAvailableToolNames: vi.fn(() => [
|
||||
'knowledge_list',
|
||||
'knowledge_search',
|
||||
'note_list',
|
||||
'note_get',
|
||||
'note_search',
|
||||
'note_create',
|
||||
'note_update',
|
||||
'note_entry_create',
|
||||
'note_entry_update',
|
||||
'note_entry_delete',
|
||||
'note_delete',
|
||||
'goodbuddy_config_capabilities',
|
||||
'goodbuddy_config_get',
|
||||
'goodbuddy_config_plan',
|
||||
'goodbuddy_config_apply'
|
||||
])
|
||||
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const context = {
|
||||
conversationId: 'knowledge-capacity',
|
||||
@@ -1050,7 +1035,7 @@ describe('ModelToolProvider', () => {
|
||||
}))
|
||||
|
||||
mocks.client.listTools.mockResolvedValueOnce({
|
||||
tools: createTools(81)
|
||||
tools: createTools(100 - 4 - scopedDataToolByName.size)
|
||||
})
|
||||
const validProvider = new ModelToolProvider(
|
||||
workspace,
|
||||
@@ -1066,7 +1051,7 @@ describe('ModelToolProvider', () => {
|
||||
await validProvider.dispose()
|
||||
|
||||
mocks.client.listTools.mockResolvedValueOnce({
|
||||
tools: createTools(82)
|
||||
tools: createTools(100 - 4 - scopedDataToolByName.size + 1)
|
||||
})
|
||||
const overflowingProvider = new ModelToolProvider(
|
||||
workspace,
|
||||
@@ -1081,10 +1066,77 @@ describe('ModelToolProvider', () => {
|
||||
context,
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toHaveLength(19)
|
||||
).resolves.toHaveLength(4 + scopedDataToolByName.size)
|
||||
await overflowingProvider.dispose()
|
||||
})
|
||||
|
||||
it('rechecks cached and refreshed MCP catalogs against each request scope without shrinking the cache', async () => {
|
||||
const gateway = {
|
||||
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer(true)],
|
||||
undefined, gateway, false, { ripgrepExecutablePath: rgPath })
|
||||
const unscoped = { ...toolContext, runtimeTarget: 'model' as const }
|
||||
const scoped = { ...unscoped, knowledgeCapabilityToken: 'scoped' }
|
||||
const signal = new AbortController().signal
|
||||
const catalog = (count: number) => ({ tools: Array.from({ length: count }, (_, index) => ({
|
||||
name: `remote_${index}`, inputSchema: { type: 'object' }
|
||||
})) })
|
||||
mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } })
|
||||
mocks.client.listTools.mockResolvedValue(catalog(90))
|
||||
try {
|
||||
// Start with the narrower request to ensure it cannot poison later discovery.
|
||||
await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size)
|
||||
const tools = await provider.listTools(unscoped, signal)
|
||||
expect(tools).toHaveLength(94)
|
||||
const name = tools.find((tool) => tool.source === 'mcp')!.name
|
||||
await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size)
|
||||
await expect(provider.callTool(name, {}, signal, scoped)).rejects.toThrow('未知工具')
|
||||
await expect(provider.listTools(unscoped, signal)).resolves.toHaveLength(94)
|
||||
await expect(provider.callTool(name, {}, signal, unscoped)).resolves.toBeDefined()
|
||||
expect(mocks.client.connect).toHaveBeenCalledOnce()
|
||||
expect(mocks.client.listTools).toHaveBeenCalledOnce()
|
||||
|
||||
const options = mocks.Client.mock.calls[0]![1] as {
|
||||
listChanged: { tools: { onChanged(error: Error | null): void } }
|
||||
}
|
||||
mocks.client.listTools.mockResolvedValue(catalog(96))
|
||||
options.listChanged.tools.onChanged(null)
|
||||
await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size)
|
||||
await expect(provider.listTools(unscoped, signal)).resolves.toHaveLength(100)
|
||||
expect(mocks.client.listTools).toHaveBeenCalledTimes(2)
|
||||
|
||||
mocks.client.listTools.mockResolvedValue(catalog(100 - 4 - scopedDataToolByName.size))
|
||||
options.listChanged.tools.onChanged(null)
|
||||
await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(100)
|
||||
expect(mocks.client.connect).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces the aggregate MCP limit when a cached catalog gains scoped tools', async () => {
|
||||
const gateway = {
|
||||
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
|
||||
} as unknown as KnowledgeMcpGateway
|
||||
mocks.client.listTools.mockResolvedValue({ tools: Array.from({ length: 40 }, (_, index) => ({
|
||||
name: `remote_${index}`, inputSchema: { type: 'object' }
|
||||
})) })
|
||||
const provider = new ModelToolProvider(await createWorkspace(), [
|
||||
createMcpServer(), { ...createMcpServer(), id: 'second', name: 'Second' }
|
||||
], undefined, gateway)
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
await expect(provider.listTools(toolContext, signal)).resolves.toHaveLength(82)
|
||||
await expect(provider.listTools({ ...toolContext, knowledgeCapabilityToken: 'scoped' }, signal))
|
||||
.rejects.toThrow('100')
|
||||
await expect(provider.listTools(toolContext, signal)).resolves.toHaveLength(82)
|
||||
expect(mocks.client.listTools).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads every paginated MCP tool before exposing the catalog', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
mocks.client.listTools.mockImplementation(
|
||||
@@ -2151,6 +2203,38 @@ describe('ModelToolProvider', () => {
|
||||
).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it.each([false, true])('preserves bounded native MCP error text (wrapped: %s)', async (wrapped) => {
|
||||
mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'read', inputSchema: { type: 'object' } }] })
|
||||
const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer()])
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
const tools = await provider.listTools(toolContext, signal)
|
||||
const name = tools.find((tool) => tool.source === 'mcp')!.name
|
||||
for (const detail of ['File not found: note.md', '\u4e2d'.repeat(100_000), '']) {
|
||||
const result = { isError: true, content: [
|
||||
{ type: 'text', text: detail }, { type: 'text', text: 'Use list_directory.' }
|
||||
] }
|
||||
mocks.client.callTool.mockResolvedValue(wrapped ? { toolResult: result } : result)
|
||||
const error = await provider.callTool(name, {}, signal, toolContext).catch((reason: unknown) => reason)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
const message = (error as Error).message
|
||||
expect(message).toContain('MCP Server')
|
||||
expect(Buffer.byteLength(message)).toBeLessThanOrEqual(256 * 1024)
|
||||
if (Buffer.byteLength(detail) > 256 * 1024) {
|
||||
expect(message).toContain(detail.slice(0, 100))
|
||||
expect(message).toContain('[GoodBuddy result truncated]')
|
||||
expect(message).not.toContain('\ufffd')
|
||||
} else {
|
||||
expect(message).toContain(`${detail}\nUse list_directory.`)
|
||||
}
|
||||
}
|
||||
mocks.client.callTool.mockResolvedValue({ isError: true, content: [] })
|
||||
await expect(provider.callTool(name, {}, signal, toolContext)).rejects.toThrow('MCP Server')
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts encoded and decoded image data against the MCP result budget', async () => {
|
||||
const workspace = await createWorkspace()
|
||||
mocks.client.listTools.mockResolvedValue({
|
||||
|
||||
@@ -6,7 +6,6 @@ import { builtinModelTools } from '../../shared/builtin-model-tools'
|
||||
import {
|
||||
goodbuddyConfigWriteToolNames,
|
||||
magicNoteWriteToolNames,
|
||||
maximumScopedToolCount,
|
||||
scopedDataToolByName,
|
||||
scopedReadToolNames,
|
||||
type ScopedDataToolName
|
||||
@@ -121,7 +120,7 @@ const scopedToolJsonSchemas = new Map(
|
||||
[...scopedDataToolByName].map(([name, definition]) => {
|
||||
const schema = z.toJSONSchema(
|
||||
definition.inputSchema,
|
||||
{ target: 'draft-7' }
|
||||
{ target: 'draft-7', io: 'input' }
|
||||
) as Record<string, unknown>
|
||||
Reflect.deleteProperty(schema, '$schema')
|
||||
return [name, schema] as const
|
||||
@@ -539,7 +538,15 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
|
||||
}
|
||||
const record = result as Record<string, unknown>
|
||||
if (record.isError === true) {
|
||||
throw new Error('MCP Server 报告工具执行失败')
|
||||
const detail = Array.isArray(record.content)
|
||||
? record.content.slice(0, MAX_MCP_CONTENT_BLOCKS).flatMap((item) =>
|
||||
item?.type === 'text' && typeof item.text === 'string' ? [item.text] : []
|
||||
).join('\n')
|
||||
: ''
|
||||
const message = `MCP Server 报告工具执行失败${detail ? `\n${detail}` : ''}`
|
||||
throw new Error(Buffer.byteLength(message) > MAX_TOOL_RESULT_BYTES
|
||||
? utf8Prefix(message, MAX_TOOL_RESULT_BYTES - Buffer.byteLength(TOOL_RESULT_TRUNCATION_MARKER)) + TOOL_RESULT_TRUNCATION_MARKER
|
||||
: message)
|
||||
}
|
||||
if ('toolResult' in record) {
|
||||
if (
|
||||
@@ -734,7 +741,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
return tools
|
||||
}
|
||||
|
||||
private getReservedToolCount(): number {
|
||||
private getReservedToolCount(scopedToolCount = 0): number {
|
||||
return (
|
||||
this.getBuiltinTools().length +
|
||||
(this.browserService ? browserToolNames.length : 0) +
|
||||
@@ -743,7 +750,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
(this.programming.subagentService ? 1 : 0) +
|
||||
(this.programming.ripgrepExecutablePath || this.programming.processService || this.programming.subagentService
|
||||
? 1 : 0) +
|
||||
(this.knowledgeGateway ? maximumScopedToolCount : 0)
|
||||
scopedToolCount
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1097,6 +1104,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
|
||||
private async getMcpBindings(
|
||||
signal: AbortSignal,
|
||||
scopedToolCount: number,
|
||||
refreshDynamic = false
|
||||
): Promise<Map<string, McpToolBinding>> {
|
||||
signal.throwIfAborted()
|
||||
@@ -1162,8 +1170,12 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const bindings = new Map<string, McpToolBinding>()
|
||||
const reservedToolCount = this.getReservedToolCount()
|
||||
const reservedToolCount = this.getReservedToolCount(scopedToolCount)
|
||||
for (const connection of connections) {
|
||||
// Cached catalogs are shared; a request's grant must not shrink the cache.
|
||||
if (connection.tools.length > MAX_MODEL_TOOLS - reservedToolCount) {
|
||||
continue
|
||||
}
|
||||
for (const binding of connection.tools) {
|
||||
if (bindings.size + reservedToolCount >= MAX_MODEL_TOOLS) {
|
||||
throw new Error('直连模型工具总数超过 100 个安全限制')
|
||||
@@ -1260,7 +1272,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
]
|
||||
}
|
||||
const processTools = await this.getProcessTool(context, signal)
|
||||
const bindings = await this.getMcpBindings(signal, true)
|
||||
const bindings = await this.getMcpBindings(signal, scopedTools.length, true)
|
||||
const browserTools = this.getBrowserTools(context)
|
||||
return [
|
||||
...workspaceTools,
|
||||
@@ -1394,6 +1406,17 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
): Promise<ModelToolResult> {
|
||||
signal.throwIfAborted()
|
||||
assertToolAuthorizedForWorkMode(name, context)
|
||||
if (name.startsWith('obsidian_') && scopedDataToolByName.has(name as ScopedDataToolName)) {
|
||||
if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) {
|
||||
throw new Error('Obsidian 工具授权不可用')
|
||||
}
|
||||
return normalizeMcpResult(await this.knowledgeGateway.callObsidianTool(
|
||||
context.knowledgeCapabilityToken,
|
||||
name as Extract<ScopedDataToolName, `obsidian_${string}`>,
|
||||
argumentsValue,
|
||||
signal
|
||||
))
|
||||
}
|
||||
if (name === 'generate_image') {
|
||||
if (!context.imageToolBinding || !context.toolCallId) throw new Error('Image tool request binding is unavailable')
|
||||
return createTextToolResult(JSON.stringify(await context.imageToolBinding.call(argumentsValue, context.toolCallId, signal)))
|
||||
|
||||
Reference in New Issue
Block a user