GoodBuddy could share Skills across runtimes, but custom MCP remained limited and DeepSeek Harness could not manage third-party extensions. The app now provides a default-off DSH npm marketplace with managed installation, configuration, failure isolation, and packaged npm support, while assigned custom MCP is available to managed OpenCode, Continue Agent, and DeepSeek Harness in Execute. Third-party DSH install scripts, initialization, and tools run with the current user's permissions. Ask remains read-only at dispatch, and turning off the marketplace hides management without disabling installed plugins. Release note: 新增默认关闭的 DSH 插件市场,并让自定义 MCP 可分配给 OpenCode、Continue 和 DeepSeek Harness;安装第三方插件前会明确提示当前用户权限边界。
1403 lines
40 KiB
TypeScript
1403 lines
40 KiB
TypeScript
import { randomBytes } from 'node:crypto'
|
|
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 {
|
|
CallToolRequestSchema,
|
|
CallToolResultSchema,
|
|
ListToolsRequestSchema,
|
|
type CallToolResult,
|
|
type Tool
|
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
import { z } from 'zod'
|
|
import type { KnowledgeSearchReference } from '../../shared/contracts'
|
|
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
|
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 {
|
|
MagicNoteDetail,
|
|
MagicNoteEntry,
|
|
MagicNoteRichContent,
|
|
MagicNoteSearchResult,
|
|
MagicNoteSummary
|
|
} 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 {
|
|
createMcpToolName,
|
|
isValidMcpToolName,
|
|
normalizeMcpToolSchema
|
|
} from './mcp-tool-utils'
|
|
|
|
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 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 DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
|
|
const MAX_CAPABILITY_TTL_MS = 15 * 60_000
|
|
|
|
export {
|
|
knowledgeToolNames,
|
|
magicNoteReadToolNames,
|
|
magicNoteWriteToolNames,
|
|
maximumScopedToolCount,
|
|
scopedReadToolNames
|
|
}
|
|
|
|
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(): MagicNoteSummary[]
|
|
getMagicNote(noteId: string): MagicNoteDetail
|
|
getMagicNoteEntry(entryId: string): MagicNoteEntry
|
|
searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[]
|
|
createMagicNote(input: {
|
|
title: string
|
|
content?: MagicNoteRichContent
|
|
}): MagicNoteDetail
|
|
updateMagicNote(input: {
|
|
noteId: string
|
|
title?: string
|
|
pinned?: boolean
|
|
expectedRevision: number
|
|
}): MagicNoteDetail
|
|
deleteMagicNote(noteId: string): void
|
|
createMagicNoteEntry(input: {
|
|
noteId: string
|
|
content: MagicNoteRichContent
|
|
plainText: string
|
|
}): MagicNoteDetail
|
|
updateMagicNoteEntry(input: {
|
|
entryId: string
|
|
content: MagicNoteRichContent
|
|
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
|
|
revision: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export type MagicNoteToolEntry = {
|
|
id: string
|
|
content: string
|
|
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 = {
|
|
requestId: string
|
|
libraryIds: readonly string[]
|
|
magicNotesAccess: MagicNotesCapabilityAccess
|
|
configAccess: MagicNotesCapabilityAccess
|
|
configWorkspacePath?: string
|
|
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
|
|
expiresAt: number
|
|
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'
|
|
}
|
|
|
|
type CustomMcpConnection = {
|
|
client: Client
|
|
server: ResolvedMcpServer
|
|
bindings: CustomMcpBinding[]
|
|
dynamicToolsSupported: boolean
|
|
dynamicToolsChanged: boolean
|
|
}
|
|
|
|
export type KnowledgeMcpGatewayOptions = {
|
|
capabilityTtlMs?: number
|
|
maximumBodyBytes?: number
|
|
now?: () => number
|
|
magicNotesDatabase?: MagicNotesDatabase
|
|
configService?: GoodBuddyConfigService
|
|
}
|
|
|
|
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,
|
|
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 [
|
|
reference.libraryId,
|
|
reference.documentId,
|
|
reference.chunkId ?? '',
|
|
reference.locator ?? '',
|
|
reference.snippet
|
|
].join('\0')
|
|
}
|
|
|
|
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 capabilities = new Map<string, Capability>()
|
|
private readonly customMcpCleanups = new Set<Promise<void>>()
|
|
private readonly now: () => number
|
|
private readonly capabilityTtlMs: number
|
|
private readonly maximumBodyBytes: number
|
|
private readonly magicNotesDatabase?: MagicNotesDatabase
|
|
private readonly configService?: GoodBuddyConfigService
|
|
private server?: Server
|
|
private endpoint?: string
|
|
|
|
constructor(
|
|
private readonly knowledgeService: KnowledgeService,
|
|
options: KnowledgeMcpGatewayOptions = {}
|
|
) {
|
|
const ttl = options.capabilityTtlMs ?? DEFAULT_CAPABILITY_TTL_MS
|
|
if (
|
|
!Number.isSafeInteger(ttl) ||
|
|
ttl < 1 ||
|
|
ttl > MAX_CAPABILITY_TTL_MS
|
|
) {
|
|
throw new RangeError('Knowledge capability TTL is invalid')
|
|
}
|
|
this.capabilityTtlMs = ttl
|
|
this.maximumBodyBytes =
|
|
options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES
|
|
this.now = options.now ?? Date.now
|
|
this.magicNotesDatabase = options.magicNotesDatabase
|
|
this.configService = options.configService
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
if (this.server) {
|
|
return
|
|
}
|
|
const server = createServer((request, response) => {
|
|
void this.handleRequest(request, response).catch(() => {
|
|
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
|
|
}
|
|
): string | undefined {
|
|
const effectiveMagicNotesAccess = this.magicNotesDatabase
|
|
? magicNotesAccess
|
|
: 'none'
|
|
const effectiveConfigAccess = this.configService
|
|
? config?.access ?? 'none'
|
|
: 'none'
|
|
if (
|
|
authorizedLibraryIds.length === 0 &&
|
|
effectiveMagicNotesAccess === 'none' &&
|
|
effectiveConfigAccess === 'none'
|
|
) {
|
|
return undefined
|
|
}
|
|
signal.throwIfAborted()
|
|
return this.storeCapability({
|
|
requestId,
|
|
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
|
|
magicNotesAccess: effectiveMagicNotesAccess,
|
|
configAccess: effectiveConfigAccess,
|
|
...(effectiveConfigAccess !== 'none'
|
|
? {
|
|
configWorkspacePath: config?.workspacePath,
|
|
authorizeConfigApply: config?.authorizeApply
|
|
}
|
|
: {}),
|
|
signal,
|
|
customMcpServers: []
|
|
})
|
|
}
|
|
|
|
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,
|
|
| 'expiresAt'
|
|
| 'references'
|
|
| 'removeAbortListener'
|
|
| 'brokerController'
|
|
| 'customMcpConnections'
|
|
>
|
|
): string {
|
|
value.signal.throwIfAborted()
|
|
const token = randomBytes(32).toString('base64url')
|
|
const brokerController = new AbortController()
|
|
const abort = (): void => {
|
|
this.revoke(token)
|
|
}
|
|
value.signal.addEventListener('abort', abort, { once: true })
|
|
this.capabilities.set(token, {
|
|
...value,
|
|
expiresAt: this.now() + this.capabilityTtlMs,
|
|
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')
|
|
)
|
|
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)
|
|
})
|
|
}
|
|
|
|
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 ||
|
|
capability.expiresAt <= this.now()
|
|
) {
|
|
this.revoke(token)
|
|
throw new Error('Knowledge capability is unavailable or expired')
|
|
}
|
|
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 = signal
|
|
? AbortSignal.any([signal, capability.signal])
|
|
: capability.signal
|
|
effectiveSignal.throwIfAborted()
|
|
const libraries = this.knowledgeService.database.listKnowledgeBases(500)
|
|
const libraryNames = new Map(
|
|
libraries.map((library) => [library.id, library.name])
|
|
)
|
|
const results = await this.knowledgeService.searchHybridMany(
|
|
capability.libraryIds,
|
|
query,
|
|
limit,
|
|
effectiveSignal
|
|
)
|
|
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 = {
|
|
libraryId: knowledgeBaseId,
|
|
libraryName: libraryNames.get(knowledgeBaseId) ?? '知识库',
|
|
documentId: result.document.id,
|
|
chunkId: result.chunk.id,
|
|
documentName: result.document.title.slice(0, 500),
|
|
sourceName: result.source.displayName.slice(0, 500),
|
|
locator: result.chunk.location?.slice(0, 1_000),
|
|
snippet: stripKnowledgeHighlightTags(result.snippet).slice(0, 12_000),
|
|
rank: result.rank,
|
|
score: result.retrieval.score,
|
|
lexicalRank: result.retrieval.lexicalRank,
|
|
vectorRank: result.retrieval.vectorRank,
|
|
graphRank: result.retrieval.graphRank,
|
|
similarity: result.retrieval.similarity,
|
|
retrievalChannels: result.retrieval.channels,
|
|
evidenceIds: result.retrieval.evidenceIds?.slice(0, 100)
|
|
}
|
|
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): ScopedDataToolName[] {
|
|
const capability = this.getCapability(token)
|
|
return [
|
|
...(capability.libraryIds.length > 0
|
|
? knowledgeToolNames
|
|
: []),
|
|
...(capability.magicNotesAccess !== 'none'
|
|
? magicNoteReadToolNames
|
|
: []),
|
|
...(capability.magicNotesAccess === 'write'
|
|
? magicNoteWriteToolNames
|
|
: []),
|
|
...(capability.configAccess !== 'none'
|
|
? goodbuddyConfigReadToolNames
|
|
: []),
|
|
...(capability.configAccess === 'write'
|
|
? goodbuddyConfigWriteToolNames
|
|
: [])
|
|
]
|
|
}
|
|
|
|
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,
|
|
exposedTool: {
|
|
name: createMcpToolName(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 connectCustomMcpServer(
|
|
capability: Capability,
|
|
server: ResolvedMcpServer
|
|
): Promise<CustomMcpConnection> {
|
|
let connection: CustomMcpConnection | undefined
|
|
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 && connection) {
|
|
connection.dynamicToolsChanged = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
: undefined
|
|
)
|
|
const signal = AbortSignal.any([
|
|
capability.signal,
|
|
capability.brokerController.signal
|
|
])
|
|
try {
|
|
await client.connect(createMcpTransport(server), {
|
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
|
signal
|
|
})
|
|
const result = await client.listTools(undefined, {
|
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
|
signal
|
|
})
|
|
connection = {
|
|
client,
|
|
server,
|
|
bindings: this.createCustomMcpBindings(
|
|
client,
|
|
server,
|
|
result.tools
|
|
),
|
|
dynamicToolsSupported:
|
|
server.allowDynamicTools &&
|
|
client.getServerCapabilities()?.tools?.listChanged === true,
|
|
dynamicToolsChanged: false
|
|
}
|
|
return connection
|
|
} catch (error) {
|
|
await client.close().catch(() => undefined)
|
|
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)
|
|
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] : []
|
|
)
|
|
const failure = results.find(
|
|
(result) => result.status === 'rejected'
|
|
)
|
|
if (failure?.status === 'rejected') {
|
|
await Promise.allSettled(
|
|
connections.map((connection) => connection.client.close())
|
|
)
|
|
throw failure.reason
|
|
}
|
|
return connections
|
|
})()
|
|
}
|
|
let connections: CustomMcpConnection[]
|
|
try {
|
|
connections = await capability.customMcpConnections
|
|
} catch (error) {
|
|
capability.customMcpConnections = undefined
|
|
throw error
|
|
}
|
|
if (refreshDynamic) {
|
|
const effectiveSignal = signal
|
|
? AbortSignal.any([
|
|
signal,
|
|
capability.signal,
|
|
capability.brokerController.signal
|
|
])
|
|
: AbortSignal.any([
|
|
capability.signal,
|
|
capability.brokerController.signal
|
|
])
|
|
for (const connection of connections) {
|
|
if (
|
|
!connection.dynamicToolsSupported ||
|
|
!connection.dynamicToolsChanged
|
|
) {
|
|
continue
|
|
}
|
|
connection.dynamicToolsChanged = false
|
|
try {
|
|
const result = await connection.client.listTools(undefined, {
|
|
timeout: CUSTOM_MCP_TIMEOUT_MS,
|
|
signal: effectiveSignal
|
|
})
|
|
connection.bindings = this.createCustomMcpBindings(
|
|
connection.client,
|
|
connection.server,
|
|
result.tools
|
|
)
|
|
} catch (error) {
|
|
connection.dynamicToolsChanged = true
|
|
throw new Error(
|
|
`无法刷新 MCP Server「${connection.server.name}」的工具`,
|
|
{ cause: error }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
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 ensureBoundedCustomMcpResult(
|
|
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
|
|
)) {
|
|
if (
|
|
(message.type === 'taskCreated' ||
|
|
message.type === 'taskStatus') &&
|
|
typeof message.task.taskId === 'string'
|
|
) {
|
|
taskId = message.task.taskId
|
|
} else if (message.type === 'result') {
|
|
return ensureBoundedCustomMcpResult(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 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())
|
|
)
|
|
}
|
|
|
|
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 } = magicNoteListTool.inputSchema.parse(input)
|
|
const notes: MagicNoteToolSummary[] = []
|
|
for (const note of database.listMagicNotes().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),
|
|
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 } : {})
|
|
}).id
|
|
}
|
|
)
|
|
}
|
|
|
|
updateMagicNote(token: string, input: unknown): MagicNoteToolDetail {
|
|
const { database } = this.requireMagicNotes(token, 'write')
|
|
const parsed = magicNoteUpdateTool.inputSchema.parse(input)
|
|
database.updateMagicNote(parsed)
|
|
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)
|
|
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 }
|
|
}
|
|
|
|
private async callScopedTool(
|
|
token: string,
|
|
name: ScopedDataToolName,
|
|
input: unknown
|
|
): Promise<Record<string, unknown>> {
|
|
switch (name) {
|
|
case 'knowledge_list':
|
|
return { libraries: this.listLibraries(token, input) }
|
|
case 'knowledge_search':
|
|
return { references: await this.search(token, input) }
|
|
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)
|
|
}
|
|
}
|
|
|
|
private async handleRequest(
|
|
request: IncomingMessage,
|
|
response: ServerResponse
|
|
): Promise<void> {
|
|
if (request.url !== '/mcp') {
|
|
sendJson(response, 404, { error: 'Not found' })
|
|
return
|
|
}
|
|
if (request.method !== 'POST') {
|
|
response.setHeader('allow', 'POST')
|
|
sendJson(response, 405, {
|
|
jsonrpc: '2.0',
|
|
error: { code: -32000, message: 'Method not allowed' },
|
|
id: null
|
|
})
|
|
return
|
|
}
|
|
const authorization = request.headers.authorization
|
|
if (
|
|
typeof authorization !== 'string' ||
|
|
!authorization.startsWith('Bearer ')
|
|
) {
|
|
sendJson(response, 401, { error: 'Unauthorized' })
|
|
return
|
|
}
|
|
const token = authorization.slice('Bearer '.length)
|
|
try {
|
|
this.getCapability(token)
|
|
} catch {
|
|
sendJson(response, 401, { error: 'Unauthorized' })
|
|
return
|
|
}
|
|
|
|
let body: unknown
|
|
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
|
|
}
|
|
|
|
const availableScopedTools = new Set(
|
|
this.getAvailableToolNames(token)
|
|
)
|
|
const mcp = new McpProtocolServer(
|
|
{
|
|
name: 'goodbuddy-request-scoped-capabilities',
|
|
version: '1.0.0'
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {}
|
|
}
|
|
}
|
|
)
|
|
mcp.setRequestHandler(
|
|
ListToolsRequestSchema,
|
|
async (_request, extra) => {
|
|
const customBindings = await this.getCustomMcpBindings(
|
|
token,
|
|
extra.signal
|
|
)
|
|
const scopedTools = [...availableScopedTools].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'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
return {
|
|
tools: [
|
|
...scopedTools,
|
|
...[...customBindings.values()].map(
|
|
(binding) => binding.exposedTool
|
|
)
|
|
]
|
|
}
|
|
}
|
|
)
|
|
mcp.setRequestHandler(
|
|
CallToolRequestSchema,
|
|
async (call, extra) => {
|
|
const name = call.params.name
|
|
const input = call.params.arguments ?? {}
|
|
if (
|
|
availableScopedTools.has(name as ScopedDataToolName)
|
|
) {
|
|
const definition = scopedDataToolByName.get(
|
|
name as ScopedDataToolName
|
|
)
|
|
if (!definition) {
|
|
throw new Error('GoodBuddy 工具不存在')
|
|
}
|
|
const parsedInput = definition.inputSchema.parse(input)
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text' as const,
|
|
text: JSON.stringify(
|
|
await this.callScopedTool(
|
|
token,
|
|
name as ScopedDataToolName,
|
|
parsedInput
|
|
)
|
|
)
|
|
}
|
|
]
|
|
}
|
|
}
|
|
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
|
|
)
|
|
}
|
|
)
|
|
const transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: undefined
|
|
})
|
|
const close = (): void => {
|
|
void Promise.allSettled([transport.close(), mcp.close()])
|
|
}
|
|
response.once('close', close)
|
|
try {
|
|
await mcp.connect(transport)
|
|
await transport.handleRequest(request, response, body)
|
|
} finally {
|
|
if (response.writableFinished) {
|
|
response.off('close', close)
|
|
close()
|
|
}
|
|
}
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
for (const token of [...this.capabilities.keys()]) {
|
|
this.revoke(token)
|
|
}
|
|
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()))
|
|
}
|
|
}
|
|
}
|