feat: configure built-in MCP access

Built-in MCP servers were always granted to supported runtimes without per-server controls. They now have persistent enablement and runtime assignments for direct models, managed OpenCode, and Continue, while DeepSeek Harness remains visibly unsupported and cannot be assigned.

MCP settings are reorganized into Built-in MCP, Direct model, Custom MCP, and Computer control tabs. Direct-model web search now uses the same accessible collapsible inventory pattern as other tool groups.

Release note: 内置 MCP 现在可分别启停并分配给直连模型、OpenCode 和 Continue;MCP 设置分类与直连模型工具列表也更清晰,DeepSeek Harness 会明确显示为暂不支持。
This commit is contained in:
mesalogo
2026-08-17 00:25:46 +08:00
parent b751c70d75
commit 792d80e67c
21 changed files with 1012 additions and 218 deletions
@@ -236,6 +236,70 @@ describe('CapabilityService', () => {
})
})
it('persists built-in MCP enablement and supported runtime assignments', async () => {
const { filePath, builtinRoot, importedRoot, service } =
await createService()
await expect(service.getSnapshot()).resolves.toMatchObject({
builtinMcpServers: [
{
id: 'knowledge-base',
enabled: true,
assignments: ['model', 'opencode', 'continue']
},
{
id: 'magic-notes',
enabled: true,
assignments: ['model', 'opencode', 'continue']
},
{
id: 'goodbuddy-config',
enabled: true,
assignments: ['model', 'opencode', 'continue']
}
]
})
await service.setBuiltinMcpServerEnabled('magic-notes', false)
await service.setBuiltinMcpServerAssignments('knowledge-base', [
'model'
])
expect(() =>
service.setBuiltinMcpServerAssignments('knowledge-base', [
'deepseek-harness'
])
).toThrow('DeepSeek Harness 当前不支持内置 MCP')
await expect(
service.getEnabledBuiltinMcpServerIds('model')
).resolves.toEqual(['knowledge-base', 'goodbuddy-config'])
await expect(
service.getEnabledBuiltinMcpServerIds('opencode')
).resolves.toEqual(['goodbuddy-config'])
await expect(
service.getEnabledBuiltinMcpServerIds('deepseek-harness')
).resolves.toEqual([])
const reloaded = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher
)
await expect(reloaded.getSnapshot()).resolves.toMatchObject({
builtinMcpServers: expect.arrayContaining([
expect.objectContaining({
id: 'knowledge-base',
assignments: ['model']
}),
expect.objectContaining({
id: 'magic-notes',
enabled: false
})
])
})
})
it('discovers built-in skills and persists enablement and assignments', async () => {
const { filePath, builtinRoot, importedRoot, service } =
await createService()
@@ -723,7 +787,7 @@ describe('CapabilityService', () => {
await expect(service.getResolvedMcpServers('model')).resolves.toEqual([])
})
it('migrates v1 to v3 without losing skills, MCP configuration, or encrypted secrets', async () => {
it('migrates v1 to v5 without losing skills, MCP configuration, or encrypted secrets', async () => {
const { filePath, builtinRoot, importedRoot } = await createService()
const credential = Buffer.from(
'encrypted:{"version":1,"serverId":"d2ef774b-146c-4467-a909-6feb112a9c2c","secret":"preserved-secret"}'
@@ -803,7 +867,7 @@ describe('CapabilityService', () => {
}
})
const persisted = await readFile(filePath, 'utf8')
expect(persisted).toContain('"version": 4')
expect(persisted).toContain('"version": 5')
expect(persisted).toContain(credential)
expect(persisted).not.toContain('preserved-secret')
})
@@ -839,7 +903,7 @@ describe('CapabilityService', () => {
await expect(service.getSnapshot()).resolves.toMatchObject({
webSearch: { enabled: true }
})
expect(await readFile(filePath, 'utf8')).toContain('"version": 4')
expect(await readFile(filePath, 'utf8')).toContain('"version": 5')
})
it('migrates v3 MCP servers with dynamic tools disabled', async () => {
@@ -889,10 +953,51 @@ describe('CapabilityService', () => {
]
})
const persisted = await readFile(filePath, 'utf8')
expect(persisted).toContain('"version": 4')
expect(persisted).toContain('"version": 5')
expect(persisted).toContain('"allowDynamicTools": false')
})
it('migrates v4 capabilities with built-in MCP enabled for supported runtimes', async () => {
const { filePath, builtinRoot, importedRoot } = await createService()
await writeFile(
filePath,
JSON.stringify({
version: 4,
skills: {},
mcpServers: [],
webSearch: { enabled: true },
computerCapabilities: {
'host-browser-control': {
enabled: false,
browserProfileId: null
},
'linux-desktop-control': {
enabled: false,
browserProfileId: null
}
}
}),
'utf8'
)
const service = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher
)
await expect(service.getSnapshot()).resolves.toMatchObject({
builtinMcpServers: expect.arrayContaining([
expect.objectContaining({
id: 'knowledge-base',
enabled: true,
assignments: ['model', 'opencode', 'continue']
})
])
})
expect(await readFile(filePath, 'utf8')).toContain('"version": 5')
})
it('preserves capabilities created by a newer unsupported version', async () => {
const { directory, filePath, builtinRoot, importedRoot } =
await createService()
+114 -7
View File
@@ -18,6 +18,9 @@ import {
browserProfileIdSchema,
browserProfileNameSchema,
browserProfilesSummarySchema,
builtinMcpAssignmentsSchema,
builtinMcpServerIdSchema,
builtinMcpServerStateSummarySchema,
capabilityDiagnosticReportSchema,
capabilityAssignmentsSchema,
computerCapabilityConfigSummarySchema,
@@ -25,6 +28,7 @@ import {
mcpServerIdSchema,
mcpServerInputSchema,
mcpServerSummarySchema,
runtimeTargetSchema,
skillIdSchema,
skillSummarySchema,
webSearchCapabilitySchema,
@@ -32,6 +36,7 @@ import {
type CapabilityDiagnosticReport,
type CapabilitySnapshot,
type BrowserProfilesSummary,
type BuiltinMcpServerId,
type ComputerCapabilityId,
type McpServerInput,
type McpServerSummary,
@@ -105,6 +110,21 @@ const skillStateSchema = z
})
.strict()
const builtinMcpServerStateSchema = z
.object({
enabled: z.boolean(),
assignments: builtinMcpAssignmentsSchema
})
.strict()
const builtinMcpServerStatesSchema = z
.object({
'knowledge-base': builtinMcpServerStateSchema,
'magic-notes': builtinMcpServerStateSchema,
'goodbuddy-config': builtinMcpServerStateSchema
})
.strict()
const encryptedSecretSchema =
encryptedSettingsCredentialSchema.optional()
@@ -193,10 +213,15 @@ const storedCapabilitiesV3Schema = z
})
.strict()
const storedCapabilitiesSchema = storedCapabilitiesV3Schema.extend({
const storedCapabilitiesV4Schema = storedCapabilitiesV3Schema.extend({
version: z.literal(4)
})
const storedCapabilitiesSchema = storedCapabilitiesV4Schema.extend({
version: z.literal(5),
builtinMcpServers: builtinMcpServerStatesSchema
})
type StoredCapabilitiesV1 = z.infer<typeof storedCapabilitiesV1Schema>
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
type StoredMcpServer = z.infer<typeof storedMcpServerSchema>
@@ -254,12 +279,25 @@ function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabili
}
}
function defaultBuiltinMcpServerStates(): StoredCapabilities['builtinMcpServers'] {
const defaultState = (): z.infer<typeof builtinMcpServerStateSchema> => ({
enabled: true,
assignments: ['model', 'opencode', 'continue']
})
return {
'knowledge-base': defaultState(),
'magic-notes': defaultState(),
'goodbuddy-config': defaultState()
}
}
function emptyStoredCapabilities(
webSearchEnabled = true
): StoredCapabilities {
return {
version: 4,
version: 5,
skills: {},
builtinMcpServers: defaultBuiltinMcpServerStates(),
mcpServers: [],
webSearch: { enabled: webSearchEnabled },
computerCapabilities: defaultComputerCapabilityStates()
@@ -728,7 +766,7 @@ export class CapabilityService {
let shouldPersist = false
try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
assertSupportedSettingsVersion(raw, 4, (version) =>
assertSupportedSettingsVersion(raw, 5, (version) =>
`当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试`
)
const version = z
@@ -737,7 +775,8 @@ export class CapabilityService {
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4)
z.literal(4),
z.literal(5)
])
})
.passthrough()
@@ -746,8 +785,9 @@ export class CapabilityService {
const legacy: StoredCapabilitiesV1 =
storedCapabilitiesV1Schema.parse(raw)
loaded = {
version: 4,
version: 5,
skills: legacy.skills,
builtinMcpServers: defaultBuiltinMcpServerStates(),
mcpServers: legacy.mcpServers,
webSearch: { enabled: true },
computerCapabilities: defaultComputerCapabilityStates()
@@ -757,7 +797,8 @@ export class CapabilityService {
const legacy = storedCapabilitiesV2Schema.parse(raw)
loaded = {
...legacy,
version: 4,
version: 5,
builtinMcpServers: defaultBuiltinMcpServerStates(),
webSearch: { enabled: true }
}
shouldPersist = true
@@ -765,7 +806,16 @@ export class CapabilityService {
const legacy = storedCapabilitiesV3Schema.parse(raw)
loaded = {
...legacy,
version: 4
version: 5,
builtinMcpServers: defaultBuiltinMcpServerStates()
}
shouldPersist = true
} else if (version === 4) {
const legacy = storedCapabilitiesV4Schema.parse(raw)
loaded = {
...legacy,
version: 5,
builtinMcpServers: defaultBuiltinMcpServerStates()
}
shouldPersist = true
} else {
@@ -891,6 +941,12 @@ export class CapabilityService {
? -1
: 1
),
builtinMcpServers: builtinMcpServerIdSchema.options.map((id) =>
builtinMcpServerStateSummarySchema.parse({
id,
...state.builtinMcpServers[id]
})
),
mcpServers: state.mcpServers.map((server) =>
this.toMcpSummary(server)
),
@@ -1564,6 +1620,43 @@ export class CapabilityService {
})
}
setBuiltinMcpServerEnabled(
serverId: BuiltinMcpServerId,
enabled: boolean
): Promise<CapabilitySnapshot> {
return this.updateBuiltinMcpServerState(serverId, { enabled })
}
setBuiltinMcpServerAssignments(
serverId: BuiltinMcpServerId,
assignments: CapabilityAssignments
): Promise<CapabilitySnapshot> {
return this.updateBuiltinMcpServerState(serverId, {
assignments: builtinMcpAssignmentsSchema.parse(assignments)
})
}
private updateBuiltinMcpServerState(
serverId: BuiltinMcpServerId,
update: Partial<z.infer<typeof builtinMcpServerStateSchema>>
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = builtinMcpServerIdSchema.parse(serverId)
const state = await this.load()
await this.persistUserChange({
...state,
builtinMcpServers: {
...state.builtinMcpServers,
[id]: {
...state.builtinMcpServers[id],
...update
}
}
})
return this.getSnapshot()
})
}
private updateSkillState(
skillId: string,
update: Partial<z.infer<typeof skillStateSchema>>
@@ -1801,4 +1894,18 @@ export class CapabilityService {
assigned.map((server) => this.getResolvedMcpServer(server.id))
)
}
async getEnabledBuiltinMcpServerIds(
target: RuntimeTarget
): Promise<BuiltinMcpServerId[]> {
const runtime = runtimeTargetSchema.parse(target)
if (runtime === 'deepseek-harness') {
return []
}
const state = await this.load()
return builtinMcpServerIdSchema.options.filter((id) => {
const server = state.builtinMcpServers[id]
return server.enabled && server.assignments.includes(runtime)
})
}
}
+83 -3
View File
@@ -118,6 +118,8 @@ describe('registerIpcHandlers computer capabilities', () => {
}
const capabilityService = {
importSkill: vi.fn(async () => snapshot),
setBuiltinMcpServerEnabled: vi.fn(async () => snapshot),
setBuiltinMcpServerAssignments: vi.fn(async () => snapshot),
setComputerCapabilityEnabled: vi.fn(async () => snapshot),
setWebSearchEnabled: vi.fn(async () => snapshot),
createBrowserProfile: vi.fn(async () => snapshot),
@@ -215,6 +217,31 @@ describe('registerIpcHandlers computer capabilities', () => {
expect(capabilityService.setWebSearchEnabled).toHaveBeenCalledWith(false)
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2)
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesToggleBuiltinMcp
)?.(event, {
serverId: 'knowledge-base',
enabled: false
})
).resolves.toEqual(snapshot)
expect(
capabilityService.setBuiltinMcpServerEnabled
).toHaveBeenCalledWith('knowledge-base', false)
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesAssignBuiltinMcp
)?.(event, {
serverId: 'knowledge-base',
assignments: ['model', 'continue']
})
).resolves.toEqual(snapshot)
expect(
capabilityService.setBuiltinMcpServerAssignments
).toHaveBeenCalledWith('knowledge-base', ['model', 'continue'])
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4)
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: ['C:\\meeting-helper.zip']
@@ -290,7 +317,7 @@ describe('registerIpcHandlers computer capabilities', () => {
expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith(
'工作配置'
)
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3)
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(5)
expect(() =>
electronMocks.handlers.get(
@@ -2290,7 +2317,8 @@ describe('registerIpcHandlers agent terminal state', () => {
knowledgeServiceOverride?: Record<string, unknown>,
knowledgeGateway?: Record<string, unknown>,
magicNotesEnabled = false,
goodbuddyConfigService?: Record<string, unknown>
goodbuddyConfigService?: Record<string, unknown>,
capabilityServiceOverride?: Record<string, unknown>
) {
const assistantDatabase = {
claimDueSchedules: vi.fn(() => []),
@@ -2394,7 +2422,7 @@ describe('registerIpcHandlers agent terminal state', () => {
getPolicySettings,
getResolvedSettings
} as never,
{} as never,
(capabilityServiceOverride ?? {}) as never,
contextManager as never,
(knowledgeServiceOverride ?? {
database: { listKnowledgeBases: vi.fn(() => []) }
@@ -2697,6 +2725,58 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('does not grant a built-in MCP that is disabled or unassigned for the runtime', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'must-not-be-granted'),
getAvailableToolNames: vi.fn(() => ['note_list']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
'knowledge-base'
])
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true,
undefined,
{ getEnabledBuiltinMcpServerIds }
)
const requestId = '00000000-0000-4000-8000-000000000025'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'disabled-notes',
prompt: '读取笔记',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledWith('model')
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
await harness.dispose()
})
it.each(['ask', 'execute'] as const)(
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
async (workMode) => {
+84 -9
View File
@@ -81,6 +81,9 @@ import {
browserProfileCreateInputSchema,
browserProfileRenameInputSchema,
browserProfileSelectionInputSchema,
builtinMcpServerAssignmentsInputSchema,
builtinMcpServerIdSchema,
builtinMcpServerToggleInputSchema,
computerCapabilityConfigInputSchema,
computerCapabilityIdSchema,
computerCapabilityToggleInputSchema,
@@ -90,6 +93,8 @@ import {
skillIdSchema,
skillImportKindSchema,
skillToggleInputSchema,
runtimeTargetSchema,
type BuiltinMcpServerId,
type CapabilitySnapshot,
type CapabilityDiagnosticReport,
type McpServerTestResult,
@@ -316,6 +321,19 @@ function isAgentRuntime(runtime: AgentRuntime): boolean {
)
}
function runtimeTargetFor(
runtime: AgentRuntime
): ReturnType<typeof runtimeTargetSchema.parse> | undefined {
const target = runtimeTargetSchema.safeParse(runtime.runtimeId)
if (target.success) {
return target.data
}
return runtime.runtimeId === undefined &&
runtime.supportsScopedDataTools !== false
? 'model'
: undefined
}
type ScopedDataCapability = {
token?: string
toolNames: readonly string[]
@@ -324,6 +342,7 @@ type ScopedDataCapability = {
function grantScopedDataCapability(input: {
gateway?: KnowledgeMcpGateway
runtime: AgentRuntime
enabledServers: readonly BuiltinMcpServerId[]
requestId: string
libraryIds: readonly string[]
magicNotesAccess: MagicNotesCapabilityAccess
@@ -335,11 +354,21 @@ function grantScopedDataCapability(input: {
) => Promise<boolean>
signal: AbortSignal
}): ScopedDataCapability {
const enabledServers = new Set(input.enabledServers)
const libraryIds = enabledServers.has('knowledge-base')
? input.libraryIds
: []
const magicNotesAccess = enabledServers.has('magic-notes')
? input.magicNotesAccess
: 'none'
const configAccess = enabledServers.has('goodbuddy-config')
? input.configAccess ?? 'none'
: 'none'
if (
input.runtime.supportsScopedDataTools === false ||
(input.libraryIds.length === 0 &&
input.magicNotesAccess === 'none' &&
(input.configAccess ?? 'none') === 'none')
(libraryIds.length === 0 &&
magicNotesAccess === 'none' &&
configAccess === 'none')
) {
return { toolNames: [] }
}
@@ -347,9 +376,9 @@ function grantScopedDataCapability(input: {
throw new Error('内置数据工具服务不可用')
}
const config =
input.configAccess && input.configAccess !== 'none' && input.workspacePath
configAccess !== 'none' && input.workspacePath
? {
access: input.configAccess,
access: configAccess,
workspacePath: input.workspacePath,
authorizeApply: input.authorizeConfigApply
}
@@ -357,16 +386,16 @@ function grantScopedDataCapability(input: {
const token = config
? input.gateway.grant(
input.requestId,
input.libraryIds,
libraryIds,
input.signal,
input.magicNotesAccess,
magicNotesAccess,
config
)
: input.gateway.grant(
input.requestId,
input.libraryIds,
libraryIds,
input.signal,
input.magicNotesAccess
magicNotesAccess
)
return {
token,
@@ -1301,9 +1330,18 @@ export function registerIpcHandlers(
origin === 'channel' &&
((await applicationSettingsStore?.get())?.magicNotesEnabled ??
false)
const requestRuntimeTarget = runtimeTargetFor(requestRuntime)
const enabledBuiltinMcpServers = requestRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? await capabilityService.getEnabledBuiltinMcpServerIds(
requestRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
const notesCapability = grantScopedDataCapability({
gateway: knowledgeGateway,
runtime: requestRuntime,
enabledServers: enabledBuiltinMcpServers,
requestId,
libraryIds: [],
magicNotesAccess: magicNotesToolEnabled
@@ -2303,9 +2341,18 @@ export function registerIpcHandlers(
: enrichedRequest.projectId
? assistantDatabase.getProject(enrichedRequest.projectId).rootPath
: (await settingsStore.getResolvedSettings()).workspacePath
const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime)
const enabledBuiltinMcpServers = selectedRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? await capabilityService.getEnabledBuiltinMcpServerIds(
selectedRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
const scopedCapability = grantScopedDataCapability({
gateway: knowledgeGateway,
runtime: selectedRuntime,
enabledServers: enabledBuiltinMcpServers,
requestId: enrichedRequest.requestId,
libraryIds: hasKnowledgeScope ? knowledgeLibraryIds : [],
magicNotesAccess: magicNotesToolEnabled
@@ -4397,6 +4444,34 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.capabilitiesToggleBuiltinMcp,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = builtinMcpServerToggleInputSchema.parse(input)
return refreshCapabilities(
capabilityService.setBuiltinMcpServerEnabled(
value.serverId,
value.enabled
)
)
}
)
registerHandler(
ipcChannels.capabilitiesAssignBuiltinMcp,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = builtinMcpServerAssignmentsInputSchema.parse(input)
return refreshCapabilities(
capabilityService.setBuiltinMcpServerAssignments(
value.serverId,
value.assignments
)
)
}
)
registerHandler(
ipcChannels.capabilitiesSaveMcp,
(event, input: unknown): Promise<CapabilitySnapshot> => {