feat: add DSH plugin marketplace and shared MCP

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;安装第三方插件前会明确提示当前用户权限边界。
This commit is contained in:
mesalogo
2026-08-16 11:47:11 +08:00
parent 9e6f664e06
commit ff61b5f81d
67 changed files with 9337 additions and 443 deletions
+538 -43
View File
@@ -5,8 +5,17 @@ import {
type Server,
type ServerResponse
} from 'node:http'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
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 {
@@ -39,9 +48,26 @@ 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
@@ -140,10 +166,29 @@ type Capability = {
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
@@ -184,6 +229,38 @@ function referenceKey(reference: KnowledgeSearchReference): string {
].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,
@@ -231,6 +308,7 @@ async function readBoundedJson(
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
@@ -323,15 +401,9 @@ export class KnowledgeMcpGateway {
return undefined
}
signal.throwIfAborted()
const libraryIds = Object.freeze([...new Set(authorizedLibraryIds)])
const token = randomBytes(32).toString('base64url')
const abort = (): void => {
this.revoke(token)
}
signal.addEventListener('abort', abort, { once: true })
this.capabilities.set(token, {
return this.storeCapability({
requestId,
libraryIds,
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
...(effectiveConfigAccess !== 'none'
@@ -340,11 +412,67 @@ export class KnowledgeMcpGateway {
authorizeConfigApply: config?.authorizeApply
}
: {}),
expiresAt: this.now() + this.capabilityTtlMs,
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: () =>
signal.removeEventListener('abort', abort)
value.signal.removeEventListener('abort', abort)
})
return token
}
@@ -359,7 +487,17 @@ export class KnowledgeMcpGateway {
}
capability.removeAbortListener()
this.capabilities.delete(token)
this.configService?.revokeRequest(capability.requestId)
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(
@@ -514,6 +652,291 @@ export class KnowledgeMcpGateway {
]
}
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'>
@@ -841,40 +1264,111 @@ export class KnowledgeMcpGateway {
return
}
const mcp = new McpServer({
name: 'goodbuddy-scoped-knowledge',
version: '1.0.0'
})
const availableTools = this.getAvailableToolNames(token)
for (const name of availableTools) {
const definition = scopedDataToolByName.get(name)
if (!definition) {
continue
const availableScopedTools = new Set(
this.getAvailableToolNames(token)
)
const mcp = new McpProtocolServer(
{
name: 'goodbuddy-request-scoped-capabilities',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
mcp.registerTool(
name,
{
title: definition.title,
description: definition.description,
inputSchema: definition.inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
},
async (input: Record<string, unknown>) => ({
content: [
{
type: 'text' as const,
text: JSON.stringify(await this.callScopedTool(token, name, input))
)
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
})
@@ -897,6 +1391,7 @@ export class KnowledgeMcpGateway {
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