Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.
- Remove the Main readWithFallback path and the supervision worker's
separate business write connection; manifests are scanned read-only and
committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
directories and reclaim confirmed stale ones; stop writing duplicate
parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
acceptance fixtures, and maintenance/architecture documentation.
Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2149 lines
71 KiB
TypeScript
2149 lines
71 KiB
TypeScript
import { createHash, randomBytes } from 'node:crypto'
|
|
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from '../desktop-diagnostics'
|
|
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
|
|
import type { RuntimeTarget } from '../../shared/capability-contracts'
|
|
import { builtinModelTools } from '../../shared/builtin-model-tools'
|
|
import type { Awaitable } from '../assistant-storage-port'
|
|
|
|
export type StoryGraphBinding = { projectId?: string; conversationId?: string; runtimeTarget: RuntimeTarget }
|
|
export type StoryGraphRemoteBinding = {
|
|
available(): Promise<boolean>
|
|
call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise<Record<string, unknown>>
|
|
}
|
|
export type StoryGraphService = {
|
|
available(binding: StoryGraphBinding): Promise<boolean>
|
|
read(name: StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Record<string, unknown> | Promise<Record<string, unknown>>
|
|
}
|
|
import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference'
|
|
import {
|
|
createServer,
|
|
type IncomingMessage,
|
|
type Server,
|
|
type ServerResponse
|
|
} from 'node:http'
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js'
|
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
import type { JsonSchemaValidator } from '@modelcontextprotocol/sdk/validation'
|
|
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
|
|
import {
|
|
CallToolRequestSchema,
|
|
CallToolResultSchema,
|
|
ListToolsRequestSchema,
|
|
isInitializeRequest,
|
|
type CallToolResult,
|
|
type Tool
|
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
import { z } from 'zod'
|
|
import type {
|
|
BrowserTabId,
|
|
BrowserTabSummary,
|
|
KnowledgeSearchReference
|
|
} from '../../shared/contracts'
|
|
import {
|
|
knowledgeToolNames,
|
|
knowledgeScopedDataToolCatalog,
|
|
goodbuddyConfigReadToolNames,
|
|
goodbuddyConfigWriteToolNames,
|
|
magicNoteScopedDataToolCatalog,
|
|
magicNoteReadToolNames,
|
|
magicNoteWriteToolNames,
|
|
maximumScopedToolCount,
|
|
scopedDataToolByName,
|
|
scopedReadToolNames,
|
|
type GoodBuddyConfigToolName,
|
|
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,
|
|
MagicNoteRichContent,
|
|
MagicNoteContent,
|
|
MagicNoteSearchResult,
|
|
MagicNoteSummary
|
|
} from '../../shared/magic-notes-contracts'
|
|
import { magicNoteTagListSchema } from '../../shared/magic-notes-contracts'
|
|
import {
|
|
magicNotePlainText,
|
|
validateMagicNoteRichContent
|
|
} from '../magic-notes/rich-content'
|
|
import type { GoodBuddyConfigService } from '../goodbuddy-config-service'
|
|
import {
|
|
createMcpTransport
|
|
} from '../capabilities/mcp-client-transport'
|
|
import type {
|
|
ResolvedMcpServer
|
|
} from '../capabilities/capability-service'
|
|
import {
|
|
createGatewayMcpToolName,
|
|
isValidMcpToolName,
|
|
listAllMcpTools,
|
|
normalizeMcpToolSchema
|
|
} from './mcp-tool-utils'
|
|
import type {
|
|
LaunchEnvironmentProvider,
|
|
LaunchEnvironmentReady
|
|
} from '../local-tool-environment'
|
|
import {
|
|
BrowserModelTools,
|
|
browserNavigateInputSchema,
|
|
browserToolNames,
|
|
type BrowserToolName,
|
|
type BrowserToolService
|
|
} from '../browser/browser-model-tools'
|
|
import type { BrowserService, BrowserTabUsageLease } from '../browser/browser-service'
|
|
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
|
|
import { safeToolErrorDetail } from './approval-summary'
|
|
|
|
const MAX_REQUEST_BODY_BYTES = 64 * 1024
|
|
const MAX_RESULT_BYTES = 128 * 1024
|
|
const MAX_CUSTOM_MCP_RESULT_BYTES = 256 * 1024
|
|
const MAX_CUSTOM_MCP_SERVERS = 16
|
|
const MAX_CUSTOM_MCP_TOOLS = 100
|
|
const MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY = 8
|
|
const CUSTOM_MCP_TIMEOUT_MS = 30_000
|
|
const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
|
|
const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
|
|
const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator()
|
|
const builtinToolAccessByName = new Map<string, 'read' | 'write'>(
|
|
builtinModelTools.map((tool) => [tool.name, tool.access])
|
|
)
|
|
|
|
export {
|
|
browserToolNames,
|
|
knowledgeToolNames,
|
|
magicNoteReadToolNames,
|
|
magicNoteWriteToolNames,
|
|
maximumScopedToolCount,
|
|
scopedReadToolNames
|
|
}
|
|
|
|
export type GoodBuddyBuiltinToolName =
|
|
| 'generate_image'
|
|
| 'save_image'
|
|
| ScopedDataToolName
|
|
| BrowserToolName
|
|
|
|
const {
|
|
knowledge_list: knowledgeListTool,
|
|
knowledge_search: knowledgeSearchTool
|
|
} = knowledgeScopedDataToolCatalog
|
|
const {
|
|
note_list: magicNoteListTool,
|
|
note_get: magicNoteGetTool,
|
|
note_search: magicNoteSearchTool,
|
|
note_create: magicNoteCreateTool,
|
|
note_update: magicNoteUpdateTool,
|
|
note_entry_create: magicNoteEntryCreateTool,
|
|
note_entry_update: magicNoteEntryUpdateTool,
|
|
note_entry_delete: magicNoteEntryDeleteTool,
|
|
note_delete: magicNoteDeleteTool
|
|
} = magicNoteScopedDataToolCatalog
|
|
|
|
export type MagicNotesDatabase = {
|
|
listMagicNotes(input?: { tags?: string[] }): Awaitable<MagicNoteSummary[]>
|
|
getMagicNote(noteId: string): Awaitable<MagicNoteDetail>
|
|
getMagicNoteEntry(entryId: string): Awaitable<MagicNoteEntry>
|
|
searchMagicNotes(query: string, limit: number): Awaitable<MagicNoteSearchResult[]>
|
|
createMagicNote(input: {
|
|
title: string
|
|
content?: MagicNoteContent
|
|
tags?: string[]
|
|
}): Awaitable<MagicNoteDetail>
|
|
updateMagicNote(input: {
|
|
noteId: string
|
|
title?: string
|
|
pinned?: boolean
|
|
tags?: string[]
|
|
expectedRevision: number
|
|
}): Awaitable<MagicNoteDetail>
|
|
deleteMagicNote(noteId: string): Awaitable<void>
|
|
createMagicNoteEntry(input: {
|
|
noteId: string
|
|
content: MagicNoteContent
|
|
plainText: string
|
|
}): Awaitable<MagicNoteDetail>
|
|
updateMagicNoteEntry(input: {
|
|
entryId: string
|
|
content: MagicNoteContent
|
|
plainText: string
|
|
expectedRevision: number
|
|
}): Awaitable<MagicNoteDetail>
|
|
deleteMagicNoteEntry(entryId: string): Awaitable<MagicNoteDetail>
|
|
}
|
|
|
|
export type MagicNotesCapabilityAccess = 'none' | 'read' | 'write'
|
|
|
|
export type MagicNoteToolSummary = {
|
|
id: string
|
|
title: string
|
|
preview: string
|
|
entryCount: number
|
|
pinned: boolean
|
|
tags: string[]
|
|
revision: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export type MagicNoteToolEntry = {
|
|
id: string
|
|
content: string
|
|
contentKind: 'rich-text' | 'paged-canvas'
|
|
contentVersion: 1 | 2
|
|
plainTextEditable: boolean
|
|
revision: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export type MagicNoteToolDetail = MagicNoteToolSummary & {
|
|
entries: MagicNoteToolEntry[]
|
|
truncated: boolean
|
|
}
|
|
|
|
export type KnowledgeLibraryListItem = {
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
}
|
|
|
|
type Capability = {
|
|
storyGraph?: StoryGraphBinding
|
|
imageToolBinding?: ImageToolBinding
|
|
requestId: string
|
|
libraryIds: readonly string[]
|
|
magicNotesAccess: MagicNotesCapabilityAccess
|
|
configAccess: MagicNotesCapabilityAccess
|
|
configWorkspacePath?: string
|
|
browserConversationId?: string
|
|
browserTabId?: BrowserTabId
|
|
browserUsageLease?: BrowserTabUsageLease
|
|
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
|
|
signal: AbortSignal
|
|
brokerController: AbortController
|
|
customMcpServers: readonly ResolvedMcpServer[]
|
|
customMcpConnections?: Promise<CustomMcpConnection[]>
|
|
references: Map<string, KnowledgeSearchReference>
|
|
removeAbortListener: () => void
|
|
}
|
|
|
|
type CustomMcpBinding = {
|
|
client: Client
|
|
server: ResolvedMcpServer
|
|
originalName: string
|
|
exposedTool: Tool
|
|
taskSupport?: 'forbidden' | 'optional' | 'required'
|
|
outputValidator?: JsonSchemaValidator<Record<string, unknown>>
|
|
}
|
|
|
|
type CustomMcpConnection = {
|
|
client: Client
|
|
server: ResolvedMcpServer
|
|
bindings: CustomMcpBinding[]
|
|
dynamicToolsSupported: boolean
|
|
dynamicToolsChanged: boolean
|
|
dynamicToolsChangeVersion: number
|
|
dynamicToolsRefresh?: Promise<void>
|
|
}
|
|
|
|
type DownstreamMcpSession = {
|
|
id?: string
|
|
registryKey: string
|
|
token: string
|
|
mcp: McpProtocolServer
|
|
transport: StreamableHTTPServerTransport
|
|
initialized: boolean
|
|
listedTools: boolean
|
|
closing?: Promise<void>
|
|
}
|
|
|
|
export type KnowledgeMcpGatewayOptions = {
|
|
observeFailure?: DesktopDiagnosticFailureObserver
|
|
storyGraphService?: StoryGraphService
|
|
maximumBodyBytes?: number
|
|
magicNotesDatabase?: MagicNotesDatabase
|
|
configService?: GoodBuddyConfigService
|
|
obsidianService?: ObsidianService
|
|
browserService?: BrowserToolService & {
|
|
createTab: BrowserService['createTab']
|
|
listTabs(conversationId: string): BrowserTabSummary[]
|
|
acquireTabUsage(
|
|
conversationId: string,
|
|
tabId: BrowserTabId,
|
|
owner: string
|
|
): BrowserTabUsageLease
|
|
}
|
|
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
|
/** Awaited before reading the provider; startup builds the environment in the background. */
|
|
launchEnvironmentReady?: LaunchEnvironmentReady
|
|
}
|
|
|
|
function toMagicNoteToolSummary(
|
|
note: MagicNoteSummary
|
|
): MagicNoteToolSummary {
|
|
return {
|
|
id: note.id,
|
|
title: note.title.slice(0, 100),
|
|
preview: note.preview.slice(0, 500),
|
|
entryCount: note.entryCount,
|
|
pinned: note.pinned,
|
|
tags: note.tags ?? [],
|
|
revision: note.revision,
|
|
createdAt: note.createdAt,
|
|
updatedAt: note.updatedAt
|
|
}
|
|
}
|
|
|
|
function textContent(value: string): MagicNoteRichContent {
|
|
return validateMagicNoteRichContent({
|
|
version: 1,
|
|
ops: [{ insert: value.endsWith('\n') ? value : `${value}\n` }]
|
|
})
|
|
}
|
|
|
|
function referenceKey(reference: KnowledgeSearchReference): string {
|
|
return knowledgeReferenceKey(reference)
|
|
}
|
|
|
|
function ensureBoundedCustomMcpResult(result: unknown): CallToolResult {
|
|
const normalized =
|
|
result &&
|
|
typeof result === 'object' &&
|
|
'toolResult' in result
|
|
? {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: JSON.stringify(
|
|
(result as { toolResult: unknown }).toolResult
|
|
)
|
|
}
|
|
]
|
|
}
|
|
: result
|
|
const parsed = CallToolResultSchema.parse(normalized)
|
|
let serialized: string
|
|
try {
|
|
serialized = JSON.stringify(parsed)
|
|
} catch (error) {
|
|
throw new Error('MCP 工具结果无法序列化', { cause: error })
|
|
}
|
|
if (
|
|
!serialized ||
|
|
Buffer.byteLength(serialized) > MAX_CUSTOM_MCP_RESULT_BYTES
|
|
) {
|
|
throw new Error('MCP 工具结果超过 256KB 安全限制')
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function sendJson(
|
|
response: ServerResponse,
|
|
status: number,
|
|
value: unknown
|
|
): void {
|
|
if (response.headersSent) {
|
|
response.end()
|
|
return
|
|
}
|
|
const body = JSON.stringify(value)
|
|
response.writeHead(status, {
|
|
'content-type': 'application/json',
|
|
'content-length': Buffer.byteLength(body)
|
|
})
|
|
response.end(body)
|
|
}
|
|
|
|
async function readBoundedJson(
|
|
request: IncomingMessage,
|
|
maximumBytes: number
|
|
): Promise<unknown> {
|
|
const declaredLength = Number(request.headers['content-length'])
|
|
if (
|
|
Number.isFinite(declaredLength) &&
|
|
declaredLength > maximumBytes
|
|
) {
|
|
throw new RangeError('request body too large')
|
|
}
|
|
const chunks: Buffer[] = []
|
|
let total = 0
|
|
for await (const chunk of request) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
total += buffer.length
|
|
if (total > maximumBytes) {
|
|
throw new RangeError('request body too large')
|
|
}
|
|
chunks.push(buffer)
|
|
}
|
|
try {
|
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
} catch (error) {
|
|
throw new SyntaxError('invalid JSON', { cause: error })
|
|
}
|
|
}
|
|
|
|
export class KnowledgeMcpGateway {
|
|
private readonly storyGraphService?: StoryGraphService
|
|
private readonly capabilities = new Map<string, Capability>()
|
|
private readonly downstreamMcpSessions = new Map<
|
|
string,
|
|
DownstreamMcpSession
|
|
>()
|
|
private readonly downstreamMcpCleanups = new Set<Promise<void>>()
|
|
private readonly customMcpCleanups = new Set<Promise<void>>()
|
|
private readonly activeObsidianCalls = new Set<Promise<CallToolResult>>()
|
|
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[]
|
|
acquireTabUsage(
|
|
conversationId: string,
|
|
tabId: BrowserTabId,
|
|
owner: string
|
|
): BrowserTabUsageLease
|
|
}
|
|
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
|
|
private readonly launchEnvironmentReady?: LaunchEnvironmentReady
|
|
|
|
private readonly observeFailure?: DesktopDiagnosticFailureObserver
|
|
private server?: Server
|
|
private endpoint?: string
|
|
|
|
constructor(
|
|
private readonly knowledgeService: KnowledgeService,
|
|
options: KnowledgeMcpGatewayOptions = {}
|
|
) {
|
|
this.maximumBodyBytes =
|
|
options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES
|
|
this.storyGraphService = options.storyGraphService
|
|
this.magicNotesDatabase = options.magicNotesDatabase
|
|
this.configService = options.configService
|
|
this.obsidianService = options.obsidianService
|
|
this.browserService = options.browserService
|
|
this.launchEnvironmentProvider = options.launchEnvironmentProvider
|
|
this.launchEnvironmentReady = options.launchEnvironmentReady
|
|
|
|
this.observeFailure = options.observeFailure
|
|
}
|
|
|
|
private observeMcpFailure(mcp: DesktopMcpDiagnosticMetadata): void {
|
|
try {
|
|
this.observeFailure?.({
|
|
component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed',
|
|
error: new Error('Local MCP request failed'), mcp
|
|
})
|
|
} catch {
|
|
// Diagnostics must not change the HTTP response or session lifecycle.
|
|
}
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
if (this.server) {
|
|
return
|
|
}
|
|
const server = createServer((request, response) => {
|
|
const context = { phase: 'request' as DesktopMcpDiagnosticMetadata['phase'], startedAt: performance.now(), correlationId: undefined as string | undefined }
|
|
let failed = false
|
|
response.once('finish', () => {
|
|
if (!failed && response.statusCode >= 400) {
|
|
this.observeMcpFailure({
|
|
phase: context.phase, category: 'http-rejection', status: response.statusCode,
|
|
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
|
|
})
|
|
}
|
|
})
|
|
void this.handleRequest(request, response, context).catch(() => {
|
|
failed = true
|
|
this.observeMcpFailure({
|
|
phase: context.phase, category: 'handler-failure',
|
|
...(response.headersSent ? {} : { status: 500 }),
|
|
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
|
|
})
|
|
sendJson(response, 500, {
|
|
jsonrpc: '2.0',
|
|
error: { code: -32603, message: 'Internal server error' },
|
|
id: null
|
|
})
|
|
})
|
|
})
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (error: Error): void => {
|
|
server.off('listening', onListening)
|
|
reject(error)
|
|
}
|
|
const onListening = (): void => {
|
|
server.off('error', onError)
|
|
resolve()
|
|
}
|
|
server.once('error', onError)
|
|
server.once('listening', onListening)
|
|
server.listen(0, '127.0.0.1')
|
|
})
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
throw new Error('Knowledge MCP gateway did not bind a TCP port')
|
|
}
|
|
this.server = server
|
|
this.endpoint = `http://127.0.0.1:${address.port}/mcp`
|
|
}
|
|
|
|
getEndpoint(): string | undefined {
|
|
return this.endpoint
|
|
}
|
|
|
|
grant(
|
|
requestId: string,
|
|
authorizedLibraryIds: readonly string[],
|
|
signal: AbortSignal,
|
|
magicNotesAccess: MagicNotesCapabilityAccess = 'none',
|
|
config?: {
|
|
access: MagicNotesCapabilityAccess
|
|
workspacePath: string
|
|
},
|
|
browserConversationId?: string,
|
|
browserTabId?: BrowserTabId,
|
|
browserUsageLease?: BrowserTabUsageLease,
|
|
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' },
|
|
storyGraph?: StoryGraphBinding
|
|
): string | undefined {
|
|
const effectiveMagicNotesAccess = this.magicNotesDatabase
|
|
? magicNotesAccess
|
|
: 'none'
|
|
const effectiveConfigAccess = this.configService
|
|
? config?.access ?? 'none'
|
|
: 'none'
|
|
const effectiveObsidian = this.obsidianService ? obsidian : undefined
|
|
const effectiveBrowserConversationId = this.browserService
|
|
? browserConversationId
|
|
: undefined
|
|
const effectiveBrowserTabId = effectiveBrowserConversationId
|
|
? browserTabId
|
|
: undefined
|
|
if (
|
|
effectiveBrowserConversationId &&
|
|
(!effectiveBrowserTabId ||
|
|
(!browserUsageLease && !this.browserService!
|
|
.listTabs(effectiveBrowserConversationId)
|
|
.some((tab) => tab.tabId === effectiveBrowserTabId)))
|
|
) {
|
|
throw new Error('浏览器标签页不存在或不属于当前对话')
|
|
}
|
|
let effectiveBrowserUsageLease: BrowserTabUsageLease | undefined
|
|
if (effectiveBrowserConversationId && effectiveBrowserTabId) {
|
|
effectiveBrowserUsageLease = browserUsageLease ??
|
|
this.browserService!.acquireTabUsage(
|
|
effectiveBrowserConversationId,
|
|
effectiveBrowserTabId,
|
|
requestId
|
|
)
|
|
if (
|
|
effectiveBrowserUsageLease.conversationId !==
|
|
effectiveBrowserConversationId ||
|
|
effectiveBrowserUsageLease.tabId !== effectiveBrowserTabId ||
|
|
effectiveBrowserUsageLease.owner !== requestId
|
|
) {
|
|
effectiveBrowserUsageLease.release()
|
|
throw new Error('浏览器标签页使用租约与当前请求不匹配')
|
|
}
|
|
} else if (browserUsageLease) {
|
|
browserUsageLease.release()
|
|
throw new Error('浏览器标签页使用租约与当前请求不匹配')
|
|
}
|
|
if (
|
|
authorizedLibraryIds.length === 0 &&
|
|
effectiveMagicNotesAccess === 'none' &&
|
|
effectiveConfigAccess === 'none' &&
|
|
!effectiveBrowserConversationId &&
|
|
!effectiveObsidian &&
|
|
!(storyGraph && this.storyGraphService)
|
|
) {
|
|
return undefined
|
|
}
|
|
try {
|
|
signal.throwIfAborted()
|
|
effectiveBrowserUsageLease?.signal.throwIfAborted()
|
|
return this.storeCapability({
|
|
requestId,
|
|
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
|
|
magicNotesAccess: effectiveMagicNotesAccess,
|
|
configAccess: effectiveConfigAccess,
|
|
obsidian: effectiveObsidian,
|
|
storyGraph: this.storyGraphService ? storyGraph : undefined,
|
|
browserConversationId: effectiveBrowserConversationId,
|
|
browserTabId: effectiveBrowserTabId,
|
|
browserUsageLease: effectiveBrowserUsageLease,
|
|
...(effectiveConfigAccess !== 'none'
|
|
? {
|
|
configWorkspacePath: config?.workspacePath
|
|
}
|
|
: {}),
|
|
signal,
|
|
customMcpServers: []
|
|
})
|
|
} catch (error) {
|
|
effectiveBrowserUsageLease?.release()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
bindImageTool(binding: ImageToolBinding, signal: AbortSignal, existingToken?: string): string {
|
|
signal.throwIfAborted()
|
|
if (existingToken) {
|
|
const capability = this.getCapability(existingToken)
|
|
if (capability.requestId !== binding.context.requestId) throw new Error('Image capability request does not match')
|
|
capability.imageToolBinding = binding
|
|
return existingToken
|
|
}
|
|
return this.storeCapability({
|
|
requestId: binding.context.requestId, libraryIds: [], magicNotesAccess: 'none', configAccess: 'none',
|
|
customMcpServers: [], signal, imageToolBinding: binding
|
|
})
|
|
}
|
|
|
|
grantCustomMcp(
|
|
requestId: string,
|
|
servers: readonly ResolvedMcpServer[],
|
|
signal: AbortSignal
|
|
): string | undefined {
|
|
if (servers.length === 0) {
|
|
return undefined
|
|
}
|
|
if (servers.length > MAX_CUSTOM_MCP_SERVERS) {
|
|
throw new Error(
|
|
`Agent Runtime 最多可加载 ${MAX_CUSTOM_MCP_SERVERS} 个 MCP Server`
|
|
)
|
|
}
|
|
if (
|
|
servers.some(
|
|
(server) =>
|
|
!server.enabled ||
|
|
server.assignments.length === 0
|
|
)
|
|
) {
|
|
throw new Error('Agent Runtime MCP 授权包含无效 Server')
|
|
}
|
|
return this.storeCapability({
|
|
requestId,
|
|
libraryIds: [],
|
|
magicNotesAccess: 'none',
|
|
configAccess: 'none',
|
|
signal,
|
|
customMcpServers: Object.freeze([...servers])
|
|
})
|
|
}
|
|
|
|
private storeCapability(
|
|
value: Omit<
|
|
Capability,
|
|
| 'references'
|
|
| 'removeAbortListener'
|
|
| 'brokerController'
|
|
| 'customMcpConnections'
|
|
>
|
|
): string {
|
|
value.signal.throwIfAborted()
|
|
const token = randomBytes(32).toString('base64url')
|
|
const brokerController = new AbortController()
|
|
const abort = (): void => {
|
|
this.revoke(token)
|
|
}
|
|
// Capabilities live with the request; elapsed time must not interrupt long runs.
|
|
value.signal.addEventListener('abort', abort, { once: true })
|
|
this.capabilities.set(token, {
|
|
...value,
|
|
brokerController,
|
|
references: new Map(),
|
|
removeAbortListener: () => {
|
|
value.signal.removeEventListener('abort', abort)
|
|
}
|
|
})
|
|
return token
|
|
}
|
|
|
|
revoke(token: string | undefined): void {
|
|
if (!token) {
|
|
return
|
|
}
|
|
const capability = this.capabilities.get(token)
|
|
if (!capability) {
|
|
return
|
|
}
|
|
capability.removeAbortListener()
|
|
this.capabilities.delete(token)
|
|
capability.brokerController.abort(
|
|
new Error('MCP capability was revoked')
|
|
)
|
|
capability.browserUsageLease?.release()
|
|
for (const session of this.downstreamMcpSessions.values()) {
|
|
if (session.token === token) {
|
|
void this.closeDownstreamMcpSession(session)
|
|
}
|
|
}
|
|
if (capability.configAccess !== 'none') {
|
|
this.configService?.revokeRequest(capability.requestId)
|
|
}
|
|
const cleanup = this.closeCustomMcpConnections(capability)
|
|
this.customMcpCleanups.add(cleanup)
|
|
void cleanup.finally(() => {
|
|
this.customMcpCleanups.delete(cleanup)
|
|
})
|
|
}
|
|
|
|
revokeObsidianCapabilities(): void {
|
|
for (const [token, capability] of this.capabilities) {
|
|
if (capability.obsidian) {
|
|
this.revoke(token)
|
|
}
|
|
}
|
|
}
|
|
|
|
private closeDownstreamMcpSession(
|
|
session: DownstreamMcpSession
|
|
): Promise<void> {
|
|
if (session.closing) {
|
|
return session.closing
|
|
}
|
|
this.downstreamMcpSessions.delete(session.registryKey)
|
|
const cleanup = session.mcp
|
|
.close()
|
|
.catch(() => undefined)
|
|
.finally(() => {
|
|
this.downstreamMcpCleanups.delete(cleanup)
|
|
})
|
|
session.closing = cleanup
|
|
this.downstreamMcpCleanups.add(cleanup)
|
|
return cleanup
|
|
}
|
|
|
|
drainReferences(
|
|
token: string | undefined
|
|
): KnowledgeSearchReference[] {
|
|
if (!token) {
|
|
return []
|
|
}
|
|
const capability = this.capabilities.get(token)
|
|
if (!capability) {
|
|
return []
|
|
}
|
|
const references = [...capability.references.values()]
|
|
capability.references.clear()
|
|
return references
|
|
}
|
|
|
|
private getCapability(token: string): Capability {
|
|
const capability = this.capabilities.get(token)
|
|
if (!capability || capability.signal.aborted) {
|
|
this.revoke(token)
|
|
throw new Error('Tool authorization is unavailable: the request has ended or the token is invalid')
|
|
}
|
|
return capability
|
|
}
|
|
|
|
async search(
|
|
token: string,
|
|
input: unknown,
|
|
signal?: AbortSignal
|
|
): Promise<KnowledgeSearchReference[]> {
|
|
const capability = this.getCapability(token)
|
|
const { query, limit } = knowledgeSearchTool.inputSchema.parse(
|
|
input
|
|
)
|
|
const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
|
|
effectiveSignal.throwIfAborted()
|
|
const libraries = await this.knowledgeService.database.listKnowledgeBases(500)
|
|
const libraryNames = new Map(
|
|
libraries.map((library) => [library.id, library.name])
|
|
)
|
|
const outcomes = await this.knowledgeService.retrieveMany(
|
|
capability.libraryIds,
|
|
query,
|
|
effectiveSignal
|
|
)
|
|
effectiveSignal.throwIfAborted()
|
|
const warnings = outcomes.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
|
|
if (outcomes.length && warnings.length === outcomes.length) throw new Error(warnings.join('; '))
|
|
const results = outcomes.flatMap(item => item.response.results.map(result => ({ knowledgeBaseId: item.knowledgeBaseId, result })))
|
|
if (!results.length && warnings.length) throw new Error(warnings.join('; '))
|
|
const references: KnowledgeSearchReference[] = []
|
|
const seen = new Set<string>()
|
|
for (const { knowledgeBaseId, result } of results.sort(
|
|
(left, right) => left.result.rank - right.result.rank
|
|
)) {
|
|
if (references.length >= limit) {
|
|
break
|
|
}
|
|
const reference: KnowledgeSearchReference = {
|
|
...toKnowledgeReference(result, libraryNames.get(knowledgeBaseId) ?? '知识库'),
|
|
...(warnings.length ? { warnings } : {})
|
|
}
|
|
const key = referenceKey(reference)
|
|
if (seen.has(key)) {
|
|
continue
|
|
}
|
|
seen.add(key)
|
|
const candidate = [...references, reference]
|
|
if (
|
|
Buffer.byteLength(JSON.stringify({ references: candidate })) >
|
|
MAX_RESULT_BYTES
|
|
) {
|
|
break
|
|
}
|
|
references.push(reference)
|
|
capability.references.set(key, reference)
|
|
}
|
|
return references
|
|
}
|
|
|
|
async listLibraries(
|
|
token: string,
|
|
input: unknown = {}
|
|
): Promise<KnowledgeLibraryListItem[]> {
|
|
const capability = this.getCapability(token)
|
|
knowledgeListTool.inputSchema.parse(input)
|
|
const librariesById = new Map(
|
|
(await this.knowledgeService.database
|
|
.listKnowledgeBases(500))
|
|
.map((library) => [library.id, library])
|
|
)
|
|
const libraries: KnowledgeLibraryListItem[] = []
|
|
for (const libraryId of capability.libraryIds) {
|
|
const library = librariesById.get(libraryId)
|
|
if (!library) {
|
|
continue
|
|
}
|
|
const item: KnowledgeLibraryListItem = {
|
|
id: library.id,
|
|
name: library.name.slice(0, 500),
|
|
...(library.description
|
|
? { description: library.description.slice(0, 4_000) }
|
|
: {})
|
|
}
|
|
const candidate = [...libraries, item]
|
|
if (
|
|
Buffer.byteLength(JSON.stringify({ libraries: candidate })) >
|
|
MAX_RESULT_BYTES
|
|
) {
|
|
break
|
|
}
|
|
libraries.push(item)
|
|
}
|
|
return libraries
|
|
}
|
|
|
|
getAvailableToolNames(token: string): GoodBuddyBuiltinToolName[] {
|
|
const capability = this.getCapability(token)
|
|
return [
|
|
...(capability.storyGraph ? storyGraphToolNames : []),
|
|
...(capability.imageToolBinding ? ['generate_image' as const] : []),
|
|
...(capability.imageToolBinding?.save ? ['save_image' as const] : []),
|
|
...(capability.libraryIds.length > 0
|
|
? knowledgeToolNames
|
|
: []),
|
|
...(capability.magicNotesAccess !== 'none'
|
|
? magicNoteReadToolNames
|
|
: []),
|
|
...(capability.magicNotesAccess === 'write'
|
|
? magicNoteWriteToolNames
|
|
: []),
|
|
...(capability.configAccess !== 'none'
|
|
? goodbuddyConfigReadToolNames
|
|
: []),
|
|
...(capability.configAccess === 'write'
|
|
? goodbuddyConfigWriteToolNames
|
|
: []),
|
|
...(capability.obsidian ? obsidianReadToolNames : []),
|
|
...(capability.obsidian?.access === 'write' ? obsidianWriteToolNames : []),
|
|
...(capability.browserConversationId && capability.browserTabId
|
|
? browserToolNames
|
|
: [])
|
|
]
|
|
}
|
|
|
|
private createCustomMcpBindings(
|
|
client: Client,
|
|
server: ResolvedMcpServer,
|
|
tools: Awaited<ReturnType<Client['listTools']>>['tools']
|
|
): CustomMcpBinding[] {
|
|
if (tools.length > MAX_CUSTOM_MCP_TOOLS) {
|
|
throw new Error(
|
|
`MCP Server「${server.name}」提供的工具数量超过安全限制`
|
|
)
|
|
}
|
|
return tools.map((tool) => {
|
|
if (!isValidMcpToolName(tool.name)) {
|
|
throw new Error(
|
|
`MCP Server「${server.name}」返回了无效工具名称`
|
|
)
|
|
}
|
|
return {
|
|
client,
|
|
server,
|
|
originalName: tool.name,
|
|
taskSupport: tool.execution?.taskSupport,
|
|
outputValidator: tool.outputSchema
|
|
? customMcpJsonSchemaValidator.getValidator<
|
|
Record<string, unknown>
|
|
>(normalizeMcpToolSchema(tool.outputSchema))
|
|
: undefined,
|
|
exposedTool: {
|
|
name: createGatewayMcpToolName(server.id, tool.name),
|
|
title: `${server.name} / ${tool.name}`.slice(0, 200),
|
|
description: [
|
|
`GoodBuddy 代理的自定义 MCP Server「${server.name}」工具。`,
|
|
tool.description
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.slice(0, 1_000),
|
|
inputSchema: normalizeMcpToolSchema(tool.inputSchema),
|
|
annotations: tool.annotations
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
private async listAllCustomMcpTools(
|
|
client: Client,
|
|
server: ResolvedMcpServer,
|
|
signal: AbortSignal
|
|
): Promise<Awaited<ReturnType<Client['listTools']>>['tools']> {
|
|
return listAllMcpTools(client, server.name, signal, {
|
|
maximumTools: MAX_CUSTOM_MCP_TOOLS,
|
|
pageTimeoutMs: CUSTOM_MCP_TIMEOUT_MS,
|
|
totalTimeoutMs: CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS
|
|
})
|
|
}
|
|
|
|
private async publishCustomMcpToolListChanged(
|
|
capability: Capability
|
|
): Promise<void> {
|
|
const token = [...this.capabilities.entries()].find(
|
|
([, value]) => value === capability
|
|
)?.[0]
|
|
if (!token) {
|
|
return
|
|
}
|
|
const sessions = [...this.downstreamMcpSessions.values()].filter(
|
|
(session) =>
|
|
session.token === token &&
|
|
session.initialized &&
|
|
session.listedTools
|
|
)
|
|
await Promise.allSettled(
|
|
sessions.map((session) => session.mcp.sendToolListChanged())
|
|
)
|
|
}
|
|
|
|
private scheduleDynamicToolsRefresh(
|
|
capability: Capability,
|
|
connection: CustomMcpConnection
|
|
): void {
|
|
void this.refreshDynamicTools(capability, connection).catch(
|
|
() => undefined
|
|
)
|
|
}
|
|
|
|
private refreshDynamicTools(
|
|
capability: Capability,
|
|
connection: CustomMcpConnection,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
if (connection.dynamicToolsRefresh) {
|
|
return connection.dynamicToolsRefresh
|
|
}
|
|
const effectiveSignal = signal
|
|
? AbortSignal.any([
|
|
signal,
|
|
capability.signal,
|
|
capability.brokerController.signal
|
|
])
|
|
: AbortSignal.any([
|
|
capability.signal,
|
|
capability.brokerController.signal
|
|
])
|
|
const changeVersion = connection.dynamicToolsChangeVersion
|
|
const startedAt = performance.now()
|
|
let refreshSucceeded = false
|
|
const refresh = (async () => {
|
|
try {
|
|
const tools = await this.listAllCustomMcpTools(
|
|
connection.client,
|
|
connection.server,
|
|
effectiveSignal
|
|
)
|
|
const bindings = this.createCustomMcpBindings(
|
|
connection.client,
|
|
connection.server,
|
|
tools
|
|
)
|
|
connection.bindings = bindings
|
|
connection.dynamicToolsChanged =
|
|
connection.dynamicToolsChangeVersion !== changeVersion
|
|
refreshSucceeded = true
|
|
await this.publishCustomMcpToolListChanged(capability)
|
|
} catch (error) {
|
|
connection.bindings = []
|
|
connection.dynamicToolsChanged = true
|
|
if (effectiveSignal.aborted) {
|
|
throw effectiveSignal.reason
|
|
}
|
|
this.observeMcpFailure({
|
|
phase: 'tool-discovery', category: 'handler-failure', kind: 'custom',
|
|
elapsedMs: Math.round(performance.now() - startedAt)
|
|
})
|
|
throw new Error(
|
|
`无法刷新 MCP Server「${connection.server.name}」的工具`,
|
|
{ cause: error }
|
|
)
|
|
}
|
|
})()
|
|
connection.dynamicToolsRefresh = refresh
|
|
void refresh.finally(() => {
|
|
connection.dynamicToolsRefresh = undefined
|
|
if (
|
|
refreshSucceeded &&
|
|
connection.dynamicToolsChanged &&
|
|
!capability.signal.aborted &&
|
|
!capability.brokerController.signal.aborted
|
|
) {
|
|
this.scheduleDynamicToolsRefresh(capability, connection)
|
|
}
|
|
}).catch(() => undefined)
|
|
return refresh
|
|
}
|
|
|
|
private async connectCustomMcpServer(
|
|
capability: Capability,
|
|
server: ResolvedMcpServer
|
|
): Promise<CustomMcpConnection> {
|
|
const startedAt = performance.now()
|
|
let connection: CustomMcpConnection | undefined
|
|
let dynamicToolsChangeVersion = 0
|
|
const client = new Client(
|
|
{
|
|
name: 'goodbuddy-main-mcp-broker',
|
|
version: '1.0.0'
|
|
},
|
|
server.allowDynamicTools
|
|
? {
|
|
listChanged: {
|
|
tools: {
|
|
autoRefresh: false,
|
|
debounceMs: 0,
|
|
onChanged: (error) => {
|
|
if (!error) {
|
|
dynamicToolsChangeVersion += 1
|
|
if (connection) {
|
|
connection.dynamicToolsChangeVersion =
|
|
dynamicToolsChangeVersion
|
|
connection.dynamicToolsChanged = true
|
|
this.scheduleDynamicToolsRefresh(
|
|
capability,
|
|
connection
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
: undefined
|
|
)
|
|
const signal = AbortSignal.any([
|
|
capability.signal,
|
|
capability.brokerController.signal
|
|
])
|
|
try {
|
|
await this.launchEnvironmentReady?.()
|
|
await client.connect(
|
|
createMcpTransport(server, this.launchEnvironmentProvider),
|
|
{
|
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
|
signal
|
|
}
|
|
)
|
|
const listedAtChangeVersion = dynamicToolsChangeVersion
|
|
const tools = await this.listAllCustomMcpTools(
|
|
client,
|
|
server,
|
|
signal
|
|
)
|
|
connection = {
|
|
client,
|
|
server,
|
|
bindings: this.createCustomMcpBindings(
|
|
client,
|
|
server,
|
|
tools
|
|
),
|
|
dynamicToolsSupported:
|
|
server.allowDynamicTools &&
|
|
client.getServerCapabilities()?.tools?.listChanged === true,
|
|
dynamicToolsChanged:
|
|
dynamicToolsChangeVersion !== listedAtChangeVersion,
|
|
dynamicToolsChangeVersion
|
|
}
|
|
if (connection.dynamicToolsChanged) {
|
|
this.scheduleDynamicToolsRefresh(capability, connection)
|
|
}
|
|
return connection
|
|
} catch (error) {
|
|
await client.close().catch(() => undefined)
|
|
signal.throwIfAborted()
|
|
this.observeMcpFailure({
|
|
phase: 'tool-discovery', category: 'handler-failure', kind: 'custom',
|
|
elapsedMs: Math.round(performance.now() - startedAt)
|
|
})
|
|
throw new Error(
|
|
`无法加载 MCP Server「${server.name}」的工具`,
|
|
{ cause: error }
|
|
)
|
|
}
|
|
}
|
|
|
|
private async getCustomMcpBindings(
|
|
token: string,
|
|
signal?: AbortSignal,
|
|
refreshDynamic = true
|
|
): Promise<Map<string, CustomMcpBinding>> {
|
|
const capability = this.getCapability(token)
|
|
const effectiveSignal = AbortSignal.any([
|
|
capability.signal,
|
|
capability.brokerController.signal,
|
|
...(signal ? [signal] : [])
|
|
])
|
|
effectiveSignal.throwIfAborted()
|
|
if (capability.customMcpServers.length === 0) {
|
|
return new Map()
|
|
}
|
|
if (!capability.customMcpConnections) {
|
|
capability.customMcpConnections = (async () => {
|
|
const results = await Promise.allSettled(
|
|
capability.customMcpServers.map((server) =>
|
|
this.connectCustomMcpServer(capability, server)
|
|
)
|
|
)
|
|
const connections = results.flatMap((result) =>
|
|
result.status === 'fulfilled' ? [result.value] : []
|
|
)
|
|
return connections
|
|
})()
|
|
}
|
|
let connections: CustomMcpConnection[]
|
|
try {
|
|
connections = await capability.customMcpConnections
|
|
} catch (error) {
|
|
capability.customMcpConnections = undefined
|
|
throw error
|
|
}
|
|
effectiveSignal.throwIfAborted()
|
|
if (refreshDynamic) {
|
|
for (const connection of connections) {
|
|
if (
|
|
!connection.dynamicToolsSupported ||
|
|
(!connection.dynamicToolsChanged &&
|
|
!connection.dynamicToolsRefresh)
|
|
) {
|
|
continue
|
|
}
|
|
try {
|
|
await this.refreshDynamicTools(
|
|
capability,
|
|
connection,
|
|
signal
|
|
)
|
|
} catch {
|
|
// Failed catalogs are cleared by refresh; healthy servers remain usable.
|
|
effectiveSignal.throwIfAborted()
|
|
}
|
|
}
|
|
}
|
|
effectiveSignal.throwIfAborted()
|
|
const bindings = new Map<string, CustomMcpBinding>()
|
|
for (const connection of connections) {
|
|
for (const binding of connection.bindings) {
|
|
if (bindings.size >= MAX_CUSTOM_MCP_TOOLS) {
|
|
throw new Error('Agent Runtime MCP 工具总数超过 100 个安全限制')
|
|
}
|
|
if (bindings.has(binding.exposedTool.name)) {
|
|
throw new Error('Agent Runtime MCP 工具名称发生冲突')
|
|
}
|
|
bindings.set(binding.exposedTool.name, binding)
|
|
}
|
|
}
|
|
return bindings
|
|
}
|
|
|
|
async prepareCustomMcpTools(
|
|
token: string,
|
|
signal?: AbortSignal
|
|
): Promise<Tool[]> {
|
|
return [
|
|
...(await this.getCustomMcpBindings(token, signal)).values()
|
|
].map((binding) => binding.exposedTool)
|
|
}
|
|
|
|
private async callCustomMcpTool(
|
|
token: string,
|
|
binding: CustomMcpBinding,
|
|
argumentsValue: Record<string, unknown>,
|
|
signal: AbortSignal
|
|
): Promise<CallToolResult> {
|
|
const capability = this.getCapability(token)
|
|
const effectiveSignal = AbortSignal.any([
|
|
signal,
|
|
capability.signal,
|
|
capability.brokerController.signal
|
|
])
|
|
const params = {
|
|
name: binding.originalName,
|
|
arguments: argumentsValue
|
|
}
|
|
const options = {
|
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
|
signal: effectiveSignal,
|
|
onprogress: () => undefined,
|
|
resetTimeoutOnProgress: true,
|
|
maxTotalTimeout: CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS
|
|
}
|
|
try {
|
|
if (binding.taskSupport !== 'required') {
|
|
return this.validateCustomMcpResult(
|
|
binding,
|
|
await binding.client.callTool(params, undefined, options)
|
|
)
|
|
}
|
|
let taskId: string | undefined
|
|
try {
|
|
for await (const message of binding.client.experimental.tasks.callToolStream(
|
|
params,
|
|
undefined,
|
|
{
|
|
...options,
|
|
task: {}
|
|
}
|
|
)) {
|
|
if (
|
|
(message.type === 'taskCreated' ||
|
|
message.type === 'taskStatus') &&
|
|
typeof message.task.taskId === 'string'
|
|
) {
|
|
taskId = message.task.taskId
|
|
} else if (message.type === 'result') {
|
|
return this.validateCustomMcpResult(
|
|
binding,
|
|
message.result
|
|
)
|
|
} else if (message.type === 'error') {
|
|
throw message.error
|
|
}
|
|
}
|
|
throw new Error('MCP 任务工具未返回最终结果')
|
|
} catch (error) {
|
|
if (taskId) {
|
|
await binding.client.experimental.tasks
|
|
.cancelTask(taskId, {
|
|
timeout: CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS,
|
|
maxTotalTimeout: CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS
|
|
})
|
|
.catch(() => undefined)
|
|
}
|
|
throw error
|
|
}
|
|
} catch (error) {
|
|
if (effectiveSignal.aborted) {
|
|
throw effectiveSignal.reason
|
|
}
|
|
throw new Error(
|
|
`MCP Server「${binding.server.name}」工具调用失败`,
|
|
{ cause: error }
|
|
)
|
|
}
|
|
}
|
|
|
|
private validateCustomMcpResult(
|
|
binding: CustomMcpBinding,
|
|
result: unknown
|
|
): CallToolResult {
|
|
const bounded = ensureBoundedCustomMcpResult(result)
|
|
if (!binding.outputValidator) {
|
|
return bounded
|
|
}
|
|
if (!bounded.structuredContent) {
|
|
if (!bounded.isError) {
|
|
throw new Error(
|
|
`MCP 工具「${binding.originalName}」未返回结构化结果`
|
|
)
|
|
}
|
|
return bounded
|
|
}
|
|
const validation = binding.outputValidator(
|
|
bounded.structuredContent
|
|
)
|
|
if (!validation.valid) {
|
|
throw new Error(
|
|
`MCP 工具「${binding.originalName}」返回结果不符合声明结构:${validation.errorMessage.slice(0, 500)}`
|
|
)
|
|
}
|
|
return bounded
|
|
}
|
|
|
|
private async closeCustomMcpConnections(
|
|
capability: Capability
|
|
): Promise<void> {
|
|
const pending = capability.customMcpConnections
|
|
capability.customMcpConnections = undefined
|
|
if (!pending) {
|
|
return
|
|
}
|
|
const connections = await pending.catch(() => [])
|
|
await Promise.allSettled(
|
|
connections.map((connection) => connection.client.close())
|
|
)
|
|
}
|
|
|
|
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'>
|
|
): {
|
|
capability: Capability
|
|
service: GoodBuddyConfigService
|
|
workspacePath: string
|
|
} {
|
|
const capability = this.getCapability(token)
|
|
const allowed =
|
|
capability.configAccess === 'write' ||
|
|
(requiredAccess === 'read' && capability.configAccess === 'read')
|
|
if (
|
|
!allowed ||
|
|
!this.configService ||
|
|
!capability.configWorkspacePath
|
|
) {
|
|
throw new Error('GoodBuddy configuration capability is unavailable')
|
|
}
|
|
return {
|
|
capability,
|
|
service: this.configService,
|
|
workspacePath: capability.configWorkspacePath
|
|
}
|
|
}
|
|
|
|
async callGoodBuddyConfigTool(
|
|
token: string,
|
|
name: GoodBuddyConfigToolName,
|
|
input: unknown,
|
|
signal?: AbortSignal
|
|
): Promise<Record<string, unknown>> {
|
|
const requiredAccess = scopedDataToolByName.get(name)!.access
|
|
const { capability, service, workspacePath } =
|
|
this.requireConfig(token, requiredAccess)
|
|
const effectiveSignal = signal
|
|
? AbortSignal.any([signal, capability.signal])
|
|
: capability.signal
|
|
effectiveSignal.throwIfAborted()
|
|
switch (name) {
|
|
case 'goodbuddy_config_capabilities':
|
|
return { capabilities: service.getCapabilities(input) }
|
|
case 'goodbuddy_config_get':
|
|
return { config: await service.getSnapshot(input) }
|
|
case 'goodbuddy_config_plan':
|
|
return {
|
|
plan: await service.plan(
|
|
capability.requestId,
|
|
workspacePath,
|
|
input
|
|
)
|
|
}
|
|
case 'goodbuddy_config_apply':
|
|
return {
|
|
result: await service.apply(
|
|
capability.requestId,
|
|
input,
|
|
effectiveSignal
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private requireMagicNotes(
|
|
token: string,
|
|
requiredAccess: Exclude<MagicNotesCapabilityAccess, 'none'>
|
|
): { capability: Capability; database: MagicNotesDatabase } {
|
|
const capability = this.getCapability(token)
|
|
const allowed =
|
|
capability.magicNotesAccess === 'write' ||
|
|
(requiredAccess === 'read' &&
|
|
capability.magicNotesAccess === 'read')
|
|
if (!allowed || !this.magicNotesDatabase) {
|
|
throw new Error('Magic Notes capability is unavailable')
|
|
}
|
|
return { capability, database: this.magicNotesDatabase }
|
|
}
|
|
|
|
async listMagicNotes(
|
|
token: string,
|
|
input: unknown = {}
|
|
): Promise<MagicNoteToolSummary[]> {
|
|
const { database } = this.requireMagicNotes(token, magicNoteListTool.access)
|
|
const { limit, tags } = magicNoteListTool.inputSchema.parse(input)
|
|
const filter = tags ? magicNoteTagListSchema.parse(tags) : undefined
|
|
const notes: MagicNoteToolSummary[] = []
|
|
const listed = await database.listMagicNotes(filter?.length ? { tags: filter } : {})
|
|
for (const note of listed.slice(0, limit)) {
|
|
const item = toMagicNoteToolSummary(note)
|
|
if (
|
|
Buffer.byteLength(JSON.stringify({ notes: [...notes, item] })) >
|
|
MAX_RESULT_BYTES
|
|
) {
|
|
break
|
|
}
|
|
notes.push(item)
|
|
}
|
|
return notes
|
|
}
|
|
|
|
async getMagicNote(token: string, input: unknown): Promise<MagicNoteToolDetail> {
|
|
const { database } = this.requireMagicNotes(token, magicNoteGetTool.access)
|
|
const { noteId } = magicNoteGetTool.inputSchema.parse(input)
|
|
const detail = await database.getMagicNote(noteId)
|
|
const result: MagicNoteToolDetail = {
|
|
...toMagicNoteToolSummary(detail),
|
|
entries: [],
|
|
truncated: false
|
|
}
|
|
for (const entry of detail.entries) {
|
|
const item: MagicNoteToolEntry = {
|
|
id: entry.id,
|
|
content: entry.plainText.slice(0, 12_000),
|
|
contentKind: entry.content.version === 2 ? 'paged-canvas' : 'rich-text',
|
|
contentVersion: entry.content.version,
|
|
plainTextEditable: entry.content.version === 1,
|
|
revision: entry.revision,
|
|
createdAt: entry.createdAt,
|
|
updatedAt: entry.updatedAt
|
|
}
|
|
if (
|
|
Buffer.byteLength(
|
|
JSON.stringify({
|
|
note: { ...result, entries: [...result.entries, item] }
|
|
})
|
|
) > MAX_RESULT_BYTES
|
|
) {
|
|
result.truncated = true
|
|
break
|
|
}
|
|
result.entries.push(item)
|
|
}
|
|
if (result.entries.length < detail.entries.length) {
|
|
result.truncated = true
|
|
}
|
|
return result
|
|
}
|
|
|
|
async searchMagicNotes(
|
|
token: string,
|
|
input: unknown,
|
|
signal?: AbortSignal
|
|
): Promise<MagicNoteSearchResult[]> {
|
|
const { capability, database } = this.requireMagicNotes(token, magicNoteSearchTool.access)
|
|
const { query, limit } = magicNoteSearchTool.inputSchema.parse(input)
|
|
const effectiveSignal = signal
|
|
? AbortSignal.any([signal, capability.signal])
|
|
: capability.signal
|
|
effectiveSignal.throwIfAborted()
|
|
const notes = await database.searchMagicNotes(query, limit)
|
|
effectiveSignal.throwIfAborted()
|
|
const bounded: MagicNoteSearchResult[] = []
|
|
for (const note of notes) {
|
|
const candidate = [...bounded, note]
|
|
if (
|
|
Buffer.byteLength(JSON.stringify({ notes: candidate })) >
|
|
MAX_RESULT_BYTES
|
|
) {
|
|
break
|
|
}
|
|
bounded.push(note)
|
|
}
|
|
return bounded
|
|
}
|
|
|
|
async createMagicNote(token: string, input: unknown): Promise<MagicNoteToolDetail> {
|
|
const { database } = this.requireMagicNotes(token, magicNoteCreateTool.access)
|
|
const parsed = magicNoteCreateTool.inputSchema.parse(input)
|
|
const content =
|
|
typeof parsed.content === 'string'
|
|
? textContent(parsed.content)
|
|
: undefined
|
|
return this.getMagicNote(
|
|
token,
|
|
{
|
|
noteId: (await database.createMagicNote({
|
|
title: parsed.title,
|
|
...(content ? { content } : {}),
|
|
...(parsed.tags ? { tags: magicNoteTagListSchema.parse(parsed.tags) } : {})
|
|
})).id
|
|
}
|
|
)
|
|
}
|
|
|
|
async updateMagicNote(token: string, input: unknown): Promise<MagicNoteToolDetail> {
|
|
const { database } = this.requireMagicNotes(token, magicNoteUpdateTool.access)
|
|
const parsed = magicNoteUpdateTool.inputSchema.parse(input)
|
|
await database.updateMagicNote({
|
|
...parsed,
|
|
...(parsed.tags ? { tags: magicNoteTagListSchema.parse(parsed.tags) } : {})
|
|
})
|
|
return this.getMagicNote(token, { noteId: parsed.noteId })
|
|
}
|
|
|
|
async createMagicNoteEntry(
|
|
token: string,
|
|
input: unknown
|
|
): Promise<MagicNoteToolDetail> {
|
|
const { database } = this.requireMagicNotes(token, magicNoteEntryCreateTool.access)
|
|
const parsed = magicNoteEntryCreateTool.inputSchema.parse(input)
|
|
const content = textContent(parsed.content)
|
|
await database.createMagicNoteEntry({
|
|
noteId: parsed.noteId,
|
|
content,
|
|
plainText: magicNotePlainText(content)
|
|
})
|
|
return this.getMagicNote(token, { noteId: parsed.noteId })
|
|
}
|
|
|
|
async updateMagicNoteEntry(
|
|
token: string,
|
|
input: unknown
|
|
): Promise<MagicNoteToolDetail> {
|
|
const { database } = this.requireMagicNotes(token, magicNoteEntryUpdateTool.access)
|
|
const parsed = magicNoteEntryUpdateTool.inputSchema.parse(input)
|
|
if ((await database.getMagicNoteEntry(parsed.entryId)).content.version === 2) {
|
|
throw new Error('画布记录不能通过纯文本工具覆盖,请在画布编辑器中修改,或追加新的纯文本记录')
|
|
}
|
|
const content = textContent(parsed.content)
|
|
const detail = await database.updateMagicNoteEntry({
|
|
entryId: parsed.entryId,
|
|
content,
|
|
plainText: magicNotePlainText(content),
|
|
expectedRevision: parsed.expectedRevision
|
|
})
|
|
return this.getMagicNote(token, { noteId: detail.id })
|
|
}
|
|
|
|
async deleteMagicNoteEntry(
|
|
token: string,
|
|
input: unknown
|
|
): Promise<MagicNoteToolDetail> {
|
|
const { database } = this.requireMagicNotes(token, magicNoteEntryDeleteTool.access)
|
|
const parsed = magicNoteEntryDeleteTool.inputSchema.parse(input)
|
|
const entry = await database.getMagicNoteEntry(parsed.entryId)
|
|
if (entry.revision !== parsed.expectedRevision) {
|
|
throw new Error('记录已被更新,请重新读取后重试')
|
|
}
|
|
const detail = await database.deleteMagicNoteEntry(parsed.entryId)
|
|
return this.getMagicNote(token, { noteId: detail.id })
|
|
}
|
|
|
|
async deleteMagicNote(
|
|
token: string,
|
|
input: unknown
|
|
): Promise<{ deleted: true; noteId: string }> {
|
|
const { database } = this.requireMagicNotes(token, magicNoteDeleteTool.access)
|
|
const parsed = magicNoteDeleteTool.inputSchema.parse(input)
|
|
const note = await database.getMagicNote(parsed.noteId)
|
|
if (note.revision !== parsed.expectedRevision) {
|
|
throw new Error('笔记已被更新,请重新读取后重试')
|
|
}
|
|
await database.deleteMagicNote(parsed.noteId)
|
|
return { deleted: true, noteId: parsed.noteId }
|
|
}
|
|
|
|
async isStoryGraphAvailable(token: string): Promise<boolean> {
|
|
const binding = this.getCapability(token).storyGraph
|
|
return Boolean(binding && this.storyGraphService && await this.storyGraphService.available(binding))
|
|
}
|
|
|
|
bindRemoteStoryGraph(token: string): StoryGraphRemoteBinding | undefined {
|
|
if (!this.getCapability(token).storyGraph) return undefined
|
|
return { available: () => this.isStoryGraphAvailable(token), call: (name, input, signal) => this.callStoryGraphTool(token, name, input, signal) }
|
|
}
|
|
|
|
async callStoryGraphTool(token: string, name: StoryGraphToolName, input: unknown, signal?: AbortSignal): Promise<Record<string, unknown>> {
|
|
const capability = this.getCapability(token)
|
|
const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
|
|
effectiveSignal.throwIfAborted()
|
|
if (!await this.isStoryGraphAvailable(token)) throw new Error('story_graph_unavailable: Supervisor or runtime capability is disabled')
|
|
effectiveSignal.throwIfAborted()
|
|
const result = await this.storyGraphService!.read(name, input, capability.storyGraph!.projectId, effectiveSignal)
|
|
effectiveSignal.throwIfAborted()
|
|
if (!await this.isStoryGraphAvailable(token)) throw new Error('story_graph_unavailable: Supervisor or runtime capability is disabled')
|
|
effectiveSignal.throwIfAborted()
|
|
return result
|
|
}
|
|
|
|
private async callScopedTool(
|
|
token: string,
|
|
name: ScopedDataToolName,
|
|
input: unknown,
|
|
signal?: AbortSignal
|
|
): Promise<Record<string, unknown>> {
|
|
switch (name) {
|
|
case 'story_graph_search':
|
|
case 'story_graph_get_context':
|
|
case 'story_graph_read_source':
|
|
return this.callStoryGraphTool(token, name, input, signal)
|
|
case 'knowledge_list':
|
|
return { libraries: await this.listLibraries(token, input) }
|
|
case 'knowledge_search':
|
|
return { references: await this.search(token, input, signal) }
|
|
case 'note_list':
|
|
return { notes: await this.listMagicNotes(token, input) }
|
|
case 'note_get':
|
|
return { note: await this.getMagicNote(token, input) }
|
|
case 'note_search':
|
|
return { notes: await this.searchMagicNotes(token, input, signal) }
|
|
case 'note_create':
|
|
return { note: await this.createMagicNote(token, input) }
|
|
case 'note_update':
|
|
return { note: await this.updateMagicNote(token, input) }
|
|
case 'note_entry_create':
|
|
return { note: await this.createMagicNoteEntry(token, input) }
|
|
case 'note_entry_update':
|
|
return { note: await this.updateMagicNoteEntry(token, input) }
|
|
case 'note_entry_delete':
|
|
return { note: await this.deleteMagicNoteEntry(token, input) }
|
|
case 'note_delete':
|
|
return this.deleteMagicNote(token, input)
|
|
case 'goodbuddy_config_capabilities':
|
|
case 'goodbuddy_config_get':
|
|
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)
|
|
}
|
|
}
|
|
|
|
private createDownstreamMcpSession(
|
|
token: string,
|
|
availableTools: ReadonlySet<GoodBuddyBuiltinToolName>
|
|
): DownstreamMcpSession {
|
|
const mcp = new McpProtocolServer(
|
|
{
|
|
name: 'goodbuddy-request-scoped-capabilities',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {
|
|
listChanged: true
|
|
}
|
|
}
|
|
}
|
|
)
|
|
const transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => randomBytes(32).toString('base64url'),
|
|
onsessioninitialized: (sessionId) => {
|
|
this.downstreamMcpSessions.delete(session.registryKey)
|
|
session.id = sessionId
|
|
session.registryKey = sessionId
|
|
this.downstreamMcpSessions.set(sessionId, session)
|
|
},
|
|
onsessionclosed: (sessionId) => {
|
|
this.downstreamMcpSessions.delete(sessionId)
|
|
}
|
|
})
|
|
const session: DownstreamMcpSession = {
|
|
registryKey: randomBytes(32).toString('base64url'),
|
|
token,
|
|
mcp,
|
|
transport,
|
|
initialized: false,
|
|
listedTools: false
|
|
}
|
|
transport.onclose = () => {
|
|
this.downstreamMcpSessions.delete(session.registryKey)
|
|
}
|
|
mcp.oninitialized = () => {
|
|
session.initialized = true
|
|
}
|
|
mcp.setRequestHandler(
|
|
ListToolsRequestSchema,
|
|
async (_request, extra) => {
|
|
const startedAt = performance.now()
|
|
try {
|
|
const customBindings = await this.getCustomMcpBindings(
|
|
token,
|
|
extra.signal
|
|
)
|
|
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
|
|
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
|
|
(name): Tool[] => {
|
|
const definition = scopedDataToolByName.get(
|
|
name as ScopedDataToolName
|
|
)
|
|
if (!definition) {
|
|
return []
|
|
}
|
|
const inputSchema = z.toJSONSchema(
|
|
definition.inputSchema,
|
|
{ target: 'draft-7' }
|
|
) as Tool['inputSchema'] & { $schema?: string }
|
|
Reflect.deleteProperty(inputSchema, '$schema')
|
|
return [
|
|
{
|
|
name,
|
|
title: definition.title,
|
|
description: definition.description,
|
|
inputSchema,
|
|
annotations: {
|
|
readOnlyHint: definition.access === 'read',
|
|
destructiveHint:
|
|
name === 'goodbuddy_config_apply' ||
|
|
name === 'note_delete' ||
|
|
name === 'note_entry_delete'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
const capability = this.getCapability(token)
|
|
const imageTool = await imageToolDefinition(capability.imageToolBinding)
|
|
const imageSaveTool = await imageSaveToolDefinition(capability.imageToolBinding)
|
|
const browserTools = capability.browserConversationId && capability.browserTabId
|
|
? new BrowserModelTools({
|
|
service: this.browserService!,
|
|
conversationId: capability.browserConversationId,
|
|
browserTabId: capability.browserTabId
|
|
})
|
|
: undefined
|
|
const browserDefinitions = browserTools
|
|
? browserTools.listTools().filter(
|
|
(definition) => availableTools.has(definition.name as BrowserToolName)
|
|
).map(
|
|
(definition): Tool => ({
|
|
name: definition.name,
|
|
title: definition.displayName,
|
|
description: definition.description,
|
|
inputSchema: definition.inputSchema as Tool['inputSchema'],
|
|
annotations: {
|
|
readOnlyHint: builtinToolAccessByName.get(definition.name) === 'read',
|
|
destructiveHint: false
|
|
}
|
|
})
|
|
)
|
|
: []
|
|
session.listedTools = true
|
|
return {
|
|
tools: [
|
|
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
|
|
...(imageSaveTool ? [{ name: imageSaveTool.name, title: imageSaveTool.displayName, description: imageSaveTool.description, inputSchema: imageSaveTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: true } }] : []),
|
|
...scopedTools,
|
|
...browserDefinitions,
|
|
...[...customBindings.values()].map(
|
|
(binding) => binding.exposedTool
|
|
)
|
|
]
|
|
}
|
|
} catch (error) {
|
|
this.observeMcpFailure({
|
|
phase: 'tool-discovery', category: 'handler-failure',
|
|
elapsedMs: Math.round(performance.now() - startedAt),
|
|
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
)
|
|
mcp.setRequestHandler(
|
|
CallToolRequestSchema,
|
|
async (call, extra) => {
|
|
const name = call.params.name
|
|
const input = call.params.arguments ?? {}
|
|
if (isStoryGraphTool(name)) {
|
|
try { return { content: [{ type: 'text' as const, text: JSON.stringify(await this.callStoryGraphTool(token, name, input, extra.signal)) }] } }
|
|
catch (error) { return { isError: true, content: [{ type: 'text' as const, text: error instanceof Error ? error.message : 'Story Graph read failed' }] } }
|
|
}
|
|
if (name === 'generate_image') {
|
|
const capability = this.getCapability(token)
|
|
if (!capability.imageToolBinding) throw new Error('Image capability is unavailable')
|
|
const operation = await capability.imageToolBinding.call(input, `${session.registryKey}:${extra.requestId}`, AbortSignal.any([extra.signal, capability.signal]))
|
|
return { content: [{ type: 'text' as const, text: JSON.stringify(operation) }] }
|
|
}
|
|
if (name === 'save_image') {
|
|
const capability = this.getCapability(token)
|
|
if (!capability.imageToolBinding?.save) throw new Error('Image save capability is unavailable')
|
|
try {
|
|
const saved = await capability.imageToolBinding.save(input, AbortSignal.any([extra.signal, capability.signal]))
|
|
return { content: [{ type: 'text' as const, text: JSON.stringify(saved) }] }
|
|
} catch (error) {
|
|
return { isError: true, content: [{ type: 'text' as const, text: error instanceof Error ? error.message : 'Saving image failed' }] }
|
|
}
|
|
}
|
|
if (availableTools.has(name as ScopedDataToolName)) {
|
|
const definition = scopedDataToolByName.get(
|
|
name as ScopedDataToolName
|
|
)
|
|
if (definition) {
|
|
const parsedInput = definition.inputSchema.safeParse(input)
|
|
if (!parsedInput.success) {
|
|
return {
|
|
isError: true,
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Invalid arguments for ${name}: ${parsedInput.error.issues
|
|
.map((issue) => `${issue.path.join('.') || 'arguments'}: ${issue.message}`)
|
|
.join('; ')}. Correct the arguments and retry.`
|
|
}]
|
|
}
|
|
}
|
|
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(result)
|
|
}
|
|
]
|
|
}
|
|
}
|
|
const capability = this.getCapability(token)
|
|
let browserTools = capability.browserConversationId && capability.browserTabId
|
|
? new BrowserModelTools({
|
|
service: this.browserService!,
|
|
conversationId: capability.browserConversationId,
|
|
browserTabId: capability.browserTabId
|
|
})
|
|
: undefined
|
|
if (browserTools?.ownsTool(name)) {
|
|
let lease = capability.browserUsageLease!
|
|
try {
|
|
const callSignal = AbortSignal.any([
|
|
extra.signal,
|
|
capability.signal,
|
|
capability.brokerController.signal
|
|
])
|
|
callSignal.throwIfAborted()
|
|
if (lease.signal.aborted) {
|
|
if (name !== 'browser_navigate') {
|
|
throw new Error('浏览器标签页已关闭,请调用 browser_navigate 打开新标签页。')
|
|
}
|
|
browserNavigateInputSchema.parse(input)
|
|
// A stable, request-specific ID also deduplicates concurrent recovery calls.
|
|
const tab = await this.browserService!.createTab(
|
|
capability.browserConversationId!,
|
|
undefined,
|
|
callSignal,
|
|
`browser-recovery:${capability.requestId}:${lease.tabId}`
|
|
)
|
|
callSignal.throwIfAborted()
|
|
if (capability.browserUsageLease === lease) {
|
|
const replacement = this.browserService!.acquireTabUsage(
|
|
capability.browserConversationId!,
|
|
tab.tabId,
|
|
capability.requestId
|
|
)
|
|
capability.browserTabId = tab.tabId
|
|
capability.browserUsageLease = replacement
|
|
lease.release()
|
|
}
|
|
lease = capability.browserUsageLease!
|
|
browserTools = new BrowserModelTools({
|
|
service: this.browserService!,
|
|
conversationId: capability.browserConversationId!,
|
|
browserTabId: capability.browserTabId!
|
|
})
|
|
}
|
|
const browserSignal = AbortSignal.any([callSignal, lease.signal])
|
|
browserSignal.throwIfAborted()
|
|
const result = await browserTools.callTool(
|
|
name,
|
|
input,
|
|
browserSignal
|
|
)
|
|
browserSignal.throwIfAborted()
|
|
const openedTabId = browserTools.getBoundTabId()
|
|
if (openedTabId !== lease.tabId && capability.browserUsageLease === lease) {
|
|
const replacement = this.browserService!.acquireTabUsage(
|
|
capability.browserConversationId!, openedTabId, capability.requestId
|
|
)
|
|
capability.browserTabId = openedTabId
|
|
capability.browserUsageLease = replacement
|
|
lease.release()
|
|
}
|
|
return {
|
|
content: result.parts.map((part) =>
|
|
part.type === 'text'
|
|
? { type: 'text' as const, text: part.text }
|
|
: {
|
|
type: 'image' as const,
|
|
data: part.data,
|
|
mimeType: part.mimeType
|
|
}
|
|
)
|
|
}
|
|
} catch (error) {
|
|
const detail =
|
|
(lease.signal.aborted
|
|
? '浏览器标签页已关闭,请调用 browser_navigate 打开新标签页。'
|
|
: safeToolErrorDetail(error, 2_000)) ??
|
|
'内置浏览器工具执行失败'
|
|
return {
|
|
isError: true,
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text:
|
|
error instanceof BrowserStaleReferenceError
|
|
? `${detail}。请调用 browser_snapshot 获取新快照,然后使用新引用重试。`
|
|
: detail
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const binding = (
|
|
await this.getCustomMcpBindings(token, extra.signal)
|
|
).get(name)
|
|
if (!binding) {
|
|
throw new Error('GoodBuddy MCP 工具不存在或已失效')
|
|
}
|
|
return this.callCustomMcpTool(
|
|
token,
|
|
binding,
|
|
input,
|
|
extra.signal
|
|
)
|
|
}
|
|
)
|
|
return session
|
|
}
|
|
|
|
private async handleRequest(
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
context: { phase: DesktopMcpDiagnosticMetadata['phase']; startedAt: number; correlationId?: string }
|
|
): Promise<void> {
|
|
if (request.url !== '/mcp') {
|
|
sendJson(response, 404, { error: 'Not found' })
|
|
return
|
|
}
|
|
if (
|
|
request.method !== 'POST' &&
|
|
request.method !== 'GET' &&
|
|
request.method !== 'DELETE'
|
|
) {
|
|
response.setHeader('allow', 'POST, GET, DELETE')
|
|
sendJson(response, 405, {
|
|
jsonrpc: '2.0',
|
|
error: { code: -32000, message: 'Method not allowed' },
|
|
id: null
|
|
})
|
|
return
|
|
}
|
|
context.phase = 'authentication'
|
|
context.startedAt = performance.now()
|
|
const authorization = request.headers.authorization
|
|
if (
|
|
typeof authorization !== 'string' ||
|
|
!authorization.startsWith('Bearer ')
|
|
) {
|
|
sendJson(response, 401, { error: 'Unauthorized' })
|
|
return
|
|
}
|
|
const token = authorization.slice('Bearer '.length)
|
|
context.correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
|
|
try {
|
|
this.getCapability(token)
|
|
} catch {
|
|
sendJson(response, 401, { error: 'Unauthorized' })
|
|
return
|
|
}
|
|
|
|
context.phase = 'request'
|
|
context.startedAt = performance.now()
|
|
let body: unknown
|
|
if (request.method === 'POST') {
|
|
try {
|
|
body = await readBoundedJson(request, this.maximumBodyBytes)
|
|
} catch (error) {
|
|
sendJson(response, error instanceof RangeError ? 413 : 400, {
|
|
error:
|
|
error instanceof RangeError
|
|
? 'Request body too large'
|
|
: 'Invalid JSON'
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
context.phase = 'session'
|
|
context.startedAt = performance.now()
|
|
const sessionId = request.headers['mcp-session-id']
|
|
let createdSession = false
|
|
let session =
|
|
typeof sessionId === 'string'
|
|
? this.downstreamMcpSessions.get(sessionId)
|
|
: undefined
|
|
if (session && session.token !== token) {
|
|
session = undefined
|
|
}
|
|
if (!session) {
|
|
if (
|
|
request.method !== 'POST' ||
|
|
!isInitializeRequest(body) ||
|
|
typeof sessionId === 'string'
|
|
) {
|
|
sendJson(response, typeof sessionId === 'string' ? 404 : 400, {
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code:
|
|
typeof sessionId === 'string' ? -32001 : -32000,
|
|
message:
|
|
typeof sessionId === 'string'
|
|
? 'Session not found'
|
|
: 'Bad Request: No valid session ID provided'
|
|
},
|
|
id: null
|
|
})
|
|
return
|
|
}
|
|
const sessionCount = [
|
|
...this.downstreamMcpSessions.values()
|
|
].filter((candidate) => candidate.token === token).length
|
|
if (
|
|
sessionCount >=
|
|
MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY
|
|
) {
|
|
sendJson(response, 429, {
|
|
jsonrpc: '2.0',
|
|
error: {
|
|
code: -32000,
|
|
message: 'Too many MCP sessions'
|
|
},
|
|
id: null
|
|
})
|
|
return
|
|
}
|
|
const availableTools = new Set(
|
|
this.getAvailableToolNames(token)
|
|
)
|
|
context.phase = 'initialize'
|
|
context.startedAt = performance.now()
|
|
session = this.createDownstreamMcpSession(
|
|
token,
|
|
availableTools
|
|
)
|
|
createdSession = true
|
|
this.downstreamMcpSessions.set(session.registryKey, session)
|
|
}
|
|
try {
|
|
if (createdSession) await session.mcp.connect(session.transport)
|
|
if (body && typeof body === 'object' && 'method' in body && body.method === 'tools/list') {
|
|
context.phase = 'tool-discovery'
|
|
context.startedAt = performance.now()
|
|
}
|
|
await session.transport.handleRequest(request, response, body)
|
|
} finally {
|
|
if (createdSession && session.id === undefined) {
|
|
await this.closeDownstreamMcpSession(session)
|
|
}
|
|
}
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
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
|
|
this.server = undefined
|
|
this.endpoint = undefined
|
|
if (server) {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
}
|
|
}
|
|
}
|
|
import { imageSaveToolDefinition, imageToolDefinition, type ImageToolBinding } from './image-tool-binding'
|