From 291b2ebeb65ca418caa4dbce5f54901f66701290 Mon Sep 17 00:00:00 2001
From: probe
Date: Mon, 26 Oct 2026 22:24:53 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat(buddy):=20=E6=8F=92=E4=BB=B6=E6=B8=85?=
=?UTF-8?q?=E5=8D=95=E6=94=AF=E6=8C=81=E8=B4=A1=E7=8C=AE=20MCP=20=E8=BF=9E?=
=?UTF-8?q?=E6=8E=A5=E5=99=A8=E4=B8=8E=E5=AD=90=E4=BB=A3=E7=90=86=20(#468)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
为什么:一项完整能力常需要技能、MCP 连接器和子代理配套,但插件清单目前只能贡献技能,连接器和子代理要用户手动到设置里添加,卸载时也不会清理。
做了什么:
- 清单新增 contributes.mcpConnectors(仅 streamable-http、无凭据的 HTTPS 地址,默认关闭)与 contributes.subagents(仅 NPC,工具只能引用本包声明的连接器,文件权限默认只读),需要 API 4。
- 主进程汇总已安装插件的声明,服务进程对账:创建、更新、移除对应记录并随插件启停,不新增数据库迁移;用台账文件区分用户改动,读不出台账时不删除任何东西。
- 安装审核页列出连接器与子代理;卸载对话框提前列出用户改过、将被保留的内容。
- plugin-creator 技能补充说明与类型声明。
Co-Authored-By: Claude Sonnet 5.5
---
.../electron/main/app/DesktopIntegrations.ts | 6 +
.../electron/main/app/DesktopRuntimeHost.ts | 8 +
.../main/extensions/registerExtensionIpc.ts | 11 +-
apps/buddy/electron/preload/extensions.ts | 1 +
.../platform/extensions/ExtensionService.ts | 26 +-
.../__tests__/ExtensionService.spec.ts | 29 ++
.../resources/skills/plugin-creator/SKILL.md | 2 +
.../skills/plugin-creator/references/api.d.ts | 31 ++
.../references/mcp-and-subagents.md | 48 +++
apps/buddy/service/src/BuddyService.ts | 24 ++
.../service/src/buddies/BuddiesService.ts | 5 +
.../src/plugins/ExtensionCapabilitySync.ts | 287 ++++++++++++++++++
.../__tests__/ExtensionCapabilitySync.spec.ts | 226 ++++++++++++++
.../__tests__/capabilityLedgerFile.spec.ts | 27 ++
.../src/plugins/capabilityLedgerFile.ts | 44 +++
.../service/src/storage/buddyRepository.ts | 27 ++
.../__tests__/extensionCapabilities.spec.ts | 57 ++++
apps/buddy/shared/extensions/extensionApi.ts | 3 +
.../extensions/extensionCapabilities.ts | 82 +++++
.../shared/extensions/extensionManifest.ts | 13 +
.../src/modules/extensions/extensionLabels.ts | 24 ++
.../widgets/DesktopExtensionInstallReview.vue | 42 +++
.../widgets/DesktopExtensionManager.vue | 11 +-
.../DesktopExtensionUninstallDialog.vue | 13 +-
.../DesktopExtensionInstallReview.spec.ts | 32 ++
.../DesktopExtensionUninstallDialog.spec.ts | 36 +++
26 files changed, 1110 insertions(+), 5 deletions(-)
create mode 100644 apps/buddy/service/resources/skills/plugin-creator/references/mcp-and-subagents.md
create mode 100644 apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts
create mode 100644 apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts
create mode 100644 apps/buddy/service/src/plugins/__tests__/capabilityLedgerFile.spec.ts
create mode 100644 apps/buddy/service/src/plugins/capabilityLedgerFile.ts
create mode 100644 apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts
create mode 100644 apps/buddy/shared/extensions/extensionCapabilities.ts
create mode 100644 apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionUninstallDialog.spec.ts
diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts
index eedebfc0e..191fc2c06 100644
--- a/apps/buddy/electron/main/app/DesktopIntegrations.ts
+++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts
@@ -20,6 +20,7 @@ import { extensionActionRpc } from '../../../shared/extensions/extensionActionAp
import { extensionAgentRpc } from '../../../shared/extensions/extensionAgent'
import { extensionJsonSchema } from '../../../shared/extensions/extensionApi'
import { EXTENSION_REVIEW_REQUEST } from '../../../shared/extensions/extensionAuthoring'
+import { extensionCapabilitiesRpc, extensionCapabilityImpactSchema } from '../../../shared/extensions/extensionCapabilities'
import { extensionConditionSnapshotRpc } from '../../../shared/extensions/extensionConditionContext'
import { extensionShellRpc } from '../../../shared/extensions/extensionShell'
import { extensionSkillsRpc } from '../../../shared/extensions/extensionSkills'
@@ -144,6 +145,8 @@ export class DesktopIntegrations {
agentChanged: catalog => service.notify(extensionAgentRpc.changed, catalog),
shellsChanged: () => service.notify(extensionShellRpc.changed, {}),
skillsChanged: () => service.notify(extensionSkillsRpc.changed, {}),
+ capabilitiesChanged: () => service.notify(extensionCapabilitiesRpc.changed, {}),
+ capabilityImpact: async extensionId => extensionCapabilityImpactSchema.parse(await service.request(extensionCapabilitiesRpc.impact, { extensionId })),
agentRequest: async (input, signal) => extensionJsonSchema.parse(await service.request(extensionAgentRpc.request, input, { signal, timeoutMs: null })),
readResource: (target, signal, request) => readSpaceFileResource(target, signal, async input => spaceFilesRpc.locate.response.parse(await service.request(spaceFilesRpc.locate.method, input, { signal })), request),
readTaskFileResource: async (target, signal, request) => {
@@ -175,14 +178,17 @@ export class DesktopIntegrations {
runtime.extensionStorage = extensions
runtime.extensionShells = extensions.shells
runtime.extensionSkills = extensions.skills
+ runtime.extensionCapabilities = extensions.capabilities
service.notify(extensionShellRpc.changed, {})
service.notify(extensionSkillsRpc.changed, {})
+ service.notify(extensionCapabilitiesRpc.changed, {})
this.#subscriptions.push(() => {
runtime.inspectExtension = null
runtime.extensionAgent = null
runtime.extensionStorage = null
runtime.extensionShells = null
runtime.extensionSkills = null
+ runtime.extensionCapabilities = null
})
this.#subscriptions.push(registerWorkbenchIpc(new WorkbenchStateStore(paths.buddyHome), () => windows.window))
this.#subscriptions.push(registerContextPanelIpc(runtime.contextPanel, () => windows.window))
diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts
index a611cc874..ca4e825a3 100644
--- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts
+++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts
@@ -20,6 +20,7 @@ import { automationNotifications } from '../../../shared/automation/automationAp
import { contextPanelRpc, contextPanelSourceSchema } from '../../../shared/context-panel/contextPanel'
import { extensionAgentInvocationSchema, extensionAgentRpc } from '../../../shared/extensions/extensionAgent'
import { extensionAgentResourceRequestSchema } from '../../../shared/extensions/extensionAgentResources'
+import { extensionCapabilitiesRpc } from '../../../shared/extensions/extensionCapabilities'
import { extensionSkillsRpc } from '../../../shared/extensions/extensionSkills'
import { networkPermissionGrantSchema, networkPermissionsRpc } from '../../../shared/permissions/networkPermissionsRpc'
import { isLinux } from '../../../shared/platform/identifiers'
@@ -50,6 +51,7 @@ export class DesktopRuntimeHost {
extensionAgent: ReturnType['agent'] | null = null
extensionShells: ReturnType['shells'] | null = null
extensionSkills: ReturnType['skills'] | null = null
+ extensionCapabilities: ReturnType['capabilities'] | null = null
extensionStorage: Pick, 'withStoppedStorage'> | null = null
readonly contextPanel: ContextPanelHost
readonly configStore: LexoraConfigStore
@@ -221,6 +223,12 @@ export class DesktopRuntimeHost {
throw new Error('EXTENSION_AGENT_UNAVAILABLE')
return this.extensionAgent.readResource(extensionAgentResourceRequestSchema.parse(input), signal ?? new AbortController().signal)
}),
+ // Unlike skills, an empty answer here would read as "everything was uninstalled", so an unready service must fail instead.
+ peer.onRequest(extensionCapabilitiesRpc.list, () => {
+ if (!this.extensionCapabilities)
+ throw new Error('EXTENSION_SERVICE_UNAVAILABLE')
+ return this.extensionCapabilities.list()
+ }),
peer.onRequest(extensionAgentRpc.invoke, (input, signal) => {
if (!this.extensionAgent)
throw new Error('EXTENSION_AGENT_UNAVAILABLE')
diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts
index 5fdaba0b3..8d84c64b0 100644
--- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts
+++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts
@@ -6,6 +6,7 @@ import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskAction
import type { ExtensionAgentCatalog, ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent'
import type { ExtensionReview, ExtensionReviewResult, ExtensionWorkbenchEvent } from '../../../shared/extensions/extensionApi'
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
+import type { ExtensionCapabilityImpact } from '../../../shared/extensions/extensionCapabilities'
import type { ExtensionConditionRuntime } from '../../../shared/extensions/extensionConditionContext'
import type { TaskFileTarget } from '../../../shared/files/taskFileApi'
import type { SpaceFileTarget } from '../../../shared/spaces/spaceFileApi'
@@ -43,11 +44,13 @@ export function registerExtensionIpc(options: {
invokeTaskAction: (input: ExtensionTaskActionInput) => Promise
shellsChanged?: () => void
skillsChanged?: () => void
+ capabilitiesChanged?: () => void
+ capabilityImpact?: (extensionId: string) => Promise
agentChanged?: (catalog: ExtensionAgentCatalog) => void
record?: ApplicationDiagnosticReporter
conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise
agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise
-}): { shells: { list: ExtensionService['shellContributions'] }, skills: { list: ExtensionService['skillContributions'] }, withStoppedStorage: ExtensionService['withStoppedStorage'], themesReady: Promise, conditions: ExtensionService['conditions'], dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { readResource: ExtensionService['readAgentResource'], list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } {
+}): { shells: { list: ExtensionService['shellContributions'] }, skills: { list: ExtensionService['skillContributions'] }, capabilities: { list: ExtensionService['capabilityContributions'] }, withStoppedStorage: ExtensionService['withStoppedStorage'], themesReady: Promise, conditions: ExtensionService['conditions'], dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { readResource: ExtensionService['readAgentResource'], list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } {
const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version)
async function reviewResult(operation: () => Promise): Promise {
try {
@@ -73,6 +76,7 @@ export function registerExtensionIpc(options: {
clipboard: createExtensionClipboard(options.getWindow),
shellsChanged: options.shellsChanged,
skillsChanged: options.skillsChanged,
+ capabilitiesChanged: options.capabilitiesChanged,
themes: options.themes,
createHost: (pkg, broker, failed) => {
const host = new SandboxedExtensionHost(pkg, protocol, broker, failed)
@@ -281,6 +285,7 @@ export function registerExtensionIpc(options: {
case 'restart': return await service.restart(input.id)
case 'applyUpdate': return await service.applyUpdate(input.id)
case 'uninstall': return await service.uninstall(input.id, input.clearData)
+ case 'capabilityImpact': return await (options.capabilityImpact?.(input.id) ?? { modified: [] })
case 'devtools': return await service.devtools(input.id)
case 'revokeResources': return await service.revokeResources(input.id)
case 'execute': return await service.execute(input.id, input.command, input.resource)
@@ -352,6 +357,10 @@ export function registerExtensionIpc(options: {
await prepared
return service.skillContributions()
} },
+ capabilities: { list: async () => {
+ await prepared
+ return service.capabilityContributions()
+ } },
shells: { list: async () => {
await prepared
return service.shellContributions()
diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts
index 865571f34..5a6ffffb5 100644
--- a/apps/buddy/electron/preload/extensions.ts
+++ b/apps/buddy/electron/preload/extensions.ts
@@ -32,6 +32,7 @@ export function createExtensionApi(): ExtensionApi {
restart: id => request({ action: 'restart', id }),
applyUpdate: id => request({ action: 'applyUpdate', id }),
uninstall: (id, options) => request({ action: 'uninstall', id, clearData: options?.clearData ?? false }),
+ capabilityImpact: id => request({ action: 'capabilityImpact', id }),
devtools: id => request({ action: 'devtools', id }),
revokeResources: id => request({ action: 'revokeResources', id }),
execute: (id, command, resource) => request({ action: 'execute', id, command, resource }),
diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts
index 537626624..0daca1610 100644
--- a/apps/buddy/platform/extensions/ExtensionService.ts
+++ b/apps/buddy/platform/extensions/ExtensionService.ts
@@ -2,6 +2,7 @@ import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../s
import type { ExtensionAgentResourceRequest, ExtensionAgentResourceResponse } from '../../shared/extensions/extensionAgentResources'
import type { ExtensionFileOpeningInput, ExtensionFileOpeningSource, ExtensionMenuInvocation, ExtensionPreparedFileOpening, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi'
import type { ExtensionInspection } from '../../shared/extensions/extensionAuthoring'
+import type { ExtensionCapabilityDescriptor } from '../../shared/extensions/extensionCapabilities'
import type { ExtensionClipboardData } from '../../shared/extensions/extensionClipboard'
import type { ExtensionConditionRuntime } from '../../shared/extensions/extensionConditionContext'
import type { ExtensionConditionState } from '../../shared/extensions/extensionConditions'
@@ -90,6 +91,7 @@ export interface ExtensionServicePorts {
agentChanged?: () => void
shellsChanged?: () => void
skillsChanged?: () => void
+ capabilitiesChanged?: () => void
conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise
agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise
notify?: (id: string, notification: { title: string, body: string }) => boolean
@@ -184,8 +186,10 @@ export class ExtensionService {
workbench: () => this.#panes.map(({ id, active, visible }) => ({ id, active, visible })),
})
this.onDidChange((change) => {
- if (change.kind === 'package' || change.kind === 'enabled')
+ if (change.kind === 'package' || change.kind === 'enabled') {
ports.skillsChanged?.()
+ ports.capabilitiesChanged?.()
+ }
if (change.kind === 'package' || change.kind === 'enabled' || (change.kind === 'configuration' && change.application.status === 'committed'))
this.#refreshShellFingerprint()
if (change.kind === 'resources-revoked') {
@@ -398,6 +402,26 @@ export class ExtensionService {
})
}
+ /** MCP connectors and sub-agents that installed packages declare; the service process turns them into its own records. */
+ async capabilityContributions(): Promise {
+ await this.initialize()
+ await this.#mutating
+ if (this.#disposed)
+ throw new Error('EXTENSION_SERVICE_UNAVAILABLE')
+ return Object.entries(this.store.installed).flatMap(([extensionId, record]) => {
+ const { manifest, revision } = record.current
+ const { mcpConnectors, subagents } = manifest.contributes
+ if (!mcpConnectors.length && !subagents.length)
+ return []
+ let enabled = record.enabled
+ try {
+ this.#order(extensionId)
+ }
+ catch { enabled = false }
+ return [{ extensionId, extensionName: manifest.name, revision, enabled, mcpConnectors: structuredClone(mcpConnectors), subagents: structuredClone(subagents) }]
+ })
+ }
+
async #readAgentContributions(): Promise {
if (this.#disposed)
return []
diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts
index 2767d2922..24ac77b75 100644
--- a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts
+++ b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts
@@ -1,4 +1,5 @@
import type { ApplicationDiagnostic } from '../../../shared/diagnostics/applicationDiagnostic'
+import type { ExtensionMcpConnectorContribution, ExtensionSubagentContribution } from '../../../shared/extensions/extensionCapabilities'
import type { ExtensionHostFailure } from '../../../shared/extensions/extensionDiagnostic'
import type { ExtensionLocalFile } from '../../../shared/extensions/extensionResources'
import type { JsonValue } from '../../../shared/workbench/workbenchState'
@@ -837,3 +838,31 @@ it('denies clipboard and document metadata to decoration views despite declared
for (const [method, data] of [['clipboard.read', null], ['clipboard.write', { text: 'denied' }], ['view.setDocumentInfo', { title: 'denied', dirty: false }]] as const)
await expect(service.viewRequest(session.id, session.generation, session.token, method, data)).rejects.toThrow('EXTENSION_METHOD_DENIED')
})
+
+const connector: ExtensionMcpConnectorContribution = { id: 'legal-search', name: 'Legal search', transport: 'streamable-http', url: 'https://legal.example.com/mcp', deferred: true, credential: 'bearer' }
+const subagent: ExtensionSubagentContribution = { id: 'law-finder', kind: 'npc', name: 'Law finder', description: '', instructions: 'Search statutes first.', tools: { mode: 'selected', ids: [JSON.stringify(['mcp', 'legal-search', 'legal__search'])] }, fileAccess: 'read_only' }
+
+it('publishes declared MCP connectors and sub-agents without activating hosts and follows package state', async () => {
+ const value = manifest({ id: 'tests.legal', apiVersion: 4, entry: undefined, permissions: {}, contributes: { mcpConnectors: [connector], subagents: [subagent] } })
+ const { service, hosts } = await fixture({}, value)
+ const [descriptor] = await service.capabilityContributions()
+ expect(descriptor).toMatchObject({ extensionId: 'tests.legal', extensionName: value.name, enabled: true, mcpConnectors: [{ id: 'legal-search', url: 'https://legal.example.com/mcp', credential: 'bearer', deferred: true }], subagents: [{ id: 'law-finder', kind: 'npc' }] })
+ await service.enable('tests.legal', false)
+ expect(await service.capabilityContributions()).toEqual([{ ...descriptor, enabled: false }])
+ await service.enable('tests.legal', true)
+ expect(await service.capabilityContributions()).toEqual([descriptor])
+ await service.uninstall('tests.legal')
+ expect(await service.capabilityContributions()).toEqual([])
+ expect(hosts).toHaveLength(0)
+})
+
+it('announces capability changes when packages are installed, switched or removed', async () => {
+ const capabilitiesChanged = vi.fn()
+ const value = manifest({ id: 'tests.legal', apiVersion: 4, entry: undefined, permissions: {}, contributes: { mcpConnectors: [connector] } })
+ const { service } = await fixture({ capabilitiesChanged }, value)
+ await service.initialize()
+ capabilitiesChanged.mockClear()
+ await service.enable('tests.legal', false)
+ await service.uninstall('tests.legal')
+ expect(capabilitiesChanged).toHaveBeenCalledTimes(2)
+})
diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md
index 569ed69fe..2497d2655 100644
--- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md
+++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md
@@ -35,6 +35,8 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括
需要随插件提供可搜索、按需读取的工作方法或资源时,读取 [插件 Skills](references/skills.md)。使用标准 `SKILL.md` 与 `contributes.skills`,纯 Skills 包不需要脚本入口;工具仍通过 Agent 能力单独声明。
+需要随插件提供 MCP 连接或子代理时,读取 [MCP 连接与子代理](references/mcp-and-subagents.md)。它们是无需脚本入口的纯声明贡献,安装后默认关闭连接且不含凭据。
+
1. 读取 [协议与清单](references/protocol.md) 和 [完整 API 类型](references/api.d.ts)。这些文件与当前 Lexora 一起分发;不要根据其他产品的插件格式猜测接口。
需要实时配置、表单依赖或运行状态判断时,读取 [条件函数](references/conditions.md),声明数据输入与作用域,不将函数序列化进清单。需要 Agent 指令、后台任务动作、模型调用、任务标题或原生设置时,追加读取 [Agent 与设置](references/agent-settings.md),并确认当前宿主能力目录支持。事件驱动动作与订阅按 [事件与触发列表](references/events.md) 选择精确入口和载荷,不把普通事件监听器当作任务能力调用。
2. 在当前可写工作区创建独立源码目录,例如 `plugins/<插件名>/`。通过文件工具写入真实文件,保留源码以便后续对话修改。不需要用户另外安装 Node、Bun 或 SDK。
diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts
index 70f3a0530..e49aac3cb 100644
--- a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts
+++ b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts
@@ -52,6 +52,37 @@ export interface NavigationContribution {
}
/** API 4: contributes.skills; path points to a packaged Skill directory's SKILL.md. */
export interface SkillContribution { id: string, path: string }
+/**
+ * API 4: contributes.mcpConnectors; adds one MCP connector record when the package is installed. The record is created
+ * disabled and without credentials, nothing is downloaded or run, and the user enables it (after entering a token when
+ * `credential` asks for one) in settings. `id` is the connector ID, not prefixed with the plugin ID.
+ */
+export interface McpConnectorContribution {
+ id: string
+ name: string
+ transport: 'streamable-http'
+ /** HTTPS address without embedded credentials. */
+ url: string
+ /** Defaults to true: tools are discovered on demand instead of being offered to the model directly. */
+ deferred?: boolean
+ /** Defaults to 'none'. Credentials never travel with the package. */
+ credential?: 'none' | 'bearer' | 'oauth'
+}
+/**
+ * API 4: contributes.subagents; adds one NPC sub-agent when the package is installed and lists it for delegation by the
+ * default companion. Only `kind: 'npc'` is accepted. Selected tool IDs are JSON `[source, group, name]` strings; an
+ * `mcp` source must name a connector declared in the same package's `mcpConnectors`.
+ */
+export interface SubagentContribution {
+ id: string
+ kind: 'npc'
+ name: string
+ description?: string
+ instructions: string
+ tools: { mode: 'all' | 'none' } | { mode: 'selected', ids: string[] }
+ /** Defaults to 'read_only'. */
+ fileAccess?: 'inherit' | 'read_only'
+}
export interface Resource { readonly id: string, readonly name: string, readonly sourceId?: string }
export interface ResourceTransferOptions { name?: string, signal?: AbortSignal }
export interface ResourceApi {
diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/mcp-and-subagents.md b/apps/buddy/service/resources/skills/plugin-creator/references/mcp-and-subagents.md
new file mode 100644
index 000000000..1b121424f
--- /dev/null
+++ b/apps/buddy/service/resources/skills/plugin-creator/references/mcp-and-subagents.md
@@ -0,0 +1,48 @@
+# 随插件提供 MCP 连接与子代理
+
+API 4 的 `contributes.mcpConnectors` 与 `contributes.subagents` 让一个纯声明包(无 `entry`、无权限)向应用添加 MCP 连接记录和 NPC 子代理;技能沿用 `contributes.skills`(见 [插件 Skills](skills.md))。三者可以放在同一个包里。
+
+```json
+{
+ "apiVersion": 4,
+ "contributes": {
+ "mcpConnectors": [
+ { "id": "legal-search", "name": "法规检索", "transport": "streamable-http", "url": "https://legal.example.com/mcp", "credential": "bearer" }
+ ],
+ "subagents": [
+ {
+ "id": "law-finder",
+ "kind": "npc",
+ "name": "法规检索员",
+ "description": "按问题检索并核对法规条文",
+ "instructions": "先检索法规原文,再返回条文编号与出处;不要改动用户文件。",
+ "tools": { "mode": "selected", "ids": ["[\"mcp\",\"legal-search\",\"legal__search\"]"] },
+ "fileAccess": "read_only"
+ }
+ ]
+ }
+}
+```
+
+## MCP 连接
+
+- 只允许 `streamable-http` 与不带账号密码的 HTTPS 地址;不支持本机命令启动(`stdio`)。
+- 安装只添加一条连接记录:不下载或运行任何代码,默认关闭,不含凭据。`credential` 为 `bearer` 或 `oauth` 时,用户需在设置中填写 Token 或登录后才能启用;包永远不携带凭据。
+- `deferred`(默认 `true`)决定工具按需发现还是直接提供给模型。
+- 连接 `id` 为小写字母、数字、`-`、`_`,直接作为连接器 ID,不加插件 ID 前缀。若用户已有同 ID 的连接,宿主不会接管它。
+- 最多 16 个。
+
+## 子代理
+
+- 只允许 `kind: "npc"`。`id` 同样不带插件 ID 前缀,宿主会存为 `ext.<插件 ID>.`,以免与用户自建或内置子代理重名。不要使用内置 ID(`npc-explorer`、`npc-researcher`、`npc-executor`、`npc-reviewer`)。
+- 安装后自动加入默认伙伴的可委派列表;卸载时移除。
+- `tools`:`all`、`none` 或 `selected`。`selected` 的工具 ID 是 JSON 字符串 `["来源","分组","名称"]`;来源为 `mcp` 的工具必须指向同一个包 `mcpConnectors` 里声明的连接,否则清单无效,保证被引用的 MCP 随包一起安装。
+- `fileAccess` 默认 `read_only`;`inherit` 表示与当前任务的文件权限一致,不会高于用户授权。
+- 最多 16 个。
+
+## 启停、卸载与用户修改
+
+- 插件被停用时,它添加的连接和子代理一并关闭;重新启用后恢复。
+- 卸载包会删除没被用户改过的连接记录和子代理。用户改过的内容(子代理的名称、说明、提示词、工具或文件权限;连接的名称、地址、暴露方式,或已填写的凭据)会保留,成为用户自己的;卸载对话框会提前列出。
+- 包升级只更新没被改过的记录。
+- 用户在安装后自己删除的连接或子代理,不会在下次同步时被装回,直到重新安装整个插件。
diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts
index 0ba892de2..653f2c702 100644
--- a/apps/buddy/service/src/BuddyService.ts
+++ b/apps/buddy/service/src/BuddyService.ts
@@ -30,6 +30,7 @@ import { Emitter } from '../../shared/events/Emitter'
import { executionsChanged } from '../../shared/executions/executionPresentationApi'
import { extensionActionRpc } from '../../shared/extensions/extensionActionApi'
import { extensionAgentRpc } from '../../shared/extensions/extensionAgent'
+import { extensionCapabilitiesRpc, extensionCapabilityDescriptorSchema, extensionCapabilityImpactRpc } from '../../shared/extensions/extensionCapabilities'
import { extensionShellRpc } from '../../shared/extensions/extensionShell'
import { extensionSkillDescriptorSchema, extensionSkillsRpc } from '../../shared/extensions/extensionSkills'
import { ServiceHost } from '../../shared/lifecycle/ServiceHost'
@@ -146,9 +147,11 @@ import { NetworkPermissionsClient } from './permissions/NetworkPermissionsClient
import { observePetActionDiagnostics } from './pet/observePetActionDiagnostics'
import { PetActionService } from './pet/PetActionService'
import { bindExtensionActions } from './plugins/bindExtensionActions'
+import { createCapabilityLedgerFile } from './plugins/capabilityLedgerFile'
import { ExtensionActionService } from './plugins/ExtensionActionService'
import { ExtensionAgentRuntime } from './plugins/ExtensionAgentRuntime'
import { createExtensionResourceCapabilities } from './plugins/extensionResourceCapabilities'
+import { ExtensionCapabilitySync } from './plugins/ExtensionCapabilitySync'
import { observeExtensionAgentDiagnostics } from './plugins/observeExtensionAgentDiagnostics'
import { PluginAuthoringService } from './plugins/PluginAuthoringService'
import { registerExtensionConditionRpc } from './plugins/registerExtensionConditionRpc'
@@ -760,6 +763,27 @@ export async function startBuddyService(
connectorService.start()
return consumer
})
+ await host.start('runtime.extension_capabilities', ({ defer }) => {
+ const sync = new ExtensionCapabilitySync({
+ buddies,
+ connectors: connectorService,
+ ledger: createCapabilityLedgerFile(join(options.buddyHome, 'extension-capabilities.json')),
+ descriptors: async () => extensionCapabilityDescriptorSchema.array().parse(await options.rpc.request(extensionCapabilitiesRpc.list, {})),
+ report: event => record(event),
+ })
+ const impact = registerRuntimeRequest(options.rpc, extensionCapabilityImpactRpc, input => sync.impact(input.extensionId))
+ defer(options.rpc.onNotification((method) => {
+ if (method === extensionCapabilitiesRpc.changed)
+ void sync.schedule()
+ }))
+ defer(async () => {
+ impact()
+ await sync.dispose()
+ })
+ // The host announces itself once its extensions are loaded; this first pass only covers a service restarted on its own.
+ void sync.schedule()
+ return sync
+ }, ['runtime.buddies', 'runtime.connectors'])
const browserHost = new BrowserHostClient(options.rpc)
const webSettings = await host.start('runtime.web_settings', ({ defer }) => {
const service = new WebSettingsService(workspace, options.rpc)
diff --git a/apps/buddy/service/src/buddies/BuddiesService.ts b/apps/buddy/service/src/buddies/BuddiesService.ts
index 4342007bb..4b5143e20 100644
--- a/apps/buddy/service/src/buddies/BuddiesService.ts
+++ b/apps/buddy/service/src/buddies/BuddiesService.ts
@@ -55,6 +55,11 @@ export class BuddiesService {
return this.#changeProfile(() => this.#repository.update(request))
}
+ installManagedNpc(input: Parameters[0], signal?: AbortSignal) {
+ this.#check(signal)
+ return this.#changeProfile(() => this.#repository.upsertManagedNpc(input))
+ }
+
delete(input: BuddyMutation, signal?: AbortSignal) {
this.#check(signal)
const request = parse(buddyMutationSchema, input)
diff --git a/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts b/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts
new file mode 100644
index 000000000..577404ea4
--- /dev/null
+++ b/apps/buddy/service/src/plugins/ExtensionCapabilitySync.ts
@@ -0,0 +1,287 @@
+import type { BuddyMutation, BuddyUpdate, LocalBuddy } from '../../../shared/buddies/buddyApi'
+import type { BuddySettings } from '../../../shared/buddies/buddySettings'
+import type { ExtensionCapabilityDescriptor, ExtensionCapabilityImpact, ExtensionMcpConnectorContribution, ExtensionSubagentContribution } from '../../../shared/extensions/extensionCapabilities'
+import type { McpServerConfig } from '../connectors/mcp/mcpSchemas'
+import type { McpServerRecord } from '../storage/connectorRepository'
+import { createHash } from 'node:crypto'
+import { BUILTIN_BUDDY_ID } from '../../../shared/buddies/buddyApi'
+import { buddySettingsSchema, DEFAULT_BUDDY_SETTINGS } from '../../../shared/buddies/buddySettings'
+import { BUILTIN_SUBAGENT_IDS, connectorToolExposure, extensionSubagentId } from '../../../shared/extensions/extensionCapabilities'
+
+/** What the sync remembers about a record it created, so it can tell the user's edits from its own definition. */
+export interface CapabilityLedgerEntry {
+ extensionId: string
+ kind: 'mcp' | 'agent'
+ /** Connector id, or the sub-agent row id. */
+ id: string
+ /** Digest of the definition as installed. A record whose current digest differs was edited by the user. */
+ digest: string
+ /** The package is disabled and the sync switched the record off; enabling the package lifts this. */
+ suspended?: boolean
+}
+export interface CapabilityLedger { version: 1, entries: CapabilityLedgerEntry[] }
+
+export interface CapabilityLedgerStore {
+ read: () => Promise
+ write: (ledger: CapabilityLedger) => Promise
+}
+export interface CapabilityBuddies {
+ get: (buddyId: string) => LocalBuddy
+ update: (input: BuddyUpdate) => Promise
+ delete: (input: BuddyMutation) => LocalBuddy
+ installManagedNpc: (input: { id: string, name: string, description: string, settings: BuddySettings }) => LocalBuddy
+}
+export interface CapabilityConnectors {
+ list: () => readonly McpServerRecord[]
+ upsert: (config: McpServerConfig) => Promise
+ setEnabled: (id: string, enabled: boolean) => Promise
+ remove: (id: string) => Promise
+}
+export interface ExtensionCapabilitySyncOptions {
+ buddies: CapabilityBuddies
+ connectors: CapabilityConnectors
+ ledger: CapabilityLedgerStore
+ descriptors: () => Promise
+ report?: (event: { event: string, level: 'info' | 'warn', errorCode?: string, extensionId?: string, count?: number }) => void
+}
+
+const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
+export const connectorDigest = (connector: Pick) => digest(['mcp', connector.name, connector.url, connectorToolExposure(connector)])
+export const subagentDigest = (agent: { name: string, description: string, instructions: string, tools: unknown, fileAccess: string }) => digest(['agent', agent.name, agent.description, agent.instructions, agent.tools, agent.fileAccess])
+const connectorRecordDigest = (record: McpServerRecord) => digest(['mcp', record.name, record.url, record.toolExposure === 'direct' ? 'direct' : 'deferred'])
+const buddyDigest = (buddy: LocalBuddy) => subagentDigest({ name: buddy.name, description: buddy.description, instructions: buddy.settings.instructions, tools: buddy.settings.tools, fileAccess: buddy.settings.limits.fileAccess })
+
+/**
+ * Turns the MCP connectors and sub-agents that installed packages declare into records in the service's own stores,
+ * and takes them out again when the package goes. Nothing is downloaded or executed: a connector is a disabled record
+ * without credentials, a sub-agent is a row bound to the default companion's delegation list.
+ */
+export class ExtensionCapabilitySync {
+ readonly #options: ExtensionCapabilitySyncOptions
+ #running: Promise | undefined
+ #again = false
+ #disposed = false
+
+ constructor(options: ExtensionCapabilitySyncOptions) {
+ this.#options = options
+ }
+
+ /** Coalesces requests: a change that arrives while a pass is running triggers one more pass afterwards. */
+ schedule(): Promise {
+ if (this.#disposed)
+ return Promise.resolve()
+ if (this.#running) {
+ this.#again = true
+ return this.#running
+ }
+ const run = (async () => {
+ try {
+ do {
+ this.#again = false
+ await this.reconcile().catch(error => this.#options.report?.({ event: 'extensions.capabilities.reconcile_failed', level: 'warn', errorCode: errorCode(error) }))
+ } while (this.#again && !this.#disposed)
+ }
+ finally {
+ this.#running = undefined
+ }
+ })()
+ this.#running = run
+ return run
+ }
+
+ async dispose(): Promise {
+ this.#disposed = true
+ await this.#running
+ }
+
+ /** Items the user changed since they were installed; uninstalling their package keeps these. */
+ async impact(extensionId: string): Promise {
+ const { entries } = await this.#options.ledger.read()
+ const modified: ExtensionCapabilityImpact['modified'] = []
+ for (const entry of entries.filter(entry => entry.extensionId === extensionId)) {
+ const current = this.#current(entry)
+ if (current && current.modified)
+ modified.push({ kind: entry.kind, name: current.name })
+ }
+ return { modified }
+ }
+
+ async reconcile(): Promise {
+ // A failed read must leave everything alone: an empty answer would mean "everything was uninstalled".
+ const descriptors = await this.#options.descriptors()
+ const ledger = await this.#options.ledger.read()
+ const kept = new Map(ledger.entries.map(entry => [key(entry), entry]))
+ const wanted = new Set()
+ for (const descriptor of descriptors) {
+ for (const connector of descriptor.mcpConnectors) {
+ wanted.add(key({ kind: 'mcp', id: connector.id }))
+ await this.#guard(descriptor, () => this.#connector(descriptor, connector, kept))
+ }
+ for (const subagent of descriptor.subagents) {
+ wanted.add(key({ kind: 'agent', id: extensionSubagentId(descriptor.extensionId, subagent.id) }))
+ await this.#guard(descriptor, () => this.#subagent(descriptor, subagent, kept))
+ }
+ }
+ for (const [entryKey, entry] of [...kept]) {
+ if (wanted.has(entryKey))
+ continue
+ try {
+ await this.#remove(entry)
+ kept.delete(entryKey)
+ }
+ catch (error) {
+ this.#options.report?.({ event: 'extensions.capabilities.remove_failed', level: 'warn', extensionId: entry.extensionId, errorCode: errorCode(error) })
+ }
+ }
+ await this.#options.ledger.write({ version: 1, entries: [...kept.values()] })
+ }
+
+ async #guard(descriptor: ExtensionCapabilityDescriptor, operation: () => Promise): Promise {
+ try {
+ await operation()
+ }
+ catch (error) {
+ this.#options.report?.({ event: 'extensions.capabilities.install_failed', level: 'warn', extensionId: descriptor.extensionId, errorCode: errorCode(error) })
+ }
+ }
+
+ async #connector(descriptor: ExtensionCapabilityDescriptor, connector: ExtensionMcpConnectorContribution, kept: Map) {
+ const entryKey = key({ kind: 'mcp', id: connector.id })
+ const entry = kept.get(entryKey)
+ if (entry && entry.extensionId !== descriptor.extensionId) {
+ this.#options.report?.({ event: 'extensions.capabilities.conflict', level: 'warn', extensionId: descriptor.extensionId })
+ return
+ }
+ const existing = this.#options.connectors.list().find(record => record.id === connector.id)
+ const config = (enabled: boolean, credentialRef: string | null): McpServerConfig => ({ id: connector.id, name: connector.name, transport: 'streamable-http', url: connector.url, toolExposure: connectorToolExposure(connector), enabled, credentialRef })
+ if (!entry) {
+ // An unknown connector with the same id is the user's; never take it over.
+ if (existing) {
+ this.#options.report?.({ event: 'extensions.capabilities.conflict', level: 'warn', extensionId: descriptor.extensionId })
+ return
+ }
+ await this.#options.connectors.upsert(config(false, null))
+ kept.set(entryKey, { extensionId: descriptor.extensionId, kind: 'mcp', id: connector.id, digest: connectorDigest(connector) })
+ return
+ }
+ // The user removed it after installing: leave it removed until the package itself is reinstalled.
+ if (!existing)
+ return
+ const next = connectorDigest(connector)
+ if (entry.digest !== next && connectorRecordDigest(existing) === entry.digest && (!existing.credentialRef || existing.url === connector.url)) {
+ await this.#options.connectors.upsert(config(existing.enabled, existing.credentialRef))
+ entry.digest = next
+ }
+ if (!descriptor.enabled && existing.enabled) {
+ await this.#options.connectors.setEnabled(existing.id, false)
+ entry.suspended = true
+ }
+ else if (descriptor.enabled && entry.suspended) {
+ delete entry.suspended
+ }
+ }
+
+ async #subagent(descriptor: ExtensionCapabilityDescriptor, subagent: ExtensionSubagentContribution, kept: Map) {
+ const id = extensionSubagentId(descriptor.extensionId, subagent.id)
+ if ((BUILTIN_SUBAGENT_IDS as readonly string[]).includes(subagent.id))
+ return
+ const entryKey = key({ kind: 'agent', id })
+ const entry = kept.get(entryKey)
+ const existing = this.#buddy(id)
+ const definition = { id, name: subagent.name, description: subagent.description, settings: subagentSettings(subagent) }
+ if (!entry) {
+ if (existing && !existing.deletedAt) {
+ this.#options.report?.({ event: 'extensions.capabilities.conflict', level: 'warn', extensionId: descriptor.extensionId })
+ return
+ }
+ this.#options.buddies.installManagedNpc(definition)
+ await this.#delegate(id, true)
+ kept.set(entryKey, { extensionId: descriptor.extensionId, kind: 'agent', id, digest: subagentDigest(subagent) })
+ return
+ }
+ // Removed by the user after installing: leave it removed until the package itself is reinstalled.
+ if (!existing || existing.deletedAt)
+ return
+ const next = subagentDigest(subagent)
+ if (entry.digest !== next && buddyDigest(existing) === entry.digest) {
+ this.#options.buddies.installManagedNpc(definition)
+ entry.digest = next
+ }
+ const current = this.#buddy(id)
+ if (current && !descriptor.enabled && current.enabled) {
+ await this.#options.buddies.update({ buddyId: id, expectedRevision: current.revision, enabled: false })
+ entry.suspended = true
+ }
+ else if (current && descriptor.enabled && entry.suspended) {
+ if (!current.enabled)
+ await this.#options.buddies.update({ buddyId: id, expectedRevision: current.revision, enabled: true })
+ delete entry.suspended
+ }
+ }
+
+ async #remove(entry: CapabilityLedgerEntry): Promise {
+ const current = this.#current(entry)
+ // Already gone, or edited by the user: keep it as the user's own instead of deleting their work.
+ if (!current || current.modified)
+ return
+ if (entry.kind === 'mcp') {
+ await this.#options.connectors.remove(entry.id)
+ return
+ }
+ await this.#delegate(entry.id, false)
+ const buddy = this.#buddy(entry.id)
+ if (buddy && !buddy.deletedAt)
+ this.#options.buddies.delete({ buddyId: buddy.id, expectedRevision: buddy.revision })
+ }
+
+ #current(entry: CapabilityLedgerEntry): { name: string, modified: boolean } | null {
+ if (entry.kind === 'mcp') {
+ const record = this.#options.connectors.list().find(record => record.id === entry.id)
+ return record ? { name: record.name, modified: !!record.credentialRef || connectorRecordDigest(record) !== entry.digest } : null
+ }
+ const buddy = this.#buddy(entry.id)
+ return buddy && !buddy.deletedAt ? { name: buddy.name, modified: buddyDigest(buddy) !== entry.digest } : null
+ }
+
+ #buddy(id: string): LocalBuddy | null {
+ try {
+ return this.#options.buddies.get(id)
+ }
+ catch (error) {
+ if (errorCode(error) === 'BUDDY_NOT_FOUND')
+ return null
+ throw error
+ }
+ }
+
+ /** Puts a sub-agent into, or takes it out of, the default companion's delegation list. */
+ async #delegate(npcId: string, bound: boolean): Promise {
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const actor = this.#options.buddies.get(BUILTIN_BUDDY_ID)
+ const present = actor.settings.npcIds.includes(npcId)
+ if (present === bound)
+ return
+ const npcIds = bound ? [...actor.settings.npcIds, npcId] : actor.settings.npcIds.filter(id => id !== npcId)
+ try {
+ await this.#options.buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, settings: buddySettingsSchema.parse({ ...actor.settings, npcIds }) })
+ return
+ }
+ catch (error) {
+ if (errorCode(error) !== 'BUDDY_CONFLICT' || attempt === 2)
+ throw error
+ }
+ }
+ }
+}
+
+function subagentSettings(subagent: ExtensionSubagentContribution): BuddySettings {
+ return { ...structuredClone(DEFAULT_BUDDY_SETTINGS), instructions: subagent.instructions, tools: structuredClone(subagent.tools), limits: { fileAccess: subagent.fileAccess } }
+}
+
+function key(entry: Pick): string {
+ return `${entry.kind}:${entry.id}`
+}
+
+function errorCode(error: unknown): string {
+ return typeof error === 'object' && error && 'code' in error && typeof error.code === 'string' ? error.code : 'EXTENSION_CAPABILITY_FAILED'
+}
diff --git a/apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts b/apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts
new file mode 100644
index 000000000..1f3905c4b
--- /dev/null
+++ b/apps/buddy/service/src/plugins/__tests__/ExtensionCapabilitySync.spec.ts
@@ -0,0 +1,226 @@
+import type { DatabaseSync } from 'node:sqlite'
+import type { ExtensionCapabilityDescriptor } from '../../../../shared/extensions/extensionCapabilities'
+import type { McpServerConfig } from '../../connectors/mcp/mcpSchemas'
+import type { McpServerRecord } from '../../storage/connectorRepository'
+import type { CapabilityLedger } from '../ExtensionCapabilitySync'
+import { afterEach, describe, expect, it } from 'vitest'
+import { BUILTIN_BUDDY_ID } from '../../../../shared/buddies/buddyApi'
+import { buddySettingsSchema } from '../../../../shared/buddies/buddySettings'
+import { BUILTIN_SUBAGENT_IDS, extensionCapabilityDescriptorSchema, extensionSubagentId } from '../../../../shared/extensions/extensionCapabilities'
+import { BuddiesService } from '../../buddies/BuddiesService'
+import { createBuddyRepository } from '../../storage/buddyRepository'
+import { openBuddyDatabase } from '../../storage/database'
+import { ExtensionCapabilitySync } from '../ExtensionCapabilitySync'
+
+const extensionId = 'tests.legal'
+
+let database: DatabaseSync
+let buddies: BuddiesService
+let records: McpServerRecord[]
+let ledger: CapabilityLedger
+let descriptors: ExtensionCapabilityDescriptor[]
+let events: string[]
+let failure: Error | null
+
+function descriptor(patch: Partial = {}): ExtensionCapabilityDescriptor {
+ return extensionCapabilityDescriptorSchema.parse({
+ extensionId,
+ extensionName: 'Legal pack',
+ revision: 'a',
+ enabled: true,
+ mcpConnectors: [{ id: 'legal-search', name: 'Legal search', transport: 'streamable-http', url: 'https://legal.example.com/mcp', credential: 'bearer' }],
+ subagents: [{ id: 'law-finder', kind: 'npc', name: 'Law finder', description: 'Finds statutes', instructions: 'Search statutes first.', tools: { mode: 'selected', ids: [JSON.stringify(['mcp', 'legal-search', 'legal__search'])] }, fileAccess: 'read_only' }],
+ ...patch,
+ })
+}
+
+function setup() {
+ database = openBuddyDatabase({ databasePath: ':memory:' })
+ buddies = new BuddiesService(createBuddyRepository(database))
+ records = []
+ ledger = { version: 1, entries: [] }
+ descriptors = []
+ events = []
+ failure = null
+ const connectors = {
+ list: () => records,
+ async upsert(config: McpServerConfig) {
+ const existing = records.find(record => record.id === config.id)
+ const record: McpServerRecord = { id: config.id, name: config.name, toolNamespace: config.id, toolExposure: config.toolExposure ?? 'deferred', transport: 'streamable-http', command: null, args: null, cwd: null, url: 'url' in config ? config.url : null, credentialRef: config.credentialRef, executionConfirmedAt: null, enabled: config.enabled, createdAt: existing?.createdAt ?? 'now', updatedAt: 'now' }
+ records = [...records.filter(item => item.id !== config.id), record]
+ return record
+ },
+ async setEnabled(id: string, enabled: boolean) {
+ const record = { ...records.find(record => record.id === id)!, enabled }
+ records = records.map(item => item.id === id ? record : item)
+ return record
+ },
+ async remove(id: string) {
+ const before = records.length
+ records = records.filter(record => record.id !== id)
+ return records.length !== before
+ },
+ }
+ return new ExtensionCapabilitySync({
+ buddies,
+ connectors,
+ ledger: { read: async () => structuredClone(ledger), write: async (value) => { ledger = structuredClone(value) } },
+ descriptors: async () => {
+ if (failure)
+ throw failure
+ return structuredClone(descriptors)
+ },
+ report: event => events.push(event.event),
+ })
+}
+
+const rowId = extensionSubagentId(extensionId, 'law-finder')
+const npcIds = () => buddies.get(BUILTIN_BUDDY_ID).settings.npcIds
+
+afterEach(() => {
+ buddies?.dispose()
+ database?.close()
+})
+
+describe('installing extension capabilities', () => {
+ it('adds a disabled connector record and a prefixed sub-agent the default companion can delegate to', async () => {
+ const sync = setup()
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ expect(records).toMatchObject([{ id: 'legal-search', name: 'Legal search', url: 'https://legal.example.com/mcp', enabled: false, credentialRef: null, toolExposure: 'deferred' }])
+ expect(buddies.get(rowId)).toMatchObject({ kind: 'npc', enabled: true, name: 'Law finder', description: 'Finds statutes', settings: { instructions: 'Search statutes first.', limits: { fileAccess: 'read_only' }, tools: { mode: 'selected' } } })
+ expect(rowId.startsWith('ext.')).toBe(true)
+ expect(npcIds()).toContain(rowId)
+ expect(ledger.entries.map(entry => entry.kind).sort()).toEqual(['agent', 'mcp'])
+ })
+
+ it('does nothing more on a second pass and respects the user removing the sub-agent from the delegation list or deleting it', async () => {
+ const sync = setup()
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ const actor = buddies.get(BUILTIN_BUDDY_ID)
+ await buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, settings: buddySettingsSchema.parse({ ...actor.settings, npcIds: actor.settings.npcIds.filter(id => id !== rowId) }) })
+ const row = buddies.get(rowId)
+ await sync.reconcile()
+ expect(buddies.get(rowId)).toEqual(row)
+ expect(npcIds()).not.toContain(rowId)
+ buddies.delete({ buddyId: rowId, expectedRevision: row.revision })
+ records = []
+ await sync.reconcile()
+ expect(buddies.get(rowId).deletedAt).not.toBeNull()
+ expect(records).toEqual([])
+ })
+
+ it('leaves a connector or sub-agent that already uses the id alone', async () => {
+ const sync = setup()
+ records = [{ id: 'legal-search', name: 'Mine', toolNamespace: 'mine', toolExposure: 'direct', transport: 'streamable-http', command: null, args: null, cwd: null, url: 'https://mine.example.com/mcp', credentialRef: 'legal-search', executionConfirmedAt: null, enabled: true, createdAt: 'then', updatedAt: 'then' }]
+ buddies.installManagedNpc({ id: rowId, name: 'Mine', description: '', settings: buddySettingsSchema.parse(buddies.get('npc-explorer').settings) })
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ expect(records).toMatchObject([{ name: 'Mine', url: 'https://mine.example.com/mcp', enabled: true }])
+ expect(buddies.get(rowId).name).toBe('Mine')
+ expect(ledger.entries).toEqual([])
+ expect(events.filter(event => event === 'extensions.capabilities.conflict')).toHaveLength(2)
+ })
+
+ it('never recreates the built-in sub-agents', async () => {
+ const sync = setup()
+ const builtin = descriptor()
+ builtin.subagents[0]!.id = 'npc-explorer'
+ descriptors = [builtin]
+ await sync.reconcile()
+ expect(() => buddies.get(extensionSubagentId(extensionId, 'npc-explorer'))).toThrow()
+ expect(ledger.entries).toEqual([])
+ })
+
+ it('keeps the built-in registry in step with what ships in the application', () => {
+ setup()
+ const shipped = buddies.list().buddies.filter(buddy => buddy.kind === 'npc').map(buddy => buddy.id).sort()
+ expect([...BUILTIN_SUBAGENT_IDS].sort()).toEqual(shipped)
+ })
+})
+
+describe('keeping packages and records in step', () => {
+ it('removes untouched records and the delegation entry on uninstall, and brings the sub-agent back on reinstall', async () => {
+ const sync = setup()
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ descriptors = []
+ await sync.reconcile()
+ expect(records).toEqual([])
+ expect(buddies.get(rowId).deletedAt).not.toBeNull()
+ expect(npcIds()).not.toContain(rowId)
+ expect(ledger.entries).toEqual([])
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ expect(buddies.get(rowId)).toMatchObject({ deletedAt: null, enabled: true })
+ expect(npcIds()).toContain(rowId)
+ expect(records).toHaveLength(1)
+ })
+
+ it('keeps what the user changed and reports it before the uninstall', async () => {
+ const sync = setup()
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ const row = buddies.get(rowId)
+ await buddies.update({ buddyId: rowId, expectedRevision: row.revision, settings: buddySettingsSchema.parse({ ...row.settings, instructions: 'My own rules.' }) })
+ records = records.map(record => ({ ...record, credentialRef: 'legal-search', enabled: true }))
+ expect((await sync.impact(extensionId)).modified).toEqual([{ kind: 'mcp', name: 'Legal search' }, { kind: 'agent', name: 'Law finder' }])
+ descriptors = []
+ await sync.reconcile()
+ expect(records).toHaveLength(1)
+ expect(buddies.get(rowId)).toMatchObject({ deletedAt: null, settings: { instructions: 'My own rules.' } })
+ expect(ledger.entries).toEqual([])
+ expect((await sync.impact(extensionId)).modified).toEqual([])
+ })
+
+ it('applies a package update to untouched records only', async () => {
+ const sync = setup()
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ const updated = descriptor({ revision: 'b' })
+ updated.mcpConnectors[0]!.url = 'https://legal.example.com/v2/mcp'
+ updated.subagents[0]!.instructions = 'Search statutes, then cases.'
+ descriptors = [updated]
+ await sync.reconcile()
+ expect(records[0]).toMatchObject({ url: 'https://legal.example.com/v2/mcp', enabled: false })
+ expect(buddies.get(rowId).settings.instructions).toBe('Search statutes, then cases.')
+ const edited = buddies.get(rowId)
+ await buddies.update({ buddyId: rowId, expectedRevision: edited.revision, name: 'Renamed' })
+ const third = descriptor({ revision: 'c' })
+ third.subagents[0]!.instructions = 'Third version.'
+ descriptors = [third]
+ await sync.reconcile()
+ expect(buddies.get(rowId)).toMatchObject({ name: 'Renamed' })
+ expect(buddies.get(rowId).settings.instructions).toBe('Search statutes, then cases.')
+ })
+
+ it('switches records off while the package is disabled and does not switch them on again by itself', async () => {
+ const sync = setup()
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ await sync.reconcile()
+ records = records.map(record => ({ ...record, enabled: true }))
+ descriptors = [descriptor({ enabled: false })]
+ await sync.reconcile()
+ expect(records[0]!.enabled).toBe(false)
+ expect(buddies.get(rowId).enabled).toBe(false)
+ descriptors = [descriptor({ enabled: true })]
+ await sync.reconcile()
+ expect(buddies.get(rowId).enabled).toBe(true)
+ expect(records[0]!.enabled).toBe(false)
+ })
+
+ it('removes nothing when the package list cannot be read', async () => {
+ const sync = setup()
+ descriptors = [descriptor()]
+ await sync.reconcile()
+ failure = new Error('EXTENSION_SERVICE_UNAVAILABLE')
+ await expect(sync.reconcile()).rejects.toThrow('EXTENSION_SERVICE_UNAVAILABLE')
+ await sync.schedule()
+ expect(records).toHaveLength(1)
+ expect(buddies.get(rowId).deletedAt).toBeNull()
+ expect(ledger.entries).toHaveLength(2)
+ expect(events).toContain('extensions.capabilities.reconcile_failed')
+ })
+})
diff --git a/apps/buddy/service/src/plugins/__tests__/capabilityLedgerFile.spec.ts b/apps/buddy/service/src/plugins/__tests__/capabilityLedgerFile.spec.ts
new file mode 100644
index 000000000..5e55a4e79
--- /dev/null
+++ b/apps/buddy/service/src/plugins/__tests__/capabilityLedgerFile.spec.ts
@@ -0,0 +1,27 @@
+import type { CapabilityLedger } from '../ExtensionCapabilitySync'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import { createCapabilityLedgerFile } from '../capabilityLedgerFile'
+
+const directories: string[] = []
+afterEach(async () => {
+ await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })))
+})
+
+it('round-trips the ledger and treats a missing or damaged file as empty', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'lexora-capability-ledger-'))
+ directories.push(directory)
+ const path = join(directory, 'extension-capabilities.json')
+ const store = createCapabilityLedgerFile(path)
+ expect(await store.read()).toEqual({ version: 1, entries: [] })
+ const value: CapabilityLedger = { version: 1, entries: [{ extensionId: 'tests.legal', kind: 'agent', id: 'ext.tests.legal.law-finder', digest: 'a'.repeat(64) }] }
+ await store.write(value)
+ expect(await store.read()).toEqual(value)
+ expect(JSON.parse(await readFile(path, 'utf8'))).toEqual(value)
+ await expect(store.write({ version: 1, entries: [{ ...value.entries[0]!, digest: 'short' }] })).rejects.toThrow()
+ expect(await store.read()).toEqual(value)
+ await rm(path)
+ expect(await store.read()).toEqual({ version: 1, entries: [] })
+})
diff --git a/apps/buddy/service/src/plugins/capabilityLedgerFile.ts b/apps/buddy/service/src/plugins/capabilityLedgerFile.ts
new file mode 100644
index 000000000..748a1cd0b
--- /dev/null
+++ b/apps/buddy/service/src/plugins/capabilityLedgerFile.ts
@@ -0,0 +1,44 @@
+import type { CapabilityLedger, CapabilityLedgerStore } from './ExtensionCapabilitySync'
+import { randomUUID } from 'node:crypto'
+import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
+import { dirname } from 'node:path'
+import { z } from 'zod'
+
+const ledgerSchema = z.object({
+ version: z.literal(1),
+ entries: z.array(z.object({
+ extensionId: z.string().min(1).max(120),
+ kind: z.enum(['mcp', 'agent']),
+ id: z.string().min(1).max(256),
+ digest: z.string().regex(/^[a-f0-9]{64}$/),
+ suspended: z.boolean().optional(),
+ }).strict()).max(4096),
+}).strict()
+
+/**
+ * Keeps the ledger next to the other profile data. A missing or unreadable file reads as empty, which is the safe
+ * direction: with no ledger nothing counts as installed by an extension, so nothing is deleted on uninstall.
+ */
+export function createCapabilityLedgerFile(path: string): CapabilityLedgerStore {
+ return {
+ async read(): Promise {
+ try {
+ return ledgerSchema.parse(JSON.parse(await readFile(path, 'utf8')))
+ }
+ catch {
+ return { version: 1, entries: [] }
+ }
+ },
+ async write(ledger) {
+ await mkdir(dirname(path), { recursive: true })
+ const temporary = `${path}.${randomUUID()}.tmp`
+ try {
+ await writeFile(temporary, JSON.stringify(ledgerSchema.parse(ledger)), { mode: 0o600 })
+ await rename(temporary, path)
+ }
+ finally {
+ await rm(temporary, { force: true })
+ }
+ },
+ }
+}
diff --git a/apps/buddy/service/src/storage/buddyRepository.ts b/apps/buddy/service/src/storage/buddyRepository.ts
index 505b35801..83dc5c78d 100644
--- a/apps/buddy/service/src/storage/buddyRepository.ts
+++ b/apps/buddy/service/src/storage/buddyRepository.ts
@@ -1,5 +1,6 @@
import type { DatabaseSync } from 'node:sqlite'
import type { BuddyCatalog, BuddyCreate, BuddyDefaultUpdate, BuddyMutation, BuddyUpdate, LocalBuddy } from '../../../shared/buddies/buddyApi'
+import type { BuddySettings } from '../../../shared/buddies/buddySettings'
import { randomUUID } from 'node:crypto'
import { buddySchema, BUILTIN_BUDDY_ID } from '../../../shared/buddies/buddyApi'
import { buddySettingsSchema, DEFAULT_BUDDY_SETTINGS } from '../../../shared/buddies/buddySettings'
@@ -88,6 +89,32 @@ export function createBuddyRepository(database: DatabaseSync) {
})
},
update: (input: BuddyUpdate) => withTransaction(database, () => updateProfile(input)),
+ /**
+ * Inserts or refreshes a sub-agent that an extension package ships. A live row keeps its enabled switch; a row the user
+ * removed earlier comes back enabled. An id that belongs to an actor is never touched.
+ */
+ upsertManagedNpc(input: { id: string, name: string, description: string, settings: BuddySettings }): LocalBuddy {
+ return withTransaction(database, () => {
+ const settings = JSON.stringify(buddySettingsSchema.parse(input.settings))
+ if (input.settings.npcIds.length)
+ throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING')
+ const now = new Date().toISOString()
+ const row = database.prepare('SELECT kind, deleted_at AS deletedAt FROM buddies WHERE id = ?').get(input.id) as { kind: string, deletedAt: string | null } | undefined
+ if (!row) {
+ database.prepare('INSERT INTO buddies (id, kind, name, description, settings_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(input.id, 'npc', input.name, input.description, settings, now, now)
+ }
+ else if (row.kind !== 'npc') {
+ throw new BuddyServiceError('BUDDY_INVALID_NPC_BINDING')
+ }
+ else {
+ database.prepare('UPDATE buddies SET name = ?, description = ?, settings_json = ?, enabled = CASE WHEN deleted_at IS NULL THEN enabled ELSE 1 END, deleted_at = NULL, revision = revision + 1, updated_at = ? WHERE id = ?')
+ .run(input.name, input.description, settings, now, input.id)
+ }
+ changed()
+ return get(input.id)
+ })
+ },
resetProfile(input: BuddyMutation): LocalBuddy {
return withTransaction(database, () => {
writable(input)
diff --git a/apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts b/apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts
new file mode 100644
index 000000000..84fda91b7
--- /dev/null
+++ b/apps/buddy/shared/extensions/__tests__/extensionCapabilities.spec.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { extensionCapabilityDescriptorSchema, extensionSubagentId, subagentConnectorReferences } from '../extensionCapabilities'
+import { extensionManifestSchema } from '../extensionManifest'
+
+const id = 'tests.legal'
+const connector = { id: 'legal-search', name: 'Legal search', transport: 'streamable-http', url: 'https://legal.example.com/mcp' }
+const tool = JSON.stringify(['mcp', 'legal-search', 'legal__search'])
+const subagent = { id: 'law-finder', kind: 'npc', name: 'Law finder', instructions: 'Search statutes first.', tools: { mode: 'selected', ids: [tool] } }
+
+function manifest(contributes: Record, patch: Record = {}) {
+ return { schemaVersion: 1, id, name: 'Legal', version: '1.0.0', apiVersion: 4, engines: { lexora: '*' }, contributes, ...patch }
+}
+
+describe('manifest capabilities', () => {
+ it('parses MCP connectors and NPC sub-agents with their defaults', () => {
+ const parsed = extensionManifestSchema.parse(manifest({ mcpConnectors: [connector], subagents: [subagent] }))
+ expect(parsed.contributes.mcpConnectors).toEqual([{ ...connector, deferred: true, credential: 'none' }])
+ expect(parsed.contributes.subagents[0]).toMatchObject({ kind: 'npc', description: '', fileAccess: 'read_only', tools: { mode: 'selected' } })
+ expect(parsed.entry).toBeUndefined()
+ expect(extensionManifestSchema.parse(manifest({})).contributes).toMatchObject({ mcpConnectors: [], subagents: [] })
+ })
+
+ it('needs API 4 and a package that declares every connector its sub-agents select', () => {
+ expect(extensionManifestSchema.safeParse(manifest({ mcpConnectors: [connector] }, { apiVersion: 3 })).success).toBe(false)
+ expect(extensionManifestSchema.safeParse(manifest({ subagents: [{ ...subagent, tools: { mode: 'all' } }] }, { apiVersion: 3 })).success).toBe(false)
+ expect(extensionManifestSchema.safeParse(manifest({ subagents: [subagent] })).success).toBe(false)
+ expect(extensionManifestSchema.safeParse(manifest({ mcpConnectors: [{ ...connector, id: 'other' }], subagents: [subagent] })).success).toBe(false)
+ expect(extensionManifestSchema.safeParse(manifest({ mcpConnectors: [connector], subagents: [subagent] })).success).toBe(true)
+ expect(extensionManifestSchema.safeParse(manifest({ subagents: [{ ...subagent, tools: { mode: 'selected', ids: [JSON.stringify(['builtin', 'core', 'read'])] } }] })).success).toBe(true)
+ })
+
+ it.each([
+ ['an actor sub-agent', { subagents: [{ ...subagent, kind: 'actor', tools: { mode: 'none' } }] }],
+ ['a plain HTTP address', { mcpConnectors: [{ ...connector, url: 'http://legal.example.com/mcp' }] }],
+ ['an address with credentials', { mcpConnectors: [{ ...connector, url: 'https://user:secret@legal.example.com/mcp' }] }],
+ ['a local command transport', { mcpConnectors: [{ id: 'local', name: 'Local', transport: 'stdio', command: 'npx', args: [] }] }],
+ ['a duplicate connector id', { mcpConnectors: [connector, connector] }],
+ ['a built-in sub-agent id', { subagents: [{ ...subagent, id: 'npc-explorer', tools: { mode: 'none' } }] }],
+ ['empty instructions', { subagents: [{ ...subagent, instructions: '', tools: { mode: 'none' } }] }],
+ ['an unknown field', { mcpConnectors: [{ ...connector, headers: { Authorization: 'secret' } }] }],
+ ])('rejects %s', (_name, contributes) => {
+ expect(extensionManifestSchema.safeParse(manifest(contributes)).success).toBe(false)
+ })
+
+ it('names the connectors a sub-agent selects tools from', () => {
+ const base = extensionManifestSchema.parse(manifest({ mcpConnectors: [connector], subagents: [subagent] })).contributes.subagents[0]!
+ expect(subagentConnectorReferences(base)).toEqual(['legal-search'])
+ expect(subagentConnectorReferences({ tools: { mode: 'all' } })).toEqual([])
+ expect(extensionSubagentId(id, 'law-finder')).toBe('ext.tests.legal.law-finder')
+ })
+
+ it('exchanges descriptors strictly', () => {
+ const descriptor = { extensionId: id, extensionName: 'Legal', revision: 'a', enabled: true, mcpConnectors: [{ ...connector, deferred: true, credential: 'none' }], subagents: [] }
+ expect(extensionCapabilityDescriptorSchema.parse(descriptor)).toEqual(descriptor)
+ expect(extensionCapabilityDescriptorSchema.safeParse({ ...descriptor, extra: true }).success).toBe(false)
+ })
+})
diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts
index 75e9805dd..3417b6ea2 100644
--- a/apps/buddy/shared/extensions/extensionApi.ts
+++ b/apps/buddy/shared/extensions/extensionApi.ts
@@ -4,6 +4,7 @@ import type { WorkbenchHitRegion, WorkbenchPaneSnapshot } from '../workbench/wor
import type { JsonValue } from '../workbench/workbenchState'
import type { ControlProposal, WorkbenchPresentation } from '../workbench/workbenchUi'
import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskActionResult } from './extensionActionApi'
+import type { ExtensionCapabilityImpact } from './extensionCapabilities'
import type { ExtensionCatalogSnapshot } from './extensionCatalog'
import type { ExtensionCompatibilityIssue, ExtensionIncompatibleReview } from './extensionCompatibility'
import type { ExtensionConditionsChanged, ExtensionConditionState } from './extensionConditions'
@@ -168,6 +169,7 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('restart'), id: extensionIdSchema }).strict(),
z.object({ action: z.literal('applyUpdate'), id: extensionIdSchema }).strict(),
z.object({ action: z.literal('uninstall'), id: extensionIdSchema, clearData: z.boolean().default(false) }).strict(),
+ z.object({ action: z.literal('capabilityImpact'), id: extensionIdSchema }).strict(),
z.object({ action: z.literal('devtools'), id: extensionIdSchema }).strict(),
z.object({ action: z.literal('revokeResources'), id: extensionIdSchema }).strict(),
z.object({ action: z.literal('execute'), id: extensionIdSchema, command: z.string().max(180), resource: spaceFileTargetSchema.nullable() }).strict(),
@@ -209,6 +211,7 @@ export interface ExtensionApi {
restart: (id: string) => Promise
applyUpdate: (id: string) => Promise
uninstall: (id: string, options?: { clearData?: boolean }) => Promise
+ capabilityImpact: (id: string) => Promise
devtools: (id: string) => Promise
revokeResources: (id: string) => Promise
execute: (id: string, command: string, resource: import('../spaces/spaceFileApi').SpaceFileTarget | null) => Promise
diff --git a/apps/buddy/shared/extensions/extensionCapabilities.ts b/apps/buddy/shared/extensions/extensionCapabilities.ts
new file mode 100644
index 000000000..d2be35cfe
--- /dev/null
+++ b/apps/buddy/shared/extensions/extensionCapabilities.ts
@@ -0,0 +1,82 @@
+import { z } from 'zod'
+import { buddyToolSelectionSchema } from '../buddies/buddySettings.ts'
+
+// Capabilities an extension contributes besides skills (which keep using `contributes.skills`): MCP connector
+// records and NPC sub-agents. Neither carries code. The host turns them into rows in its own stores.
+const localId = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/)
+
+export const extensionMcpConnectorContributionSchema = z.object({
+ id: localId,
+ name: z.string().trim().min(1).max(128),
+ transport: z.literal('streamable-http'),
+ url: z.url().max(2048).refine((value) => {
+ const url = new URL(value)
+ return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password
+ }, 'Expected an HTTPS address without credentials'),
+ deferred: z.boolean().default(true),
+ credential: z.enum(['none', 'bearer', 'oauth']).default('none'),
+}).strict()
+
+export const extensionSubagentContributionSchema = z.object({
+ id: localId,
+ kind: z.literal('npc'),
+ name: z.string().trim().min(1).max(80),
+ description: z.string().trim().max(500).default(''),
+ instructions: z.string().min(1).max(16000),
+ tools: buddyToolSelectionSchema,
+ fileAccess: z.enum(['inherit', 'read_only']).default('read_only'),
+}).strict()
+
+export type ExtensionMcpConnectorContribution = z.infer
+export type ExtensionSubagentContribution = z.infer
+
+/** MCP connectors a sub-agent's selected tools point at, from the `[source, group, name]` tool identities. */
+export function subagentConnectorReferences(subagent: Pick): string[] {
+ if (subagent.tools.mode !== 'selected')
+ return []
+ const ids = subagent.tools.ids.flatMap((id) => {
+ const identity: unknown = JSON.parse(id)
+ return Array.isArray(identity) && identity[0] === 'mcp' && typeof identity[1] === 'string' ? [identity[1]] : []
+ })
+ return [...new Set(ids)]
+}
+
+export const extensionCapabilityDescriptorSchema = z.object({
+ extensionId: z.string().min(1),
+ extensionName: z.string().min(1),
+ revision: z.string().min(1),
+ enabled: z.boolean(),
+ mcpConnectors: z.array(extensionMcpConnectorContributionSchema),
+ subagents: z.array(extensionSubagentContributionSchema),
+}).strict()
+
+export type ExtensionCapabilityDescriptor = z.infer
+
+export const extensionCapabilityImpactSchema = z.object({
+ modified: z.array(z.object({ kind: z.enum(['mcp', 'agent']), name: z.string() }).strict()),
+}).strict()
+export type ExtensionCapabilityImpact = z.infer
+
+export const extensionCapabilityImpactRpc = {
+ method: 'extensions.capabilities.impact',
+ input: z.object({ extensionId: z.string().min(1).max(120) }).strict(),
+ response: extensionCapabilityImpactSchema,
+} as const
+
+export const extensionCapabilitiesRpc = {
+ list: 'extensions.capabilities.list',
+ changed: 'extensions.capabilities.changed',
+ impact: extensionCapabilityImpactRpc.method,
+} as const
+
+/** Row id of a sub-agent an extension contributes. The prefix keeps it apart from sub-agents the user created and from the built-in ones. */
+export function extensionSubagentId(extensionId: string, subagentId: string): string {
+ return `ext.${extensionId}.${subagentId}`
+}
+
+/** Sub-agents that ship inside the application. A package may not declare one with the same ID. */
+export const BUILTIN_SUBAGENT_IDS = ['npc-explorer', 'npc-researcher', 'npc-executor', 'npc-reviewer'] as const
+
+export function connectorToolExposure(connector: Pick) {
+ return connector.deferred ? 'deferred' as const : 'direct' as const
+}
diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts
index ec9e85424..d5b8dc7eb 100644
--- a/apps/buddy/shared/extensions/extensionManifest.ts
+++ b/apps/buddy/shared/extensions/extensionManifest.ts
@@ -7,6 +7,7 @@ import { workbenchSlashSchema } from '../workbench/workbenchCommand.ts'
import { workbenchConditionSchema } from '../workbench/workbenchContext.ts'
import { workbenchAnchorSchema, workbenchControls, workbenchControlSchema, workbenchMenuSchema, workbenchMountTargetSchema, workbenchPresentationSchema, workbenchSlots, workbenchSlotSchema } from '../workbench/workbenchUi.ts'
import { extensionAgentSchema } from './extensionAgent.ts'
+import { BUILTIN_SUBAGENT_IDS, extensionMcpConnectorContributionSchema, extensionSubagentContributionSchema, subagentConnectorReferences } from './extensionCapabilities.ts'
import { extensionConditionDefinitionSchema } from './extensionConditions.ts'
import { extensionAuthorSchema } from './extensionIdentity.ts'
import { extensionPathSchema } from './extensionPath.ts'
@@ -130,6 +131,8 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({
permissions: extensionPermissionsSchema.prefault({}),
contributes: z.object({
skills: z.array(extensionSkillContributionSchema).max(32).default([]),
+ mcpConnectors: z.array(extensionMcpConnectorContributionSchema).max(16).default([]),
+ subagents: z.array(extensionSubagentContributionSchema).max(16).default([]),
iconThemes: z.array(fileIconThemeContributionSchema).max(32).default([]),
themes: z.array(themeContributionSchema).max(64).default([]),
conditions: z.array(extensionConditionDefinitionSchema).max(32).default([]),
@@ -167,6 +170,16 @@ export const extensionManifestSchema = extensionMetadataSchema.extend({
const skillRoots = extensionSkillRoots(manifest.contributes.skills)
if (skillRoots.length && manifest.apiVersion < 4)
issue('Skill contributions require API 4')
+ const { mcpConnectors, subagents } = manifest.contributes
+ if ((mcpConnectors.length || subagents.length) && manifest.apiVersion < 4)
+ issue('MCP connector and sub-agent contributions require API 4')
+ if (new Set(mcpConnectors.map(connector => connector.id)).size !== mcpConnectors.length || new Set(subagents.map(subagent => subagent.id)).size !== subagents.length)
+ issue('MCP connector and sub-agent IDs must be unique')
+ if (subagents.some(subagent => (BUILTIN_SUBAGENT_IDS as readonly string[]).includes(subagent.id)))
+ issue('Sub-agent IDs must not reuse built-in ones')
+ // A sub-agent may only select tools of MCP connectors that travel with the same package, so the selection never points at a missing tool.
+ if (subagents.some(subagent => subagentConnectorReferences(subagent).some(id => !mcpConnectors.some(connector => connector.id === id))))
+ issue('Sub-agent tools may only reference MCP connectors declared by the same extension')
if (skillRoots.some((root, index) => skillRoots.some((other, otherIndex) => index !== otherIndex && root.startsWith(other))))
issue('Skill directories must be distinct and must not contain one another')
if ([manifest.entry, ...manifest.contributes.views.map(view => view.entry)].some(entry => entry && skillRoots.some(root => entry.startsWith(root))))
diff --git a/apps/buddy/src/modules/extensions/extensionLabels.ts b/apps/buddy/src/modules/extensions/extensionLabels.ts
index 30b0dd3f5..392804e84 100644
--- a/apps/buddy/src/modules/extensions/extensionLabels.ts
+++ b/apps/buddy/src/modules/extensions/extensionLabels.ts
@@ -54,6 +54,18 @@ const zh = {
noAdded: '没有新增权限',
installFailed: '操作未完成',
error: '错误代码',
+ reviewMcp: 'MCP 连接',
+ reviewMcpNoDownload: '每个连接器只添加一条记录,不下载任何代码;默认关闭,由你在设置里启用。',
+ reviewMcpAddress: '连接地址',
+ reviewMcpCredential: { none: '无需凭据,可直接启用', bearer: '需要 Token:安装后保持关闭,在设置里填写 Token 后再启用', oauth: '需要登录授权:安装后在设置里登录' },
+ reviewMcpExposure: { deferred: '工具按需发现', direct: '工具直接提供给模型' },
+ reviewAgents: '子代理',
+ reviewAgentDelegate: '安装后自动加入默认伙伴的可委派列表;卸载时一并移除。',
+ reviewAgentTools: { all: '可使用全部工具', none: '不使用工具', selected: '仅限 {n} 个指定工具' },
+ reviewAgentFiles: { inherit: '文件权限与当前任务一致', read_only: '只能读取文件,不能修改' },
+ reviewAgentConnectors: '使用的 MCP(随本包一起安装):',
+ keptTitle: '你修改过的内容会保留,不会被删除:',
+ keptKinds: { mcp: 'MCP 连接', agent: '子代理' },
}
const en: typeof zh = {
title: 'Plugins',
@@ -111,6 +123,18 @@ const en: typeof zh = {
noAdded: 'No additional permissions',
installFailed: 'The operation did not complete',
error: 'Error code',
+ reviewMcp: 'MCP connections',
+ reviewMcpNoDownload: 'Adds one connector record and downloads no code. It stays off until you enable it in settings.',
+ reviewMcpAddress: 'Address',
+ reviewMcpCredential: { none: 'No credential needed; can be enabled right away', bearer: 'Needs a token: stays off until you enter it in settings and enable it', oauth: 'Needs sign-in: authorize it in settings after installing' },
+ reviewMcpExposure: { deferred: 'Tools are discovered on demand', direct: 'Tools are offered to the model directly' },
+ reviewAgents: 'Sub-agents',
+ reviewAgentDelegate: 'Added to the default companion’s delegation list on install and removed again on uninstall.',
+ reviewAgentTools: { all: 'May use every tool', none: 'Uses no tools', selected: 'Limited to {n} selected tools' },
+ reviewAgentFiles: { inherit: 'File access follows the current task', read_only: 'Can read files but not change them' },
+ reviewAgentConnectors: 'MCP connections it uses (installed with this package):',
+ keptTitle: 'Content you changed is kept and will not be deleted:',
+ keptKinds: { mcp: 'MCP connection', agent: 'Sub-agent' },
}
export function extensionLabels(language: string) {
return language === 'en-US' ? en : zh
diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue
index 7b0b2a354..96aa7b008 100644
--- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue
+++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue
@@ -1,6 +1,7 @@