Expose save_image across direct model, MCP gateway, DeepSeek Harness and remote Agents (written on the remote host via chunked reads), and show the image MCP tool list in settings.
2141 lines
70 KiB
TypeScript
2141 lines
70 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'
|
|
|
|
export type StoryGraphBinding = { projectId?: 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>
|
|
}
|
|
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 {
|
|
GoodBuddyConfigApplyAuthorizer,
|
|
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 } 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()
|
|
|
|
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[] }): MagicNoteSummary[]
|
|
getMagicNote(noteId: string): MagicNoteDetail
|
|
getMagicNoteEntry(entryId: string): MagicNoteEntry
|
|
searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[]
|
|
createMagicNote(input: {
|
|
title: string
|
|
content?: MagicNoteContent
|
|
tags?: string[]
|
|
}): MagicNoteDetail
|
|
updateMagicNote(input: {
|
|
noteId: string
|
|
title?: string
|
|
pinned?: boolean
|
|
tags?: string[]
|
|
expectedRevision: number
|
|
}): MagicNoteDetail
|
|
deleteMagicNote(noteId: string): void
|
|
createMagicNoteEntry(input: {
|
|
noteId: string
|
|
content: MagicNoteContent
|
|
plainText: string
|
|
}): MagicNoteDetail
|
|
updateMagicNoteEntry(input: {
|
|
entryId: string
|
|
content: MagicNoteContent
|
|
plainText: string
|
|
expectedRevision: number
|
|
}): MagicNoteDetail
|
|
deleteMagicNoteEntry(entryId: string): 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
|
|
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
|
|
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
|
|
}
|
|
|
|
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 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.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
|
|
authorizeApply?: GoodBuddyConfigApplyAuthorizer
|
|
},
|
|
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,
|
|
authorizeConfigApply: config?.authorizeApply
|
|
}
|
|
: {}),
|
|
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 = 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
|
|
}
|
|
|
|
listLibraries(
|
|
token: string,
|
|
input: unknown = {}
|
|
): KnowledgeLibraryListItem[] {
|
|
const capability = this.getCapability(token)
|
|
knowledgeListTool.inputSchema.parse(input)
|
|
const librariesById = new Map(
|
|
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 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 =
|
|
name === 'goodbuddy_config_apply' ? 'write' : 'read'
|
|
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,
|
|
capability.authorizeConfigApply
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
listMagicNotes(
|
|
token: string,
|
|
input: unknown = {}
|
|
): MagicNoteToolSummary[] {
|
|
const { database } = this.requireMagicNotes(token, 'read')
|
|
const { limit, tags } = magicNoteListTool.inputSchema.parse(input)
|
|
const filter = tags ? magicNoteTagListSchema.parse(tags) : undefined
|
|
const notes: MagicNoteToolSummary[] = []
|
|
const listed = 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
|
|
}
|
|
|
|
getMagicNote(token: string, input: unknown): MagicNoteToolDetail {
|
|
const { database } = this.requireMagicNotes(token, 'read')
|
|
const { noteId } = magicNoteGetTool.inputSchema.parse(input)
|
|
const detail = 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
|
|
}
|
|
|
|
searchMagicNotes(
|
|
token: string,
|
|
input: unknown,
|
|
signal?: AbortSignal
|
|
): MagicNoteSearchResult[] {
|
|
const { capability, database } = this.requireMagicNotes(token, 'read')
|
|
const { query, limit } = magicNoteSearchTool.inputSchema.parse(input)
|
|
const effectiveSignal = signal
|
|
? AbortSignal.any([signal, capability.signal])
|
|
: capability.signal
|
|
effectiveSignal.throwIfAborted()
|
|
const notes = database.searchMagicNotes(query, limit)
|
|
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
|
|
}
|
|
|
|
createMagicNote(token: string, input: unknown): MagicNoteToolDetail {
|
|
const { database } = this.requireMagicNotes(token, 'write')
|
|
const parsed = magicNoteCreateTool.inputSchema.parse(input)
|
|
const content =
|
|
typeof parsed.content === 'string'
|
|
? textContent(parsed.content)
|
|
: undefined
|
|
return this.getMagicNote(
|
|
token,
|
|
{
|
|
noteId: database.createMagicNote({
|
|
title: parsed.title,
|
|
...(content ? { content } : {}),
|
|
...(parsed.tags ? { tags: magicNoteTagListSchema.parse(parsed.tags) } : {})
|
|
}).id
|
|
}
|
|
)
|
|
}
|
|
|
|
updateMagicNote(token: string, input: unknown): MagicNoteToolDetail {
|
|
const { database } = this.requireMagicNotes(token, 'write')
|
|
const parsed = magicNoteUpdateTool.inputSchema.parse(input)
|
|
database.updateMagicNote({
|
|
...parsed,
|
|
...(parsed.tags ? { tags: magicNoteTagListSchema.parse(parsed.tags) } : {})
|
|
})
|
|
return this.getMagicNote(token, { noteId: parsed.noteId })
|
|
}
|
|
|
|
createMagicNoteEntry(
|
|
token: string,
|
|
input: unknown
|
|
): MagicNoteToolDetail {
|
|
const { database } = this.requireMagicNotes(token, 'write')
|
|
const parsed = magicNoteEntryCreateTool.inputSchema.parse(input)
|
|
const content = textContent(parsed.content)
|
|
database.createMagicNoteEntry({
|
|
noteId: parsed.noteId,
|
|
content,
|
|
plainText: magicNotePlainText(content)
|
|
})
|
|
return this.getMagicNote(token, { noteId: parsed.noteId })
|
|
}
|
|
|
|
updateMagicNoteEntry(
|
|
token: string,
|
|
input: unknown
|
|
): MagicNoteToolDetail {
|
|
const { database } = this.requireMagicNotes(token, 'write')
|
|
const parsed = magicNoteEntryUpdateTool.inputSchema.parse(input)
|
|
if (database.getMagicNoteEntry(parsed.entryId).content.version === 2) {
|
|
throw new Error('画布记录不能通过纯文本工具覆盖,请在画布编辑器中修改,或追加新的纯文本记录')
|
|
}
|
|
const content = textContent(parsed.content)
|
|
const detail = database.updateMagicNoteEntry({
|
|
entryId: parsed.entryId,
|
|
content,
|
|
plainText: magicNotePlainText(content),
|
|
expectedRevision: parsed.expectedRevision
|
|
})
|
|
return this.getMagicNote(token, { noteId: detail.id })
|
|
}
|
|
|
|
deleteMagicNoteEntry(
|
|
token: string,
|
|
input: unknown
|
|
): MagicNoteToolDetail {
|
|
const { database } = this.requireMagicNotes(token, 'write')
|
|
const parsed = magicNoteEntryDeleteTool.inputSchema.parse(input)
|
|
const entry = database.getMagicNoteEntry(parsed.entryId)
|
|
if (entry.revision !== parsed.expectedRevision) {
|
|
throw new Error('记录已被更新,请重新读取后重试')
|
|
}
|
|
const detail = database.deleteMagicNoteEntry(parsed.entryId)
|
|
return this.getMagicNote(token, { noteId: detail.id })
|
|
}
|
|
|
|
deleteMagicNote(
|
|
token: string,
|
|
input: unknown
|
|
): { deleted: true; noteId: string } {
|
|
const { database } = this.requireMagicNotes(token, 'write')
|
|
const parsed = magicNoteDeleteTool.inputSchema.parse(input)
|
|
const note = database.getMagicNote(parsed.noteId)
|
|
if (note.revision !== parsed.expectedRevision) {
|
|
throw new Error('笔记已被更新,请重新读取后重试')
|
|
}
|
|
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 = 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: this.listLibraries(token, input) }
|
|
case 'knowledge_search':
|
|
return { references: await this.search(token, input, signal) }
|
|
case 'note_list':
|
|
return { notes: this.listMagicNotes(token, input) }
|
|
case 'note_get':
|
|
return { note: this.getMagicNote(token, input) }
|
|
case 'note_search':
|
|
return { notes: this.searchMagicNotes(token, input) }
|
|
case 'note_create':
|
|
return { note: this.createMagicNote(token, input) }
|
|
case 'note_update':
|
|
return { note: this.updateMagicNote(token, input) }
|
|
case 'note_entry_create':
|
|
return { note: this.createMagicNoteEntry(token, input) }
|
|
case 'note_entry_update':
|
|
return { note: this.updateMagicNoteEntry(token, input) }
|
|
case 'note_entry_delete':
|
|
return { note: 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().map(
|
|
(definition): Tool => ({
|
|
name: definition.name,
|
|
title: definition.displayName,
|
|
description: definition.description,
|
|
inputSchema: definition.inputSchema as Tool['inputSchema'],
|
|
annotations: {
|
|
readOnlyHint:
|
|
definition.name === 'browser_snapshot' ||
|
|
definition.name === 'browser_screenshot',
|
|
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'
|