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
+1 -1
View File
@@ -184,7 +184,7 @@ git push github "$tag"
6. OpenCode 与 Continue 的权限边界、取消和超时。 6. OpenCode 与 Continue 的权限边界、取消和超时。
7. DeepSeek Harness Ask 拒绝写入和第三方插件工具,可调用 Main 管理的 Web Search/FetchExecute 可调用已启用插件工具。文本模型在网络调用前拒绝图片,声明图片能力的模型可以实际接收 JPEG/PNG。 7. DeepSeek Harness Ask 拒绝写入和第三方插件工具,可调用 Main 管理的 Web Search/FetchExecute 可调用已启用插件工具。文本模型在网络调用前拒绝图片,声明图片能力的模型可以实际接收 JPEG/PNG。
8. OpenCode Agent/Command、原生上下文 Compact,以及 Continue Rules/Prompt 预设、结构化提问和 GoodBuddy 手动摘要压缩。 8. OpenCode Agent/Command、原生上下文 Compact,以及 Continue Rules/Prompt 预设、结构化提问和 GoodBuddy 手动摘要压缩。
9. Runtime 原生清单把 Tools 与 Commands/LSP/Formatters 分开,显示来源及 Ask/Execute 可用性,不混入 GoodBuddy 分配的 Skills/MCP;外部 OpenCode 只报告连接状态,Continue 明确标记原生 Tools 静态发现不支持;MCP 测试只读取有界 Prompt/Resource 元数据,不读取 Resource 内容。 9. Runtime 原生清单把 Tools 与 Commands/LSP/Formatters 分开,显示来源及 Ask/Execute 可用性,不混入 GoodBuddy 分配的 Skills/MCP;外部 OpenCode 只报告连接状态,Continue 明确标记原生 Tools 静态发现不支持;内置 MCP 的启停与 Runtime 分配会持久化并限制后续请求,DeepSeek Harness 保持不可分配;MCP 测试只读取有界 Prompt/Resource 元数据,不读取 Resource 内容。
10. DSH 市场可安装、停用、重新启用和移除插件;启动失败插件不会阻止 Host,并显示为自动停用。 10. DSH 市场可安装、停用、重新启用和移除插件;启动失败插件不会阻止 Host,并显示为自动停用。
11. 智能心跳的创建、暂停、恢复和历史记录。 11. 智能心跳的创建、暂停、恢复和历史记录。
12. 应用退出后无残留 Runtime 子进程。 12. 应用退出后无残留 Runtime 子进程。
+1
View File
@@ -38,6 +38,7 @@
### Skills、MCP 与知识库 ### Skills、MCP 与知识库
- [x] **Skills 按需接入**:可分配给直连模型、OpenCode、Continue 和 DeepSeek Harness,并使用有界资源和受控 Runtime 边界。 - [x] **Skills 按需接入**:可分配给直连模型、OpenCode、Continue 和 DeepSeek Harness,并使用有界资源和受控 Runtime 边界。
- [x] **内置 MCP 按需接入**:知识库、魔法笔记与 GoodBuddy 配置 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 ContinueDeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍会重新发现并经过现有执行记录与权限边界。 - [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍会重新发现并经过现有执行记录与权限边界。
- [x] **MCP Prompts 与 Resources 元数据**MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。 - [x] **MCP Prompts 与 Resources 元数据**MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。 - [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。
+1
View File
@@ -537,6 +537,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 - Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。 - “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
- OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。 - OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。直连模型中的联网搜索与其他内置工具组使用一致的折叠卡片,展开后显示开关、测试状态、隐私说明和工具列表。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
- MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。 - MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。
### 13.8 文档解析设置 ### 13.8 文档解析设置
+1 -1
View File
@@ -699,7 +699,7 @@ npm run build
- `deepseek-harness` 可被保存、选择、检测和显示。 - `deepseek-harness` 可被保存、选择、检测和显示。
- Runtime 详情卡内显示状态、路径、版本和当前用户执行权限。 - Runtime 详情卡内显示状态、路径、版本和当前用户执行权限。
- Skills 与 MCP 设置可把能力分配给 DeepSeek Harness布局、键盘语义、文案和保存回显通过真机检查。 - Skills 与自定义 MCP 设置可把能力分配给 DeepSeek Harness;请求级 GoodBuddy 内置 MCP 当前不支持分配,并在内置 MCP 卡片中以置灰、未选择状态明确显示。布局、键盘语义、文案和保存回显通过真机检查。
- DSH 市场初始关闭且不加载 npm 目录;显式开启后可以搜索、安装、启停、配置和移除插件,安装前只出现一次准确的当前用户权限确认。关闭市场后已有启用插件继续运行,重新开启后管理状态不变。 - DSH 市场初始关闭且不加载 npm 目录;显式开启后可以搜索、安装、启停、配置和移除插件,安装前只出现一次准确的当前用户权限确认。关闭市场后已有启用插件继续运行,重新开启后管理状态不变。
- Ask 写入测试在 Runtime 边界失败。 - Ask 写入测试在 Runtime 边界失败。
- Ask 可使用已启用的 Main Web Search/Fetch,且插件无法通过同名工具绕过所有权校验。 - Ask 可使用已启用的 Main Web Search/Fetch,且插件无法通过同名工具绕过所有权校验。
@@ -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 () => { it('discovers built-in skills and persists enablement and assignments', async () => {
const { filePath, builtinRoot, importedRoot, service } = const { filePath, builtinRoot, importedRoot, service } =
await createService() await createService()
@@ -723,7 +787,7 @@ describe('CapabilityService', () => {
await expect(service.getResolvedMcpServers('model')).resolves.toEqual([]) 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 { filePath, builtinRoot, importedRoot } = await createService()
const credential = Buffer.from( const credential = Buffer.from(
'encrypted:{"version":1,"serverId":"d2ef774b-146c-4467-a909-6feb112a9c2c","secret":"preserved-secret"}' 'encrypted:{"version":1,"serverId":"d2ef774b-146c-4467-a909-6feb112a9c2c","secret":"preserved-secret"}'
@@ -803,7 +867,7 @@ describe('CapabilityService', () => {
} }
}) })
const persisted = await readFile(filePath, 'utf8') const persisted = await readFile(filePath, 'utf8')
expect(persisted).toContain('"version": 4') expect(persisted).toContain('"version": 5')
expect(persisted).toContain(credential) expect(persisted).toContain(credential)
expect(persisted).not.toContain('preserved-secret') expect(persisted).not.toContain('preserved-secret')
}) })
@@ -839,7 +903,7 @@ describe('CapabilityService', () => {
await expect(service.getSnapshot()).resolves.toMatchObject({ await expect(service.getSnapshot()).resolves.toMatchObject({
webSearch: { enabled: true } 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 () => { it('migrates v3 MCP servers with dynamic tools disabled', async () => {
@@ -889,10 +953,51 @@ describe('CapabilityService', () => {
] ]
}) })
const persisted = await readFile(filePath, 'utf8') const persisted = await readFile(filePath, 'utf8')
expect(persisted).toContain('"version": 4') expect(persisted).toContain('"version": 5')
expect(persisted).toContain('"allowDynamicTools": false') 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 () => { it('preserves capabilities created by a newer unsupported version', async () => {
const { directory, filePath, builtinRoot, importedRoot } = const { directory, filePath, builtinRoot, importedRoot } =
await createService() await createService()
+114 -7
View File
@@ -18,6 +18,9 @@ import {
browserProfileIdSchema, browserProfileIdSchema,
browserProfileNameSchema, browserProfileNameSchema,
browserProfilesSummarySchema, browserProfilesSummarySchema,
builtinMcpAssignmentsSchema,
builtinMcpServerIdSchema,
builtinMcpServerStateSummarySchema,
capabilityDiagnosticReportSchema, capabilityDiagnosticReportSchema,
capabilityAssignmentsSchema, capabilityAssignmentsSchema,
computerCapabilityConfigSummarySchema, computerCapabilityConfigSummarySchema,
@@ -25,6 +28,7 @@ import {
mcpServerIdSchema, mcpServerIdSchema,
mcpServerInputSchema, mcpServerInputSchema,
mcpServerSummarySchema, mcpServerSummarySchema,
runtimeTargetSchema,
skillIdSchema, skillIdSchema,
skillSummarySchema, skillSummarySchema,
webSearchCapabilitySchema, webSearchCapabilitySchema,
@@ -32,6 +36,7 @@ import {
type CapabilityDiagnosticReport, type CapabilityDiagnosticReport,
type CapabilitySnapshot, type CapabilitySnapshot,
type BrowserProfilesSummary, type BrowserProfilesSummary,
type BuiltinMcpServerId,
type ComputerCapabilityId, type ComputerCapabilityId,
type McpServerInput, type McpServerInput,
type McpServerSummary, type McpServerSummary,
@@ -105,6 +110,21 @@ const skillStateSchema = z
}) })
.strict() .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 = const encryptedSecretSchema =
encryptedSettingsCredentialSchema.optional() encryptedSettingsCredentialSchema.optional()
@@ -193,10 +213,15 @@ const storedCapabilitiesV3Schema = z
}) })
.strict() .strict()
const storedCapabilitiesSchema = storedCapabilitiesV3Schema.extend({ const storedCapabilitiesV4Schema = storedCapabilitiesV3Schema.extend({
version: z.literal(4) version: z.literal(4)
}) })
const storedCapabilitiesSchema = storedCapabilitiesV4Schema.extend({
version: z.literal(5),
builtinMcpServers: builtinMcpServerStatesSchema
})
type StoredCapabilitiesV1 = z.infer<typeof storedCapabilitiesV1Schema> type StoredCapabilitiesV1 = z.infer<typeof storedCapabilitiesV1Schema>
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema> type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
type StoredMcpServer = z.infer<typeof storedMcpServerSchema> 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( function emptyStoredCapabilities(
webSearchEnabled = true webSearchEnabled = true
): StoredCapabilities { ): StoredCapabilities {
return { return {
version: 4, version: 5,
skills: {}, skills: {},
builtinMcpServers: defaultBuiltinMcpServerStates(),
mcpServers: [], mcpServers: [],
webSearch: { enabled: webSearchEnabled }, webSearch: { enabled: webSearchEnabled },
computerCapabilities: defaultComputerCapabilityStates() computerCapabilities: defaultComputerCapabilityStates()
@@ -728,7 +766,7 @@ export class CapabilityService {
let shouldPersist = false let shouldPersist = false
try { try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
assertSupportedSettingsVersion(raw, 4, (version) => assertSupportedSettingsVersion(raw, 5, (version) =>
`当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试` `当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试`
) )
const version = z const version = z
@@ -737,7 +775,8 @@ export class CapabilityService {
z.literal(1), z.literal(1),
z.literal(2), z.literal(2),
z.literal(3), z.literal(3),
z.literal(4) z.literal(4),
z.literal(5)
]) ])
}) })
.passthrough() .passthrough()
@@ -746,8 +785,9 @@ export class CapabilityService {
const legacy: StoredCapabilitiesV1 = const legacy: StoredCapabilitiesV1 =
storedCapabilitiesV1Schema.parse(raw) storedCapabilitiesV1Schema.parse(raw)
loaded = { loaded = {
version: 4, version: 5,
skills: legacy.skills, skills: legacy.skills,
builtinMcpServers: defaultBuiltinMcpServerStates(),
mcpServers: legacy.mcpServers, mcpServers: legacy.mcpServers,
webSearch: { enabled: true }, webSearch: { enabled: true },
computerCapabilities: defaultComputerCapabilityStates() computerCapabilities: defaultComputerCapabilityStates()
@@ -757,7 +797,8 @@ export class CapabilityService {
const legacy = storedCapabilitiesV2Schema.parse(raw) const legacy = storedCapabilitiesV2Schema.parse(raw)
loaded = { loaded = {
...legacy, ...legacy,
version: 4, version: 5,
builtinMcpServers: defaultBuiltinMcpServerStates(),
webSearch: { enabled: true } webSearch: { enabled: true }
} }
shouldPersist = true shouldPersist = true
@@ -765,7 +806,16 @@ export class CapabilityService {
const legacy = storedCapabilitiesV3Schema.parse(raw) const legacy = storedCapabilitiesV3Schema.parse(raw)
loaded = { loaded = {
...legacy, ...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 shouldPersist = true
} else { } else {
@@ -891,6 +941,12 @@ export class CapabilityService {
? -1 ? -1
: 1 : 1
), ),
builtinMcpServers: builtinMcpServerIdSchema.options.map((id) =>
builtinMcpServerStateSummarySchema.parse({
id,
...state.builtinMcpServers[id]
})
),
mcpServers: state.mcpServers.map((server) => mcpServers: state.mcpServers.map((server) =>
this.toMcpSummary(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( private updateSkillState(
skillId: string, skillId: string,
update: Partial<z.infer<typeof skillStateSchema>> update: Partial<z.infer<typeof skillStateSchema>>
@@ -1801,4 +1894,18 @@ export class CapabilityService {
assigned.map((server) => this.getResolvedMcpServer(server.id)) 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 = { const capabilityService = {
importSkill: vi.fn(async () => snapshot), importSkill: vi.fn(async () => snapshot),
setBuiltinMcpServerEnabled: vi.fn(async () => snapshot),
setBuiltinMcpServerAssignments: vi.fn(async () => snapshot),
setComputerCapabilityEnabled: vi.fn(async () => snapshot), setComputerCapabilityEnabled: vi.fn(async () => snapshot),
setWebSearchEnabled: vi.fn(async () => snapshot), setWebSearchEnabled: vi.fn(async () => snapshot),
createBrowserProfile: vi.fn(async () => snapshot), createBrowserProfile: vi.fn(async () => snapshot),
@@ -215,6 +217,31 @@ describe('registerIpcHandlers computer capabilities', () => {
expect(capabilityService.setWebSearchEnabled).toHaveBeenCalledWith(false) expect(capabilityService.setWebSearchEnabled).toHaveBeenCalledWith(false)
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2) 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({ electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false, canceled: false,
filePaths: ['C:\\meeting-helper.zip'] filePaths: ['C:\\meeting-helper.zip']
@@ -290,7 +317,7 @@ describe('registerIpcHandlers computer capabilities', () => {
expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith( expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith(
'工作配置' '工作配置'
) )
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3) expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(5)
expect(() => expect(() =>
electronMocks.handlers.get( electronMocks.handlers.get(
@@ -2290,7 +2317,8 @@ describe('registerIpcHandlers agent terminal state', () => {
knowledgeServiceOverride?: Record<string, unknown>, knowledgeServiceOverride?: Record<string, unknown>,
knowledgeGateway?: Record<string, unknown>, knowledgeGateway?: Record<string, unknown>,
magicNotesEnabled = false, magicNotesEnabled = false,
goodbuddyConfigService?: Record<string, unknown> goodbuddyConfigService?: Record<string, unknown>,
capabilityServiceOverride?: Record<string, unknown>
) { ) {
const assistantDatabase = { const assistantDatabase = {
claimDueSchedules: vi.fn(() => []), claimDueSchedules: vi.fn(() => []),
@@ -2394,7 +2422,7 @@ describe('registerIpcHandlers agent terminal state', () => {
getPolicySettings, getPolicySettings,
getResolvedSettings getResolvedSettings
} as never, } as never,
{} as never, (capabilityServiceOverride ?? {}) as never,
contextManager as never, contextManager as never,
(knowledgeServiceOverride ?? { (knowledgeServiceOverride ?? {
database: { listKnowledgeBases: vi.fn(() => []) } database: { listKnowledgeBases: vi.fn(() => []) }
@@ -2697,6 +2725,58 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() 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)( it.each(['ask', 'execute'] as const)(
'does not grant or advertise scoped data tools to external OpenCode in %s mode', 'does not grant or advertise scoped data tools to external OpenCode in %s mode',
async (workMode) => { async (workMode) => {
+84 -9
View File
@@ -81,6 +81,9 @@ import {
browserProfileCreateInputSchema, browserProfileCreateInputSchema,
browserProfileRenameInputSchema, browserProfileRenameInputSchema,
browserProfileSelectionInputSchema, browserProfileSelectionInputSchema,
builtinMcpServerAssignmentsInputSchema,
builtinMcpServerIdSchema,
builtinMcpServerToggleInputSchema,
computerCapabilityConfigInputSchema, computerCapabilityConfigInputSchema,
computerCapabilityIdSchema, computerCapabilityIdSchema,
computerCapabilityToggleInputSchema, computerCapabilityToggleInputSchema,
@@ -90,6 +93,8 @@ import {
skillIdSchema, skillIdSchema,
skillImportKindSchema, skillImportKindSchema,
skillToggleInputSchema, skillToggleInputSchema,
runtimeTargetSchema,
type BuiltinMcpServerId,
type CapabilitySnapshot, type CapabilitySnapshot,
type CapabilityDiagnosticReport, type CapabilityDiagnosticReport,
type McpServerTestResult, 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 = { type ScopedDataCapability = {
token?: string token?: string
toolNames: readonly string[] toolNames: readonly string[]
@@ -324,6 +342,7 @@ type ScopedDataCapability = {
function grantScopedDataCapability(input: { function grantScopedDataCapability(input: {
gateway?: KnowledgeMcpGateway gateway?: KnowledgeMcpGateway
runtime: AgentRuntime runtime: AgentRuntime
enabledServers: readonly BuiltinMcpServerId[]
requestId: string requestId: string
libraryIds: readonly string[] libraryIds: readonly string[]
magicNotesAccess: MagicNotesCapabilityAccess magicNotesAccess: MagicNotesCapabilityAccess
@@ -335,11 +354,21 @@ function grantScopedDataCapability(input: {
) => Promise<boolean> ) => Promise<boolean>
signal: AbortSignal signal: AbortSignal
}): ScopedDataCapability { }): 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 ( if (
input.runtime.supportsScopedDataTools === false || input.runtime.supportsScopedDataTools === false ||
(input.libraryIds.length === 0 && (libraryIds.length === 0 &&
input.magicNotesAccess === 'none' && magicNotesAccess === 'none' &&
(input.configAccess ?? 'none') === 'none') configAccess === 'none')
) { ) {
return { toolNames: [] } return { toolNames: [] }
} }
@@ -347,9 +376,9 @@ function grantScopedDataCapability(input: {
throw new Error('内置数据工具服务不可用') throw new Error('内置数据工具服务不可用')
} }
const config = const config =
input.configAccess && input.configAccess !== 'none' && input.workspacePath configAccess !== 'none' && input.workspacePath
? { ? {
access: input.configAccess, access: configAccess,
workspacePath: input.workspacePath, workspacePath: input.workspacePath,
authorizeApply: input.authorizeConfigApply authorizeApply: input.authorizeConfigApply
} }
@@ -357,16 +386,16 @@ function grantScopedDataCapability(input: {
const token = config const token = config
? input.gateway.grant( ? input.gateway.grant(
input.requestId, input.requestId,
input.libraryIds, libraryIds,
input.signal, input.signal,
input.magicNotesAccess, magicNotesAccess,
config config
) )
: input.gateway.grant( : input.gateway.grant(
input.requestId, input.requestId,
input.libraryIds, libraryIds,
input.signal, input.signal,
input.magicNotesAccess magicNotesAccess
) )
return { return {
token, token,
@@ -1301,9 +1330,18 @@ export function registerIpcHandlers(
origin === 'channel' && origin === 'channel' &&
((await applicationSettingsStore?.get())?.magicNotesEnabled ?? ((await applicationSettingsStore?.get())?.magicNotesEnabled ??
false) false)
const requestRuntimeTarget = runtimeTargetFor(requestRuntime)
const enabledBuiltinMcpServers = requestRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? await capabilityService.getEnabledBuiltinMcpServerIds(
requestRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
const notesCapability = grantScopedDataCapability({ const notesCapability = grantScopedDataCapability({
gateway: knowledgeGateway, gateway: knowledgeGateway,
runtime: requestRuntime, runtime: requestRuntime,
enabledServers: enabledBuiltinMcpServers,
requestId, requestId,
libraryIds: [], libraryIds: [],
magicNotesAccess: magicNotesToolEnabled magicNotesAccess: magicNotesToolEnabled
@@ -2303,9 +2341,18 @@ export function registerIpcHandlers(
: enrichedRequest.projectId : enrichedRequest.projectId
? assistantDatabase.getProject(enrichedRequest.projectId).rootPath ? assistantDatabase.getProject(enrichedRequest.projectId).rootPath
: (await settingsStore.getResolvedSettings()).workspacePath : (await settingsStore.getResolvedSettings()).workspacePath
const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime)
const enabledBuiltinMcpServers = selectedRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? await capabilityService.getEnabledBuiltinMcpServerIds(
selectedRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
const scopedCapability = grantScopedDataCapability({ const scopedCapability = grantScopedDataCapability({
gateway: knowledgeGateway, gateway: knowledgeGateway,
runtime: selectedRuntime, runtime: selectedRuntime,
enabledServers: enabledBuiltinMcpServers,
requestId: enrichedRequest.requestId, requestId: enrichedRequest.requestId,
libraryIds: hasKnowledgeScope ? knowledgeLibraryIds : [], libraryIds: hasKnowledgeScope ? knowledgeLibraryIds : [],
magicNotesAccess: magicNotesToolEnabled 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( registerHandler(
ipcChannels.capabilitiesSaveMcp, ipcChannels.capabilitiesSaveMcp,
(event, input: unknown): Promise<CapabilitySnapshot> => { (event, input: unknown): Promise<CapabilitySnapshot> => {
+17
View File
@@ -30,6 +30,7 @@ import { ipcChannels } from '../shared/ipc-channels'
import type { import type {
BrowserProfileCreateInput, BrowserProfileCreateInput,
BrowserProfileRenameInput, BrowserProfileRenameInput,
BuiltinMcpServerId,
CapabilityDiagnosticReport, CapabilityDiagnosticReport,
CapabilitySnapshot, CapabilitySnapshot,
ComputerCapabilityId, ComputerCapabilityId,
@@ -811,6 +812,22 @@ const desktopApi: DesktopApi = {
skillId, skillId,
assignments assignments
}) as Promise<CapabilitySnapshot>, }) as Promise<CapabilitySnapshot>,
setBuiltinMcpServerEnabled: (
serverId: BuiltinMcpServerId,
enabled: boolean
) =>
ipcRenderer.invoke(ipcChannels.capabilitiesToggleBuiltinMcp, {
serverId,
enabled
}) as Promise<CapabilitySnapshot>,
setBuiltinMcpServerAssignments: (
serverId: BuiltinMcpServerId,
assignments
) =>
ipcRenderer.invoke(ipcChannels.capabilitiesAssignBuiltinMcp, {
serverId,
assignments
}) as Promise<CapabilitySnapshot>,
saveMcpServer: (serverId, input) => saveMcpServer: (serverId, input) =>
ipcRenderer.invoke(ipcChannels.capabilitiesSaveMcp, { ipcRenderer.invoke(ipcChannels.capabilitiesSaveMcp, {
serverId, serverId,
+8
View File
@@ -474,6 +474,14 @@ const api: DesktopApi = {
skills: [], skills: [],
mcpServers: [] mcpServers: []
})), })),
setBuiltinMcpServerEnabled: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
setBuiltinMcpServerAssignments: vi.fn(async () => ({
skills: [],
mcpServers: []
})),
saveMcpServer: vi.fn(async () => ({ saveMcpServer: vi.fn(async () => ({
skills: [], skills: [],
mcpServers: [] mcpServers: []
+334 -158
View File
@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers' import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools' import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import type { import type {
BuiltinMcpServerId,
CapabilityDiagnosticReport, CapabilityDiagnosticReport,
CapabilityAssignments, CapabilityAssignments,
CapabilitySnapshot, CapabilitySnapshot,
@@ -43,7 +44,11 @@ const configurableMcpTargets: RuntimeTarget[] = [
'continue', 'continue',
'deepseek-harness' 'deepseek-harness'
] ]
type McpSettingsTab = 'builtin' | 'computer' | 'custom' type McpSettingsTab =
| 'builtin'
| 'custom'
| 'model-tools'
| 'computer'
type McpEditor = { type McpEditor = {
id?: string id?: string
@@ -342,6 +347,23 @@ export function McpSettingsSection({
}) })
} }
const updateBuiltinAssignment = (
serverId: BuiltinMcpServerId,
assignments: CapabilityAssignments,
target: RuntimeTarget,
checked: boolean
): void => {
const next = checked
? [...assignments, target]
: assignments.filter((item) => item !== target)
void run(`builtin:assign:${serverId}`, () =>
window.goodbuddy.capabilities.setBuiltinMcpServerAssignments(
serverId,
next
)
)
}
const openEditor = ( const openEditor = (
nextEditor: McpEditor, nextEditor: McpEditor,
trigger: HTMLButtonElement trigger: HTMLButtonElement
@@ -374,11 +396,8 @@ export function McpSettingsSection({
} }
const computerCapabilities = snapshot?.computerCapabilities ?? [] const computerCapabilities = snapshot?.computerCapabilities ?? []
const visibleComputerCapabilities = computerCapabilities.filter( const builtinMcpStates = new Map(
(capability) => snapshot?.builtinMcpServers?.map((server) => [server.id, server])
activeTab === 'builtin'
? capability.id === 'host-browser-control'
: capability.id !== 'host-browser-control'
) )
const browserProfiles = snapshot?.browserProfiles ?? { const browserProfiles = snapshot?.browserProfiles ?? {
profiles: [], profiles: [],
@@ -414,24 +433,43 @@ export function McpSettingsSection({
headingId="mcp-settings-heading" headingId="mcp-settings-heading"
/> />
<SettingsWarningList warnings={snapshot?.warnings} /> <SettingsWarningList warnings={snapshot?.warnings} />
<PageTabs <div className="mcp-settings__tabs">
ariaLabel={t('mcp.tabs.ariaLabel')} <PageTabs
idPrefix="mcp-settings" ariaLabel={t('mcp.tabs.ariaLabel')}
onChange={(tab) => { idPrefix="mcp-settings"
setError(undefined) onChange={(tab) => {
setActiveTab(tab) setError(undefined)
}} setActiveTab(tab)
tabs={[ }}
{ id: 'builtin', label: t('mcp.tabs.builtin') }, tabs={[
{ id: 'computer', label: t('mcp.tabs.computer') }, {
{ id: 'custom', label: t('mcp.tabs.custom') } id: 'builtin',
]} icon: <Database size={14} />,
value={activeTab} label: t('mcp.tabs.builtin')
variant="segmented" },
/> {
id: 'model-tools',
icon: <Wrench size={14} />,
label: t('mcp.tabs.modelTools')
},
{
id: 'custom',
icon: <Network size={14} />,
label: t('mcp.tabs.custom')
},
{
id: 'computer',
icon: <MonitorCog size={14} />,
label: t('mcp.tabs.computer')
}
]}
value={activeTab}
variant="segmented"
/>
</div>
<section <section
aria-labelledby={`mcp-settings-tab-${activeTab}`} aria-labelledby={`mcp-settings-tab-${activeTab}`}
className="settings-section" className="settings-section mcp-settings__panel"
id={`mcp-settings-panel-${activeTab}`} id={`mcp-settings-panel-${activeTab}`}
role="tabpanel" role="tabpanel"
> >
@@ -446,36 +484,24 @@ export function McpSettingsSection({
</p> </p>
</> </>
)} )}
{activeTab !== 'custom' && ( {activeTab === 'computer' && (
<section <section
aria-labelledby="computer-capabilities-heading" aria-labelledby="computer-capabilities-heading"
className="mcp-tool-section" className="mcp-tool-section"
> >
<div className="mcp-subsection-heading"> <div className="mcp-subsection-heading">
<div> <div>
{activeTab === 'builtin' ? ( <MonitorCog size={15} />
<Globe2 size={15} />
) : (
<MonitorCog size={15} />
)}
<strong id="computer-capabilities-heading"> <strong id="computer-capabilities-heading">
{t( {t('mcp.computer.title')}
activeTab === 'builtin'
? 'mcp.computer.browserTitle'
: 'mcp.computer.title'
)}
</strong> </strong>
</div> </div>
<small> <small>
{t( {t('mcp.computer.subtitle')}
activeTab === 'builtin'
? 'mcp.computer.browserSubtitle'
: 'mcp.computer.subtitle'
)}
</small> </small>
</div> </div>
<div className="capability-list"> <div className="capability-list">
{visibleComputerCapabilities.map((capability) => { {computerCapabilities.map((capability) => {
const report = diagnostics[capability.id] const report = diagnostics[capability.id]
return ( return (
<article className="capability-card" key={capability.id}> <article className="capability-card" key={capability.id}>
@@ -601,8 +627,7 @@ export function McpSettingsSection({
</section> </section>
)} )}
{activeTab === 'builtin' && ( {activeTab === 'computer' && (
<>
<section <section
aria-labelledby="browser-profiles-heading" aria-labelledby="browser-profiles-heading"
className="mcp-tool-section" className="mcp-tool-section"
@@ -748,7 +773,9 @@ export function McpSettingsSection({
})} })}
</div> </div>
</section> </section>
)}
{activeTab === 'builtin' && (
<section <section
aria-labelledby="builtin-mcp-heading" aria-labelledby="builtin-mcp-heading"
className="mcp-tool-section" className="mcp-tool-section"
@@ -777,16 +804,126 @@ export function McpSettingsSection({
const expansionId = `builtin:${server.id}` const expansionId = `builtin:${server.id}`
const expanded = expandedItemIds.has(expansionId) const expanded = expandedItemIds.has(expansionId)
const panelId = `mcp-server-tools-${server.id}` const panelId = `mcp-server-tools-${server.id}`
const enabled = const state = builtinMcpStates.get(server.id) ?? {
id: server.id,
enabled: true,
assignments:
[...server.supportedAssignments] as CapabilityAssignments
}
const featureAvailable =
!('requiresFeature' in server) || !('requiresFeature' in server) ||
magicNotesEnabled === true magicNotesEnabled === true
return ( return (
<article <article
className={`mcp-server-card${ className={`capability-card builtin-mcp-card${
enabled ? '' : ' mcp-server-card--disabled' state.enabled && featureAvailable
? ''
: ' capability-card--disabled'
}`} }`}
key={server.id} key={server.id}
> >
<div className="capability-card__header">
<div>
<strong>{server.name}</strong>
<small>
{!state.enabled
? t('mcp.builtin.disabled')
: !featureAvailable
? t('mcp.builtin.serverSummaryDisabled')
: server.access === 'mixed'
? t('mcp.builtin.serverSummaryMixed')
: t('mcp.builtin.serverSummaryReadOnly')}
</small>
</div>
</div>
<label className="toggle-row">
<input
aria-label={t('mcp.builtin.enableAriaLabel', {
name: server.name
})}
checked={state.enabled}
disabled={Boolean(busy)}
onChange={(event) =>
void run(`builtin:toggle:${server.id}`, () =>
window.goodbuddy.capabilities.setBuiltinMcpServerEnabled(
server.id,
event.target.checked
)
)
}
role="switch"
type="checkbox"
/>
<span>
{state.enabled
? t('mcp.builtin.enabled')
: t('mcp.builtin.disabled')}
</span>
</label>
<p>{server.description}</p>
{!featureAvailable && (
<p className="computer-capability-risk">
<CircleAlert aria-hidden="true" size={13} />
{t('mcp.builtin.featureDisabled')}
</p>
)}
<div className="runtime-assignments">
<small>{t('mcp.builtin.assignedTo')}</small>
{configurableMcpTargets.map((target) => {
const unsupported =
!server.supportedAssignments.some(
(supportedTarget) =>
supportedTarget === target
)
return (
<label
className={
unsupported
? 'runtime-assignment--unsupported'
: undefined
}
key={target}
title={
unsupported
? t('mcp.builtin.runtimeUnsupported')
: undefined
}
>
<input
aria-label={
unsupported
? t(
'mcp.builtin.runtimeAssignmentUnsupportedAriaLabel',
{
name: server.name,
runtime: runtimeLabels[target]
}
)
: undefined
}
checked={
!unsupported &&
state.assignments.includes(target)
}
disabled={Boolean(busy) || unsupported}
onChange={(event) =>
updateBuiltinAssignment(
server.id,
state.assignments,
target,
event.target.checked
)
}
type="checkbox"
/>
{runtimeLabels[target]}
{unsupported
? t('mcp.builtin.unsupportedSuffix')
: ''}
</label>
)
})}
</div>
<button <button
aria-controls={panelId} aria-controls={panelId}
aria-expanded={expanded} aria-expanded={expanded}
@@ -796,43 +933,28 @@ export function McpSettingsSection({
: 'mcp.builtin.expandServer', : 'mcp.builtin.expandServer',
{ name: server.name } { name: server.name }
)} )}
className="mcp-server-card__toggle" className="secondary-button builtin-mcp-card__details"
onClick={() => toggleItem(expansionId)} onClick={() => toggleItem(expansionId)}
type="button" type="button"
> >
<div> {t('mcp.builtin.toolCount', {
<strong>{server.name}</strong> count: server.tools.length
<small> })}
{!enabled <ChevronDown
? t('mcp.builtin.serverSummaryDisabled') aria-hidden="true"
: server.access === 'mixed' className={
? t('mcp.builtin.serverSummaryMixed') expanded
: t('mcp.builtin.serverSummaryReadOnly')} ? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
</small> : 'mcp-server-card__chevron'
</div> }
<span className="mcp-server-card__summary"> size={15}
{t('mcp.builtin.toolCount', { />
count: server.tools.length
})}
<ChevronDown
aria-hidden="true"
className={
expanded
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
: 'mcp-server-card__chevron'
}
size={15}
/>
</span>
</button> </button>
{expanded && ( {expanded && (
<div className="mcp-server-card__body" id={panelId}> <div
{!enabled && ( className="builtin-mcp-card__tools"
<p className="mcp-server-card__disabled-notice"> id={panelId}
{t('mcp.builtin.featureDisabled')} >
</p>
)}
<p>{server.description}</p>
<section <section
aria-label={t('mcp.builtin.toolsAriaLabel', { aria-label={t('mcp.builtin.toolsAriaLabel', {
name: server.name name: server.name
@@ -870,7 +992,9 @@ export function McpSettingsSection({
})} })}
</div> </div>
</section> </section>
)}
{activeTab === 'model-tools' && (
<div className="mcp-tool-section"> <div className="mcp-tool-section">
<div className="mcp-subsection-heading"> <div className="mcp-subsection-heading">
<div> <div>
@@ -884,89 +1008,142 @@ export function McpSettingsSection({
</small> </small>
</div> </div>
<div className="mcp-server-list"> <div className="mcp-server-list">
<article className="capability-card"> {builtinModelToolGroups
<div className="capability-card__header"> .filter((group) => group.id === 'web')
<div> .map((group) => {
<strong>{t('mcp.webSearch.title')}</strong> const expansionId = `model-tools:${group.id}`
<small>{t('mcp.webSearch.subtitle')}</small> const expanded = expandedItemIds.has(expansionId)
</div> const panelId = `model-tool-group-${group.id}`
</div> return (
<label className="toggle-row"> <article className="mcp-server-card" key={group.id}>
<input <button
aria-label={t('mcp.webSearch.enableAriaLabel')} aria-controls={panelId}
checked={webSearch.enabled} aria-expanded={expanded}
disabled={Boolean(busy)} aria-label={t(
onChange={(event) => expanded
void run('web-search:toggle', () => ? 'mcp.modelTools.collapseGroup'
window.goodbuddy.capabilities.setWebSearchEnabled?.( : 'mcp.modelTools.expandGroup',
event.target.checked { name: t('mcp.webSearch.title') }
) ?? )}
Promise.reject( className="mcp-server-card__toggle"
new Error(t('mcp.webSearch.unsupported')) onClick={() => toggleItem(expansionId)}
) type="button"
) >
} <div>
role="switch" <strong>{t('mcp.webSearch.title')}</strong>
type="checkbox" <small>{t('mcp.webSearch.subtitle')}</small>
/> </div>
<span> <span className="mcp-server-card__summary">
{webSearch.enabled {t('mcp.builtin.toolCount', {
? t('mcp.webSearch.enabled') count: group.tools.length
: t('mcp.webSearch.disabled')} })}
</span> <ChevronDown
</label> aria-hidden="true"
<p>{t('mcp.webSearch.description')}</p> className={
<p className="computer-capability-risk"> expanded
<CircleAlert aria-hidden="true" size={13} /> ? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
{t('mcp.webSearch.privacy')} : 'mcp-server-card__chevron'
</p> }
<div className="capability-card__actions"> size={15}
<button />
className="secondary-button" </span>
disabled={Boolean(busy)} </button>
onClick={() => void testDirectModelWebSearch()} {expanded && (
type="button" <div className="mcp-server-card__body" id={panelId}>
> <label className="toggle-row">
<FlaskConical aria-hidden="true" size={13} /> <input
{busy === 'test:web-search' aria-label={t('mcp.webSearch.enableAriaLabel')}
? t('mcp.webSearch.testing') checked={webSearch.enabled}
: t('mcp.webSearch.test')} disabled={Boolean(busy)}
</button> onChange={(event) =>
</div> void run('web-search:toggle', () =>
{webSearchTestResult && ( window.goodbuddy.capabilities.setWebSearchEnabled?.(
<div event.target.checked
aria-label={t('mcp.webSearch.resultAriaLabel')} ) ??
className="capability-diagnostic__result" Promise.reject(
> new Error(t('mcp.webSearch.unsupported'))
<strong> )
{t('mcp.webSearch.result', { )
duration: webSearchTestResult.durationMs }
})} role="switch"
</strong> type="checkbox"
<p>{webSearchTestResult.preview}</p> />
</div> <span>
)} {webSearch.enabled
<section ? t('mcp.webSearch.enabled')
aria-label={t('mcp.webSearch.toolsAriaLabel')} : t('mcp.webSearch.disabled')}
className="mcp-server-tools"
>
<ul>
{builtinModelToolGroups
.find((group) => group.id === 'web')
?.tools.map((tool) => (
<li key={tool.name}>
<div>
<code>{tool.name}</code>
<span className="builtin-tool-badge">
{t('mcp.builtin.readOnly')}
</span> </span>
</label>
<p>{t('mcp.webSearch.description')}</p>
<p className="computer-capability-risk">
<CircleAlert aria-hidden="true" size={13} />
{t('mcp.webSearch.privacy')}
</p>
<div className="capability-card__actions">
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() => void testDirectModelWebSearch()}
type="button"
>
<FlaskConical aria-hidden="true" size={13} />
{busy === 'test:web-search'
? t('mcp.webSearch.testing')
: t('mcp.webSearch.test')}
</button>
</div> </div>
<p>{tool.description}</p> {webSearchTestResult && (
</li> <div
))} aria-label={t(
</ul> 'mcp.webSearch.resultAriaLabel'
</section> )}
</article> className="capability-diagnostic__result"
>
<strong>
{t('mcp.webSearch.result', {
duration:
webSearchTestResult.durationMs
})}
</strong>
<p>{webSearchTestResult.preview}</p>
</div>
)}
<section
aria-label={t(
'mcp.webSearch.toolsAriaLabel'
)}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.builtin.tools')}</strong>
<small>
{t('mcp.profiles.count', {
count: group.tools.length
})}
</small>
</div>
<ul>
{group.tools.map((tool) => (
<li key={tool.name}>
<div>
<span className="mcp-server-tool__identity">
<strong>{tool.displayName}</strong>
<code>{tool.name}</code>
</span>
<span className="builtin-tool-badge">
{t('mcp.builtin.readOnly')}
</span>
</div>
<p>{tool.description}</p>
</li>
))}
</ul>
</section>
</div>
)}
</article>
)
})}
{builtinModelToolGroups {builtinModelToolGroups
.filter((group) => group.id !== 'web') .filter((group) => group.id !== 'web')
.map((group) => { .map((group) => {
@@ -1050,7 +1227,6 @@ export function McpSettingsSection({
})} })}
</div> </div>
</div> </div>
</>
)} )}
{editor && {editor &&
+118 -19
View File
@@ -15,7 +15,10 @@ import type {
DesktopApi, DesktopApi,
RuntimeSettings RuntimeSettings
} from '../../shared/contracts' } from '../../shared/contracts'
import type { CapabilitySnapshot } from '../../shared/capability-contracts' import type {
CapabilityAssignments,
CapabilitySnapshot
} from '../../shared/capability-contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { import type {
EmbeddingDiagnosticResult, EmbeddingDiagnosticResult,
@@ -191,6 +194,35 @@ const capabilitySnapshot = {
)[] )[]
} }
], ],
builtinMcpServers: [
{
id: 'knowledge-base' as const,
enabled: true,
assignments: ['model', 'opencode', 'continue'] as (
| 'model'
| 'opencode'
| 'continue'
)[]
},
{
id: 'magic-notes' as const,
enabled: true,
assignments: ['model', 'opencode', 'continue'] as (
| 'model'
| 'opencode'
| 'continue'
)[]
},
{
id: 'goodbuddy-config' as const,
enabled: true,
assignments: ['model', 'opencode', 'continue'] as (
| 'model'
| 'opencode'
| 'continue'
)[]
}
],
mcpServers: [] as CapabilitySnapshot['mcpServers'], mcpServers: [] as CapabilitySnapshot['mcpServers'],
webSearch: { webSearch: {
provider: 'exa' as const, provider: 'exa' as const,
@@ -254,6 +286,24 @@ const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
enabled enabled
})) }))
})) }))
const setBuiltinMcpServerEnabled = vi.fn(
async (serverId: string, enabled: boolean) => ({
...capabilitySnapshot,
builtinMcpServers: capabilitySnapshot.builtinMcpServers.map(
(server) =>
server.id === serverId ? { ...server, enabled } : server
)
})
)
const setBuiltinMcpServerAssignments = vi.fn(
async (serverId: string, assignments: CapabilityAssignments) => ({
...capabilitySnapshot,
builtinMcpServers: capabilitySnapshot.builtinMcpServers.map(
(server) =>
server.id === serverId ? { ...server, assignments } : server
)
})
)
const setComputerCapabilityEnabled = vi.fn( const setComputerCapabilityEnabled = vi.fn(
async (_capabilityId: string, enabled: boolean) => ({ async (_capabilityId: string, enabled: boolean) => ({
...capabilitySnapshot, ...capabilitySnapshot,
@@ -539,6 +589,8 @@ describe('SettingsPanel runtime files', () => {
removeSkill: vi.fn(async () => capabilitySnapshot), removeSkill: vi.fn(async () => capabilitySnapshot),
setSkillEnabled, setSkillEnabled,
setSkillAssignments: vi.fn(async () => capabilitySnapshot), setSkillAssignments: vi.fn(async () => capabilitySnapshot),
setBuiltinMcpServerEnabled,
setBuiltinMcpServerAssignments,
saveMcpServer, saveMcpServer,
removeMcpServer: vi.fn(async () => capabilitySnapshot), removeMcpServer: vi.fn(async () => capabilitySnapshot),
testMcpServer: vi.fn(async () => ({ testMcpServer: vi.fn(async () => ({
@@ -1042,7 +1094,7 @@ describe('SettingsPanel runtime files', () => {
name: '展开服务器 笔记' name: '展开服务器 笔记'
}) })
expect(noteServerToggle.closest('article')).toHaveClass( expect(noteServerToggle.closest('article')).toHaveClass(
'mcp-server-card--disabled' 'capability-card--disabled'
) )
fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
@@ -1063,7 +1115,7 @@ describe('SettingsPanel runtime files', () => {
screen screen
.getByRole('button', { name: '展开服务器 笔记' }) .getByRole('button', { name: '展开服务器 笔记' })
.closest('article') .closest('article')
).not.toHaveClass('mcp-server-card--disabled') ).not.toHaveClass('capability-card--disabled')
) )
expect( expect(
screen.getAllByText('内置 MCP Server · 按模式读写 · 按对话授权') screen.getAllByText('内置 MCP Server · 按模式读写 · 按对话授权')
@@ -1089,6 +1141,9 @@ describe('SettingsPanel runtime files', () => {
expect(navigation.parentElement).toHaveClass('settings-panel__body') expect(navigation.parentElement).toHaveClass('settings-panel__body')
expect(content.parentElement).toBe(navigation.parentElement) expect(content.parentElement).toBe(navigation.parentElement)
expect(content).toHaveClass('settings-panel__content') expect(content).toHaveClass('settings-panel__content')
expect(
screen.getByRole('tab', { name: 'Agent Runtime' })
).toHaveTextContent('配置 Agent Runtime、默认工作区与原生能力')
}) })
it('omits the redundant close-only footer on passive settings pages', () => { it('omits the redundant close-only footer on passive settings pages', () => {
@@ -3470,12 +3525,11 @@ describe('SettingsPanel runtime files', () => {
within(mcpTabs) within(mcpTabs)
.getAllByRole('tab') .getAllByRole('tab')
.map((tab) => tab.textContent) .map((tab) => tab.textContent)
).toEqual(['内置能力', '电脑控制', '自定义 MCP']) ).toEqual(['内置 MCP', '直连模型', '自定义 MCP', '电脑控制'])
expect( expect(
within(mcpTabs).getByRole('tab', { name: '内置能力' }) within(mcpTabs).getByRole('tab', { name: '内置 MCP' })
).toHaveAttribute('aria-selected', 'true') ).toHaveAttribute('aria-selected', 'true')
expect(await screen.findByText('浏览器能力')).toBeInTheDocument() expect(await screen.findByText('GoodBuddy 内置 MCP')).toBeInTheDocument()
expect(screen.getAllByText('托管浏览器配置').length).toBeGreaterThan(0)
expect( expect(
screen.queryByRole('switch', { screen.queryByRole('switch', {
name: '启用 Linux 桌面控制' name: '启用 Linux 桌面控制'
@@ -3484,6 +3538,43 @@ describe('SettingsPanel runtime files', () => {
expect( expect(
screen.queryByRole('button', { name: /添加 Server/ }) screen.queryByRole('button', { name: /添加 Server/ })
).not.toBeInTheDocument() ).not.toBeInTheDocument()
const knowledgeBuiltinCard = screen
.getByRole('switch', { name: '启用 知识库 内置 MCP' })
.closest('article')
expect(knowledgeBuiltinCard).not.toBeNull()
expect(
within(knowledgeBuiltinCard!).getByLabelText(
/知识库 无法分配给 DeepSeek Harness/
)
).toBeDisabled()
expect(
within(knowledgeBuiltinCard!).getByLabelText(
/知识库 无法分配给 DeepSeek Harness/
)
).not.toBeChecked()
fireEvent.click(
within(knowledgeBuiltinCard!).getByLabelText('Continue')
)
await waitFor(() =>
expect(setBuiltinMcpServerAssignments).toHaveBeenCalledWith(
'knowledge-base',
['model', 'opencode']
)
)
fireEvent.click(
screen.getByRole('switch', { name: '启用 知识库 内置 MCP' })
)
await waitFor(() =>
expect(setBuiltinMcpServerEnabled).toHaveBeenCalledWith(
'knowledge-base',
false
)
)
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '电脑控制' })
)
expect(await screen.findByText('电脑控制能力')).toBeInTheDocument()
expect(screen.getAllByText('托管浏览器配置').length).toBeGreaterThan(0)
fireEvent.click( fireEvent.click(
screen.getByRole('switch', { name: '启用 浏览器控制' }) screen.getByRole('switch', { name: '启用 浏览器控制' })
) )
@@ -3529,24 +3620,28 @@ describe('SettingsPanel runtime files', () => {
await waitFor(() => await waitFor(() =>
expect(removeBrowserProfile).toHaveBeenCalledWith(browserProfileId) expect(removeBrowserProfile).toHaveBeenCalledWith(browserProfileId)
) )
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '电脑控制' })
)
expect(await screen.findByText('电脑控制能力')).toBeInTheDocument()
expect( expect(
screen.getByRole('switch', { screen.getByRole('switch', {
name: '启用 Linux 桌面控制' name: '启用 Linux 桌面控制'
}) })
).toBeDisabled() ).toBeDisabled()
expect( expect(
screen.queryByRole('switch', { name: '启用 浏览器控制' }) screen.getByRole('switch', { name: '启用 浏览器控制' })
).not.toBeInTheDocument() ).toBeInTheDocument()
fireEvent.click( fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '内置能力' }) within(mcpTabs).getByRole('tab', { name: '直连模型' })
) )
expect(await screen.findByText('文件系统操作')).toBeInTheDocument() expect(await screen.findByText('文件系统操作')).toBeInTheDocument()
expect(screen.getByText('浏览器操作')).toBeInTheDocument() expect(screen.getByText('浏览器操作')).toBeInTheDocument()
expect(screen.getByText('联网搜索')).toBeInTheDocument() expect(screen.getByText('联网搜索')).toBeInTheDocument()
expect(screen.queryByText('web_search')).not.toBeInTheDocument()
expect(screen.queryByText('web_fetch')).not.toBeInTheDocument()
const webSearchToggle = screen.getByRole('button', {
name: '展开工具组 联网搜索'
})
expect(webSearchToggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(webSearchToggle)
expect(webSearchToggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('web_search')).toBeInTheDocument() expect(screen.getByText('web_search')).toBeInTheDocument()
expect(screen.getByText('web_fetch')).toBeInTheDocument() expect(screen.getByText('web_fetch')).toBeInTheDocument()
expect( expect(
@@ -3569,6 +3664,9 @@ describe('SettingsPanel runtime files', () => {
expect(screen.getByText('GoodBuddy search result')).toBeInTheDocument() expect(screen.getByText('GoodBuddy search result')).toBeInTheDocument()
expect(testWebSearch).toHaveBeenCalledOnce() expect(testWebSearch).toHaveBeenCalledOnce()
expect(screen.queryByText('读取工作区文本')).not.toBeInTheDocument() expect(screen.queryByText('读取工作区文本')).not.toBeInTheDocument()
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '内置 MCP' })
)
expect(screen.getByText('知识库')).toBeInTheDocument() expect(screen.getByText('知识库')).toBeInTheDocument()
expect(screen.queryByText('knowledge_list')).not.toBeInTheDocument() expect(screen.queryByText('knowledge_list')).not.toBeInTheDocument()
expect(screen.queryByText('knowledge_search')).not.toBeInTheDocument() expect(screen.queryByText('knowledge_search')).not.toBeInTheDocument()
@@ -3599,7 +3697,7 @@ describe('SettingsPanel runtime files', () => {
) )
).toBeInTheDocument() ).toBeInTheDocument()
expect(noteServerToggle.closest('article')).toHaveClass( expect(noteServerToggle.closest('article')).toHaveClass(
'mcp-server-card--disabled' 'capability-card--disabled'
) )
fireEvent.click(noteServerToggle) fireEvent.click(noteServerToggle)
expect( expect(
@@ -3614,11 +3712,14 @@ describe('SettingsPanel runtime files', () => {
}) })
).toHaveLength(builtinMcpServers.length) ).toHaveLength(builtinMcpServers.length)
expect( expect(
screen.getByText('可用于:模型、OpenCode、Continue') screen.getByText(/按请求提供,可分别控制启停与 Runtime 分配/)
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.getByText(/不公开服务地址或凭据/) screen.getByText(/不公开服务地址或凭据/)
).toBeInTheDocument() ).toBeInTheDocument()
fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '直连模型' })
)
const filesystemToggle = screen.getByRole('button', { const filesystemToggle = screen.getByRole('button', {
name: '展开工具组 文件系统操作' name: '展开工具组 文件系统操作'
}) })
@@ -3633,9 +3734,7 @@ describe('SettingsPanel runtime files', () => {
expect(screen.getByText('浏览器导航')).toBeInTheDocument() expect(screen.getByText('浏览器导航')).toBeInTheDocument()
expect( expect(
screen.getAllByRole('button', { name: //u }) screen.getAllByRole('button', { name: //u })
).toHaveLength( ).toHaveLength(builtinModelToolGroups.length)
builtinModelToolGroups.filter((group) => group.id !== 'web').length
)
fireEvent.click( fireEvent.click(
within(mcpTabs).getByRole('tab', { name: '自定义 MCP' }) within(mcpTabs).getByRole('tab', { name: '自定义 MCP' })
) )
@@ -174,7 +174,8 @@ export const integrations = {
sectionAriaLabel: 'MCP configuration', sectionAriaLabel: 'MCP configuration',
tabs: { tabs: {
ariaLabel: 'MCP settings categories', ariaLabel: 'MCP settings categories',
builtin: 'Built-in capabilities', builtin: 'Built-in MCP',
modelTools: 'Direct model',
computer: 'Computer control', computer: 'Computer control',
custom: 'Custom MCP' custom: 'Custom MCP'
}, },
@@ -222,9 +223,19 @@ export const integrations = {
}, },
builtin: { builtin: {
title: 'Built-in GoodBuddy MCP', title: 'Built-in GoodBuddy MCP',
availableTo: 'Available to: Model, OpenCode, Continue', availableTo:
'Provided per request with independent enablement and runtime assignment',
notice: notice:
'GoodBuddy grants built-in MCP short-lived permissions for the current conversation in the main process without exposing service addresses or credentials.', 'GoodBuddy grants built-in MCP short-lived permissions for the current conversation in the main process without exposing service addresses or credentials. Changes apply to subsequent requests, while Ask and Execute access boundaries remain enforced by the system.',
enabled: 'Enabled',
disabled: 'Disabled',
enableAriaLabel: 'Enable the built-in {{name}} MCP',
assignedTo: 'Assign to',
runtimeUnsupported:
'This runtime does not currently support request-scoped built-in MCP',
runtimeAssignmentUnsupportedAriaLabel:
'{{name}} cannot be assigned to {{runtime}} because this runtime does not support built-in MCP',
unsupportedSuffix: ' (not supported yet)',
serverSummaryMixed: serverSummaryMixed:
'Built-in MCP server · Access depends on mode · Authorized per conversation', 'Built-in MCP server · Access depends on mode · Authorized per conversation',
serverSummaryReadOnly: serverSummaryReadOnly:
@@ -43,9 +43,9 @@ export const settings = {
runtime: { runtime: {
label: 'Agent Runtime', label: 'Agent Runtime',
navigationDescription: navigationDescription:
'OpenCode, Continue, DeepSeek Harness, and workspace settings', 'Configure Agent Runtimes, the default workspace, and native capabilities',
description: description:
'OpenCode, Continue, DeepSeek Harness, and workspace settings' 'Configure Agent Runtimes, the default workspace, and native capabilities'
}, },
security: { security: {
label: 'Security and data', label: 'Security and data',
@@ -161,7 +161,8 @@ export const integrations = {
sectionAriaLabel: 'MCP 配置', sectionAriaLabel: 'MCP 配置',
tabs: { tabs: {
ariaLabel: 'MCP 设置分类', ariaLabel: 'MCP 设置分类',
builtin: '内置能力', builtin: '内置 MCP',
modelTools: '直连模型',
computer: '电脑控制', computer: '电脑控制',
custom: '自定义 MCP' custom: '自定义 MCP'
}, },
@@ -209,9 +210,17 @@ export const integrations = {
}, },
builtin: { builtin: {
title: 'GoodBuddy 内置 MCP', title: 'GoodBuddy 内置 MCP',
availableTo: '可用于:模型、OpenCode、Continue', availableTo: '按请求提供,可分别控制启停与 Runtime 分配',
notice: notice:
'内置 MCP 由 GoodBuddy 在主进程按当前对话签发短期权限,不公开服务地址或凭据。', '内置 MCP 由 GoodBuddy 在主进程按当前对话签发短期权限,不公开服务地址或凭据。设置更改会应用到后续请求,Ask / Execute 的读写边界仍由系统强制。',
enabled: '已启用',
disabled: '已停用',
enableAriaLabel: '启用 {{name}} 内置 MCP',
assignedTo: '分配给',
runtimeUnsupported: '当前 Runtime 不支持请求级内置 MCP',
runtimeAssignmentUnsupportedAriaLabel:
'{{name}} 无法分配给 {{runtime}},当前 Runtime 不支持内置 MCP',
unsupportedSuffix: '(暂不支持)',
serverSummaryMixed: '内置 MCP Server · 按模式读写 · 按对话授权', serverSummaryMixed: '内置 MCP Server · 按模式读写 · 按对话授权',
serverSummaryReadOnly: '内置 MCP Server · 只读 · 按对话授权', serverSummaryReadOnly: '内置 MCP Server · 只读 · 按对话授权',
serverSummaryDisabled: serverSummaryDisabled:
@@ -35,8 +35,8 @@ export const settings = {
}, },
runtime: { runtime: {
label: 'Agent Runtime', label: 'Agent Runtime',
navigationDescription: 'OpenCode、Continue、DeepSeek Harness 与工作区', navigationDescription: '配置 Agent Runtime、默认工作区与原生能力',
description: 'OpenCode、Continue、DeepSeek Harness 与工作区' description: '配置 Agent Runtime、默认工作区与原生能力'
}, },
security: { security: {
label: '安全与数据', label: '安全与数据',
+47
View File
@@ -6812,6 +6812,27 @@ details.settings-section > :not(summary) + :not(summary) {
gap: var(--space-3); gap: var(--space-3);
} }
.mcp-settings__tabs {
max-width: 100%;
overflow-x: auto;
flex: 0 0 auto;
}
.mcp-settings__tabs > .page-tabs {
width: 100%;
min-width: max-content;
}
.mcp-settings__tabs .page-tabs__tab {
justify-content: center;
flex: 1;
}
.mcp-settings__panel {
min-width: 0;
gap: var(--space-4);
}
.mcp-server-list { .mcp-server-list {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
@@ -6833,6 +6854,32 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--text-muted); color: var(--text-muted);
} }
.capability-card--disabled {
border-style: dashed;
background: var(--surface-muted);
}
.builtin-mcp-card__details {
display: inline-flex;
width: fit-content;
align-items: center;
gap: var(--space-2);
}
.builtin-mcp-card__tools {
display: grid;
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
.runtime-assignment--unsupported {
color: var(--text-muted);
}
.runtime-assignment--unsupported input {
cursor: not-allowed;
}
.mcp-server-card__header { .mcp-server-card__header {
display: flex; display: flex;
align-items: center; align-items: center;
+9 -6
View File
@@ -1,4 +1,7 @@
import type { RuntimeTarget } from './capability-contracts' import type {
BuiltinMcpServerId,
RuntimeTarget
} from './capability-contracts'
import { import {
knowledgeScopedDataTools, knowledgeScopedDataTools,
@@ -7,7 +10,7 @@ import {
import { goodbuddyConfigTools } from './goodbuddy-config-tools' import { goodbuddyConfigTools } from './goodbuddy-config-tools'
export type BuiltinMcpServerSummary = { export type BuiltinMcpServerSummary = {
id: string id: BuiltinMcpServerId
name: string name: string
description: string description: string
tools: readonly { tools: readonly {
@@ -15,7 +18,7 @@ export type BuiltinMcpServerSummary = {
description: string description: string
access: 'read' | 'write' access: 'read' | 'write'
}[] }[]
assignments: readonly RuntimeTarget[] supportedAssignments: readonly RuntimeTarget[]
access: 'read' | 'mixed' access: 'read' | 'mixed'
authorization: 'conversation-scoped' authorization: 'conversation-scoped'
requiresFeature?: 'magic-notes' requiresFeature?: 'magic-notes'
@@ -32,7 +35,7 @@ export const builtinMcpServers = [
description: summary, description: summary,
access access
})), })),
assignments: ['model', 'opencode', 'continue'], supportedAssignments: ['model', 'opencode', 'continue'],
access: 'read', access: 'read',
authorization: 'conversation-scoped' authorization: 'conversation-scoped'
}, },
@@ -46,7 +49,7 @@ export const builtinMcpServers = [
description: summary, description: summary,
access access
})), })),
assignments: ['model', 'opencode', 'continue'], supportedAssignments: ['model', 'opencode', 'continue'],
access: 'mixed', access: 'mixed',
authorization: 'conversation-scoped', authorization: 'conversation-scoped',
requiresFeature: 'magic-notes' requiresFeature: 'magic-notes'
@@ -61,7 +64,7 @@ export const builtinMcpServers = [
description: summary, description: summary,
access access
})), })),
assignments: ['model', 'opencode', 'continue'], supportedAssignments: ['model', 'opencode', 'continue'],
access: 'mixed', access: 'mixed',
authorization: 'conversation-scoped' authorization: 'conversation-scoped'
} }
+44
View File
@@ -35,6 +35,46 @@ export type CapabilityAssignments = z.infer<
typeof capabilityAssignmentsSchema typeof capabilityAssignmentsSchema
> >
export const builtinMcpServerIdSchema = z.enum([
'knowledge-base',
'magic-notes',
'goodbuddy-config'
])
export type BuiltinMcpServerId = z.infer<
typeof builtinMcpServerIdSchema
>
export const builtinMcpAssignmentsSchema =
capabilityAssignmentsSchema.refine(
(assignments) => !assignments.includes('deepseek-harness'),
'DeepSeek Harness 当前不支持内置 MCP'
)
export const builtinMcpServerToggleInputSchema = z
.object({
serverId: builtinMcpServerIdSchema,
enabled: z.boolean()
})
.strict()
export const builtinMcpServerAssignmentsInputSchema = z
.object({
serverId: builtinMcpServerIdSchema,
assignments: builtinMcpAssignmentsSchema
})
.strict()
export const builtinMcpServerStateSummarySchema = z
.object({
id: builtinMcpServerIdSchema,
enabled: z.boolean(),
assignments: builtinMcpAssignmentsSchema
})
.strict()
export type BuiltinMcpServerStateSummary = z.infer<
typeof builtinMcpServerStateSummarySchema
>
export const secretActionSchema = z.discriminatedUnion('action', [ export const secretActionSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('keep') }).strict(), z.object({ action: z.literal('keep') }).strict(),
z z
@@ -327,6 +367,10 @@ export type WebSearchCapability = z.infer<
export const capabilitySnapshotSchema = z export const capabilitySnapshotSchema = z
.object({ .object({
skills: z.array(skillSummarySchema).max(256), skills: z.array(skillSummarySchema).max(256),
builtinMcpServers: z
.array(builtinMcpServerStateSummarySchema)
.max(3)
.optional(),
mcpServers: z.array(mcpServerSummarySchema).max(64), mcpServers: z.array(mcpServerSummarySchema).max(64),
webSearch: webSearchCapabilitySchema.optional(), webSearch: webSearchCapabilitySchema.optional(),
computerCapabilities: z computerCapabilities: z
+9
View File
@@ -6,6 +6,7 @@ import {
import type { import type {
BrowserProfileCreateInput, BrowserProfileCreateInput,
BrowserProfileRenameInput, BrowserProfileRenameInput,
BuiltinMcpServerId,
CapabilityDiagnosticReport, CapabilityDiagnosticReport,
CapabilityAssignments, CapabilityAssignments,
CapabilitySnapshot, CapabilitySnapshot,
@@ -1532,6 +1533,14 @@ export type DesktopApi = {
skillId: string, skillId: string,
assignments: CapabilityAssignments assignments: CapabilityAssignments
) => Promise<CapabilitySnapshot> ) => Promise<CapabilitySnapshot>
setBuiltinMcpServerEnabled: (
serverId: BuiltinMcpServerId,
enabled: boolean
) => Promise<CapabilitySnapshot>
setBuiltinMcpServerAssignments: (
serverId: BuiltinMcpServerId,
assignments: CapabilityAssignments
) => Promise<CapabilitySnapshot>
saveMcpServer: ( saveMcpServer: (
serverId: string | undefined, serverId: string | undefined,
input: McpServerInput input: McpServerInput
+2
View File
@@ -128,6 +128,8 @@ export const ipcChannels = {
capabilitiesRemoveSkill: 'capabilities:skill:remove', capabilitiesRemoveSkill: 'capabilities:skill:remove',
capabilitiesToggleSkill: 'capabilities:skill:toggle', capabilitiesToggleSkill: 'capabilities:skill:toggle',
capabilitiesAssignSkill: 'capabilities:skill:assign', capabilitiesAssignSkill: 'capabilities:skill:assign',
capabilitiesToggleBuiltinMcp: 'capabilities:builtin-mcp:toggle',
capabilitiesAssignBuiltinMcp: 'capabilities:builtin-mcp:assign',
capabilitiesSaveMcp: 'capabilities:mcp:save', capabilitiesSaveMcp: 'capabilities:mcp:save',
capabilitiesRemoveMcp: 'capabilities:mcp:remove', capabilitiesRemoveMcp: 'capabilities:mcp:remove',
capabilitiesTestMcp: 'capabilities:mcp:test', capabilitiesTestMcp: 'capabilities:mcp:test',