From 0fe2df6ec34a64fa9dd80355dbbfa064a0e48752 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 07:50:33 +0000 Subject: [PATCH 01/23] feat(schema): define plugin contracts --- .../workbench/src/mock/dev-mock-host.ts | 1 + .../schema/src/model/agent/event.ts | 6 + packages/foundation/schema/src/model/index.ts | 1 + .../foundation/schema/src/model/plugin.ts | 155 ++++++++++++++++++ packages/foundation/schema/src/wire/config.ts | 9 +- .../foundation/schema/src/wire/message.ts | 3 +- .../foundation/schema/src/wire/payload.ts | 2 + packages/foundation/schema/src/wire/plugin.ts | 12 ++ .../schema/tests/contract/wire/plugin.test.ts | 47 ++++++ .../host/engine/src/agent/request-handler.ts | 1 + 10 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 packages/foundation/schema/src/model/plugin.ts create mode 100644 packages/foundation/schema/src/wire/plugin.ts create mode 100644 packages/foundation/schema/tests/contract/wire/plugin.test.ts diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index a0e594def..2951d9da7 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -323,6 +323,7 @@ export class DevMockHost { replyTo: p.clientReqId, providers: this.providers, accounts: this.accounts, + plugins: { units: [], connectors: [] }, }); break; case 'agent-runtime.list': diff --git a/packages/foundation/schema/src/model/agent/event.ts b/packages/foundation/schema/src/model/agent/event.ts index 382b5e1a8..b45052fa1 100644 --- a/packages/foundation/schema/src/model/agent/event.ts +++ b/packages/foundation/schema/src/model/agent/event.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ContentBlockSchema } from '../content'; import { PermissionOutcomeSchema, PermissionRequestSchema } from '../permission'; import { PlanSchema } from '../plan'; +import { McpPluginIdSchema, PluginWarningReasonSchema } from '../plugin'; import { AgentHistoryIdSchema, MessageIdSchema, @@ -125,6 +126,11 @@ export const AgentEventSchema = z.discriminatedUnion('type', [ code: z.string().optional(), recoverable: z.boolean().default(true), }), + z.object({ + type: z.literal('plugin-warning'), + unitId: McpPluginIdSchema, + reason: PluginWarningReasonSchema, + }), // Agent → client requests await a reply via AgentInput, correlated by requestId. PermissionRequestSchema.extend({ diff --git a/packages/foundation/schema/src/model/index.ts b/packages/foundation/schema/src/model/index.ts index 8799c5447..01100ced5 100644 --- a/packages/foundation/schema/src/model/index.ts +++ b/packages/foundation/schema/src/model/index.ts @@ -12,6 +12,7 @@ export * from './loop'; export * from './managed-asset'; export * from './permission'; export * from './plan'; +export * from './plugin'; export * from './primitives'; export * from './provider-config'; export * from './question'; diff --git a/packages/foundation/schema/src/model/plugin.ts b/packages/foundation/schema/src/model/plugin.ts new file mode 100644 index 000000000..79aec8ef0 --- /dev/null +++ b/packages/foundation/schema/src/model/plugin.ts @@ -0,0 +1,155 @@ +import { z } from 'zod'; +import { McpServerSchema } from './agent/input'; +import { TimestampSchema } from './primitives'; + +export const McpPluginIdSchema = z.enum(['github-read']); +export type McpPluginId = z.infer; + +export const McpPluginServiceSchema = z.enum(['github']); +export type McpPluginService = z.infer; + +export const McpPluginLabelKeySchema = z.enum(['units.githubRead.label']); +export const McpPluginDescriptionKeySchema = z.enum(['units.githubRead.description']); + +export const McpPluginCredentialSlotSchema = z.discriminatedUnion('target', [ + z.object({ target: z.literal('env'), name: z.string().min(1) }), + z.object({ target: z.literal('header'), name: z.string().min(1) }), +]); +export type McpPluginCredentialSlot = z.infer; + +const McpPluginBackingSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('preset'), + server: McpServerSchema, + credentialSlots: z.array(McpPluginCredentialSlotSchema).default([]), + }), + z.object({ + type: z.literal('managed-connector'), + name: z.string().min(1), + }), +]); + +export const McpPluginDescriptorSchema = z + .object({ + id: McpPluginIdSchema, + labelKey: McpPluginLabelKeySchema, + descriptionKey: McpPluginDescriptionKeySchema, + service: McpPluginServiceSchema.optional(), + backing: McpPluginBackingSchema, + }) + .superRefine((descriptor, context) => { + if (descriptor.backing.type === 'managed-connector' && descriptor.service === undefined) { + context.addIssue({ code: 'custom', message: 'managed connector requires a service' }); + return; + } + if (descriptor.backing.type !== 'preset') return; + if (descriptor.backing.credentialSlots.length > 0 && descriptor.service === undefined) { + context.addIssue({ code: 'custom', message: 'credential slots require a service' }); + } + const target = descriptor.backing.server.type === 'stdio' ? 'env' : 'header'; + for (const slot of descriptor.backing.credentialSlots) { + if (slot.target !== target) { + context.addIssue({ + code: 'custom', + message: `${descriptor.backing.server.type} MCP cannot inject a ${slot.target} credential`, + }); + } + } + }); +export type McpPluginDescriptor = z.infer; + +export const McpPluginCatalogSchema = z + .array(McpPluginDescriptorSchema) + .superRefine((catalog, context) => { + const ids = new Set(); + const names = new Set(); + for (const descriptor of catalog) { + const name = + descriptor.backing.type === 'preset' + ? descriptor.backing.server.name + : descriptor.backing.name; + if (ids.has(descriptor.id)) { + context.addIssue({ code: 'custom', message: `duplicate plugin id: ${descriptor.id}` }); + } + if (names.has(name)) { + context.addIssue({ code: 'custom', message: `duplicate MCP server name: ${name}` }); + } + ids.add(descriptor.id); + names.add(name); + } + }); +export type McpPluginCatalog = z.infer; + +export const PluginBindingSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('managed') }), + z.object({ type: z.literal('local'), connectorId: z.string().min(1) }), +]); +export type PluginBinding = z.infer; + +export const PluginUnitStateSchema = z.object({ + unitId: McpPluginIdSchema, + enabled: z.boolean(), + binding: PluginBindingSchema.optional(), +}); +export type PluginUnitState = z.infer; + +export const PluginConnectorCredentialSchema = z.object({ + type: z.enum(['api-key', 'auth-token']), + secret: z.string().min(1), + expiresAt: TimestampSchema.optional(), +}); +export type PluginConnectorCredential = z.infer; + +export const PluginConnectorSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1).optional(), + service: McpPluginServiceSchema, + credential: PluginConnectorCredentialSchema, +}); +export type PluginConnector = z.infer; + +export const PluginConnectorPublicSchema = PluginConnectorSchema.omit({ credential: true }).extend({ + credential: z.object({ + type: z.enum(['api-key', 'auth-token']), + configured: z.literal(true), + expiresAt: TimestampSchema.optional(), + }), +}); +export type PluginConnectorPublic = z.infer; + +export const PluginConfigSchema = z.object({ + units: z.array(PluginUnitStateSchema), + connectors: z.array(PluginConnectorSchema), +}); +export type PluginConfig = z.infer; + +export const PluginConfigPublicSchema = z.object({ + units: z.array(PluginUnitStateSchema), + connectors: z.array(PluginConnectorPublicSchema), +}); +export type PluginConfigPublic = z.infer; + +export const PluginConnectorOperationSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('create'), connector: PluginConnectorSchema }), + z.object({ + type: z.literal('update'), + connectorId: z.string().min(1), + label: z.string().min(1).nullable().optional(), + credential: PluginConnectorCredentialSchema.optional(), + }), + z.object({ type: z.literal('delete'), connectorId: z.string().min(1) }), +]); +export type PluginConnectorOperation = z.infer; + +export const PluginConfigSetSchema = z.object({ + units: z.array(PluginUnitStateSchema).optional(), + connectorOperations: z.array(PluginConnectorOperationSchema).optional(), +}); +export type PluginConfigSet = z.infer; + +export const PluginWarningReasonSchema = z.enum([ + 'unsatisfied-binding', + 'unsupported-transport', + 'broker-unavailable', +]); +export type PluginWarningReason = z.infer; diff --git a/packages/foundation/schema/src/wire/config.ts b/packages/foundation/schema/src/wire/config.ts index 845b72e4e..1f75221db 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -1,11 +1,11 @@ import { z } from 'zod'; import { AccountsSchema } from '../model/account'; +import { PluginConfigPublicSchema, PluginConfigSetSchema } from '../model/plugin'; import { ProvidersConfigSchema } from '../model/provider-config'; import { WireRequestIdSchema } from './request'; -/** Host configuration wire variants — per-agent provider settings (provider-config.ts) plus the - * global account pool (account.ts); both travel together so a single `config.get`/`config.set` - * round-trips the whole editable config. */ +/** Host configuration wire variants: per-agent providers, the global account pool, and plugin + * enablement/local connections. The catalog is a separate read-only wire resource. */ export const configWireVariants = [ z.object({ kind: z.literal('config.get'), clientReqId: WireRequestIdSchema }), z.object({ @@ -13,6 +13,7 @@ export const configWireVariants = [ replyTo: WireRequestIdSchema, providers: ProvidersConfigSchema, accounts: AccountsSchema, + plugins: PluginConfigPublicSchema, }), z.object({ kind: z.literal('config.set'), @@ -21,5 +22,7 @@ export const configWireVariants = [ providers: ProvidersConfigSchema.optional(), /** The global account pool; omitted by a client editing only provider settings. */ accounts: AccountsSchema.optional(), + /** Global plugin state and daemon-local connector operations. */ + plugins: PluginConfigSetSchema.optional(), }), ] as const; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index a6ff87dc6..bde11d79b 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -13,7 +13,8 @@ import { WirePayloadSchema } from './payload'; // schema it does not speak. // 43 combines 42's agent.catalog/agent.cataloged with CODE-316's parallel 42 bump for // file.host/file.hosted, keeping every distinct schema on a distinct protocol version. -export const WIRE_PROTOCOL_VERSION = 43 as const; +// 44 adds the plugin catalog/config contracts and plugin resolution warning event (CODE-382). +export const WIRE_PROTOCOL_VERSION = 44 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/foundation/schema/src/wire/payload.ts b/packages/foundation/schema/src/wire/payload.ts index 1316ba8f5..4e97a71e4 100644 --- a/packages/foundation/schema/src/wire/payload.ts +++ b/packages/foundation/schema/src/wire/payload.ts @@ -11,6 +11,7 @@ import { historyWireVariants } from './history'; import { keepAliveWireVariants } from './keep-alive'; import { loopWireVariants } from './loop'; import { managedAssetWireVariants } from './managed-asset'; +import { pluginWireVariants } from './plugin'; import { requestWireVariants } from './request'; import { scheduleWireVariants } from './schedule'; import { scriptWireVariants } from './script'; @@ -27,6 +28,7 @@ export const WirePayloadSchema = z.discriminatedUnion('kind', [ ...agentRuntimeWireVariants, ...agentCatalogWireVariants, ...agentLoginWireVariants, + ...pluginWireVariants, ...managedAssetWireVariants, ...workspaceWireVariants, ...gitWireVariants, diff --git a/packages/foundation/schema/src/wire/plugin.ts b/packages/foundation/schema/src/wire/plugin.ts new file mode 100644 index 000000000..96a9f6c91 --- /dev/null +++ b/packages/foundation/schema/src/wire/plugin.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { McpPluginCatalogSchema } from '../model/plugin'; +import { WireRequestIdSchema } from './request'; + +export const pluginWireVariants = [ + z.object({ kind: z.literal('plugin.catalog.get'), clientReqId: WireRequestIdSchema }), + z.object({ + kind: z.literal('plugin.catalog.result'), + replyTo: WireRequestIdSchema, + catalog: McpPluginCatalogSchema, + }), +] as const; diff --git a/packages/foundation/schema/tests/contract/wire/plugin.test.ts b/packages/foundation/schema/tests/contract/wire/plugin.test.ts new file mode 100644 index 000000000..27f194460 --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/plugin.test.ts @@ -0,0 +1,47 @@ +import { + McpPluginDescriptorSchema, + PluginConfigSetSchema, + WIRE_PROTOCOL_VERSION, + WireMessageSchema, +} from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +describe('plugin contracts', () => { + it('keeps the catalog on its own wire resource', () => { + const parsed = WireMessageSchema.safeParse({ + v: WIRE_PROTOCOL_VERSION, + id: 'message-1', + ts: 0, + payload: { kind: 'plugin.catalog.get', clientReqId: 'request-1' }, + }); + expect(parsed.success).toBe(true); + }); + + it('rejects credential slots that do not match the preset transport', () => { + const parsed = McpPluginDescriptorSchema.safeParse({ + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + service: 'github', + backing: { + type: 'preset', + server: { type: 'http', name: 'github', url: 'http://localhost/mcp' }, + credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], + }, + }); + expect(parsed.success).toBe(false); + }); + + it('does not accept a masked credential as an update secret', () => { + const parsed = PluginConfigSetSchema.safeParse({ + connectorOperations: [ + { + type: 'update', + connectorId: 'github-local', + credential: { type: 'auth-token', configured: true }, + }, + ], + }); + expect(parsed.success).toBe(false); + }); +}); diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index b62f37afd..b96362990 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -98,6 +98,7 @@ export class AgentRequestHandler { replyTo: payload.clientReqId, providers: this.providers.get(), accounts: this.providers.getAccounts(), + plugins: { units: [], connectors: [] }, }), ), catch: (cause) => From 9257af70194161e243af3cd21e1b1a82b36b7e38 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 07:57:40 +0000 Subject: [PATCH 02/23] feat(engine): serve plugin catalog --- .../__tests__/engine-plugin-catalog.test.ts | 32 +++++++++++++++++++ .../host/engine/src/agent/request-handler.ts | 18 +++++++++++ packages/host/engine/src/plugin/catalog.ts | 13 ++++++++ .../host/engine/src/wire/request-router.ts | 1 + packages/presentation/i18n/package.json | 1 + .../i18n/src/__tests__/plugin-copy.test.ts | 25 +++++++++++++++ packages/presentation/i18n/src/locales/en.ts | 9 ++++++ .../presentation/i18n/src/locales/zh-cn.ts | 8 +++++ pnpm-lock.yaml | 3 ++ 9 files changed, 110 insertions(+) create mode 100644 packages/host/engine/src/__tests__/engine-plugin-catalog.test.ts create mode 100644 packages/host/engine/src/plugin/catalog.ts create mode 100644 packages/presentation/i18n/src/__tests__/plugin-copy.test.ts diff --git a/packages/host/engine/src/__tests__/engine-plugin-catalog.test.ts b/packages/host/engine/src/__tests__/engine-plugin-catalog.test.ts new file mode 100644 index 000000000..c44d07d6b --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-plugin-catalog.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { MCP_PLUGIN_CATALOG } from '../plugin/catalog'; +import { createSessionHarness } from './fixtures/session-harness'; + +describe('engine plugin catalog', () => { + it('declares one valid endpoint-free GitHub unit', () => { + expect(MCP_PLUGIN_CATALOG).toEqual([ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + service: 'github', + backing: { type: 'managed-connector', name: 'linkcode-github' }, + }, + ]); + expect(JSON.stringify(MCP_PLUGIN_CATALOG)).not.toContain('token'); + expect(JSON.stringify(MCP_PLUGIN_CATALOG)).not.toContain('url'); + }); + + it('serves the catalog without constructing an adapter', async () => { + const h = createSessionHarness(); + await h.engine.start(); + await h.inject({ kind: 'plugin.catalog.get', clientReqId: 'plugin-catalog-1' }); + + expect(h.adapters).toHaveLength(0); + expect(h.sent).toContainEqual({ + kind: 'plugin.catalog.result', + replyTo: 'plugin-catalog-1', + catalog: MCP_PLUGIN_CATALOG, + }); + }); +}); diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index b96362990..8e3328a7e 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -4,6 +4,7 @@ import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; import { OperationError, RequestError } from '../failure'; +import { MCP_PLUGIN_CATALOG } from '../plugin/catalog'; import type { WireResponder } from '../wire/responder'; import type { AgentLoginService } from './login-service'; import type { ProviderConfigStore } from './provider-config'; @@ -16,6 +17,7 @@ type AgentRequest = Extract< kind: | 'agent-runtime.list' | 'agent.catalog' + | 'plugin.catalog.get' | 'config.get' | 'config.set' | 'agent-login.start' @@ -37,6 +39,22 @@ export class AgentRequestHandler { handle(payload: AgentRequest): Effect.Effect { switch (payload.kind) { + case 'plugin.catalog.get': + return this.responder.reply( + payload.clientReqId, + Effect.try({ + try: () => + this.transport.send( + createWireMessage({ + kind: 'plugin.catalog.result', + replyTo: payload.clientReqId, + catalog: MCP_PLUGIN_CATALOG, + }), + ), + catch: (cause) => + providerFailure('plugin.catalog.get', 'Failed to load plugin catalog', cause), + }), + ); case 'agent.catalog': return this.responder.reply( payload.clientReqId, diff --git a/packages/host/engine/src/plugin/catalog.ts b/packages/host/engine/src/plugin/catalog.ts new file mode 100644 index 000000000..2d2b39ace --- /dev/null +++ b/packages/host/engine/src/plugin/catalog.ts @@ -0,0 +1,13 @@ +import { McpPluginCatalogSchema } from '@linkcode/schema'; + +/** Official daemon-owned MCP capability catalog. Managed entries name the server but never carry + * an HQ/broker endpoint or credential; those are materialized per session by CODE-96. */ +export const MCP_PLUGIN_CATALOG = McpPluginCatalogSchema.parse([ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + service: 'github', + backing: { type: 'managed-connector', name: 'linkcode-github' }, + }, +]); diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index e48e31eed..1599d798e 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -68,6 +68,7 @@ export class WireRequestRouter { } case 'agent-runtime.list': case 'agent.catalog': + case 'plugin.catalog.get': case 'config.get': case 'config.set': { return this.handlers.agent.handle(p); diff --git a/packages/presentation/i18n/package.json b/packages/presentation/i18n/package.json index ff7ef8836..569719afa 100644 --- a/packages/presentation/i18n/package.json +++ b/packages/presentation/i18n/package.json @@ -12,6 +12,7 @@ "lint": "eslint --format=sukka ." }, "devDependencies": { + "@linkcode/schema": "workspace:*", "typescript": "catalog:" } } diff --git a/packages/presentation/i18n/src/__tests__/plugin-copy.test.ts b/packages/presentation/i18n/src/__tests__/plugin-copy.test.ts new file mode 100644 index 000000000..ef1345c09 --- /dev/null +++ b/packages/presentation/i18n/src/__tests__/plugin-copy.test.ts @@ -0,0 +1,25 @@ +import { McpPluginDescriptionKeySchema, McpPluginLabelKeySchema } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { en } from '../locales/en'; +import { zhCN } from '../locales/zh-cn'; + +function copyAt(messages: unknown, key: string): unknown { + let value = property(messages, 'settings'); + value = property(value, 'plugins'); + for (const segment of key.split('.')) value = property(value, segment); + return value; +} + +function property(value: unknown, key: string): unknown { + return typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined; +} + +describe('plugin catalog copy', () => { + it.each([ + ...McpPluginLabelKeySchema.options, + ...McpPluginDescriptionKeySchema.options, + ])('defines %s in every locale', (key) => { + expect(copyAt(zhCN, key)).toBeTypeOf('string'); + expect(copyAt(en, key)).toBeTypeOf('string'); + }); +}); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 04ee48308..6ceab48f3 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -809,6 +809,15 @@ export const en = { protocolNone: 'Default', }, }, + plugins: { + units: { + githubRead: { + label: 'GitHub read-only tools', + description: + 'Reads repositories, issues, and pull requests without creating, changing, or deleting GitHub content.', + }, + }, + }, notifications: { title: 'Notifications', hint: 'Send system notifications when a session finishes a turn in the background or needs approval.', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index c324281b1..9c7d6f514 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -789,6 +789,14 @@ export const zhCN = { protocolNone: '默认', }, }, + plugins: { + units: { + githubRead: { + label: 'GitHub 只读能力', + description: '读取仓库、Issue 和 Pull Request 信息;不会创建、修改或删除 GitHub 内容。', + }, + }, + }, notifications: { title: '通知', hint: '会话在后台完成回复或需要审批时发送系统通知。', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb1ce69dd..8ca72370c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1049,6 +1049,9 @@ importers: packages/presentation/i18n: devDependencies: + '@linkcode/schema': + specifier: workspace:* + version: link:../../foundation/schema typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' From 66124beb048265c43b00d4801c18da20d0acbe6c Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 08:08:10 +0000 Subject: [PATCH 03/23] feat(config): persist plugin settings --- apps/daemon/src/__tests__/config.test.ts | 68 +++++++++++++- apps/daemon/src/__tests__/logger.test.ts | 4 + apps/daemon/src/config.ts | 51 ++++++++++- apps/daemon/src/index.ts | 6 +- apps/daemon/src/provider-store.ts | 11 ++- .../__tests__/engine-plugin-config.test.ts | 78 ++++++++++++++++ .../src/__tests__/provider-config.test.ts | 66 +++++++++++++- .../host/engine/src/agent/provider-config.ts | 88 +++++++++++++++++++ .../host/engine/src/agent/request-handler.ts | 23 +++-- 9 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 packages/host/engine/src/__tests__/engine-plugin-config.test.ts diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 4ac4588c6..462f3ae12 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { noop } from 'foxts/noop'; @@ -9,6 +9,7 @@ import { hqCredentialsPath, loadConfig, runtimeFilePath, + savePlugins, } from '../config'; import { logger } from '../logger'; @@ -38,6 +39,12 @@ function writeAccountsConfig(accounts: unknown): void { writeFileSync(join(dir, 'config.json'), JSON.stringify({ accounts })); } +function writeRawConfig(config: unknown): void { + const dir = join(process.env.HOME ?? '', '.linkcode'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify(config)); +} + const validAccount = { id: 'acc_1', label: 'Personal key', @@ -167,3 +174,62 @@ describe('loadConfig accounts', () => { expect(errorSpy).not.toHaveBeenCalled(); }); }); + +describe('loadConfig plugins', () => { + const connector = { + id: 'github-personal', + label: 'Personal GitHub', + service: 'github', + credential: { type: 'auth-token', secret: 'github-secret' }, + } as const; + + it('keeps valid entries and drops malformed entries independently', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); + writeRawConfig({ + plugins: { + units: [ + { + unitId: 'github-read', + enabled: true, + binding: { type: 'local', connectorId: connector.id }, + }, + { unitId: 'unknown-unit', enabled: true }, + ], + connectors: [connector, { id: 'bad', service: 'github' }], + }, + }); + + expect(loadConfig().plugins).toEqual({ + units: [ + { + unitId: 'github-read', + enabled: true, + binding: { type: 'local', connectorId: connector.id }, + }, + ], + connectors: [connector], + }); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it('round-trips credentials at mode 0600 without overwriting providers or accounts', () => { + writeRawConfig({ providers: { codex: { enabled: true } }, accounts: [validAccount] }); + const plugins = { units: [], connectors: [connector] }; + + savePlugins(plugins); + + expect(loadConfig()).toMatchObject({ + providers: { codex: { enabled: true } }, + accounts: [validAccount], + plugins, + }); + expect(statSync(join(process.env.HOME ?? '', '.linkcode', 'config.json')).mode & 0o777).toBe( + 0o600, + ); + }); + + it('defaults an older config without a plugins section to empty state', () => { + writeRawConfig({ providers: {} }); + expect(loadConfig().plugins).toEqual({ units: [], connectors: [] }); + }); +}); diff --git a/apps/daemon/src/__tests__/logger.test.ts b/apps/daemon/src/__tests__/logger.test.ts index c7dbd049b..93e22df33 100644 --- a/apps/daemon/src/__tests__/logger.test.ts +++ b/apps/daemon/src/__tests__/logger.test.ts @@ -32,6 +32,9 @@ describe('daemon logger', () => { nested: { apiKey: 'nested-secret', token: 'nested-token' }, providers: { codex: { apiKey: 'provider-secret' } }, accounts: [{ credential: { key: 'account-secret' } }], + plugins: { + connectors: [{ credential: { type: 'auth-token', secret: 'plugin-secret' } }], + }, sessionId: 'session-1', }, 'Session started', @@ -48,6 +51,7 @@ describe('daemon logger', () => { nested: { apiKey: '[Redacted]', token: '[Redacted]' }, providers: { codex: { apiKey: '[Redacted]' } }, accounts: [{ credential: '[Redacted]' }], + plugins: { connectors: [{ credential: '[Redacted]' }] }, sessionId: 'session-1', }); }); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 8cdf67069..a77d814dd 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -1,13 +1,15 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { daemonRuntimeFilePath } from '@linkcode/common/node'; -import type { Accounts, ProvidersConfig } from '@linkcode/schema'; +import type { Accounts, PluginConfig, ProvidersConfig } from '@linkcode/schema'; import { AccountSchema, AgentKindSchema, DAEMON_DEFAULT_PORT, linkcodeStateDirName, + PluginConnectorSchema, + PluginUnitStateSchema, ProviderConfigSchema, parseProfileName, } from '@linkcode/schema'; @@ -28,6 +30,8 @@ export interface DaemonConfig { providers?: ProvidersConfig; /** Global account pool (data plane); undefined when nothing is configured. */ accounts?: Accounts; + /** Global plugin enablement and daemon-local connector credentials. */ + plugins?: PluginConfig; } const DEFAULT_PORT = DAEMON_DEFAULT_PORT; @@ -39,6 +43,7 @@ interface ConfigFile { listeners?: unknown; providers?: unknown; accounts?: unknown; + plugins?: unknown; } /** @@ -114,9 +119,43 @@ export function loadConfig(): DaemonConfig { ), providers: parseProviders(file.providers), accounts: parseAccounts(file.accounts), + plugins: parsePlugins(file.plugins), }; } +function parsePlugins(raw: unknown): PluginConfig { + if (raw === undefined) return { units: [], connectors: [] }; + if (!isRecord(raw)) { + logger.warn({ operation: 'config.load' }, 'Invalid plugins config: expected an object'); + return { units: [], connectors: [] }; + } + return { + units: parsePluginEntries(raw.units, PluginUnitStateSchema, 'unit'), + connectors: parsePluginEntries(raw.connectors, PluginConnectorSchema, 'connector'), + }; +} + +function parsePluginEntries( + raw: unknown, + schema: { safeParse(value: unknown): { success: true; data: T } | { success: false } }, + entryType: string, +): T[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + logger.warn( + { operation: 'config.load' }, + `Invalid plugin ${entryType}s config: expected an array`, + ); + return []; + } + return raw.flatMap((value) => { + const parsed = schema.safeParse(value); + if (parsed.success) return [parsed.data]; + logger.warn({ operation: 'config.load' }, `Dropping invalid plugin ${entryType} config`); + return []; + }); +} + /** * Parse element by element: an invalid account is dropped and logged, never blanking the pool — * `saveAccounts` would persist that loss on the next write. Mirrors {@link parseProviders}. @@ -179,8 +218,13 @@ export function saveAccounts(accounts: Accounts): void { writeConfigField('accounts', accounts); } +/** Persist plugin state and local credentials, preserving other config fields; `0600`. */ +export function savePlugins(plugins: PluginConfig): void { + writeConfigField('plugins', plugins); +} + /** Read-modify-write a single top-level field of config.json, preserving the rest; `0600`. */ -function writeConfigField(key: 'providers' | 'accounts', value: unknown): void { +function writeConfigField(key: 'providers' | 'accounts' | 'plugins', value: unknown): void { const path = configPath(); let file: Record = {}; try { @@ -192,6 +236,7 @@ function writeConfigField(key: 'providers' | 'accounts', value: unknown): void { file[key] = value; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); } function createDefaultSocketIoListener(file: ConfigFile): DaemonListenerConfig { diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index aeebdb60c..010baf8f6 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -170,7 +170,11 @@ async function main(): Promise { const EngineSubsystemLive = Layer.unwrap( Effect.gen(function* () { const { config, hub, previewRoutes } = yield* Shared; - const store = createProviderConfigStore(config.providers ?? {}, config.accounts ?? []); + const store = createProviderConfigStore( + config.providers ?? {}, + config.accounts ?? [], + config.plugins ?? { units: [], connectors: [] }, + ); // Managed assets (CODE-111): GC superseded versions before anything can spawn, then feed the // store into spawn resolution — managed wins over detected as soon as an install lands. const assets = new AssetManager(); diff --git a/apps/daemon/src/provider-store.ts b/apps/daemon/src/provider-store.ts index 0475ac5dd..84ddf27ea 100644 --- a/apps/daemon/src/provider-store.ts +++ b/apps/daemon/src/provider-store.ts @@ -1,6 +1,6 @@ import type { ProviderConfigStore } from '@linkcode/engine'; -import type { Accounts, ProvidersConfig } from '@linkcode/schema'; -import { saveAccounts, saveProviders } from './config'; +import type { Accounts, PluginConfig, ProvidersConfig } from '@linkcode/schema'; +import { saveAccounts, savePlugins, saveProviders } from './config'; /** * Daemon-backed data-plane config store: in-memory providers + account pool seeded at boot, each @@ -10,9 +10,11 @@ import { saveAccounts, saveProviders } from './config'; export function createProviderConfigStore( initialProviders: ProvidersConfig, initialAccounts: Accounts, + initialPlugins: PluginConfig, ): ProviderConfigStore { let providers = initialProviders; let accounts = initialAccounts; + let plugins = initialPlugins; return { get: () => providers, set(next) { @@ -24,5 +26,10 @@ export function createProviderConfigStore( accounts = next; saveAccounts(next); }, + getPlugins: () => plugins, + setPlugins(next) { + savePlugins(next); + plugins = next; + }, }; } diff --git a/packages/host/engine/src/__tests__/engine-plugin-config.test.ts b/packages/host/engine/src/__tests__/engine-plugin-config.test.ts new file mode 100644 index 000000000..efa94e13d --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-plugin-config.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryProviderConfigStore } from '../agent/provider-config'; +import { createSessionHarness } from './fixtures/session-harness'; + +describe('engine plugin config', () => { + it('masks local credentials on config.get', async () => { + const store = new InMemoryProviderConfigStore(); + store.setPlugins({ + units: [], + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', secret: 'private-github-token' }, + }, + ], + }); + const h = createSessionHarness(undefined, undefined, undefined, undefined, undefined, store); + await h.engine.start(); + + await h.inject({ kind: 'config.get', clientReqId: 'plugin-config-get' }); + + expect(h.sent).toContainEqual({ + kind: 'config.get.result', + replyTo: 'plugin-config-get', + providers: {}, + accounts: [], + plugins: { + units: [], + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', configured: true }, + }, + ], + }, + }); + expect(JSON.stringify(h.sent)).not.toContain('private-github-token'); + }); + + it('updates plugins without changing provider or account state', async () => { + const store = new InMemoryProviderConfigStore(); + store.set({ codex: { enabled: true } }); + store.setAccounts([ + { + id: 'codex-key', + label: 'Codex', + credential: { type: 'api-key', key: 'provider-secret' }, + createdAt: 0, + }, + ]); + const h = createSessionHarness(undefined, undefined, undefined, undefined, undefined, store); + await h.engine.start(); + + await h.inject({ + kind: 'config.set', + clientReqId: 'plugin-config-set', + plugins: { + connectorOperations: [ + { + type: 'create', + connector: { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', secret: 'github-secret' }, + }, + }, + ], + }, + }); + + expect(store.get()).toEqual({ codex: { enabled: true } }); + expect(store.getAccounts()).toHaveLength(1); + expect(store.getPlugins().connectors[0]?.credential.secret).toBe('github-secret'); + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'plugin-config-set' }); + }); +}); diff --git a/packages/host/engine/src/__tests__/provider-config.test.ts b/packages/host/engine/src/__tests__/provider-config.test.ts index afa1c0845..79ba5b1d0 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -1,6 +1,10 @@ -import type { Account, ProvidersConfig, StartOptions } from '@linkcode/schema'; +import type { Account, PluginConfig, ProvidersConfig, StartOptions } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; -import { applyProviderDefaults } from '../agent/provider-config'; +import { + applyPluginConfigSet, + applyProviderDefaults, + publicPluginConfig, +} from '../agent/provider-config'; const baseOpts: StartOptions = { kind: 'codex', cwd: '/repo' }; @@ -110,3 +114,61 @@ describe('applyProviderDefaults account pool', () => { expect(applyProviderDefaults(baseOpts, providers, [oauth]).config).toEqual({}); }); }); + +describe('plugin config', () => { + const config: PluginConfig = { + units: [ + { + unitId: 'github-read', + enabled: true, + binding: { type: 'local', connectorId: 'github-personal' }, + }, + ], + connectors: [ + { + id: 'github-personal', + label: 'Personal', + service: 'github', + credential: { type: 'auth-token', secret: 'old-secret' }, + }, + ], + }; + + it('returns credential metadata without exposing a secret or a reusable mask', () => { + const publicConfig = publicPluginConfig(config); + expect(publicConfig.connectors[0]?.credential).toEqual({ + type: 'auth-token', + configured: true, + }); + expect(JSON.stringify(publicConfig)).not.toContain('old-secret'); + }); + + it('keeps an omitted credential and replaces an explicitly supplied credential', () => { + const kept = applyPluginConfigSet(config, { + connectorOperations: [{ type: 'update', connectorId: 'github-personal', label: 'Renamed' }], + }); + expect(kept.connectors[0]?.credential.secret).toBe('old-secret'); + + const replaced = applyPluginConfigSet(kept, { + connectorOperations: [ + { + type: 'update', + connectorId: 'github-personal', + credential: { type: 'auth-token', secret: 'new-secret' }, + }, + ], + }); + expect(replaced.connectors[0]?.credential.secret).toBe('new-secret'); + }); + + it('deletes a connector and atomically disables and unbinds every referencing unit', () => { + expect( + applyPluginConfigSet(config, { + connectorOperations: [{ type: 'delete', connectorId: 'github-personal' }], + }), + ).toEqual({ + units: [{ unitId: 'github-read', enabled: false }], + connectors: [], + }); + }); +}); diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 4db3696b0..75eb8e652 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -1,10 +1,14 @@ import type { Account, Accounts, + PluginConfig, + PluginConfigPublic, + PluginConfigSet, ProviderConfig, ProvidersConfig, StartOptions, } from '@linkcode/schema'; +import { nullthrow } from 'foxts/guard'; /** * Daemon-owned data-plane config store: per-agent provider settings plus the global account pool, @@ -17,11 +21,15 @@ export interface ProviderConfigStore { /** The global account pool bound by `providers[kind].activeAccountId`. */ getAccounts(): Accounts; setAccounts(next: Accounts): void | Promise; + /** Global MCP plugin enablement and daemon-local connector credentials. */ + getPlugins(): PluginConfig; + setPlugins(next: PluginConfig): void | Promise; } export class InMemoryProviderConfigStore implements ProviderConfigStore { private providers: ProvidersConfig = {}; private accounts: Accounts = []; + private plugins: PluginConfig = { units: [], connectors: [] }; get(): ProvidersConfig { return this.providers; @@ -38,6 +46,86 @@ export class InMemoryProviderConfigStore implements ProviderConfigStore { setAccounts(next: Accounts): void { this.accounts = next; } + + getPlugins(): PluginConfig { + return this.plugins; + } + + setPlugins(next: PluginConfig): void { + this.plugins = next; + } +} + +/** Apply one validated plugin patch without mutating the stored snapshot. Connector deletion also + * disables and unbinds every unit that referenced it, so a successful write cannot create stale + * references through deletion. */ +export function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): PluginConfig { + let units = patch.units === undefined ? current.units : patch.units; + let connectors = current.connectors; + + for (const operation of patch.connectorOperations ?? []) { + const connectorId = + operation.type === 'create' ? operation.connector.id : operation.connectorId; + const existing = connectors.find((connector) => connector.id === connectorId); + switch (operation.type) { + case 'create': + if (existing !== undefined) { + throw new TypeError(`Plugin connector already exists: ${operation.connector.id}`); + } + connectors = [...connectors, operation.connector]; + break; + case 'update': { + const existingConnector = nullthrow( + existing, + `Plugin connector does not exist: ${operation.connectorId}`, + ); + const next = { ...existingConnector }; + if (operation.label !== undefined) { + if (operation.label === null) { + delete next.label; + } else { + next.label = operation.label; + } + } + if (operation.credential !== undefined) { + next.credential = operation.credential; + } + connectors = connectors.map((connector) => + connector.id === operation.connectorId ? next : connector, + ); + break; + } + case 'delete': + nullthrow(existing, `Plugin connector does not exist: ${operation.connectorId}`); + connectors = connectors.filter((connector) => connector.id !== operation.connectorId); + units = units.map((unit) => + unit.binding?.type === 'local' && unit.binding.connectorId === operation.connectorId + ? { unitId: unit.unitId, enabled: false } + : unit, + ); + break; + default: + break; + } + } + + return { units, connectors }; +} + +/** Remove connector secrets at the data-plane boundary. A configured credential is represented by + * metadata only; no mask string is ever returned or eligible to be written back as a secret. */ +export function publicPluginConfig(config: PluginConfig): PluginConfigPublic { + return { + units: config.units, + connectors: config.connectors.map(({ credential, ...connector }) => ({ + ...connector, + credential: { + type: credential.type, + configured: true, + ...(credential.expiresAt !== undefined && { expiresAt: credential.expiresAt }), + }, + })), + }; } /** diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index 8e3328a7e..cc172e1f4 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -8,7 +8,7 @@ import { MCP_PLUGIN_CATALOG } from '../plugin/catalog'; import type { WireResponder } from '../wire/responder'; import type { AgentLoginService } from './login-service'; import type { ProviderConfigStore } from './provider-config'; -import { applyProviderDefaults } from './provider-config'; +import { applyPluginConfigSet, applyProviderDefaults, publicPluginConfig } from './provider-config'; import type { AgentRuntimeService } from './runtime-service'; type AgentRequest = Extract< @@ -116,7 +116,7 @@ export class AgentRequestHandler { replyTo: payload.clientReqId, providers: this.providers.get(), accounts: this.providers.getAccounts(), - plugins: { units: [], connectors: [] }, + plugins: publicPluginConfig(this.providers.getPlugins()), }), ), catch: (cause) => @@ -126,6 +126,7 @@ export class AgentRequestHandler { case 'config.set': { const providers = payload.providers; const accounts = payload.accounts; + const plugins = payload.plugins; return this.responder.reply( payload.clientReqId, Effect.andThen( @@ -137,9 +138,21 @@ export class AgentRequestHandler { : updateProviderConfig('config.set-accounts', () => this.providers.setAccounts(accounts), ), - ).pipe( - Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), - ), + ) + .pipe( + Effect.andThen( + plugins === undefined + ? Effect.void + : updateProviderConfig('config.set-plugins', () => + this.providers.setPlugins( + applyPluginConfigSet(this.providers.getPlugins(), plugins), + ), + ), + ), + ) + .pipe( + Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), + ), ); } case 'agent-login.start': { From e226c955a89f6cfa942fe49e786467cfc9cfaecd Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 08:17:42 +0000 Subject: [PATCH 04/23] feat(engine): resolve plugin servers --- .../__tests__/engine-plugin-config.test.ts | 27 ++++ .../src/__tests__/plugin-resolver.test.ts | 119 +++++++++++++++++ .../engine/src/session/lifecycle-service.ts | 44 +++++-- .../host/engine/src/session/orchestrator.ts | 3 + .../src/session/start-options-resolver.ts | 120 +++++++++++++++++- 5 files changed, 293 insertions(+), 20 deletions(-) create mode 100644 packages/host/engine/src/__tests__/plugin-resolver.test.ts diff --git a/packages/host/engine/src/__tests__/engine-plugin-config.test.ts b/packages/host/engine/src/__tests__/engine-plugin-config.test.ts index efa94e13d..11b442479 100644 --- a/packages/host/engine/src/__tests__/engine-plugin-config.test.ts +++ b/packages/host/engine/src/__tests__/engine-plugin-config.test.ts @@ -75,4 +75,31 @@ describe('engine plugin config', () => { expect(store.getPlugins().connectors[0]?.credential.secret).toBe('github-secret'); expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'plugin-config-set' }); }); + + it('emits a typed warning when an enabled managed unit cannot be resolved', async () => { + const store = new InMemoryProviderConfigStore(); + store.setPlugins({ + units: [{ unitId: 'github-read', enabled: true, binding: { type: 'managed' } }], + connectors: [], + }); + const h = createSessionHarness(undefined, undefined, undefined, undefined, undefined, store); + await h.engine.start(); + + await h.inject({ + kind: 'session.start', + clientReqId: 'plugin-session-start', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + + const started = h.sent.find( + (payload) => payload.kind === 'session.started' && payload.replyTo === 'plugin-session-start', + ); + expect(started?.kind).toBe('session.started'); + expect(h.sent).toContainEqual({ + kind: 'agent.event', + sessionId: started?.kind === 'session.started' ? started.sessionId : '', + event: { type: 'plugin-warning', unitId: 'github-read', reason: 'broker-unavailable' }, + }); + expect(JSON.stringify(await h.store.load())).not.toContain('credential'); + }); }); diff --git a/packages/host/engine/src/__tests__/plugin-resolver.test.ts b/packages/host/engine/src/__tests__/plugin-resolver.test.ts new file mode 100644 index 000000000..1ccb46497 --- /dev/null +++ b/packages/host/engine/src/__tests__/plugin-resolver.test.ts @@ -0,0 +1,119 @@ +import type { McpPluginCatalog, PluginConfig, StartOptions } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { MCP_PLUGIN_CATALOG } from '../plugin/catalog'; +import { resolvePluginServers } from '../session/start-options-resolver'; + +const PRESET_CATALOG: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + service: 'github', + backing: { + type: 'preset', + server: { + type: 'stdio', + name: 'linkcode-github', + command: 'github-mcp-server', + args: ['stdio'], + env: { LOG_LEVEL: 'warn' }, + }, + credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], + }, + }, +]; + +const LOCAL_CONFIG: PluginConfig = { + units: [ + { + unitId: 'github-read', + enabled: true, + binding: { type: 'local', connectorId: 'github-personal' }, + }, + ], + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', secret: 'github-secret' }, + }, + ], +}; + +const OPTIONS: StartOptions = { kind: 'codex', cwd: '/repo' }; + +describe('resolvePluginServers', () => { + it('materializes a preset and injects its local credential only at session start', () => { + const resolved = resolvePluginServers( + { + ...OPTIONS, + mcpServers: [{ type: 'http', name: 'client-server', url: 'https://client.test/mcp' }], + }, + LOCAL_CONFIG, + PRESET_CATALOG, + ); + + expect(resolved).toEqual({ + options: { + ...OPTIONS, + mcpServers: [ + { type: 'http', name: 'client-server', url: 'https://client.test/mcp' }, + { + type: 'stdio', + name: 'linkcode-github', + command: 'github-mcp-server', + args: ['stdio'], + env: { LOG_LEVEL: 'warn', GITHUB_TOKEN: 'github-secret' }, + }, + ], + }, + warnings: [], + }); + expect(PRESET_CATALOG[0]?.backing).not.toHaveProperty('server.env.GITHUB_TOKEN'); + }); + + it('lets a client-supplied server win by name without injecting the stored secret', () => { + const clientServer = { + type: 'http' as const, + name: 'linkcode-github', + url: 'https://client.test/github', + }; + const resolved = resolvePluginServers( + { ...OPTIONS, mcpServers: [clientServer] }, + { ...LOCAL_CONFIG, connectors: [] }, + PRESET_CATALOG, + ); + + expect(resolved).toEqual({ + options: { ...OPTIONS, mcpServers: [clientServer] }, + warnings: [], + }); + expect(JSON.stringify(resolved)).not.toContain('github-secret'); + }); + + it('reports unsupported agents instead of silently dropping an enabled unit', () => { + const resolved = resolvePluginServers( + { kind: 'pi', cwd: '/repo' }, + LOCAL_CONFIG, + PRESET_CATALOG, + ); + expect(resolved.warnings).toEqual([ + { type: 'plugin-warning', unitId: 'github-read', reason: 'unsupported-transport' }, + ]); + expect(resolved.options.mcpServers).toBeUndefined(); + }); + + it('reports the managed path as unavailable until the broker contract lands', () => { + const resolved = resolvePluginServers( + OPTIONS, + { + units: [{ unitId: 'github-read', enabled: true, binding: { type: 'managed' } }], + connectors: [], + }, + MCP_PLUGIN_CATALOG, + ); + expect(resolved.warnings).toEqual([ + { type: 'plugin-warning', unitId: 'github-read', reason: 'broker-unavailable' }, + ]); + }); +}); diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 31b854a62..f761da2e2 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -57,7 +57,7 @@ export class SessionLifecycleService { const { sessions, startOptions, workspaces } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const resolved = yield* startOptions.resolve(options); + const { options: resolved, warnings } = yield* startOptions.resolve(options); const now = Date.now(); const record: SessionRecord = { sessionId, @@ -70,8 +70,11 @@ export class SessionLifecycleService { runs: [{ startedAt: now }], }; if (resolved.cwd) yield* workspaceTouch(workspaces, resolved.cwd); - yield* sessions.startLive(replyTo, record, (adapter) => - sessions.startAdapter(adapter, resolved), + yield* sessions.startLive( + replyTo, + record, + (adapter) => sessions.startAdapter(adapter, resolved), + warnings, ); }); } @@ -122,7 +125,7 @@ export class SessionLifecycleService { const { history, sessions, startOptions: resolver, workspaces } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const startOptions = yield* resolver.resolve({ ...options, kind }); + const { options: startOptions, warnings } = yield* resolver.resolve({ ...options, kind }); const now = Date.now(); const record: SessionRecord = { sessionId, @@ -134,8 +137,11 @@ export class SessionLifecycleService { runs: [{ historyId, startedAt: now }], }; if (startOptions.cwd) yield* workspaceTouch(workspaces, startOptions.cwd); - yield* sessions.startLive(replyTo, record, (adapter) => - history.resume(adapter, historyId, startOptions), + yield* sessions.startLive( + replyTo, + record, + (adapter) => history.resume(adapter, historyId, startOptions), + warnings, ); }); } @@ -165,15 +171,22 @@ export class SessionLifecycleService { const historyId = this.records.historyId(sessionId); const { history, sessions, startOptions: resolver, workspaces } = this; return Effect.gen(function* () { - const startOptions = yield* resolver.resolve({ kind: record.kind, cwd: record.cwd }); + const { options: startOptions, warnings } = yield* resolver.resolve({ + kind: record.kind, + cwd: record.cwd, + }); // Register before starting so a persistence failure cannot follow a successful // `session.started` reply with a contradictory request failure. if (record.cwd) yield* workspaceTouch(workspaces, record.cwd); record.runs.push({ historyId, startedAt: Date.now() }); - yield* sessions.startLive(replyTo, record, (adapter) => - historyId === undefined - ? sessions.startAdapter(adapter, startOptions) - : history.resume(adapter, historyId, startOptions), + yield* sessions.startLive( + replyTo, + record, + (adapter) => + historyId === undefined + ? sessions.startAdapter(adapter, startOptions) + : history.resume(adapter, historyId, startOptions), + warnings, ); }); }); @@ -189,7 +202,7 @@ export class SessionLifecycleService { const { sessions, startOptions: resolver, workspaces } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const startOptions = yield* resolver.resolve({ + const { options: startOptions, warnings } = yield* resolver.resolve({ kind: options.kind, cwd: options.cwd, model: options.model, @@ -207,8 +220,11 @@ export class SessionLifecycleService { runs: [{ startedAt: now }], }; if (startOptions.cwd) yield* workspaceTouch(workspaces, startOptions.cwd); - yield* sessions.startLive(undefined, record, (adapter) => - sessions.startAdapter(adapter, startOptions), + yield* sessions.startLive( + undefined, + record, + (adapter) => sessions.startAdapter(adapter, startOptions), + warnings, ); return record.sessionId; }); diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index eafaf5917..523d8c186 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -1,5 +1,6 @@ import type { AdapterFactory, AgentAdapter } from '@linkcode/agent-adapter'; import type { + AgentEvent, AgentInput, ContentBlock, SessionId, @@ -157,6 +158,7 @@ export class SessionOrchestrator { replyTo: string | undefined, record: SessionRecord, startAdapter: (adapter: AgentAdapter) => Effect.Effect, + initialEvents: Iterable = [], ): Effect.Effect { const { events, factory, records, runtimes, scope: parentScope, sessions, transport } = this; const discardFailedStart = (session: LiveSession): Effect.Effect => @@ -179,6 +181,7 @@ export class SessionOrchestrator { if (sessions.get(sessionId) !== session) return yield* Effect.interrupt; yield* startAdapter(adapter); if (sessions.get(sessionId) !== session) return yield* Effect.interrupt; + events.broadcast(sessionId, initialEvents); if (replyTo !== undefined) { transport.send(createWireMessage({ kind: 'session.started', replyTo, sessionId })); } diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index a6747d975..b2081e334 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -1,25 +1,46 @@ -import type { StartOptions } from '@linkcode/schema'; +import type { + AgentEvent, + McpPluginCatalog, + McpServer, + PluginConfig, + StartOptions, +} from '@linkcode/schema'; import { Effect } from 'effect'; import type { ProviderConfigStore } from '../agent/provider-config'; import { applyProviderDefaults } from '../agent/provider-config'; import type { TranslatorService } from '../agent/translator'; import { translationUpstream, withTranslatorEndpoint } from '../agent/translator'; import { OperationError, RequestError } from '../failure'; +import { MCP_PLUGIN_CATALOG } from '../plugin/catalog'; + +type PluginWarning = Extract; + +export interface ResolvedStartOptions { + options: StartOptions; + warnings: PluginWarning[]; +} + +const MCP_CAPABLE_AGENTS = new Set(['claude-code', 'codex', 'opencode']); /** Resolves daemon-owned provider defaults and the optional cross-protocol translation endpoint. */ export class SessionStartOptionsResolver { constructor( private readonly providers: ProviderConfigStore, private readonly translator: TranslatorService | undefined, + private readonly pluginCatalog: McpPluginCatalog = MCP_PLUGIN_CATALOG, ) {} - resolve(options: StartOptions): Effect.Effect { - const resolved = applyProviderDefaults( - options, + resolve( + options: StartOptions, + ): Effect.Effect { + const plugins = resolvePluginServers(options, this.providers.getPlugins(), this.pluginCatalog); + const providerResolved = applyProviderDefaults( + plugins.options, this.providers.get(), this.providers.getAccounts(), ); - const upstream = translationUpstream(resolved); + const resolved = { ...plugins, options: providerResolved }; + const upstream = translationUpstream(resolved.options); if (!upstream) return Effect.succeed(resolved); if (!this.translator) { return Effect.fail( @@ -39,6 +60,93 @@ export class SessionStartOptionsResolver { publicMessage: 'Failed to start cross-protocol translation', cause, }), - }).pipe(Effect.map((url) => withTranslatorEndpoint(resolved, url))); + }).pipe( + Effect.map((url) => ({ + ...resolved, + options: withTranslatorEndpoint(resolved.options, url), + })), + ); + } +} + +export function resolvePluginServers( + options: StartOptions, + config: PluginConfig, + catalog: McpPluginCatalog = MCP_PLUGIN_CATALOG, +): ResolvedStartOptions { + const warnings: PluginWarning[] = []; + const clientServers = options.mcpServers ?? []; + const clientNames = new Set(clientServers.map((server) => server.name)); + const pluginServers: McpServer[] = []; + const catalogById = new Map(catalog.map((descriptor) => [descriptor.id, descriptor])); + + for (const unit of config.units) { + if (!unit.enabled) continue; + const descriptor = catalogById.get(unit.unitId); + const binding = unit.binding; + if (!descriptor || !binding) { + warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'unsatisfied-binding' }); + continue; + } + if (!MCP_CAPABLE_AGENTS.has(options.kind)) { + warnings.push({ + type: 'plugin-warning', + unitId: unit.unitId, + reason: 'unsupported-transport', + }); + continue; + } + const serverName = + descriptor.backing.type === 'preset' + ? descriptor.backing.server.name + : descriptor.backing.name; + if (clientNames.has(serverName)) continue; + if (binding.type === 'managed') { + warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'broker-unavailable' }); + continue; + } + const connector = config.connectors.find( + (entry) => entry.id === binding.connectorId && entry.service === descriptor.service, + ); + if (!connector) { + warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'unsatisfied-binding' }); + continue; + } + if (descriptor.backing.type === 'managed-connector') { + warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'broker-unavailable' }); + continue; + } + pluginServers.push(injectCredential(descriptor.backing, connector.credential.secret)); + } + + return { + options: + pluginServers.length === 0 + ? options + : { ...options, mcpServers: [...clientServers, ...pluginServers] }, + warnings, + }; +} + +function injectCredential( + backing: Extract, + secret: string, +): McpServer { + const server = backing.server; + if (server.type === 'stdio') { + return { + ...server, + env: { + ...server.env, + ...Object.fromEntries(backing.credentialSlots.map((slot) => [slot.name, secret])), + }, + }; } + return { + ...server, + headers: { + ...server.headers, + ...Object.fromEntries(backing.credentialSlots.map((slot) => [slot.name, secret])), + }, + }; } From 80e217a42fbba3375bbafe68b841bc0dc9f8da67 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 08:31:20 +0000 Subject: [PATCH 05/23] feat(client): manage plugin settings --- packages/client/core/src/client.ts | 19 +++++ .../client/core/src/client/control-channel.ts | 28 +++++++ .../core/src/client/pending-registry.ts | 6 ++ .../tests/integration/control-client.test.ts | 52 +++++++++++++ packages/client/sdk/src/client.ts | 16 ++++ packages/client/sdk/src/operations.ts | 17 +++++ packages/client/workbench/src/index.ts | 1 + .../workbench/src/mock/dev-mock-host.ts | 74 +++++++++++++++++- .../workbench/src/settings/plugins/hooks.ts | 24 ++++++ .../integration/dev-mock-transport.test.ts | 75 +++++++++++++++++++ 10 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 packages/client/workbench/src/settings/plugins/hooks.ts diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 23b9b94a0..dc0fc2121 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -26,7 +26,10 @@ import type { LoopSpec, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, Schedule, @@ -296,6 +299,7 @@ export class LinkCodeClient { case 'config.get.result': // One result carries both; each resolve is a no-op unless a request awaits that reply id. this.pending.resolve('configGet', p.replyTo, p.providers); + this.pending.resolve('pluginConfigGet', p.replyTo, p.plugins); this.pending.resolve('accountsGet', p.replyTo, p.accounts); break; case 'agent-runtime.listed': @@ -304,6 +308,9 @@ export class LinkCodeClient { case 'agent.cataloged': this.pending.resolve('agentCatalog', p.replyTo, p.catalog); break; + case 'plugin.catalog.result': + this.pending.resolve('pluginCatalog', p.replyTo, p.catalog); + break; case 'agent-runtime.changed': for (const cb of this.agentRuntimesChangedSubs) cb(p.runtimes); break; @@ -451,6 +458,10 @@ export class LinkCodeClient { return this.control.getAgentCatalog(agentKind, cwd); } + getPluginCatalog(): Promise { + return this.control.getPluginCatalog(); + } + listSessions(): Promise { return this.control.listSessions(); } @@ -567,6 +578,10 @@ export class LinkCodeClient { return this.control.getProviderConfig(); } + getPluginConfig(): Promise { + return this.control.getPluginConfig(); + } + getAccounts(): Promise { return this.control.getAccounts(); } @@ -606,6 +621,10 @@ export class LinkCodeClient { return this.control.setProviderConfig(providers); } + setPluginConfig(plugins: PluginConfigSet): Promise { + return this.control.setPluginConfig(plugins); + } + setAccounts(accounts: Accounts): Promise { return this.control.setAccounts(accounts); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 2a56628c5..5b58a29fc 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -24,7 +24,10 @@ import type { LoopSpec, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, Schedule, @@ -83,6 +86,14 @@ export class ControlChannel { })); } + /** Read the daemon-owned MCP plugin catalog. */ + getPluginCatalog(): Promise { + return this.sendCorrelated('pluginCatalog', (clientReqId) => ({ + kind: 'plugin.catalog.get', + clientReqId, + })); + } + listSessions(): Promise { return this.sendCorrelated('list', (clientReqId) => ({ kind: 'session.list', clientReqId })); } @@ -331,6 +342,14 @@ export class ControlChannel { })); } + /** Read public plugin state. Connector secrets never cross this boundary. */ + getPluginConfig(): Promise { + return this.sendCorrelated('pluginConfigGet', (clientReqId) => ({ + kind: 'config.get', + clientReqId, + })); + } + /** Read the daemon-owned global account pool (data plane). */ getAccounts(): Promise { return this.sendCorrelated('accountsGet', (clientReqId) => ({ @@ -377,6 +396,15 @@ export class ControlChannel { })); } + /** Apply plugin state and daemon-local connector credential operations. */ + setPluginConfig(plugins: PluginConfigSet): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'config.set', + clientReqId, + plugins, + })); + } + /** Persist the daemon-owned global account pool (data plane). Preserves the provider config. */ setAccounts(accounts: Accounts): Promise { return this.sendCorrelated('ack', (clientReqId) => ({ diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index e326c4286..981eb232d 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -13,6 +13,8 @@ import type { LoopInspection, LoopRecord, ManagedAssetStatus, + McpPluginCatalog, + PluginConfigPublic, ProvidersConfig, Schedule, ScheduleRun, @@ -63,9 +65,11 @@ export interface PendingValueMap { historyList: AgentHistoryListResult; historyRead: AgentHistoryReadResult; configGet: ProvidersConfig; + pluginConfigGet: PluginConfigPublic; accountsGet: Accounts; agentRuntimeList: AgentRuntimes; agentCatalog: AgentStartCatalog; + pluginCatalog: McpPluginCatalog; assetList: ManagedAssetStatus[]; assetEnsure: ManagedAssetStatus; gitStatus: GitStatus; @@ -107,9 +111,11 @@ export class PendingRegistry { historyList: new Map(), historyRead: new Map(), configGet: new Map(), + pluginConfigGet: new Map(), accountsGet: new Map(), agentRuntimeList: new Map(), agentCatalog: new Map(), + pluginCatalog: new Map(), assetList: new Map(), assetEnsure: new Map(), gitStatus: new Map(), diff --git a/packages/client/core/tests/integration/control-client.test.ts b/packages/client/core/tests/integration/control-client.test.ts index 723597ce4..9bfd36cb4 100644 --- a/packages/client/core/tests/integration/control-client.test.ts +++ b/packages/client/core/tests/integration/control-client.test.ts @@ -1,7 +1,9 @@ import type { AgentEvent, AgentStartCatalog, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, SessionId, SessionNotification, WirePayload, @@ -87,6 +89,56 @@ describe('LinkCodeClient control API', () => { serverTransport.close(); }); + it('reads the plugin catalog and masked plugin config through correlated requests', async () => { + let requestNumber = 0; + const { client, serverTransport } = await createConnectedLocalClient({ + randomUUID: () => `plugin-${++requestNumber}`, + }); + const catalog: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + service: 'github', + backing: { type: 'managed-connector', name: 'linkcode-github' }, + }, + ]; + const plugins: PluginConfigPublic = { + units: [{ unitId: 'github-read', enabled: true, binding: { type: 'managed' } }], + connectors: [], + }; + + serverTransport.onMessage((msg) => { + const payload = msg.payload; + if (payload.kind === 'plugin.catalog.get') { + serverTransport.send( + createWireMessage({ + kind: 'plugin.catalog.result', + replyTo: payload.clientReqId, + catalog, + }), + ); + } + if (payload.kind === 'config.get') { + serverTransport.send( + createWireMessage({ + kind: 'config.get.result', + replyTo: payload.clientReqId, + providers: {}, + accounts: [], + plugins, + }), + ); + } + }); + + await expect(client.getPluginCatalog()).resolves.toEqual(catalog); + await expect(client.getPluginConfig()).resolves.toEqual(plugins); + + client.dispose(); + serverTransport.close(); + }); + it('waits for control acknowledgements', async () => { const { client, serverTransport } = await createConnectedLocalClient(); diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 7e066e0b0..8fb6ba4ee 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -28,7 +28,10 @@ import type { LoopSpec, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, Schedule, @@ -113,6 +116,10 @@ export class LinkCodeSdkClient { return toResult(this.raw.getAgentCatalog(agentKind, cwd)); } + getPluginCatalog(): RequestResult { + return toResult(this.raw.getPluginCatalog()); + } + stopSession(sessionId: SessionId): RequestResult<{ ok: true }> { return toResult(this.raw.stopSession(sessionId)); } @@ -203,6 +210,15 @@ export class LinkCodeSdkClient { return toResult(this.raw.getProviderConfig()); } + /** Read public plugin state. Connector secrets are represented only by configured metadata. */ + getPluginConfig(): RequestResult { + return toResult(this.raw.getPluginConfig()); + } + + setPluginConfig(plugins: PluginConfigSet): RequestResult<{ ok: true }> { + return toResult(this.raw.setPluginConfig(plugins)); + } + /** Persist the daemon-owned provider config (data plane). */ setProviderConfig(providers: ProvidersConfig): RequestResult<{ ok: true }> { return toResult(this.raw.setProviderConfig(providers)); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index cbdadfd64..672e9842e 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -22,7 +22,10 @@ import type { LoopSpec, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, Schedule, @@ -57,6 +60,10 @@ export function getAgentCatalog( return resolveClient(options).getAgentCatalog(options.agentKind, options.cwd); } +export function getPluginCatalog(options?: Options): RequestResult { + return resolveClient(options).getPluginCatalog(); +} + export function stopSession( options: Options<{ sessionId: SessionId }>, ): RequestResult<{ ok: true }> { @@ -169,6 +176,16 @@ export function getProviderConfig(options?: Options): RequestResult { + return resolveClient(options).getPluginConfig(); +} + +export function setPluginConfig( + options: Options<{ plugins: PluginConfigSet }>, +): RequestResult<{ ok: true }> { + return resolveClient(options).setPluginConfig(options.plugins); +} + export function setProviderConfig( options: Options<{ providers: ProvidersConfig }>, ): RequestResult<{ ok: true }> { diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index 74b9e1ef9..ed071f5a6 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -42,6 +42,7 @@ export * from './settings/appearance-effects'; export * from './settings/appearance-render-prefs'; export * from './settings/appearance-settings'; export * from './settings/appearance-store'; +export * from './settings/plugins/hooks'; export * from './settings/providers/providers-settings'; export * from './settings/providers/store'; export * from './settings/search'; diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 2951d9da7..ed9cbc2e7 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -10,8 +10,12 @@ import type { EffortLevel, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, MessageId, PermissionOutcome, + PluginConfig, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, SessionId, @@ -98,6 +102,16 @@ const MOCK_DEFAULT_EFFORTS: Readonly>> = 'grok-build': 'high', }; +const MOCK_PLUGIN_CATALOG: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + service: 'github', + backing: { type: 'managed-connector', name: 'linkcode-github' }, + }, +]; + interface MockSession extends SessionInfo { /** Host-only replay state: keep it off `session.list` so the mock crosses the schema boundary. */ model?: string; @@ -164,6 +178,7 @@ export class DevMockHost { private readonly workspaces = new Map(); private providers: ProvidersConfig = {}; private accounts: Accounts = []; + private plugins: PluginConfig = { units: [], connectors: [] }; private readonly permissions = new Map(); private readonly questions = new Map(); private history: AgentHistorySession[] = []; @@ -323,7 +338,15 @@ export class DevMockHost { replyTo: p.clientReqId, providers: this.providers, accounts: this.accounts, - plugins: { units: [], connectors: [] }, + plugins: publicPluginConfig(this.plugins), + }); + break; + case 'plugin.catalog.get': + await wait(CONTROL_LATENCY_MS); + this.send({ + kind: 'plugin.catalog.result', + replyTo: p.clientReqId, + catalog: MOCK_PLUGIN_CATALOG, }); break; case 'agent-runtime.list': @@ -349,6 +372,7 @@ export class DevMockHost { await wait(CONTROL_LATENCY_MS); if (p.providers !== undefined) this.providers = structuredClone(p.providers); if (p.accounts !== undefined) this.accounts = structuredClone(p.accounts); + if (p.plugins !== undefined) this.plugins = applyPluginConfigSet(this.plugins, p.plugins); this.sendSuccess(p.clientReqId); break; case 'workspace.list': @@ -1361,6 +1385,54 @@ export class DevMockHost { } } +function publicPluginConfig(config: PluginConfig): PluginConfigPublic { + return { + units: structuredClone(config.units), + connectors: config.connectors.map(({ credential, ...connector }) => ({ + ...connector, + credential: + credential.expiresAt === undefined + ? { type: credential.type, configured: true } + : { type: credential.type, configured: true, expiresAt: credential.expiresAt }, + })), + }; +} + +function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): PluginConfig { + let units = structuredClone(patch.units ?? current.units); + let connectors = structuredClone(current.connectors); + for (const operation of patch.connectorOperations ?? []) { + switch (operation.type) { + case 'create': + connectors.push(structuredClone(operation.connector)); + break; + case 'update': + connectors = connectors.map((connector) => { + if (connector.id !== operation.connectorId) return connector; + const label = + operation.label === undefined ? connector.label : (operation.label ?? undefined); + return { + ...connector, + ...(label === undefined ? { label: undefined } : { label }), + credential: operation.credential ?? connector.credential, + }; + }); + break; + case 'delete': + connectors = connectors.filter((connector) => connector.id !== operation.connectorId); + units = units.map((unit) => + unit.binding?.type === 'local' && unit.binding.connectorId === operation.connectorId + ? { ...unit, enabled: false, binding: undefined } + : unit, + ); + break; + default: + break; + } + } + return { units, connectors }; +} + function promptText(content: readonly ContentBlock[]): string { return content .reduce((text, block) => { diff --git a/packages/client/workbench/src/settings/plugins/hooks.ts b/packages/client/workbench/src/settings/plugins/hooks.ts new file mode 100644 index 000000000..61073f091 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/hooks.ts @@ -0,0 +1,24 @@ +import type { PluginConfigSet } from '@linkcode/schema'; +import { getPluginCatalog, getPluginConfig, setPluginConfig } from '@linkcode/sdk'; +import { useData, useMutation } from '../../runtime/tayori'; + +/** Catalog and masked plugin state, plus a mutation that revalidates state after host acknowledgement. */ +export function usePluginSettings() { + const catalog = useData(getPluginCatalog, {}); + const config = useData(getPluginConfig, {}); + const mutation = useMutation(setPluginConfig); + + const save = async (plugins: PluginConfigSet): Promise => { + await mutation.trigger({ plugins }); + await config.mutate(); + }; + + return { + catalog: catalog.data, + config: config.data, + error: catalog.error ?? config.error ?? mutation.error, + isLoading: catalog.isLoading || config.isLoading, + isMutating: mutation.isMutating, + save, + }; +} diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index d874cb7de..fc26423fc 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -125,6 +125,81 @@ describe('dev mock transport', () => { client.dispose(); }); + it('serves and mutates masked plugin settings', async () => { + const client = await connectedClient(); + + expect(await client.getPluginCatalog()).toEqual([ + expect.objectContaining({ id: 'github-read', service: 'github' }), + ]); + await client.setPluginConfig({ + units: [ + { + unitId: 'github-read', + enabled: true, + binding: { type: 'local', connectorId: 'github-personal' }, + }, + ], + connectorOperations: [ + { + type: 'create', + connector: { + id: 'github-personal', + label: 'Personal GitHub', + service: 'github', + credential: { type: 'auth-token', secret: 'github-secret' }, + }, + }, + ], + }); + expect(await client.getPluginConfig()).toEqual({ + units: [ + { + unitId: 'github-read', + enabled: true, + binding: { type: 'local', connectorId: 'github-personal' }, + }, + ], + connectors: [ + { + id: 'github-personal', + label: 'Personal GitHub', + service: 'github', + credential: { type: 'auth-token', configured: true }, + }, + ], + }); + expect(JSON.stringify(await client.getPluginConfig())).not.toContain('github-secret'); + + // Updating metadata without a credential keeps the stored secret; replacing it remains masked. + await client.setPluginConfig({ + connectorOperations: [ + { type: 'update', connectorId: 'github-personal', label: 'Work GitHub' }, + ], + }); + await client.setPluginConfig({ + connectorOperations: [ + { + type: 'update', + connectorId: 'github-personal', + credential: { type: 'auth-token', secret: 'rotated-secret' }, + }, + ], + }); + expect(await client.getPluginConfig()).toMatchObject({ + connectors: [{ label: 'Work GitHub', credential: { configured: true } }], + }); + + await client.setPluginConfig({ + connectorOperations: [{ type: 'delete', connectorId: 'github-personal' }], + }); + expect(await client.getPluginConfig()).toEqual({ + units: [{ unitId: 'github-read', enabled: false }], + connectors: [], + }); + + client.dispose(); + }); + it('advertises and handles composer directives', async () => { const client = await connectedClient(); const sessionId = await client.startSession({ From 64df55cfe5411352142d7d80c1db4fc1d9c19a96 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 08:46:49 +0000 Subject: [PATCH 06/23] feat(ui): present plugin settings --- .../workbench/src/settings/plugins/store.ts | 32 +++ packages/presentation/i18n/src/locales/en.ts | 40 +++ .../presentation/i18n/src/locales/zh-cn.ts | 38 +++ packages/presentation/ui/src/shell/index.ts | 1 + .../ui/src/shell/plugin-settings-panel.tsx | 241 ++++++++++++++++++ 5 files changed, 352 insertions(+) create mode 100644 packages/client/workbench/src/settings/plugins/store.ts create mode 100644 packages/presentation/ui/src/shell/plugin-settings-panel.tsx diff --git a/packages/client/workbench/src/settings/plugins/store.ts b/packages/client/workbench/src/settings/plugins/store.ts new file mode 100644 index 000000000..cf4372d2b --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/store.ts @@ -0,0 +1,32 @@ +import type { McpPluginId, McpPluginService } from '@linkcode/schema'; +import { create } from 'zustand'; + +export type PluginSettingsDialog = + | { kind: 'closed' } + | { kind: 'add'; service: McpPluginService; enableUnitId?: McpPluginId } + | { kind: 'edit'; connectorId: string } + | { kind: 'remove'; connectorId: string }; + +interface PluginSettingsViewState { + dialog: PluginSettingsDialog; + addConnection: (service: McpPluginService, enableUnitId?: McpPluginId) => void; + editConnection: (connectorId: string) => void; + removeConnection: (connectorId: string) => void; + closeDialog: () => void; +} + +/** Ephemeral dialog state; daemon config remains the only persisted plugin state. */ +export const usePluginSettingsViewStore = create()((set) => ({ + dialog: { kind: 'closed' }, + addConnection: (service, enableUnitId) => + set({ + dialog: { + kind: 'add', + service, + ...(enableUnitId !== undefined && { enableUnitId }), + }, + }), + editConnection: (connectorId) => set({ dialog: { kind: 'edit', connectorId } }), + removeConnection: (connectorId) => set({ dialog: { kind: 'remove', connectorId } }), + closeDialog: () => set({ dialog: { kind: 'closed' } }), +})); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 6ceab48f3..5561dd753 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -579,6 +579,7 @@ export const en = { about: 'About', providers: 'Providers', agents: 'Agents', + plugins: 'Tools', imChannel: 'Messaging', }, historyImport: { @@ -810,6 +811,45 @@ export const en = { }, }, plugins: { + hint: 'Configure optional external tools and access credentials for your agents.', + managedUnavailable: + 'Cloud connections and tool delivery are not available yet. You can save a personal GitHub credential now; agents cannot use these tools until service support ships.', + loadError: 'Unable to load tool settings: {message}', + toolsTitle: 'Available tools', + connectionsTitle: 'Saved GitHub access', + enabledLabel: 'Enable {name}', + usesConnection: 'GitHub access to use', + addConnection: 'Add GitHub access', + chooseConnection: 'Choose GitHub access', + connectionsEmpty: 'No GitHub access credential has been saved.', + credentialSaved: 'Saved', + credentialType: { + 'api-key': 'API key', + 'auth-token': 'Personal access token', + }, + editConnection: 'Edit {name}', + removeConnection: 'Remove {name}', + connectionFallback: 'GitHub access', + githubConnectionDefault: 'My GitHub', + addTitle: 'Add GitHub access', + editTitle: 'Edit GitHub access', + removeTitle: 'Remove “{name}”?', + removeDescription: + 'The credential is deleted from this machine and tools using it are disabled. This cannot be undone.', + removeConfirm: 'Remove', + cancel: 'Cancel', + add: 'Add', + save: 'Save', + saveError: 'Unable to save GitHub access.', + form: { + label: 'Name', + credentialType: 'Credential type', + secret: 'Key or token', + secretHint: 'Sent only to your local daemon and never returned by the settings API.', + secretKeepPlaceholder: 'Leave blank to keep the saved credential', + secretKeepHint: + 'The saved value is not displayed for security. Enter a new value to replace it.', + }, units: { githubRead: { label: 'GitHub read-only tools', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 9c7d6f514..474bac6a1 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -568,6 +568,7 @@ export const zhCN = { about: '关于', providers: 'Providers', agents: '智能体', + plugins: '工具', imChannel: 'IM 渠道', }, historyImport: { @@ -790,6 +791,43 @@ export const zhCN = { }, }, plugins: { + hint: '为智能体配置可选的外部工具与访问凭证。凭证只保存在本机 daemon 中。', + managedUnavailable: + '云端连接与工具交付尚未开放。你可以先保存 GitHub 个人凭证;在服务支持上线前,智能体还不能使用这些工具。', + loadError: '无法加载工具设置:{message}', + toolsTitle: '可用工具', + connectionsTitle: '已保存的 GitHub 访问', + enabledLabel: '启用{name}', + usesConnection: '使用的 GitHub 访问', + addConnection: '添加 GitHub 访问', + chooseConnection: '选择 GitHub 访问', + connectionsEmpty: '尚未保存 GitHub 访问凭证。', + credentialSaved: '已保存', + credentialType: { + 'api-key': 'API 密钥', + 'auth-token': '个人访问令牌', + }, + editConnection: '编辑{name}', + removeConnection: '移除{name}', + connectionFallback: 'GitHub 访问', + githubConnectionDefault: '我的 GitHub', + addTitle: '添加 GitHub 访问', + editTitle: '编辑 GitHub 访问', + removeTitle: '移除“{name}”?', + removeDescription: '凭证将从本机删除,使用它的工具也会被停用。此操作不可撤销。', + removeConfirm: '移除', + cancel: '取消', + add: '添加', + save: '保存', + saveError: '无法保存 GitHub 访问。', + form: { + label: '名称', + credentialType: '凭证类型', + secret: '密钥或令牌', + secretHint: '凭证仅发送到本机 daemon,不会从设置接口读回。', + secretKeepPlaceholder: '留空以保留已保存的凭证', + secretKeepHint: '出于安全考虑,已保存的值不会显示。输入新值会替换它。', + }, units: { githubRead: { label: 'GitHub 只读能力', diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index 80c73c092..39c1e3521 100644 --- a/packages/presentation/ui/src/shell/index.ts +++ b/packages/presentation/ui/src/shell/index.ts @@ -15,6 +15,7 @@ export * from './history/sort-select'; export * from './im-channel-settings-panel'; export * from './new-session-surface'; export * from './notifications-settings-panel'; +export * from './plugin-settings-panel'; export * from './providers/account-detail'; export * from './providers/account-master-list'; export * from './service-icon'; diff --git a/packages/presentation/ui/src/shell/plugin-settings-panel.tsx b/packages/presentation/ui/src/shell/plugin-settings-panel.tsx new file mode 100644 index 000000000..e684402ff --- /dev/null +++ b/packages/presentation/ui/src/shell/plugin-settings-panel.tsx @@ -0,0 +1,241 @@ +import { Alert, AlertDescription } from 'coss-ui/components/alert'; +import { Badge } from 'coss-ui/components/badge'; +import { Button } from 'coss-ui/components/button'; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from 'coss-ui/components/select'; +import { Skeleton } from 'coss-ui/components/skeleton'; +import { Switch } from 'coss-ui/components/switch'; +import { PencilIcon, PlusIcon, Trash2Icon, TriangleAlertIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { SettingsCard, SettingsSection } from './settings-page'; + +export interface PluginConnectionOptionView { + id: string; + label: string; +} + +export interface PluginUnitSettingsView { + id: string; + label: string; + description: string; + enabled: boolean; + connectionId?: string; + connectionOptions: PluginConnectionOptionView[]; +} + +export interface PluginSavedConnectionView { + id: string; + label: string; + credentialType: 'api-key' | 'auth-token'; +} + +export interface PluginSettingsPanelProps { + units: PluginUnitSettingsView[] | undefined; + connections: PluginSavedConnectionView[] | undefined; + error?: string; + busy: boolean; + onEnabledChange: (unitId: string, enabled: boolean) => void; + onConnectionChange: (unitId: string, connectionId: string) => void; + onAddConnection: (unitId?: string) => void; + onEditConnection: (connectionId: string) => void; + onRemoveConnection: (connectionId: string) => void; +} + +/** Pure plugin settings presentation. Catalog/config state and every mutation arrive via props. */ +export function PluginSettingsPanel({ + units, + connections, + error, + busy, + onEnabledChange, + onConnectionChange, + onAddConnection, + onEditConnection, + onRemoveConnection, +}: PluginSettingsPanelProps): React.ReactNode { + const t = useTranslations('settings.plugins'); + + return ( +
+

{t('hint')}

+ + + + {t('managedUnavailable')} + + + {error === undefined ? null : ( + + + {t('loadError', { message: error })} + + )} + + + + {units === undefined ? ( + + ) : ( + units.map((unit) => ( +
+
+
+

{unit.label}

+

{unit.description}

+
+ onEnabledChange(unit.id, enabled)} + /> +
+
+ {t('usesConnection')} + {unit.connectionOptions.length === 0 ? ( + + ) : ( + + )} +
+
+ )) + )} +
+
+ + + + {connections === undefined ? ( + + ) : connections.length === 0 ? ( +
+

{t('connectionsEmpty')}

+ onAddConnection()} /> +
+ ) : ( + <> + {connections.map((connection) => ( +
+
+

{connection.label}

+
+ {t('credentialSaved')} + + {t(`credentialType.${connection.credentialType}`)} + +
+
+ + +
+ ))} +
+ onAddConnection()} /> +
+ + )} +
+
+
+ ); +} + +function AddConnectionButton({ + disabled, + onClick, +}: { + disabled: boolean; + onClick: () => void; +}): React.ReactNode { + const t = useTranslations('settings.plugins'); + return ( + + ); +} + +function PluginUnitSkeleton(): React.ReactNode { + return ( +
+
+
+ + +
+ +
+
+ +
+
+ ); +} + +function ConnectionSkeleton(): React.ReactNode { + return ( +
+
+ + +
+ + +
+ ); +} From 36da7b21e9a120c44654ec50355cdb1d13a6ad59 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 08:48:59 +0000 Subject: [PATCH 07/23] feat(workbench): manage plugin connections --- packages/client/workbench/src/index.ts | 1 + .../src/settings/plugins/plugins-settings.tsx | 398 ++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 packages/client/workbench/src/settings/plugins/plugins-settings.tsx diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index ed071f5a6..0c214e70d 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -43,6 +43,7 @@ export * from './settings/appearance-render-prefs'; export * from './settings/appearance-settings'; export * from './settings/appearance-store'; export * from './settings/plugins/hooks'; +export * from './settings/plugins/plugins-settings'; export * from './settings/providers/providers-settings'; export * from './settings/providers/store'; export * from './settings/search'; diff --git a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx new file mode 100644 index 000000000..bdc3033d1 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx @@ -0,0 +1,398 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { + McpPluginDescriptor, + McpPluginId, + PluginConfigPublic, + PluginConnectorOperation, + PluginConnectorPublic, + PluginUnitState, +} from '@linkcode/schema'; +import type { PluginSavedConnectionView, PluginUnitSettingsView } from '@linkcode/ui'; +import { PluginSettingsPanel } from '@linkcode/ui'; +import { Alert, AlertDescription } from 'coss-ui/components/alert'; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from 'coss-ui/components/alert-dialog'; +import { Button } from 'coss-ui/components/button'; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from 'coss-ui/components/dialog'; +import { Field, FieldDescription, FieldError, FieldLabel } from 'coss-ui/components/field'; +import { Form } from 'coss-ui/components/form'; +import { Input } from 'coss-ui/components/input'; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from 'coss-ui/components/select'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { TriangleAlertIcon } from 'lucide-react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslations } from 'use-intl'; +import { z } from 'zod'; +import { rhfErrorsToFormErrors } from '../../lib/form'; +import { usePluginSettings } from './hooks'; +import { usePluginSettingsViewStore } from './store'; + +const connectionDraftSchema = z.object({ + label: z.string().trim().min(1), + credentialType: z.enum(['api-key', 'auth-token']), + secret: z.string(), +}); +const newConnectionDraftSchema = connectionDraftSchema.extend({ secret: z.string().min(1) }); +type ConnectionDraft = z.infer; + +/** Empty secret means keep: never turn the masked read model into a credential write. */ +export function pluginConnectorUpdate( + connectorId: string, + draft: ConnectionDraft, +): PluginConnectorOperation { + return { + type: 'update', + connectorId, + label: draft.label, + ...(draft.secret !== '' && { + credential: { type: draft.credentialType, secret: draft.secret }, + }), + }; +} + +/** Transport-backed plugin settings container shared by desktop and webview. */ +export function PluginsSettingsPanel(): React.ReactNode { + const t = useTranslations('settings.plugins'); + const { catalog, config, error, isMutating, save } = usePluginSettings(); + const dialog = usePluginSettingsViewStore((state) => state.dialog); + const addConnection = usePluginSettingsViewStore((state) => state.addConnection); + const editConnection = usePluginSettingsViewStore((state) => state.editConnection); + const removeConnection = usePluginSettingsViewStore((state) => state.removeConnection); + const closeDialog = usePluginSettingsViewStore((state) => state.closeDialog); + + const connectorById = new Map(config?.connectors.map((connector) => [connector.id, connector])); + const descriptorById = new Map(catalog?.map((descriptor) => [descriptor.id, descriptor])); + const unitStateById = new Map(config?.units.map((unit) => [unit.unitId, unit])); + const units = toUnitViews(catalog, config, t); + const connections = toConnectionViews(config, t('connectionFallback')); + const selectedConnector = + dialog.kind === 'edit' || dialog.kind === 'remove' + ? connectorById.get(dialog.connectorId) + : undefined; + + const updateUnit = async ( + unitId: string, + update: (current: PluginUnitState) => PluginUnitState, + ): Promise => { + if (!catalog || !config) return; + const typedUnitId = unitId as McpPluginId; + const current = unitStateById.get(typedUnitId) ?? { unitId: typedUnitId, enabled: false }; + const next = update(current); + const nextById = new Map(unitStateById).set(typedUnitId, next); + await save({ + units: catalog.map( + (descriptor) => nextById.get(descriptor.id) ?? { unitId: descriptor.id, enabled: false }, + ), + }); + }; + + const handleEnabledChange = (unitId: string, enabled: boolean): void => { + const descriptor = descriptorById.get(unitId as McpPluginId); + if (!descriptor) return; + const current = unitStateById.get(unitId as McpPluginId); + if (enabled && current?.binding === undefined) { + const connection = config?.connectors.find( + (connector) => connector.service === descriptor.service, + ); + if (!connection && descriptor.service) { + addConnection(descriptor.service, descriptor.id); + return; + } + if (connection) { + void updateUnit(unitId, (unit) => ({ + ...unit, + enabled: true, + binding: { type: 'local', connectorId: connection.id }, + })); + return; + } + } + void updateUnit(unitId, (unit) => ({ ...unit, enabled })); + }; + + const handleConnectionChange = (unitId: string, connectorId: string): void => { + void updateUnit(unitId, (unit) => ({ + ...unit, + binding: { type: 'local', connectorId }, + })); + }; + + return ( + <> + { + const descriptor = + unitId === undefined ? catalog?.at(0) : descriptorById.get(unitId as McpPluginId); + if (descriptor?.service) { + addConnection(descriptor.service, unitId === undefined ? undefined : descriptor.id); + } + }} + onEditConnection={editConnection} + onRemoveConnection={removeConnection} + /> + + {(dialog.kind === 'add' || dialog.kind === 'edit') && ( + { + if (dialog.kind === 'add') { + const connectorId = `plugin_${crypto.randomUUID()}`; + await save({ + connectorOperations: [ + { + type: 'create', + connector: { + id: connectorId, + label: draft.label, + service: dialog.service, + credential: { type: draft.credentialType, secret: draft.secret }, + }, + }, + ], + ...(dialog.enableUnitId !== undefined && { + units: (catalog ?? []).map((descriptor) => + descriptor.id === dialog.enableUnitId + ? { + unitId: descriptor.id, + enabled: true, + binding: { type: 'local' as const, connectorId }, + } + : (unitStateById.get(descriptor.id) ?? { + unitId: descriptor.id, + enabled: false, + }), + ), + }), + }); + } else { + await save({ + connectorOperations: [pluginConnectorUpdate(dialog.connectorId, draft)], + }); + } + closeDialog(); + }} + /> + )} + + { + if (!open && !isMutating) closeDialog(); + }} + > + + + + {t('removeTitle', { name: selectedConnector?.label ?? t('connectionFallback') })} + + {t('removeDescription')} + + + {t('cancel')}} /> + + + + + + ); +} + +function ConnectionDialog({ + connector, + busy, + onClose, + onSubmit, +}: { + connector?: PluginConnectorPublic; + busy: boolean; + onClose: () => void; + onSubmit: (draft: ConnectionDraft) => Promise; +}): React.ReactNode { + const t = useTranslations('settings.plugins'); + const editing = connector !== undefined; + const { + control, + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(editing ? connectionDraftSchema : newConnectionDraftSchema), + defaultValues: { + label: connector?.label ?? t('githubConnectionDefault'), + credentialType: connector?.credential.type ?? 'auth-token', + secret: '', + }, + }); + + const submit = handleSubmit(async (draft) => { + try { + await onSubmit(draft); + } catch (error) { + setError('root', { + message: extractErrorMessage(error, false) ?? t('saveError'), + }); + } + }); + + return ( + { + if (!open && !busy && !isSubmitting) onClose(); + }} + > + +
+ + {editing ? t('editTitle') : t('addTitle')} + + + {errors.root?.message === undefined ? null : ( + + + {errors.root.message} + + )} + + {t('form.label')} + + + + + {t('form.credentialType')} + ( + + )} + /> + + + {t('form.secret')} + + + {editing ? t('form.secretKeepHint') : t('form.secretHint')} + + + + + + + + +
+
+
+ ); +} + +function toUnitViews( + catalog: McpPluginDescriptor[] | undefined, + config: PluginConfigPublic | undefined, + t: ReturnType>, +): PluginUnitSettingsView[] | undefined { + if (!catalog || !config) return undefined; + const stateById = new Map(config.units.map((unit) => [unit.unitId, unit])); + return catalog.map((descriptor) => { + const state = stateById.get(descriptor.id); + const matching = config.connectors.filter( + (connector) => connector.service === descriptor.service, + ); + return { + id: descriptor.id, + label: t(descriptor.labelKey), + description: t(descriptor.descriptionKey), + enabled: state?.enabled ?? false, + ...(state?.binding?.type === 'local' && { connectionId: state.binding.connectorId }), + connectionOptions: matching.map((connector) => ({ + id: connector.id, + label: connector.label ?? t('connectionFallback'), + })), + }; + }); +} + +function toConnectionViews( + config: PluginConfigPublic | undefined, + fallbackLabel: string, +): PluginSavedConnectionView[] | undefined { + return config?.connectors.map((connector) => ({ + id: connector.id, + label: connector.label ?? fallbackLabel, + credentialType: connector.credential.type, + })); +} From 7b57c75a4e38e3e2fdb1eeb2d4cb015c0b99104c Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 08:51:02 +0000 Subject: [PATCH 08/23] feat(settings): expose plugin tools --- .../src/renderer/src/settings/plugins-tab.tsx | 6 ++ .../renderer/src/settings/settings-view.tsx | 12 +++ .../src/renderer/src/settings/store.ts | 1 + apps/webview/src/router.tsx | 2 + apps/webview/src/routes/settings/plugins.tsx | 9 +++ .../src/routes/settings/settings-layout.tsx | 11 +++ .../src/settings/__tests__/search.test.ts | 4 +- .../__tests__/plugins-settings.test.ts | 33 +++++++++ .../client/workbench/src/settings/search.ts | 9 +++ .../__tests__/plugin-settings-panel.test.tsx | 74 +++++++++++++++++++ 10 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/src/settings/plugins-tab.tsx create mode 100644 apps/webview/src/routes/settings/plugins.tsx create mode 100644 packages/client/workbench/src/settings/plugins/__tests__/plugins-settings.test.ts create mode 100644 packages/presentation/ui/src/shell/__tests__/plugin-settings-panel.test.tsx diff --git a/apps/desktop/src/renderer/src/settings/plugins-tab.tsx b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx new file mode 100644 index 000000000..09dc721d4 --- /dev/null +++ b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx @@ -0,0 +1,6 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; + +/** Daemon-backed tool settings, shared with the browser client. */ +export function PluginsTab(): React.ReactNode { + return ; +} diff --git a/apps/desktop/src/renderer/src/settings/settings-view.tsx b/apps/desktop/src/renderer/src/settings/settings-view.tsx index 112028ea2..090495571 100644 --- a/apps/desktop/src/renderer/src/settings/settings-view.tsx +++ b/apps/desktop/src/renderer/src/settings/settings-view.tsx @@ -21,6 +21,7 @@ import { HistoryIcon, InfoIcon, KeyRoundIcon, + PlugIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -41,6 +42,7 @@ import { GeneralTab } from './general-tab'; import { HistoryImportTab } from './history-import-tab'; import { ImChannelTab } from './im-channel-tab'; import { NotificationsTab } from './notifications-tab'; +import { PluginsTab } from './plugins-tab'; import { ProvidersTab } from './providers-tab'; import type { SettingsCategory } from './store'; import { useDesktopSettingsStore } from './store'; @@ -147,6 +149,14 @@ export function SettingsView(): React.ReactNode { active: category === 'providers', onClick: () => setCategory('providers'), }, + { + key: 'plugins', + icon: , + label: t('tabs.plugins'), + keywords: searchKeywords.plugins, + active: category === 'plugins', + onClick: () => setCategory('plugins'), + }, { key: 'imChannel', icon: , @@ -301,6 +311,8 @@ function renderSettingsPanel( return ; case 'agents': return ; + case 'plugins': + return ; case 'imChannel': return ; case 'history-import': diff --git a/apps/desktop/src/renderer/src/settings/store.ts b/apps/desktop/src/renderer/src/settings/store.ts index 2173ba02b..da78c01c5 100644 --- a/apps/desktop/src/renderer/src/settings/store.ts +++ b/apps/desktop/src/renderer/src/settings/store.ts @@ -13,6 +13,7 @@ export type SettingsCategory = | 'about' | 'providers' | 'agents' + | 'plugins' | 'imChannel' | 'history-import'; diff --git a/apps/webview/src/router.tsx b/apps/webview/src/router.tsx index 1ea5f4e2e..711d3a57d 100644 --- a/apps/webview/src/router.tsx +++ b/apps/webview/src/router.tsx @@ -6,6 +6,7 @@ import { DeveloperSettings } from '@webview/routes/settings/developer'; import { GeneralSettings } from '@webview/routes/settings/general'; import { MessagingSettings } from '@webview/routes/settings/messaging'; import { NotificationsSettings } from '@webview/routes/settings/notifications'; +import { PluginsSettings } from '@webview/routes/settings/plugins'; import { ProvidersSettings } from '@webview/routes/settings/providers'; import { SettingsLayout } from '@webview/routes/settings/settings-layout'; import { TerminalSettings } from '@webview/routes/settings/terminal'; @@ -28,6 +29,7 @@ export const router = createBrowserRouter([ { path: 'developer', element: }, { path: 'notifications', element: }, { path: 'providers', element: }, + { path: 'plugins', element: }, { path: 'agents', element: }, { path: 'messaging', element: }, ], diff --git a/apps/webview/src/routes/settings/plugins.tsx b/apps/webview/src/routes/settings/plugins.tsx new file mode 100644 index 000000000..afde41355 --- /dev/null +++ b/apps/webview/src/routes/settings/plugins.tsx @@ -0,0 +1,9 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; +import { usePageTitle } from '@webview/hooks/use-page-title'; +import { useTranslations } from 'use-intl'; + +export function PluginsSettings(): React.ReactNode { + const tTabs = useTranslations('settings.tabs'); + usePageTitle(tTabs('plugins')); + return ; +} diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index d12dd56e9..8524208a0 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -5,6 +5,7 @@ import { BotIcon, CodeXmlIcon, KeyRoundIcon, + PlugIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -21,6 +22,7 @@ const SETTINGS_ROUTES: Record = { notifications: '/settings/notifications', agents: '/settings/agents', providers: '/settings/providers', + plugins: '/settings/plugins', messaging: '/settings/messaging', developer: '/settings/developer', }; @@ -92,6 +94,14 @@ export function SettingsLayout(): React.ReactNode { active: isActive(pathname, 'providers'), render: , }, + { + key: 'plugins', + icon: , + label: t('tabs.plugins'), + keywords: searchKeywords.plugins, + active: isActive(pathname, 'plugins'), + render: , + }, { key: 'messaging', icon: , @@ -163,6 +173,7 @@ function isActive( | 'developer' | 'notifications' | 'providers' + | 'plugins' | 'agents' | 'messaging', ): boolean { diff --git a/packages/client/workbench/src/settings/__tests__/search.test.ts b/packages/client/workbench/src/settings/__tests__/search.test.ts index c157403ac..33e95d6b9 100644 --- a/packages/client/workbench/src/settings/__tests__/search.test.ts +++ b/packages/client/workbench/src/settings/__tests__/search.test.ts @@ -18,6 +18,7 @@ const GROUPS = [ ]), group('integrations', [ { key: 'agents', label: 'Agents', keywords: ['Enabled'] }, + { key: 'plugins', label: 'Tools', keywords: ['GitHub read-only', 'Personal access token'] }, { key: 'messaging', label: 'Messaging', keywords: ['Connect Telegram'] }, ]), ]; @@ -30,7 +31,7 @@ describe('filterSettingsNavGroups', () => { it('returns every group untouched for an empty or whitespace query', () => { expect(visibleKeys(filterSettingsNavGroups(GROUPS, ''))).toEqual([ ['general', 'appearance', 'notifications'], - ['agents', 'messaging'], + ['agents', 'plugins', 'messaging'], ]); expect(visibleKeys(filterSettingsNavGroups(GROUPS, ' '))).toEqual( visibleKeys(filterSettingsNavGroups(GROUPS, '')), @@ -44,6 +45,7 @@ describe('filterSettingsNavGroups', () => { it('matches field-level keywords, not just the tab label', () => { expect(visibleKeys(filterSettingsNavGroups(GROUPS, 'dark'))).toEqual([['appearance'], []]); expect(visibleKeys(filterSettingsNavGroups(GROUPS, 'telegram'))).toEqual([[], ['messaging']]); + expect(visibleKeys(filterSettingsNavGroups(GROUPS, 'github'))).toEqual([[], ['plugins']]); }); it('preserves declaration order even when a later item scores higher', () => { diff --git a/packages/client/workbench/src/settings/plugins/__tests__/plugins-settings.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/plugins-settings.test.ts new file mode 100644 index 000000000..5b4c2362c --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/plugins-settings.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { pluginConnectorUpdate } from '../plugins-settings'; + +describe('plugin connector secret editing', () => { + it('keeps the saved credential when the masked edit field stays empty', () => { + expect( + pluginConnectorUpdate('github-personal', { + label: 'Personal GitHub', + credentialType: 'auth-token', + secret: '', + }), + ).toEqual({ + type: 'update', + connectorId: 'github-personal', + label: 'Personal GitHub', + }); + }); + + it('replaces the credential only when the user enters a new secret', () => { + expect( + pluginConnectorUpdate('github-personal', { + label: 'Personal GitHub', + credentialType: 'auth-token', + secret: 'github_pat_new', + }), + ).toEqual({ + type: 'update', + connectorId: 'github-personal', + label: 'Personal GitHub', + credential: { type: 'auth-token', secret: 'github_pat_new' }, + }); + }); +}); diff --git a/packages/client/workbench/src/settings/search.ts b/packages/client/workbench/src/settings/search.ts index a9a2e54a0..9f4c7d40b 100644 --- a/packages/client/workbench/src/settings/search.ts +++ b/packages/client/workbench/src/settings/search.ts @@ -26,6 +26,7 @@ export interface SettingsSearchKeywords { about: readonly string[]; agents: readonly string[]; providers: readonly string[]; + plugins: readonly string[]; imChannel: readonly string[]; historyImport: readonly string[]; } @@ -86,6 +87,14 @@ export function useSettingsSearchKeywords(): SettingsSearchKeywords { t('providers.accountModel'), ...PROVIDER_SERVICES.map((service) => t(`providers.serviceName.${service}`)), ], + plugins: [ + t('plugins.toolsTitle'), + t('plugins.units.githubRead.label'), + t('plugins.units.githubRead.description'), + t('plugins.connectionsTitle'), + t('plugins.addConnection'), + t('plugins.credentialType.auth-token'), + ], imChannel: [t('imChannel.connectTitle'), t('imChannel.bindings'), t('imChannel.autoMirror')], historyImport: [t('historyImport.portalLabel')], }; diff --git a/packages/presentation/ui/src/shell/__tests__/plugin-settings-panel.test.tsx b/packages/presentation/ui/src/shell/__tests__/plugin-settings-panel.test.tsx new file mode 100644 index 000000000..ecced6f75 --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/plugin-settings-panel.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PluginSettingsPanel } from '../plugin-settings-panel'; + +const RE_SECRET = /secret/i; + +function translateKey(key: string, values?: { name?: string }): string { + return values?.name === undefined ? key : `${key}:${values.name}`; +} + +vi.mock('use-intl', () => ({ + useTranslations: () => translateKey, +})); + +afterEach(cleanup); + +describe('PluginSettingsPanel', () => { + it('renders static sections while daemon-backed values are loading', () => { + render( + , + ); + + expect(screen.getByText('hint')).toBeTruthy(); + expect(screen.getByText('toolsTitle')).toBeTruthy(); + expect(screen.getByText('connectionsTitle')).toBeTruthy(); + }); + + it('exposes saved state without a secret and forwards enablement changes', () => { + const onEnabledChange = vi.fn(); + render( + , + ); + + expect(screen.getByText('credentialSaved')).toBeTruthy(); + expect(screen.queryByText(RE_SECRET)).toBeNull(); + fireEvent.click(screen.getByRole('switch', { name: 'enabledLabel:GitHub tools' })); + expect(onEnabledChange).toHaveBeenCalledWith('github-read', true); + }); +}); From 283356227d3f47cdd17f003c9ddeb973f2397cca Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 09:02:58 +0000 Subject: [PATCH 09/23] fix(settings): polish plugin connection state --- .../src/settings/plugins/plugins-settings.tsx | 9 +++------ packages/presentation/i18n/src/locales/en.ts | 1 + .../presentation/i18n/src/locales/zh-cn.ts | 1 + .../ui/src/shell/plugin-settings-panel.tsx | 19 +++++-------------- 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx index bdc3033d1..5fd1c2633 100644 --- a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx +++ b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx @@ -146,12 +146,9 @@ export function PluginsSettingsPanel(): React.ReactNode { busy={isMutating} onEnabledChange={handleEnabledChange} onConnectionChange={handleConnectionChange} - onAddConnection={(unitId) => { - const descriptor = - unitId === undefined ? catalog?.at(0) : descriptorById.get(unitId as McpPluginId); - if (descriptor?.service) { - addConnection(descriptor.service, unitId === undefined ? undefined : descriptor.id); - } + onAddConnection={() => { + const descriptor = catalog?.at(0); + if (descriptor?.service) addConnection(descriptor.service); }} onEditConnection={editConnection} onRemoveConnection={removeConnection} diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 5561dd753..d250355e6 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -819,6 +819,7 @@ export const en = { connectionsTitle: 'Saved GitHub access', enabledLabel: 'Enable {name}', usesConnection: 'GitHub access to use', + noConnection: 'None selected', addConnection: 'Add GitHub access', chooseConnection: 'Choose GitHub access', connectionsEmpty: 'No GitHub access credential has been saved.', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 474bac6a1..a5d92d151 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -799,6 +799,7 @@ export const zhCN = { connectionsTitle: '已保存的 GitHub 访问', enabledLabel: '启用{name}', usesConnection: '使用的 GitHub 访问', + noConnection: '尚未选择', addConnection: '添加 GitHub 访问', chooseConnection: '选择 GitHub 访问', connectionsEmpty: '尚未保存 GitHub 访问凭证。', diff --git a/packages/presentation/ui/src/shell/plugin-settings-panel.tsx b/packages/presentation/ui/src/shell/plugin-settings-panel.tsx index e684402ff..8b11764f7 100644 --- a/packages/presentation/ui/src/shell/plugin-settings-panel.tsx +++ b/packages/presentation/ui/src/shell/plugin-settings-panel.tsx @@ -41,7 +41,7 @@ export interface PluginSettingsPanelProps { busy: boolean; onEnabledChange: (unitId: string, enabled: boolean) => void; onConnectionChange: (unitId: string, connectionId: string) => void; - onAddConnection: (unitId?: string) => void; + onAddConnection: () => void; onEditConnection: (connectionId: string) => void; onRemoveConnection: (connectionId: string) => void; } @@ -98,23 +98,14 @@ export function PluginSettingsPanel({
{t('usesConnection')} {unit.connectionOptions.length === 0 ? ( - + {t('noConnection')} ) : ( ({ value: connection.id, label: connection.label, }))} - value={unit.connectionId ?? null} + value={unit.connectionId} disabled={busy} onValueChange={(connectionId) => { if (connectionId !== null) onConnectionChange(unit.id, connectionId); @@ -137,7 +146,7 @@ export function PluginSettingsPanel({ ) : connections.length === 0 ? (

{t('connectionsEmpty')}

- + onAddConnection()} />
) : ( <> @@ -175,7 +184,7 @@ export function PluginSettingsPanel({
))}
- + onAddConnection()} />
)} From f78a4333f8566d5560f1d9a66642fe1a02d636aa Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 09:20:48 +0000 Subject: [PATCH 11/23] Revert "feat(settings): expose plugin tools" This reverts commit 7b57c75a4e38e3e2fdb1eeb2d4cb015c0b99104c. --- .../src/renderer/src/settings/plugins-tab.tsx | 6 -- .../renderer/src/settings/settings-view.tsx | 12 --- .../src/renderer/src/settings/store.ts | 1 - apps/webview/src/router.tsx | 2 - apps/webview/src/routes/settings/plugins.tsx | 9 --- .../src/routes/settings/settings-layout.tsx | 11 --- .../src/settings/__tests__/search.test.ts | 4 +- .../__tests__/plugins-settings.test.ts | 33 --------- .../client/workbench/src/settings/search.ts | 9 --- .../__tests__/plugin-settings-panel.test.tsx | 74 ------------------- 10 files changed, 1 insertion(+), 160 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/settings/plugins-tab.tsx delete mode 100644 apps/webview/src/routes/settings/plugins.tsx delete mode 100644 packages/client/workbench/src/settings/plugins/__tests__/plugins-settings.test.ts delete mode 100644 packages/presentation/ui/src/shell/__tests__/plugin-settings-panel.test.tsx diff --git a/apps/desktop/src/renderer/src/settings/plugins-tab.tsx b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx deleted file mode 100644 index 09dc721d4..000000000 --- a/apps/desktop/src/renderer/src/settings/plugins-tab.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { PluginsSettingsPanel } from '@linkcode/workbench'; - -/** Daemon-backed tool settings, shared with the browser client. */ -export function PluginsTab(): React.ReactNode { - return ; -} diff --git a/apps/desktop/src/renderer/src/settings/settings-view.tsx b/apps/desktop/src/renderer/src/settings/settings-view.tsx index 090495571..112028ea2 100644 --- a/apps/desktop/src/renderer/src/settings/settings-view.tsx +++ b/apps/desktop/src/renderer/src/settings/settings-view.tsx @@ -21,7 +21,6 @@ import { HistoryIcon, InfoIcon, KeyRoundIcon, - PlugIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -42,7 +41,6 @@ import { GeneralTab } from './general-tab'; import { HistoryImportTab } from './history-import-tab'; import { ImChannelTab } from './im-channel-tab'; import { NotificationsTab } from './notifications-tab'; -import { PluginsTab } from './plugins-tab'; import { ProvidersTab } from './providers-tab'; import type { SettingsCategory } from './store'; import { useDesktopSettingsStore } from './store'; @@ -149,14 +147,6 @@ export function SettingsView(): React.ReactNode { active: category === 'providers', onClick: () => setCategory('providers'), }, - { - key: 'plugins', - icon: , - label: t('tabs.plugins'), - keywords: searchKeywords.plugins, - active: category === 'plugins', - onClick: () => setCategory('plugins'), - }, { key: 'imChannel', icon: , @@ -311,8 +301,6 @@ function renderSettingsPanel( return ; case 'agents': return ; - case 'plugins': - return ; case 'imChannel': return ; case 'history-import': diff --git a/apps/desktop/src/renderer/src/settings/store.ts b/apps/desktop/src/renderer/src/settings/store.ts index da78c01c5..2173ba02b 100644 --- a/apps/desktop/src/renderer/src/settings/store.ts +++ b/apps/desktop/src/renderer/src/settings/store.ts @@ -13,7 +13,6 @@ export type SettingsCategory = | 'about' | 'providers' | 'agents' - | 'plugins' | 'imChannel' | 'history-import'; diff --git a/apps/webview/src/router.tsx b/apps/webview/src/router.tsx index 711d3a57d..1ea5f4e2e 100644 --- a/apps/webview/src/router.tsx +++ b/apps/webview/src/router.tsx @@ -6,7 +6,6 @@ import { DeveloperSettings } from '@webview/routes/settings/developer'; import { GeneralSettings } from '@webview/routes/settings/general'; import { MessagingSettings } from '@webview/routes/settings/messaging'; import { NotificationsSettings } from '@webview/routes/settings/notifications'; -import { PluginsSettings } from '@webview/routes/settings/plugins'; import { ProvidersSettings } from '@webview/routes/settings/providers'; import { SettingsLayout } from '@webview/routes/settings/settings-layout'; import { TerminalSettings } from '@webview/routes/settings/terminal'; @@ -29,7 +28,6 @@ export const router = createBrowserRouter([ { path: 'developer', element: }, { path: 'notifications', element: }, { path: 'providers', element: }, - { path: 'plugins', element: }, { path: 'agents', element: }, { path: 'messaging', element: }, ], diff --git a/apps/webview/src/routes/settings/plugins.tsx b/apps/webview/src/routes/settings/plugins.tsx deleted file mode 100644 index afde41355..000000000 --- a/apps/webview/src/routes/settings/plugins.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { PluginsSettingsPanel } from '@linkcode/workbench'; -import { usePageTitle } from '@webview/hooks/use-page-title'; -import { useTranslations } from 'use-intl'; - -export function PluginsSettings(): React.ReactNode { - const tTabs = useTranslations('settings.tabs'); - usePageTitle(tTabs('plugins')); - return ; -} diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index 8524208a0..d12dd56e9 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -5,7 +5,6 @@ import { BotIcon, CodeXmlIcon, KeyRoundIcon, - PlugIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -22,7 +21,6 @@ const SETTINGS_ROUTES: Record = { notifications: '/settings/notifications', agents: '/settings/agents', providers: '/settings/providers', - plugins: '/settings/plugins', messaging: '/settings/messaging', developer: '/settings/developer', }; @@ -94,14 +92,6 @@ export function SettingsLayout(): React.ReactNode { active: isActive(pathname, 'providers'), render: , }, - { - key: 'plugins', - icon: , - label: t('tabs.plugins'), - keywords: searchKeywords.plugins, - active: isActive(pathname, 'plugins'), - render: , - }, { key: 'messaging', icon: , @@ -173,7 +163,6 @@ function isActive( | 'developer' | 'notifications' | 'providers' - | 'plugins' | 'agents' | 'messaging', ): boolean { diff --git a/packages/client/workbench/src/settings/__tests__/search.test.ts b/packages/client/workbench/src/settings/__tests__/search.test.ts index 33e95d6b9..c157403ac 100644 --- a/packages/client/workbench/src/settings/__tests__/search.test.ts +++ b/packages/client/workbench/src/settings/__tests__/search.test.ts @@ -18,7 +18,6 @@ const GROUPS = [ ]), group('integrations', [ { key: 'agents', label: 'Agents', keywords: ['Enabled'] }, - { key: 'plugins', label: 'Tools', keywords: ['GitHub read-only', 'Personal access token'] }, { key: 'messaging', label: 'Messaging', keywords: ['Connect Telegram'] }, ]), ]; @@ -31,7 +30,7 @@ describe('filterSettingsNavGroups', () => { it('returns every group untouched for an empty or whitespace query', () => { expect(visibleKeys(filterSettingsNavGroups(GROUPS, ''))).toEqual([ ['general', 'appearance', 'notifications'], - ['agents', 'plugins', 'messaging'], + ['agents', 'messaging'], ]); expect(visibleKeys(filterSettingsNavGroups(GROUPS, ' '))).toEqual( visibleKeys(filterSettingsNavGroups(GROUPS, '')), @@ -45,7 +44,6 @@ describe('filterSettingsNavGroups', () => { it('matches field-level keywords, not just the tab label', () => { expect(visibleKeys(filterSettingsNavGroups(GROUPS, 'dark'))).toEqual([['appearance'], []]); expect(visibleKeys(filterSettingsNavGroups(GROUPS, 'telegram'))).toEqual([[], ['messaging']]); - expect(visibleKeys(filterSettingsNavGroups(GROUPS, 'github'))).toEqual([[], ['plugins']]); }); it('preserves declaration order even when a later item scores higher', () => { diff --git a/packages/client/workbench/src/settings/plugins/__tests__/plugins-settings.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/plugins-settings.test.ts deleted file mode 100644 index 5b4c2362c..000000000 --- a/packages/client/workbench/src/settings/plugins/__tests__/plugins-settings.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { pluginConnectorUpdate } from '../plugins-settings'; - -describe('plugin connector secret editing', () => { - it('keeps the saved credential when the masked edit field stays empty', () => { - expect( - pluginConnectorUpdate('github-personal', { - label: 'Personal GitHub', - credentialType: 'auth-token', - secret: '', - }), - ).toEqual({ - type: 'update', - connectorId: 'github-personal', - label: 'Personal GitHub', - }); - }); - - it('replaces the credential only when the user enters a new secret', () => { - expect( - pluginConnectorUpdate('github-personal', { - label: 'Personal GitHub', - credentialType: 'auth-token', - secret: 'github_pat_new', - }), - ).toEqual({ - type: 'update', - connectorId: 'github-personal', - label: 'Personal GitHub', - credential: { type: 'auth-token', secret: 'github_pat_new' }, - }); - }); -}); diff --git a/packages/client/workbench/src/settings/search.ts b/packages/client/workbench/src/settings/search.ts index 9f4c7d40b..a9a2e54a0 100644 --- a/packages/client/workbench/src/settings/search.ts +++ b/packages/client/workbench/src/settings/search.ts @@ -26,7 +26,6 @@ export interface SettingsSearchKeywords { about: readonly string[]; agents: readonly string[]; providers: readonly string[]; - plugins: readonly string[]; imChannel: readonly string[]; historyImport: readonly string[]; } @@ -87,14 +86,6 @@ export function useSettingsSearchKeywords(): SettingsSearchKeywords { t('providers.accountModel'), ...PROVIDER_SERVICES.map((service) => t(`providers.serviceName.${service}`)), ], - plugins: [ - t('plugins.toolsTitle'), - t('plugins.units.githubRead.label'), - t('plugins.units.githubRead.description'), - t('plugins.connectionsTitle'), - t('plugins.addConnection'), - t('plugins.credentialType.auth-token'), - ], imChannel: [t('imChannel.connectTitle'), t('imChannel.bindings'), t('imChannel.autoMirror')], historyImport: [t('historyImport.portalLabel')], }; diff --git a/packages/presentation/ui/src/shell/__tests__/plugin-settings-panel.test.tsx b/packages/presentation/ui/src/shell/__tests__/plugin-settings-panel.test.tsx deleted file mode 100644 index ecced6f75..000000000 --- a/packages/presentation/ui/src/shell/__tests__/plugin-settings-panel.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { PluginSettingsPanel } from '../plugin-settings-panel'; - -const RE_SECRET = /secret/i; - -function translateKey(key: string, values?: { name?: string }): string { - return values?.name === undefined ? key : `${key}:${values.name}`; -} - -vi.mock('use-intl', () => ({ - useTranslations: () => translateKey, -})); - -afterEach(cleanup); - -describe('PluginSettingsPanel', () => { - it('renders static sections while daemon-backed values are loading', () => { - render( - , - ); - - expect(screen.getByText('hint')).toBeTruthy(); - expect(screen.getByText('toolsTitle')).toBeTruthy(); - expect(screen.getByText('connectionsTitle')).toBeTruthy(); - }); - - it('exposes saved state without a secret and forwards enablement changes', () => { - const onEnabledChange = vi.fn(); - render( - , - ); - - expect(screen.getByText('credentialSaved')).toBeTruthy(); - expect(screen.queryByText(RE_SECRET)).toBeNull(); - fireEvent.click(screen.getByRole('switch', { name: 'enabledLabel:GitHub tools' })); - expect(onEnabledChange).toHaveBeenCalledWith('github-read', true); - }); -}); From b715db10a37523600c85521ac3b553fcde9ab96b Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 09:20:55 +0000 Subject: [PATCH 12/23] Revert "feat(workbench): manage plugin connections" This reverts commit 36da7b21e9a120c44654ec50355cdb1d13a6ad59. --- packages/client/workbench/src/index.ts | 1 - .../src/settings/plugins/plugins-settings.tsx | 398 ------------------ 2 files changed, 399 deletions(-) delete mode 100644 packages/client/workbench/src/settings/plugins/plugins-settings.tsx diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index 0c214e70d..ed071f5a6 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -43,7 +43,6 @@ export * from './settings/appearance-render-prefs'; export * from './settings/appearance-settings'; export * from './settings/appearance-store'; export * from './settings/plugins/hooks'; -export * from './settings/plugins/plugins-settings'; export * from './settings/providers/providers-settings'; export * from './settings/providers/store'; export * from './settings/search'; diff --git a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx deleted file mode 100644 index bdc3033d1..000000000 --- a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import type { - McpPluginDescriptor, - McpPluginId, - PluginConfigPublic, - PluginConnectorOperation, - PluginConnectorPublic, - PluginUnitState, -} from '@linkcode/schema'; -import type { PluginSavedConnectionView, PluginUnitSettingsView } from '@linkcode/ui'; -import { PluginSettingsPanel } from '@linkcode/ui'; -import { Alert, AlertDescription } from 'coss-ui/components/alert'; -import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from 'coss-ui/components/alert-dialog'; -import { Button } from 'coss-ui/components/button'; -import { - Dialog, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from 'coss-ui/components/dialog'; -import { Field, FieldDescription, FieldError, FieldLabel } from 'coss-ui/components/field'; -import { Form } from 'coss-ui/components/form'; -import { Input } from 'coss-ui/components/input'; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from 'coss-ui/components/select'; -import { extractErrorMessage } from 'foxts/extract-error-message'; -import { TriangleAlertIcon } from 'lucide-react'; -import { Controller, useForm } from 'react-hook-form'; -import { useTranslations } from 'use-intl'; -import { z } from 'zod'; -import { rhfErrorsToFormErrors } from '../../lib/form'; -import { usePluginSettings } from './hooks'; -import { usePluginSettingsViewStore } from './store'; - -const connectionDraftSchema = z.object({ - label: z.string().trim().min(1), - credentialType: z.enum(['api-key', 'auth-token']), - secret: z.string(), -}); -const newConnectionDraftSchema = connectionDraftSchema.extend({ secret: z.string().min(1) }); -type ConnectionDraft = z.infer; - -/** Empty secret means keep: never turn the masked read model into a credential write. */ -export function pluginConnectorUpdate( - connectorId: string, - draft: ConnectionDraft, -): PluginConnectorOperation { - return { - type: 'update', - connectorId, - label: draft.label, - ...(draft.secret !== '' && { - credential: { type: draft.credentialType, secret: draft.secret }, - }), - }; -} - -/** Transport-backed plugin settings container shared by desktop and webview. */ -export function PluginsSettingsPanel(): React.ReactNode { - const t = useTranslations('settings.plugins'); - const { catalog, config, error, isMutating, save } = usePluginSettings(); - const dialog = usePluginSettingsViewStore((state) => state.dialog); - const addConnection = usePluginSettingsViewStore((state) => state.addConnection); - const editConnection = usePluginSettingsViewStore((state) => state.editConnection); - const removeConnection = usePluginSettingsViewStore((state) => state.removeConnection); - const closeDialog = usePluginSettingsViewStore((state) => state.closeDialog); - - const connectorById = new Map(config?.connectors.map((connector) => [connector.id, connector])); - const descriptorById = new Map(catalog?.map((descriptor) => [descriptor.id, descriptor])); - const unitStateById = new Map(config?.units.map((unit) => [unit.unitId, unit])); - const units = toUnitViews(catalog, config, t); - const connections = toConnectionViews(config, t('connectionFallback')); - const selectedConnector = - dialog.kind === 'edit' || dialog.kind === 'remove' - ? connectorById.get(dialog.connectorId) - : undefined; - - const updateUnit = async ( - unitId: string, - update: (current: PluginUnitState) => PluginUnitState, - ): Promise => { - if (!catalog || !config) return; - const typedUnitId = unitId as McpPluginId; - const current = unitStateById.get(typedUnitId) ?? { unitId: typedUnitId, enabled: false }; - const next = update(current); - const nextById = new Map(unitStateById).set(typedUnitId, next); - await save({ - units: catalog.map( - (descriptor) => nextById.get(descriptor.id) ?? { unitId: descriptor.id, enabled: false }, - ), - }); - }; - - const handleEnabledChange = (unitId: string, enabled: boolean): void => { - const descriptor = descriptorById.get(unitId as McpPluginId); - if (!descriptor) return; - const current = unitStateById.get(unitId as McpPluginId); - if (enabled && current?.binding === undefined) { - const connection = config?.connectors.find( - (connector) => connector.service === descriptor.service, - ); - if (!connection && descriptor.service) { - addConnection(descriptor.service, descriptor.id); - return; - } - if (connection) { - void updateUnit(unitId, (unit) => ({ - ...unit, - enabled: true, - binding: { type: 'local', connectorId: connection.id }, - })); - return; - } - } - void updateUnit(unitId, (unit) => ({ ...unit, enabled })); - }; - - const handleConnectionChange = (unitId: string, connectorId: string): void => { - void updateUnit(unitId, (unit) => ({ - ...unit, - binding: { type: 'local', connectorId }, - })); - }; - - return ( - <> - { - const descriptor = - unitId === undefined ? catalog?.at(0) : descriptorById.get(unitId as McpPluginId); - if (descriptor?.service) { - addConnection(descriptor.service, unitId === undefined ? undefined : descriptor.id); - } - }} - onEditConnection={editConnection} - onRemoveConnection={removeConnection} - /> - - {(dialog.kind === 'add' || dialog.kind === 'edit') && ( - { - if (dialog.kind === 'add') { - const connectorId = `plugin_${crypto.randomUUID()}`; - await save({ - connectorOperations: [ - { - type: 'create', - connector: { - id: connectorId, - label: draft.label, - service: dialog.service, - credential: { type: draft.credentialType, secret: draft.secret }, - }, - }, - ], - ...(dialog.enableUnitId !== undefined && { - units: (catalog ?? []).map((descriptor) => - descriptor.id === dialog.enableUnitId - ? { - unitId: descriptor.id, - enabled: true, - binding: { type: 'local' as const, connectorId }, - } - : (unitStateById.get(descriptor.id) ?? { - unitId: descriptor.id, - enabled: false, - }), - ), - }), - }); - } else { - await save({ - connectorOperations: [pluginConnectorUpdate(dialog.connectorId, draft)], - }); - } - closeDialog(); - }} - /> - )} - - { - if (!open && !isMutating) closeDialog(); - }} - > - - - - {t('removeTitle', { name: selectedConnector?.label ?? t('connectionFallback') })} - - {t('removeDescription')} - - - {t('cancel')}} /> - - - - - - ); -} - -function ConnectionDialog({ - connector, - busy, - onClose, - onSubmit, -}: { - connector?: PluginConnectorPublic; - busy: boolean; - onClose: () => void; - onSubmit: (draft: ConnectionDraft) => Promise; -}): React.ReactNode { - const t = useTranslations('settings.plugins'); - const editing = connector !== undefined; - const { - control, - register, - handleSubmit, - setError, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(editing ? connectionDraftSchema : newConnectionDraftSchema), - defaultValues: { - label: connector?.label ?? t('githubConnectionDefault'), - credentialType: connector?.credential.type ?? 'auth-token', - secret: '', - }, - }); - - const submit = handleSubmit(async (draft) => { - try { - await onSubmit(draft); - } catch (error) { - setError('root', { - message: extractErrorMessage(error, false) ?? t('saveError'), - }); - } - }); - - return ( - { - if (!open && !busy && !isSubmitting) onClose(); - }} - > - -
- - {editing ? t('editTitle') : t('addTitle')} - - - {errors.root?.message === undefined ? null : ( - - - {errors.root.message} - - )} - - {t('form.label')} - - - - - {t('form.credentialType')} - ( - - )} - /> - - - {t('form.secret')} - - - {editing ? t('form.secretKeepHint') : t('form.secretHint')} - - - - - - - - -
-
-
- ); -} - -function toUnitViews( - catalog: McpPluginDescriptor[] | undefined, - config: PluginConfigPublic | undefined, - t: ReturnType>, -): PluginUnitSettingsView[] | undefined { - if (!catalog || !config) return undefined; - const stateById = new Map(config.units.map((unit) => [unit.unitId, unit])); - return catalog.map((descriptor) => { - const state = stateById.get(descriptor.id); - const matching = config.connectors.filter( - (connector) => connector.service === descriptor.service, - ); - return { - id: descriptor.id, - label: t(descriptor.labelKey), - description: t(descriptor.descriptionKey), - enabled: state?.enabled ?? false, - ...(state?.binding?.type === 'local' && { connectionId: state.binding.connectorId }), - connectionOptions: matching.map((connector) => ({ - id: connector.id, - label: connector.label ?? t('connectionFallback'), - })), - }; - }); -} - -function toConnectionViews( - config: PluginConfigPublic | undefined, - fallbackLabel: string, -): PluginSavedConnectionView[] | undefined { - return config?.connectors.map((connector) => ({ - id: connector.id, - label: connector.label ?? fallbackLabel, - credentialType: connector.credential.type, - })); -} From 19509fc5d509b235cc744cf7a423840a2e6121b6 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 09:21:04 +0000 Subject: [PATCH 13/23] Revert "feat(ui): present plugin settings" This reverts commit 64df55cfe5411352142d7d80c1db4fc1d9c19a96. --- .../workbench/src/settings/plugins/store.ts | 32 --- packages/presentation/i18n/src/locales/en.ts | 40 --- .../presentation/i18n/src/locales/zh-cn.ts | 38 --- packages/presentation/ui/src/shell/index.ts | 1 - .../ui/src/shell/plugin-settings-panel.tsx | 241 ------------------ 5 files changed, 352 deletions(-) delete mode 100644 packages/client/workbench/src/settings/plugins/store.ts delete mode 100644 packages/presentation/ui/src/shell/plugin-settings-panel.tsx diff --git a/packages/client/workbench/src/settings/plugins/store.ts b/packages/client/workbench/src/settings/plugins/store.ts deleted file mode 100644 index cf4372d2b..000000000 --- a/packages/client/workbench/src/settings/plugins/store.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { McpPluginId, McpPluginService } from '@linkcode/schema'; -import { create } from 'zustand'; - -export type PluginSettingsDialog = - | { kind: 'closed' } - | { kind: 'add'; service: McpPluginService; enableUnitId?: McpPluginId } - | { kind: 'edit'; connectorId: string } - | { kind: 'remove'; connectorId: string }; - -interface PluginSettingsViewState { - dialog: PluginSettingsDialog; - addConnection: (service: McpPluginService, enableUnitId?: McpPluginId) => void; - editConnection: (connectorId: string) => void; - removeConnection: (connectorId: string) => void; - closeDialog: () => void; -} - -/** Ephemeral dialog state; daemon config remains the only persisted plugin state. */ -export const usePluginSettingsViewStore = create()((set) => ({ - dialog: { kind: 'closed' }, - addConnection: (service, enableUnitId) => - set({ - dialog: { - kind: 'add', - service, - ...(enableUnitId !== undefined && { enableUnitId }), - }, - }), - editConnection: (connectorId) => set({ dialog: { kind: 'edit', connectorId } }), - removeConnection: (connectorId) => set({ dialog: { kind: 'remove', connectorId } }), - closeDialog: () => set({ dialog: { kind: 'closed' } }), -})); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 5561dd753..6ceab48f3 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -579,7 +579,6 @@ export const en = { about: 'About', providers: 'Providers', agents: 'Agents', - plugins: 'Tools', imChannel: 'Messaging', }, historyImport: { @@ -811,45 +810,6 @@ export const en = { }, }, plugins: { - hint: 'Configure optional external tools and access credentials for your agents.', - managedUnavailable: - 'Cloud connections and tool delivery are not available yet. You can save a personal GitHub credential now; agents cannot use these tools until service support ships.', - loadError: 'Unable to load tool settings: {message}', - toolsTitle: 'Available tools', - connectionsTitle: 'Saved GitHub access', - enabledLabel: 'Enable {name}', - usesConnection: 'GitHub access to use', - addConnection: 'Add GitHub access', - chooseConnection: 'Choose GitHub access', - connectionsEmpty: 'No GitHub access credential has been saved.', - credentialSaved: 'Saved', - credentialType: { - 'api-key': 'API key', - 'auth-token': 'Personal access token', - }, - editConnection: 'Edit {name}', - removeConnection: 'Remove {name}', - connectionFallback: 'GitHub access', - githubConnectionDefault: 'My GitHub', - addTitle: 'Add GitHub access', - editTitle: 'Edit GitHub access', - removeTitle: 'Remove “{name}”?', - removeDescription: - 'The credential is deleted from this machine and tools using it are disabled. This cannot be undone.', - removeConfirm: 'Remove', - cancel: 'Cancel', - add: 'Add', - save: 'Save', - saveError: 'Unable to save GitHub access.', - form: { - label: 'Name', - credentialType: 'Credential type', - secret: 'Key or token', - secretHint: 'Sent only to your local daemon and never returned by the settings API.', - secretKeepPlaceholder: 'Leave blank to keep the saved credential', - secretKeepHint: - 'The saved value is not displayed for security. Enter a new value to replace it.', - }, units: { githubRead: { label: 'GitHub read-only tools', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 474bac6a1..9c7d6f514 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -568,7 +568,6 @@ export const zhCN = { about: '关于', providers: 'Providers', agents: '智能体', - plugins: '工具', imChannel: 'IM 渠道', }, historyImport: { @@ -791,43 +790,6 @@ export const zhCN = { }, }, plugins: { - hint: '为智能体配置可选的外部工具与访问凭证。凭证只保存在本机 daemon 中。', - managedUnavailable: - '云端连接与工具交付尚未开放。你可以先保存 GitHub 个人凭证;在服务支持上线前,智能体还不能使用这些工具。', - loadError: '无法加载工具设置:{message}', - toolsTitle: '可用工具', - connectionsTitle: '已保存的 GitHub 访问', - enabledLabel: '启用{name}', - usesConnection: '使用的 GitHub 访问', - addConnection: '添加 GitHub 访问', - chooseConnection: '选择 GitHub 访问', - connectionsEmpty: '尚未保存 GitHub 访问凭证。', - credentialSaved: '已保存', - credentialType: { - 'api-key': 'API 密钥', - 'auth-token': '个人访问令牌', - }, - editConnection: '编辑{name}', - removeConnection: '移除{name}', - connectionFallback: 'GitHub 访问', - githubConnectionDefault: '我的 GitHub', - addTitle: '添加 GitHub 访问', - editTitle: '编辑 GitHub 访问', - removeTitle: '移除“{name}”?', - removeDescription: '凭证将从本机删除,使用它的工具也会被停用。此操作不可撤销。', - removeConfirm: '移除', - cancel: '取消', - add: '添加', - save: '保存', - saveError: '无法保存 GitHub 访问。', - form: { - label: '名称', - credentialType: '凭证类型', - secret: '密钥或令牌', - secretHint: '凭证仅发送到本机 daemon,不会从设置接口读回。', - secretKeepPlaceholder: '留空以保留已保存的凭证', - secretKeepHint: '出于安全考虑,已保存的值不会显示。输入新值会替换它。', - }, units: { githubRead: { label: 'GitHub 只读能力', diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index 39c1e3521..80c73c092 100644 --- a/packages/presentation/ui/src/shell/index.ts +++ b/packages/presentation/ui/src/shell/index.ts @@ -15,7 +15,6 @@ export * from './history/sort-select'; export * from './im-channel-settings-panel'; export * from './new-session-surface'; export * from './notifications-settings-panel'; -export * from './plugin-settings-panel'; export * from './providers/account-detail'; export * from './providers/account-master-list'; export * from './service-icon'; diff --git a/packages/presentation/ui/src/shell/plugin-settings-panel.tsx b/packages/presentation/ui/src/shell/plugin-settings-panel.tsx deleted file mode 100644 index e684402ff..000000000 --- a/packages/presentation/ui/src/shell/plugin-settings-panel.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { Alert, AlertDescription } from 'coss-ui/components/alert'; -import { Badge } from 'coss-ui/components/badge'; -import { Button } from 'coss-ui/components/button'; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from 'coss-ui/components/select'; -import { Skeleton } from 'coss-ui/components/skeleton'; -import { Switch } from 'coss-ui/components/switch'; -import { PencilIcon, PlusIcon, Trash2Icon, TriangleAlertIcon } from 'lucide-react'; -import { useTranslations } from 'use-intl'; -import { SettingsCard, SettingsSection } from './settings-page'; - -export interface PluginConnectionOptionView { - id: string; - label: string; -} - -export interface PluginUnitSettingsView { - id: string; - label: string; - description: string; - enabled: boolean; - connectionId?: string; - connectionOptions: PluginConnectionOptionView[]; -} - -export interface PluginSavedConnectionView { - id: string; - label: string; - credentialType: 'api-key' | 'auth-token'; -} - -export interface PluginSettingsPanelProps { - units: PluginUnitSettingsView[] | undefined; - connections: PluginSavedConnectionView[] | undefined; - error?: string; - busy: boolean; - onEnabledChange: (unitId: string, enabled: boolean) => void; - onConnectionChange: (unitId: string, connectionId: string) => void; - onAddConnection: (unitId?: string) => void; - onEditConnection: (connectionId: string) => void; - onRemoveConnection: (connectionId: string) => void; -} - -/** Pure plugin settings presentation. Catalog/config state and every mutation arrive via props. */ -export function PluginSettingsPanel({ - units, - connections, - error, - busy, - onEnabledChange, - onConnectionChange, - onAddConnection, - onEditConnection, - onRemoveConnection, -}: PluginSettingsPanelProps): React.ReactNode { - const t = useTranslations('settings.plugins'); - - return ( -
-

{t('hint')}

- - - - {t('managedUnavailable')} - - - {error === undefined ? null : ( - - - {t('loadError', { message: error })} - - )} - - - - {units === undefined ? ( - - ) : ( - units.map((unit) => ( -
-
-
-

{unit.label}

-

{unit.description}

-
- onEnabledChange(unit.id, enabled)} - /> -
-
- {t('usesConnection')} - {unit.connectionOptions.length === 0 ? ( - - ) : ( - - )} -
-
- )) - )} -
-
- - - - {connections === undefined ? ( - - ) : connections.length === 0 ? ( -
-

{t('connectionsEmpty')}

- onAddConnection()} /> -
- ) : ( - <> - {connections.map((connection) => ( -
-
-

{connection.label}

-
- {t('credentialSaved')} - - {t(`credentialType.${connection.credentialType}`)} - -
-
- - -
- ))} -
- onAddConnection()} /> -
- - )} -
-
-
- ); -} - -function AddConnectionButton({ - disabled, - onClick, -}: { - disabled: boolean; - onClick: () => void; -}): React.ReactNode { - const t = useTranslations('settings.plugins'); - return ( - - ); -} - -function PluginUnitSkeleton(): React.ReactNode { - return ( -
-
-
- - -
- -
-
- -
-
- ); -} - -function ConnectionSkeleton(): React.ReactNode { - return ( -
-
- - -
- - -
- ); -} From 8d0697a9eac718f947d1353efdcfb7b509ad5030 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 22 Jul 2026 18:17:02 +0800 Subject: [PATCH 14/23] feat(schema,engine,client): bind plugin MCP servers to connectors per service A plugin now composes one or more MCP servers; each server names its service and resolves its credential through the global service->binding map instead of a per-unit connector reference. Wire protocol 45. --- apps/daemon/src/__tests__/config.test.ts | 28 ++--- apps/daemon/src/config.ts | 28 ++++- apps/daemon/src/index.ts | 2 +- .../tests/integration/control-client.test.ts | 6 +- .../workbench/src/mock/dev-mock-host.ts | 23 ++-- .../integration/dev-mock-transport.test.ts | 26 ++-- .../schema/src/model/agent/event.ts | 4 +- .../foundation/schema/src/model/plugin.ts | 89 ++++++++------ .../foundation/schema/src/wire/message.ts | 5 +- .../schema/tests/contract/wire/plugin.test.ts | 65 +++++++++- .../__tests__/engine-plugin-catalog.test.ts | 3 +- .../__tests__/engine-plugin-config.test.ts | 12 +- .../src/__tests__/plugin-resolver.test.ts | 111 ++++++++++++++---- .../src/__tests__/provider-config.test.ts | 14 +-- .../host/engine/src/agent/provider-config.ts | 30 +++-- packages/host/engine/src/plugin/catalog.ts | 3 +- .../src/session/start-options-resolver.ts | 99 ++++++++++------ 17 files changed, 371 insertions(+), 177 deletions(-) diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 462f3ae12..29a2f9623 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -1,6 +1,7 @@ import { mkdirSync, mkdtempSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { PluginConfig } from '@linkcode/schema'; import { noop } from 'foxts/noop'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -188,25 +189,20 @@ describe('loadConfig plugins', () => { writeRawConfig({ plugins: { units: [ - { - unitId: 'github-read', - enabled: true, - binding: { type: 'local', connectorId: connector.id }, - }, + { unitId: 'github-read', enabled: true }, { unitId: 'unknown-unit', enabled: true }, ], + serviceBindings: { + github: { type: 'local', connectorId: connector.id }, + jira: { type: 'local', connectorId: 'stale' }, + }, connectors: [connector, { id: 'bad', service: 'github' }], }, }); expect(loadConfig().plugins).toEqual({ - units: [ - { - unitId: 'github-read', - enabled: true, - binding: { type: 'local', connectorId: connector.id }, - }, - ], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: connector.id } }, connectors: [connector], }); expect(warnSpy).toHaveBeenCalledTimes(2); @@ -214,7 +210,11 @@ describe('loadConfig plugins', () => { it('round-trips credentials at mode 0600 without overwriting providers or accounts', () => { writeRawConfig({ providers: { codex: { enabled: true } }, accounts: [validAccount] }); - const plugins = { units: [], connectors: [connector] }; + const plugins: PluginConfig = { + units: [], + serviceBindings: { github: { type: 'local', connectorId: connector.id } }, + connectors: [connector], + }; savePlugins(plugins); @@ -230,6 +230,6 @@ describe('loadConfig plugins', () => { it('defaults an older config without a plugins section to empty state', () => { writeRawConfig({ providers: {} }); - expect(loadConfig().plugins).toEqual({ units: [], connectors: [] }); + expect(loadConfig().plugins).toEqual({ units: [], serviceBindings: {}, connectors: [] }); }); }); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index a77d814dd..462e8aa7b 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -8,7 +8,9 @@ import { AgentKindSchema, DAEMON_DEFAULT_PORT, linkcodeStateDirName, + McpPluginServiceSchema, PluginConnectorSchema, + PluginServiceBindingSchema, PluginUnitStateSchema, ProviderConfigSchema, parseProfileName, @@ -124,17 +126,39 @@ export function loadConfig(): DaemonConfig { } function parsePlugins(raw: unknown): PluginConfig { - if (raw === undefined) return { units: [], connectors: [] }; + const empty: PluginConfig = { units: [], serviceBindings: {}, connectors: [] }; + if (raw === undefined) return empty; if (!isRecord(raw)) { logger.warn({ operation: 'config.load' }, 'Invalid plugins config: expected an object'); - return { units: [], connectors: [] }; + return empty; } return { units: parsePluginEntries(raw.units, PluginUnitStateSchema, 'unit'), + serviceBindings: parseServiceBindings(raw.serviceBindings), connectors: parsePluginEntries(raw.connectors, PluginConnectorSchema, 'connector'), }; } +/** Parse binding by binding: an invalid entry is dropped and logged, never blanking the map. */ +function parseServiceBindings(raw: unknown): PluginConfig['serviceBindings'] { + if (raw === undefined) return {}; + if (!isRecord(raw)) { + logger.warn( + { operation: 'config.load' }, + 'Invalid plugin service bindings config: expected an object', + ); + return {}; + } + const bindings: PluginConfig['serviceBindings'] = {}; + for (const service of McpPluginServiceSchema.options) { + if (!(service in raw)) continue; + const parsed = PluginServiceBindingSchema.safeParse(raw[service]); + if (parsed.success) bindings[service] = parsed.data; + else logger.warn({ operation: 'config.load' }, 'Dropping invalid plugin service binding'); + } + return bindings; +} + function parsePluginEntries( raw: unknown, schema: { safeParse(value: unknown): { success: true; data: T } | { success: false } }, diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 010baf8f6..386d5a694 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -173,7 +173,7 @@ async function main(): Promise { const store = createProviderConfigStore( config.providers ?? {}, config.accounts ?? [], - config.plugins ?? { units: [], connectors: [] }, + config.plugins ?? { units: [], serviceBindings: {}, connectors: [] }, ); // Managed assets (CODE-111): GC superseded versions before anything can spawn, then feed the // store into spawn resolution — managed wins over detected as soon as an install lands. diff --git a/packages/client/core/tests/integration/control-client.test.ts b/packages/client/core/tests/integration/control-client.test.ts index 9bfd36cb4..ad6e93434 100644 --- a/packages/client/core/tests/integration/control-client.test.ts +++ b/packages/client/core/tests/integration/control-client.test.ts @@ -99,12 +99,12 @@ describe('LinkCodeClient control API', () => { id: 'github-read', labelKey: 'units.githubRead.label', descriptionKey: 'units.githubRead.description', - service: 'github', - backing: { type: 'managed-connector', name: 'linkcode-github' }, + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], }, ]; const plugins: PluginConfigPublic = { - units: [{ unitId: 'github-read', enabled: true, binding: { type: 'managed' } }], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'managed' } }, connectors: [], }; diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index ed9cbc2e7..d859c530f 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -11,6 +11,7 @@ import type { ManagedAssetId, ManagedAssetStatus, McpPluginCatalog, + McpPluginService, MessageId, PermissionOutcome, PluginConfig, @@ -107,8 +108,7 @@ const MOCK_PLUGIN_CATALOG: McpPluginCatalog = [ id: 'github-read', labelKey: 'units.githubRead.label', descriptionKey: 'units.githubRead.description', - service: 'github', - backing: { type: 'managed-connector', name: 'linkcode-github' }, + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], }, ]; @@ -178,7 +178,7 @@ export class DevMockHost { private readonly workspaces = new Map(); private providers: ProvidersConfig = {}; private accounts: Accounts = []; - private plugins: PluginConfig = { units: [], connectors: [] }; + private plugins: PluginConfig = { units: [], serviceBindings: {}, connectors: [] }; private readonly permissions = new Map(); private readonly questions = new Map(); private history: AgentHistorySession[] = []; @@ -1388,6 +1388,7 @@ export class DevMockHost { function publicPluginConfig(config: PluginConfig): PluginConfigPublic { return { units: structuredClone(config.units), + serviceBindings: structuredClone(config.serviceBindings), connectors: config.connectors.map(({ credential, ...connector }) => ({ ...connector, credential: @@ -1399,7 +1400,8 @@ function publicPluginConfig(config: PluginConfig): PluginConfigPublic { } function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): PluginConfig { - let units = structuredClone(patch.units ?? current.units); + const units = structuredClone(patch.units ?? current.units); + const serviceBindings = structuredClone(patch.serviceBindings ?? current.serviceBindings); let connectors = structuredClone(current.connectors); for (const operation of patch.connectorOperations ?? []) { switch (operation.type) { @@ -1420,17 +1422,18 @@ function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): Pl break; case 'delete': connectors = connectors.filter((connector) => connector.id !== operation.connectorId); - units = units.map((unit) => - unit.binding?.type === 'local' && unit.binding.connectorId === operation.connectorId - ? { ...unit, enabled: false, binding: undefined } - : unit, - ); + for (const service of Object.keys(serviceBindings) as McpPluginService[]) { + const binding = serviceBindings[service]; + if (binding?.type === 'local' && binding.connectorId === operation.connectorId) { + delete serviceBindings[service]; + } + } break; default: break; } } - return { units, connectors }; + return { units, serviceBindings, connectors }; } function promptText(content: readonly ContentBlock[]): string { diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index fc26423fc..575789127 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -129,16 +129,14 @@ describe('dev mock transport', () => { const client = await connectedClient(); expect(await client.getPluginCatalog()).toEqual([ - expect.objectContaining({ id: 'github-read', service: 'github' }), + expect.objectContaining({ + id: 'github-read', + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], + }), ]); await client.setPluginConfig({ - units: [ - { - unitId: 'github-read', - enabled: true, - binding: { type: 'local', connectorId: 'github-personal' }, - }, - ], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, connectorOperations: [ { type: 'create', @@ -152,13 +150,8 @@ describe('dev mock transport', () => { ], }); expect(await client.getPluginConfig()).toEqual({ - units: [ - { - unitId: 'github-read', - enabled: true, - binding: { type: 'local', connectorId: 'github-personal' }, - }, - ], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, connectors: [ { id: 'github-personal', @@ -193,7 +186,8 @@ describe('dev mock transport', () => { connectorOperations: [{ type: 'delete', connectorId: 'github-personal' }], }); expect(await client.getPluginConfig()).toEqual({ - units: [{ unitId: 'github-read', enabled: false }], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: {}, connectors: [], }); diff --git a/packages/foundation/schema/src/model/agent/event.ts b/packages/foundation/schema/src/model/agent/event.ts index b45052fa1..f51e65358 100644 --- a/packages/foundation/schema/src/model/agent/event.ts +++ b/packages/foundation/schema/src/model/agent/event.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { ContentBlockSchema } from '../content'; import { PermissionOutcomeSchema, PermissionRequestSchema } from '../permission'; import { PlanSchema } from '../plan'; -import { McpPluginIdSchema, PluginWarningReasonSchema } from '../plugin'; +import { McpPluginIdSchema, McpPluginServiceSchema, PluginWarningReasonSchema } from '../plugin'; import { AgentHistoryIdSchema, MessageIdSchema, @@ -129,6 +129,8 @@ export const AgentEventSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('plugin-warning'), unitId: McpPluginIdSchema, + /** The service dependency that failed, when the reason is service-scoped. */ + service: McpPluginServiceSchema.optional(), reason: PluginWarningReasonSchema, }), diff --git a/packages/foundation/schema/src/model/plugin.ts b/packages/foundation/schema/src/model/plugin.ts index 79aec8ef0..64f1b2d69 100644 --- a/packages/foundation/schema/src/model/plugin.ts +++ b/packages/foundation/schema/src/model/plugin.ts @@ -17,42 +17,54 @@ export const McpPluginCredentialSlotSchema = z.discriminatedUnion('target', [ ]); export type McpPluginCredentialSlot = z.infer; -const McpPluginBackingSchema = z.discriminatedUnion('type', [ +/** One MCP server inside a plugin. A plugin composes one or more servers; each server that needs a + * credential names its service — the connector association is per service, never per plugin. */ +export const McpPluginServerSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('preset'), server: McpServerSchema, + service: McpPluginServiceSchema.optional(), credentialSlots: z.array(McpPluginCredentialSlotSchema).default([]), }), z.object({ - type: z.literal('managed-connector'), + type: z.literal('managed'), name: z.string().min(1), + service: McpPluginServiceSchema, }), ]); +export type McpPluginServer = z.infer; + +export function mcpPluginServerName(server: McpPluginServer): string { + return server.type === 'preset' ? server.server.name : server.name; +} export const McpPluginDescriptorSchema = z .object({ id: McpPluginIdSchema, labelKey: McpPluginLabelKeySchema, descriptionKey: McpPluginDescriptionKeySchema, - service: McpPluginServiceSchema.optional(), - backing: McpPluginBackingSchema, + servers: z.array(McpPluginServerSchema).min(1), }) .superRefine((descriptor, context) => { - if (descriptor.backing.type === 'managed-connector' && descriptor.service === undefined) { - context.addIssue({ code: 'custom', message: 'managed connector requires a service' }); - return; - } - if (descriptor.backing.type !== 'preset') return; - if (descriptor.backing.credentialSlots.length > 0 && descriptor.service === undefined) { - context.addIssue({ code: 'custom', message: 'credential slots require a service' }); - } - const target = descriptor.backing.server.type === 'stdio' ? 'env' : 'header'; - for (const slot of descriptor.backing.credentialSlots) { - if (slot.target !== target) { - context.addIssue({ - code: 'custom', - message: `${descriptor.backing.server.type} MCP cannot inject a ${slot.target} credential`, - }); + const names = new Set(); + for (const entry of descriptor.servers) { + const name = mcpPluginServerName(entry); + if (names.has(name)) { + context.addIssue({ code: 'custom', message: `duplicate MCP server name: ${name}` }); + } + names.add(name); + if (entry.type !== 'preset') continue; + if (entry.credentialSlots.length > 0 && entry.service === undefined) { + context.addIssue({ code: 'custom', message: 'credential slots require a service' }); + } + const target = entry.server.type === 'stdio' ? 'env' : 'header'; + for (const slot of entry.credentialSlots) { + if (slot.target !== target) { + context.addIssue({ + code: 'custom', + message: `${entry.server.type} MCP cannot inject a ${slot.target} credential`, + }); + } } } }); @@ -64,35 +76,41 @@ export const McpPluginCatalogSchema = z const ids = new Set(); const names = new Set(); for (const descriptor of catalog) { - const name = - descriptor.backing.type === 'preset' - ? descriptor.backing.server.name - : descriptor.backing.name; if (ids.has(descriptor.id)) { context.addIssue({ code: 'custom', message: `duplicate plugin id: ${descriptor.id}` }); } - if (names.has(name)) { - context.addIssue({ code: 'custom', message: `duplicate MCP server name: ${name}` }); - } ids.add(descriptor.id); - names.add(name); + for (const entry of descriptor.servers) { + const name = mcpPluginServerName(entry); + if (names.has(name)) { + context.addIssue({ code: 'custom', message: `duplicate MCP server name: ${name}` }); + } + names.add(name); + } } }); export type McpPluginCatalog = z.infer; -export const PluginBindingSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('managed') }), - z.object({ type: z.literal('local'), connectorId: z.string().min(1) }), -]); -export type PluginBinding = z.infer; - export const PluginUnitStateSchema = z.object({ unitId: McpPluginIdSchema, enabled: z.boolean(), - binding: PluginBindingSchema.optional(), }); export type PluginUnitState = z.infer; +/** How one service's credential is satisfied, shared by every plugin server on that service: + * `managed` delegates to the HQ broker; `local` names a daemon-local connector. */ +export const PluginServiceBindingSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('managed') }), + z.object({ type: z.literal('local'), connectorId: z.string().min(1) }), +]); +export type PluginServiceBinding = z.infer; + +export const PluginServiceBindingsSchema = z.partialRecord( + McpPluginServiceSchema, + PluginServiceBindingSchema, +); +export type PluginServiceBindings = z.infer; + export const PluginConnectorCredentialSchema = z.object({ type: z.enum(['api-key', 'auth-token']), secret: z.string().min(1), @@ -119,12 +137,14 @@ export type PluginConnectorPublic = z.infer; export const PluginConfigSchema = z.object({ units: z.array(PluginUnitStateSchema), + serviceBindings: PluginServiceBindingsSchema, connectors: z.array(PluginConnectorSchema), }); export type PluginConfig = z.infer; export const PluginConfigPublicSchema = z.object({ units: z.array(PluginUnitStateSchema), + serviceBindings: PluginServiceBindingsSchema, connectors: z.array(PluginConnectorPublicSchema), }); export type PluginConfigPublic = z.infer; @@ -143,6 +163,7 @@ export type PluginConnectorOperation = z.infer; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index bde11d79b..dad594896 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -13,8 +13,9 @@ import { WirePayloadSchema } from './payload'; // schema it does not speak. // 43 combines 42's agent.catalog/agent.cataloged with CODE-316's parallel 42 bump for // file.host/file.hosted, keeping every distinct schema on a distinct protocol version. -// 44 adds the plugin catalog/config contracts and plugin resolution warning event (CODE-382). -export const WIRE_PROTOCOL_VERSION = 44 as const; +// 44 added the plugin catalog/config contracts and plugin resolution warning event (CODE-382). +// 45 recasts plugins as multi-server compositions bound to connectors per service (CODE-382). +export const WIRE_PROTOCOL_VERSION = 45 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/foundation/schema/tests/contract/wire/plugin.test.ts b/packages/foundation/schema/tests/contract/wire/plugin.test.ts index 27f194460..671996254 100644 --- a/packages/foundation/schema/tests/contract/wire/plugin.test.ts +++ b/packages/foundation/schema/tests/contract/wire/plugin.test.ts @@ -17,21 +17,74 @@ describe('plugin contracts', () => { expect(parsed.success).toBe(true); }); + it('accepts a plugin composed of several servers with per-server services', () => { + const parsed = McpPluginDescriptorSchema.safeParse({ + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + servers: [ + { + type: 'preset', + server: { type: 'stdio', name: 'github-cli', command: 'github-mcp-server' }, + service: 'github', + credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], + }, + { type: 'managed', name: 'linkcode-github', service: 'github' }, + ], + }); + expect(parsed.success).toBe(true); + }); + it('rejects credential slots that do not match the preset transport', () => { const parsed = McpPluginDescriptorSchema.safeParse({ id: 'github-read', labelKey: 'units.githubRead.label', descriptionKey: 'units.githubRead.description', - service: 'github', - backing: { - type: 'preset', - server: { type: 'http', name: 'github', url: 'http://localhost/mcp' }, - credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], - }, + servers: [ + { + type: 'preset', + server: { type: 'http', name: 'github', url: 'http://localhost/mcp' }, + service: 'github', + credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], + }, + ], + }); + expect(parsed.success).toBe(false); + }); + + it('rejects credential slots on a server without a service key', () => { + const parsed = McpPluginDescriptorSchema.safeParse({ + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + servers: [ + { + type: 'preset', + server: { type: 'stdio', name: 'github-cli', command: 'github-mcp-server' }, + credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], + }, + ], }); expect(parsed.success).toBe(false); }); + it('binds connectors per service, never per unit', () => { + const parsed = PluginConfigSetSchema.safeParse({ + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, + }); + expect(parsed.success).toBe(true); + const stale = PluginConfigSetSchema.safeParse({ + units: [ + { unitId: 'github-read', enabled: true, binding: { type: 'local', connectorId: 'x' } }, + ], + }); + expect(stale.success && Object.keys(stale.data.units?.[0] ?? {})).toEqual([ + 'unitId', + 'enabled', + ]); + }); + it('does not accept a masked credential as an update secret', () => { const parsed = PluginConfigSetSchema.safeParse({ connectorOperations: [ diff --git a/packages/host/engine/src/__tests__/engine-plugin-catalog.test.ts b/packages/host/engine/src/__tests__/engine-plugin-catalog.test.ts index c44d07d6b..2f343bc25 100644 --- a/packages/host/engine/src/__tests__/engine-plugin-catalog.test.ts +++ b/packages/host/engine/src/__tests__/engine-plugin-catalog.test.ts @@ -9,8 +9,7 @@ describe('engine plugin catalog', () => { id: 'github-read', labelKey: 'units.githubRead.label', descriptionKey: 'units.githubRead.description', - service: 'github', - backing: { type: 'managed-connector', name: 'linkcode-github' }, + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], }, ]); expect(JSON.stringify(MCP_PLUGIN_CATALOG)).not.toContain('token'); diff --git a/packages/host/engine/src/__tests__/engine-plugin-config.test.ts b/packages/host/engine/src/__tests__/engine-plugin-config.test.ts index 11b442479..314b6e993 100644 --- a/packages/host/engine/src/__tests__/engine-plugin-config.test.ts +++ b/packages/host/engine/src/__tests__/engine-plugin-config.test.ts @@ -7,6 +7,7 @@ describe('engine plugin config', () => { const store = new InMemoryProviderConfigStore(); store.setPlugins({ units: [], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, connectors: [ { id: 'github-personal', @@ -27,6 +28,7 @@ describe('engine plugin config', () => { accounts: [], plugins: { units: [], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, connectors: [ { id: 'github-personal', @@ -79,7 +81,8 @@ describe('engine plugin config', () => { it('emits a typed warning when an enabled managed unit cannot be resolved', async () => { const store = new InMemoryProviderConfigStore(); store.setPlugins({ - units: [{ unitId: 'github-read', enabled: true, binding: { type: 'managed' } }], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'managed' } }, connectors: [], }); const h = createSessionHarness(undefined, undefined, undefined, undefined, undefined, store); @@ -98,7 +101,12 @@ describe('engine plugin config', () => { expect(h.sent).toContainEqual({ kind: 'agent.event', sessionId: started?.kind === 'session.started' ? started.sessionId : '', - event: { type: 'plugin-warning', unitId: 'github-read', reason: 'broker-unavailable' }, + event: { + type: 'plugin-warning', + unitId: 'github-read', + service: 'github', + reason: 'broker-unavailable', + }, }); expect(JSON.stringify(await h.store.load())).not.toContain('credential'); }); diff --git a/packages/host/engine/src/__tests__/plugin-resolver.test.ts b/packages/host/engine/src/__tests__/plugin-resolver.test.ts index 1ccb46497..b10232264 100644 --- a/packages/host/engine/src/__tests__/plugin-resolver.test.ts +++ b/packages/host/engine/src/__tests__/plugin-resolver.test.ts @@ -8,29 +8,31 @@ const PRESET_CATALOG: McpPluginCatalog = [ id: 'github-read', labelKey: 'units.githubRead.label', descriptionKey: 'units.githubRead.description', - service: 'github', - backing: { - type: 'preset', - server: { - type: 'stdio', - name: 'linkcode-github', - command: 'github-mcp-server', - args: ['stdio'], - env: { LOG_LEVEL: 'warn' }, + servers: [ + { + type: 'preset', + server: { + type: 'stdio', + name: 'linkcode-github', + command: 'github-mcp-server', + args: ['stdio'], + env: { LOG_LEVEL: 'warn' }, + }, + service: 'github', + credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], }, - credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], - }, + { + type: 'preset', + server: { type: 'http', name: 'github-docs', url: 'https://docs.test/mcp' }, + credentialSlots: [], + }, + ], }, ]; const LOCAL_CONFIG: PluginConfig = { - units: [ - { - unitId: 'github-read', - enabled: true, - binding: { type: 'local', connectorId: 'github-personal' }, - }, - ], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, connectors: [ { id: 'github-personal', @@ -43,7 +45,7 @@ const LOCAL_CONFIG: PluginConfig = { const OPTIONS: StartOptions = { kind: 'codex', cwd: '/repo' }; describe('resolvePluginServers', () => { - it('materializes a preset and injects its local credential only at session start', () => { + it('materializes every composed server, routing credentials through the service binding', () => { const resolved = resolvePluginServers( { ...OPTIONS, @@ -65,11 +67,12 @@ describe('resolvePluginServers', () => { args: ['stdio'], env: { LOG_LEVEL: 'warn', GITHUB_TOKEN: 'github-secret' }, }, + { type: 'http', name: 'github-docs', url: 'https://docs.test/mcp' }, ], }, warnings: [], }); - expect(PRESET_CATALOG[0]?.backing).not.toHaveProperty('server.env.GITHUB_TOKEN'); + expect(PRESET_CATALOG[0]?.servers[0]).not.toHaveProperty('server.env.GITHUB_TOKEN'); }); it('lets a client-supplied server win by name without injecting the stored secret', () => { @@ -80,17 +83,71 @@ describe('resolvePluginServers', () => { }; const resolved = resolvePluginServers( { ...OPTIONS, mcpServers: [clientServer] }, - { ...LOCAL_CONFIG, connectors: [] }, + { ...LOCAL_CONFIG, serviceBindings: {}, connectors: [] }, PRESET_CATALOG, ); expect(resolved).toEqual({ - options: { ...OPTIONS, mcpServers: [clientServer] }, + options: { + ...OPTIONS, + mcpServers: [ + clientServer, + { type: 'http', name: 'github-docs', url: 'https://docs.test/mcp' }, + ], + }, warnings: [], }); expect(JSON.stringify(resolved)).not.toContain('github-secret'); }); + it('degrades an unsatisfied service dependency to a warning without dropping satisfied servers', () => { + const resolved = resolvePluginServers( + OPTIONS, + { ...LOCAL_CONFIG, serviceBindings: {} }, + PRESET_CATALOG, + ); + + expect(resolved.warnings).toEqual([ + { + type: 'plugin-warning', + unitId: 'github-read', + service: 'github', + reason: 'unsatisfied-binding', + }, + ]); + expect(resolved.options.mcpServers).toEqual([ + { type: 'http', name: 'github-docs', url: 'https://docs.test/mcp' }, + ]); + }); + + it('treats a service-mismatched connector as unsatisfied', () => { + const resolved = resolvePluginServers( + OPTIONS, + { + ...LOCAL_CONFIG, + connectors: [ + { + id: 'github-personal', + // @ts-expect-error -- future service value simulated for the mismatch path + service: 'jira', + credential: { type: 'auth-token', secret: 'jira-secret' }, + }, + ], + }, + PRESET_CATALOG, + ); + + expect(resolved.warnings).toEqual([ + { + type: 'plugin-warning', + unitId: 'github-read', + service: 'github', + reason: 'unsatisfied-binding', + }, + ]); + expect(JSON.stringify(resolved.options)).not.toContain('jira-secret'); + }); + it('reports unsupported agents instead of silently dropping an enabled unit', () => { const resolved = resolvePluginServers( { kind: 'pi', cwd: '/repo' }, @@ -107,13 +164,19 @@ describe('resolvePluginServers', () => { const resolved = resolvePluginServers( OPTIONS, { - units: [{ unitId: 'github-read', enabled: true, binding: { type: 'managed' } }], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'managed' } }, connectors: [], }, MCP_PLUGIN_CATALOG, ); expect(resolved.warnings).toEqual([ - { type: 'plugin-warning', unitId: 'github-read', reason: 'broker-unavailable' }, + { + type: 'plugin-warning', + unitId: 'github-read', + service: 'github', + reason: 'broker-unavailable', + }, ]); }); }); diff --git a/packages/host/engine/src/__tests__/provider-config.test.ts b/packages/host/engine/src/__tests__/provider-config.test.ts index 79ba5b1d0..bbfe87a2d 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -117,13 +117,8 @@ describe('applyProviderDefaults account pool', () => { describe('plugin config', () => { const config: PluginConfig = { - units: [ - { - unitId: 'github-read', - enabled: true, - binding: { type: 'local', connectorId: 'github-personal' }, - }, - ], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, connectors: [ { id: 'github-personal', @@ -161,13 +156,14 @@ describe('plugin config', () => { expect(replaced.connectors[0]?.credential.secret).toBe('new-secret'); }); - it('deletes a connector and atomically disables and unbinds every referencing unit', () => { + it('deletes a connector and atomically drops every service binding that referenced it', () => { expect( applyPluginConfigSet(config, { connectorOperations: [{ type: 'delete', connectorId: 'github-personal' }], }), ).toEqual({ - units: [{ unitId: 'github-read', enabled: false }], + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: {}, connectors: [], }); }); diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 75eb8e652..d830a82e2 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -8,6 +8,7 @@ import type { ProvidersConfig, StartOptions, } from '@linkcode/schema'; +import { McpPluginServiceSchema } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; /** @@ -29,7 +30,7 @@ export interface ProviderConfigStore { export class InMemoryProviderConfigStore implements ProviderConfigStore { private providers: ProvidersConfig = {}; private accounts: Accounts = []; - private plugins: PluginConfig = { units: [], connectors: [] }; + private plugins: PluginConfig = { units: [], serviceBindings: {}, connectors: [] }; get(): ProvidersConfig { return this.providers; @@ -57,10 +58,12 @@ export class InMemoryProviderConfigStore implements ProviderConfigStore { } /** Apply one validated plugin patch without mutating the stored snapshot. Connector deletion also - * disables and unbinds every unit that referenced it, so a successful write cannot create stale - * references through deletion. */ + * drops every service binding that referenced it, so a successful write cannot create stale + * references through deletion; units stay enabled and surface a resolution warning instead. */ export function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): PluginConfig { - let units = patch.units === undefined ? current.units : patch.units; + const units = patch.units === undefined ? current.units : patch.units; + let serviceBindings = + patch.serviceBindings === undefined ? current.serviceBindings : patch.serviceBindings; let connectors = current.connectors; for (const operation of patch.connectorOperations ?? []) { @@ -95,21 +98,25 @@ export function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigS ); break; } - case 'delete': + case 'delete': { nullthrow(existing, `Plugin connector does not exist: ${operation.connectorId}`); connectors = connectors.filter((connector) => connector.id !== operation.connectorId); - units = units.map((unit) => - unit.binding?.type === 'local' && unit.binding.connectorId === operation.connectorId - ? { unitId: unit.unitId, enabled: false } - : unit, - ); + const remaining = { ...serviceBindings }; + for (const service of McpPluginServiceSchema.options) { + const binding = remaining[service]; + if (binding?.type === 'local' && binding.connectorId === operation.connectorId) { + delete remaining[service]; + } + } + serviceBindings = remaining; break; + } default: break; } } - return { units, connectors }; + return { units, serviceBindings, connectors }; } /** Remove connector secrets at the data-plane boundary. A configured credential is represented by @@ -117,6 +124,7 @@ export function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigS export function publicPluginConfig(config: PluginConfig): PluginConfigPublic { return { units: config.units, + serviceBindings: config.serviceBindings, connectors: config.connectors.map(({ credential, ...connector }) => ({ ...connector, credential: { diff --git a/packages/host/engine/src/plugin/catalog.ts b/packages/host/engine/src/plugin/catalog.ts index 2d2b39ace..cd6bdd21a 100644 --- a/packages/host/engine/src/plugin/catalog.ts +++ b/packages/host/engine/src/plugin/catalog.ts @@ -7,7 +7,6 @@ export const MCP_PLUGIN_CATALOG = McpPluginCatalogSchema.parse([ id: 'github-read', labelKey: 'units.githubRead.label', descriptionKey: 'units.githubRead.description', - service: 'github', - backing: { type: 'managed-connector', name: 'linkcode-github' }, + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], }, ]); diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index b2081e334..7754423af 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -1,11 +1,14 @@ import type { AgentEvent, McpPluginCatalog, + McpPluginServer, McpServer, PluginConfig, StartOptions, } from '@linkcode/schema'; +import { mcpPluginServerName } from '@linkcode/schema'; import { Effect } from 'effect'; +import { nullthrow } from 'foxts/guard'; import type { ProviderConfigStore } from '../agent/provider-config'; import { applyProviderDefaults } from '../agent/provider-config'; import type { TranslatorService } from '../agent/translator'; @@ -83,8 +86,7 @@ export function resolvePluginServers( for (const unit of config.units) { if (!unit.enabled) continue; const descriptor = catalogById.get(unit.unitId); - const binding = unit.binding; - if (!descriptor || !binding) { + if (!descriptor) { warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'unsatisfied-binding' }); continue; } @@ -96,27 +98,59 @@ export function resolvePluginServers( }); continue; } - const serverName = - descriptor.backing.type === 'preset' - ? descriptor.backing.server.name - : descriptor.backing.name; - if (clientNames.has(serverName)) continue; - if (binding.type === 'managed') { - warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'broker-unavailable' }); - continue; - } - const connector = config.connectors.find( - (entry) => entry.id === binding.connectorId && entry.service === descriptor.service, - ); - if (!connector) { - warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'unsatisfied-binding' }); - continue; - } - if (descriptor.backing.type === 'managed-connector') { - warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'broker-unavailable' }); - continue; + // A plugin composes several servers; each resolves independently, so one unsatisfied service + // dependency degrades the unit to a warning without dropping its satisfied servers. + for (const entry of descriptor.servers) { + if (clientNames.has(mcpPluginServerName(entry))) continue; + if (entry.type === 'managed') { + // Managed servers only exist through the CODE-96 broker. + warnings.push({ + type: 'plugin-warning', + unitId: unit.unitId, + service: entry.service, + reason: 'broker-unavailable', + }); + continue; + } + if (entry.credentialSlots.length === 0) { + pluginServers.push(entry.server); + continue; + } + // Credential slots require a service (schema-enforced); route through its binding. + const service = nullthrow(entry.service, 'credential slots require a service'); + const binding = config.serviceBindings[service]; + if (!binding) { + warnings.push({ + type: 'plugin-warning', + unitId: unit.unitId, + service, + reason: 'unsatisfied-binding', + }); + continue; + } + if (binding.type === 'managed') { + warnings.push({ + type: 'plugin-warning', + unitId: unit.unitId, + service, + reason: 'broker-unavailable', + }); + continue; + } + const connector = config.connectors.find( + (candidate) => candidate.id === binding.connectorId && candidate.service === service, + ); + if (!connector) { + warnings.push({ + type: 'plugin-warning', + unitId: unit.unitId, + service, + reason: 'unsatisfied-binding', + }); + continue; + } + pluginServers.push(injectCredential(entry, connector.credential.secret)); } - pluginServers.push(injectCredential(descriptor.backing, connector.credential.secret)); } return { @@ -129,24 +163,13 @@ export function resolvePluginServers( } function injectCredential( - backing: Extract, + entry: Extract, secret: string, ): McpServer { - const server = backing.server; + const server = entry.server; + const slots = Object.fromEntries(entry.credentialSlots.map((slot) => [slot.name, secret])); if (server.type === 'stdio') { - return { - ...server, - env: { - ...server.env, - ...Object.fromEntries(backing.credentialSlots.map((slot) => [slot.name, secret])), - }, - }; + return { ...server, env: { ...server.env, ...slots } }; } - return { - ...server, - headers: { - ...server.headers, - ...Object.fromEntries(backing.credentialSlots.map((slot) => [slot.name, secret])), - }, - }; + return { ...server, headers: { ...server.headers, ...slots } }; } From 763ce7edc6a45c5bbb7aaf44969bbca04cf02fce Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 23 Jul 2026 12:56:14 +0800 Subject: [PATCH 15/23] feat(engine,schema): share the MCP capability matrix and replay plugin warnings AGENT_MCP_CAPABLE replaces the resolver-private agent set so clients read the same truth. The resolver skips a declaredly expired local credential with the new expired-credential warning reason (wire 47), and start diagnostics now live on LiveSession so session.attach replays them to late attachers. --- .../schema/src/model/agent/input.ts | 11 ++++++++ .../foundation/schema/src/model/plugin.ts | 1 + .../foundation/schema/src/wire/message.ts | 3 +- .../__tests__/engine-plugin-config.test.ts | 19 +++++++------ .../src/__tests__/plugin-resolver.test.ts | 28 +++++++++++++++++++ .../host/engine/src/session/live-session.ts | 5 +++- .../host/engine/src/session/orchestrator.ts | 4 ++- .../src/session/start-options-resolver.ts | 19 ++++++++++--- 8 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 50a9c0da5..b513949f0 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -121,6 +121,17 @@ export const AGENT_INPUT_CAPABILITIES = { 'grok-build': { slashCommands: false, shellCommand: false }, } as const satisfies Readonly>; +/** Which agent kinds accept `StartOptions.mcpServers`. Static per-kind truth shared by the + * engine's plugin resolution and the settings surface, so neither hardcodes its own list. + * Per-transport granularity (stdio vs http) is CODE-93's to refine. */ +export const AGENT_MCP_CAPABLE = { + 'claude-code': true, + codex: true, + opencode: true, + pi: false, + 'grok-build': false, +} as const satisfies Readonly>; + /** Input sent up to the agent, normalized into discrete actions. */ export const AgentInputSchema = z.discriminatedUnion('type', [ /** A user prompt as one or more content blocks (text / image / resource …). */ diff --git a/packages/foundation/schema/src/model/plugin.ts b/packages/foundation/schema/src/model/plugin.ts index 64f1b2d69..e4d775116 100644 --- a/packages/foundation/schema/src/model/plugin.ts +++ b/packages/foundation/schema/src/model/plugin.ts @@ -172,5 +172,6 @@ export const PluginWarningReasonSchema = z.enum([ 'unsatisfied-binding', 'unsupported-transport', 'broker-unavailable', + 'expired-credential', ]); export type PluginWarningReason = z.infer; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index bdfdc6ef2..9e81b4633 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -16,7 +16,8 @@ import { WirePayloadSchema } from './payload'; // 46 disambiguates another parallel double-bump: master's 44 (CODE-388/391 structured tool diffs // and message-identity upserts) and the plugin branch's 44/45 (CODE-382 plugin contracts, then the // multi-server/service-binding reshape) are distinct schemas; the merge gets a fresh number. -export const WIRE_PROTOCOL_VERSION = 46 as const; +// 47 adds the expired-credential plugin-warning reason (CODE-385). +export const WIRE_PROTOCOL_VERSION = 47 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/engine/src/__tests__/engine-plugin-config.test.ts b/packages/host/engine/src/__tests__/engine-plugin-config.test.ts index 314b6e993..1b4686703 100644 --- a/packages/host/engine/src/__tests__/engine-plugin-config.test.ts +++ b/packages/host/engine/src/__tests__/engine-plugin-config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { InMemoryProviderConfigStore } from '../agent/provider-config'; -import { createSessionHarness } from './fixtures/session-harness'; +import { createSessionHarness, startedSessionId } from './fixtures/session-harness'; describe('engine plugin config', () => { it('masks local credentials on config.get', async () => { @@ -94,20 +94,23 @@ describe('engine plugin config', () => { opts: { kind: 'claude-code', cwd: '/repo' }, }); - const started = h.sent.find( - (payload) => payload.kind === 'session.started' && payload.replyTo === 'plugin-session-start', - ); - expect(started?.kind).toBe('session.started'); - expect(h.sent).toContainEqual({ + const sessionId = startedSessionId(h.sent, 'plugin-session-start'); + const warningEvent = { kind: 'agent.event', - sessionId: started?.kind === 'session.started' ? started.sessionId : '', + sessionId, event: { type: 'plugin-warning', unitId: 'github-read', service: 'github', reason: 'broker-unavailable', }, - }); + }; + expect(h.sent).toContainEqual(warningEvent); expect(JSON.stringify(await h.store.load())).not.toContain('credential'); + + // A late attacher must see the same diagnostics — they replay with the live state. + const mark = h.sent.length; + await h.inject({ kind: 'session.attach', sessionId }); + expect(h.sent.slice(mark)).toContainEqual(warningEvent); }); }); diff --git a/packages/host/engine/src/__tests__/plugin-resolver.test.ts b/packages/host/engine/src/__tests__/plugin-resolver.test.ts index b10232264..423d18da1 100644 --- a/packages/host/engine/src/__tests__/plugin-resolver.test.ts +++ b/packages/host/engine/src/__tests__/plugin-resolver.test.ts @@ -120,6 +120,34 @@ describe('resolvePluginServers', () => { ]); }); + it('skips a declaredly expired credential with a typed reason instead of injecting it', () => { + const resolved = resolvePluginServers( + OPTIONS, + { + ...LOCAL_CONFIG, + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', secret: 'github-secret', expiresAt: 999 }, + }, + ], + }, + PRESET_CATALOG, + 1000, + ); + + expect(resolved.warnings).toEqual([ + { + type: 'plugin-warning', + unitId: 'github-read', + service: 'github', + reason: 'expired-credential', + }, + ]); + expect(JSON.stringify(resolved.options)).not.toContain('github-secret'); + }); + it('treats a service-mismatched connector as unsatisfied', () => { const resolved = resolvePluginServers( OPTIONS, diff --git a/packages/host/engine/src/session/live-session.ts b/packages/host/engine/src/session/live-session.ts index 52c363643..de4f15f41 100644 --- a/packages/host/engine/src/session/live-session.ts +++ b/packages/host/engine/src/session/live-session.ts @@ -28,6 +28,9 @@ export class LiveSession { availableCommands?: AgentCommand[]; availableModels?: AgentModelOption[]; capabilities: AgentCapabilities; + /** Host-synthesized diagnostics broadcast once at run start (e.g. plugin warnings). Kept so a + * late attacher's replay sees them; each run's startLive replaces the previous run's set. */ + startDiagnostics: AgentEvent[] = []; private unsubscribe: Unsubscribe = noop; private closing = false; @@ -134,7 +137,7 @@ export class LiveSession { if (this.availableModels) { events.push({ type: 'available-models-update', models: this.availableModels }); } - return events.concat(this.interactions.replay()); + return events.concat(this.startDiagnostics, this.interactions.replay()); } closeInteractions(): AgentEvent[] { diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index a0c79f45a..ad60df1cc 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -184,7 +184,9 @@ export class SessionOrchestrator { if (sessions.get(sessionId) !== session) return yield* Effect.interrupt; yield* startAdapter(adapter); if (sessions.get(sessionId) !== session) return yield* Effect.interrupt; - events.broadcast(sessionId, initialEvents); + // Keep the start diagnostics on the session so attach replay repeats them. + session.startDiagnostics = Array.from(initialEvents); + events.broadcast(sessionId, session.startDiagnostics); if (replyTo !== undefined) { transport.send(createWireMessage({ kind: 'session.started', replyTo, sessionId })); } diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index 7754423af..6d5f09bd2 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -6,7 +6,7 @@ import type { PluginConfig, StartOptions, } from '@linkcode/schema'; -import { mcpPluginServerName } from '@linkcode/schema'; +import { AGENT_MCP_CAPABLE, mcpPluginServerName } from '@linkcode/schema'; import { Effect } from 'effect'; import { nullthrow } from 'foxts/guard'; import type { ProviderConfigStore } from '../agent/provider-config'; @@ -23,8 +23,6 @@ export interface ResolvedStartOptions { warnings: PluginWarning[]; } -const MCP_CAPABLE_AGENTS = new Set(['claude-code', 'codex', 'opencode']); - /** Resolves daemon-owned provider defaults and the optional cross-protocol translation endpoint. */ export class SessionStartOptionsResolver { constructor( @@ -76,6 +74,7 @@ export function resolvePluginServers( options: StartOptions, config: PluginConfig, catalog: McpPluginCatalog = MCP_PLUGIN_CATALOG, + now: number = Date.now(), ): ResolvedStartOptions { const warnings: PluginWarning[] = []; const clientServers = options.mcpServers ?? []; @@ -90,7 +89,7 @@ export function resolvePluginServers( warnings.push({ type: 'plugin-warning', unitId: unit.unitId, reason: 'unsatisfied-binding' }); continue; } - if (!MCP_CAPABLE_AGENTS.has(options.kind)) { + if (!AGENT_MCP_CAPABLE[options.kind]) { warnings.push({ type: 'plugin-warning', unitId: unit.unitId, @@ -149,6 +148,18 @@ export function resolvePluginServers( }); continue; } + const expiresAt = connector.credential.expiresAt; + if (expiresAt !== undefined && expiresAt <= now) { + // A declaredly expired secret would only fail downstream as an opaque MCP auth error; + // skipping with a typed reason keeps the diagnostic attributable. + warnings.push({ + type: 'plugin-warning', + unitId: unit.unitId, + service, + reason: 'expired-credential', + }); + continue; + } pluginServers.push(injectCredential(entry, connector.credential.secret)); } } From eccab4f8c783b0b39bc7eb9d96544a00af0c8333 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 23 Jul 2026 12:57:25 +0800 Subject: [PATCH 16/23] feat(client): fold plugin warnings into the conversation view model Deduped by unit+service+reason so attach replays never stack while distinct reasons for one dependency all stay visible. --- .../core/src/__tests__/conversation.test.ts | 24 +++++++++++++++++++ .../client/core/src/conversation-store.ts | 1 + packages/client/core/src/conversation.ts | 22 +++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/packages/client/core/src/__tests__/conversation.test.ts b/packages/client/core/src/__tests__/conversation.test.ts index 4f9f08b9f..111d2eee4 100644 --- a/packages/client/core/src/__tests__/conversation.test.ts +++ b/packages/client/core/src/__tests__/conversation.test.ts @@ -793,6 +793,30 @@ describe('createConversationBuilder', () => { expect(builder.snapshot()).not.toBe(first); }); + it('dedupes replayed plugin warnings while keeping distinct reasons for one dependency', () => { + const builder = createConversationBuilder(); + const warning: AgentEvent = { + type: 'plugin-warning', + unitId: 'github-read', + service: 'github', + reason: 'unsatisfied-binding', + }; + builder.advance(warning); + // The engine replays start diagnostics on session.attach — the same warning must not stack. + builder.advance(warning); + expect(builder.snapshot().pluginWarnings).toEqual([ + { unitId: 'github-read', service: 'github', reason: 'unsatisfied-binding' }, + ]); + expect(builder.snapshot().items).toEqual([]); + + // Two composed servers on one service can fail for different reasons — both stay visible. + builder.advance({ ...warning, reason: 'broker-unavailable' }); + expect(builder.snapshot().pluginWarnings).toEqual([ + { unitId: 'github-read', service: 'github', reason: 'unsatisfied-binding' }, + { unitId: 'github-read', service: 'github', reason: 'broker-unavailable' }, + ]); + }); + it('never mutates previously returned snapshots (copy-on-write)', () => { const builder = createConversationBuilder(); for (const event of scenario.slice(0, 5)) builder.advance(event); diff --git a/packages/client/core/src/conversation-store.ts b/packages/client/core/src/conversation-store.ts index 5d398140e..a221d0229 100644 --- a/packages/client/core/src/conversation-store.ts +++ b/packages/client/core/src/conversation-store.ts @@ -27,6 +27,7 @@ const EMPTY_CONVERSATION: Conversation = { stopReason: null, pendingPermissionIds: [], pendingQuestionIds: [], + pluginWarnings: [], }; /** diff --git a/packages/client/core/src/conversation.ts b/packages/client/core/src/conversation.ts index ec460b6c3..8a9e3a5bd 100644 --- a/packages/client/core/src/conversation.ts +++ b/packages/client/core/src/conversation.ts @@ -33,6 +33,12 @@ export type QuestionResolution = Pick< Extract, 'outcome' | 'source' >; +/** One plugin resolution diagnostic (from `plugin-warning`): the named unit's `service` dependency + * (absent for unit-level reasons) could not be satisfied at the last session start. */ +export type ConversationPluginWarning = Pick< + Extract, + 'unitId' | 'service' | 'reason' +>; /** A single semantic item in the conversation timeline. `receivedAt` is the best-known time of the * item's latest event: the client receive time for live events (see {@link SequencedAgentEvent}), @@ -159,6 +165,10 @@ export interface ConversationViewModel { pendingPermissionIds: string[]; /** requestIds of question asks that are still open, tracked the same way as permission asks. */ pendingQuestionIds: string[]; + /** Plugin resolution diagnostics from `plugin-warning`, in first-arrival order. Deduped by + * unit+service+reason, so the attach replay of the same warnings stays idempotent while + * distinct reasons for one dependency (one per composed server) all remain visible. */ + pluginWarnings: ConversationPluginWarning[]; } export type Conversation = ConversationViewModel; @@ -212,6 +222,9 @@ export function createConversationBuilder(): ConversationBuilder { const promptResponseStatuses = new Map(); /** Every ask requestId ever folded — attach-replayed duplicates are dropped. */ const seenAskIds = new Set(); + /** unit+service+reason → diagnostic, so attach replays dedupe while distinct reasons for the + * same dependency (one per composed server) all stay visible. */ + const pluginWarningIndex = new Map(); /** parentToolCallId (undefined = main agent) → the reasoning item currently open in that scope. */ const activeReasoningByScope = new Map(); let currentTurnId: ConversationTurnId = null; @@ -542,6 +555,14 @@ export function createConversationBuilder(): ConversationBuilder { }); break; + case 'plugin-warning': + pluginWarningIndex.set(`${event.unitId}|${event.service ?? ''}|${event.reason}`, { + unitId: event.unitId, + service: event.service, + reason: event.reason, + }); + break; + case 'permission-request': { // The engine re-broadcasts open asks on session.attach; a duplicate must not add a card. if (seenAskIds.has(event.requestId)) break; @@ -699,6 +720,7 @@ export function createConversationBuilder(): ConversationBuilder { stopReason, pendingPermissionIds: approvals.filter((requestId) => !permissionResolutions.has(requestId)), pendingQuestionIds: questionAsks.filter((requestId) => !questionResolutions.has(requestId)), + pluginWarnings: [...pluginWarningIndex.values()], }; return cached; }; From 7e7493e26c9b4fe5ffe20ba8441876da93480000 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 23 Jul 2026 12:58:08 +0800 Subject: [PATCH 17/23] feat(workbench): derive plugin unit and service view models Pure derivation from catalog + public config, mirroring the resolver's verdict vocabulary; the dev mock now fails invalid connector operations with the daemon's sanitized public message. --- packages/client/workbench/src/index.ts | 1 + .../workbench/src/mock/dev-mock-host.ts | 17 +- .../settings/plugins/__tests__/hooks.test.ts | 69 ++++++++ .../settings/plugins/__tests__/view.test.ts | 145 ++++++++++++++++ .../workbench/src/settings/plugins/view.ts | 155 ++++++++++++++++++ .../integration/dev-mock-transport.test.ts | 74 +++++++++ 6 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 packages/client/workbench/src/settings/plugins/__tests__/hooks.test.ts create mode 100644 packages/client/workbench/src/settings/plugins/__tests__/view.test.ts create mode 100644 packages/client/workbench/src/settings/plugins/view.ts diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index e02bb493d..77b7bae9e 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -44,6 +44,7 @@ export * from './settings/appearance-render-prefs'; export * from './settings/appearance-settings'; export * from './settings/appearance-store'; export * from './settings/plugins/hooks'; +export * from './settings/plugins/view'; export * from './settings/providers/providers-settings'; export * from './settings/providers/store'; export * from './settings/search'; diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 9da33b161..402b72e43 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -373,7 +373,16 @@ export class DevMockHost { await wait(CONTROL_LATENCY_MS); if (p.providers !== undefined) this.providers = structuredClone(p.providers); if (p.accounts !== undefined) this.accounts = structuredClone(p.accounts); - if (p.plugins !== undefined) this.plugins = applyPluginConfigSet(this.plugins, p.plugins); + if (p.plugins !== undefined) { + try { + this.plugins = applyPluginConfigSet(this.plugins, p.plugins); + } catch { + // Engine parity: an invalid connector operation fails the request with the same + // sanitized public message the daemon sends, leaving the acknowledged state untouched. + this.sendFailure(p.clientReqId, 'Failed to update provider config'); + break; + } + } this.sendSuccess(p.clientReqId); break; case 'workspace.list': @@ -1429,11 +1438,16 @@ function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): Pl const serviceBindings = structuredClone(patch.serviceBindings ?? current.serviceBindings); let connectors = structuredClone(current.connectors); for (const operation of patch.connectorOperations ?? []) { + const connectorId = + operation.type === 'create' ? operation.connector.id : operation.connectorId; + const exists = connectors.some((connector) => connector.id === connectorId); switch (operation.type) { case 'create': + if (exists) throw new Error(`Plugin connector already exists: ${connectorId}`); connectors.push(structuredClone(operation.connector)); break; case 'update': + if (!exists) throw new Error(`Plugin connector does not exist: ${connectorId}`); connectors = connectors.map((connector) => { if (connector.id !== operation.connectorId) return connector; const label = @@ -1446,6 +1460,7 @@ function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): Pl }); break; case 'delete': + if (!exists) throw new Error(`Plugin connector does not exist: ${connectorId}`); connectors = connectors.filter((connector) => connector.id !== operation.connectorId); for (const service of Object.keys(serviceBindings) as McpPluginService[]) { const binding = serviceBindings[service]; diff --git a/packages/client/workbench/src/settings/plugins/__tests__/hooks.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/hooks.test.ts new file mode 100644 index 000000000..525c8b284 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/hooks.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom + +import type { PluginConfigSet } from '@linkcode/schema'; +import { getPluginCatalog, setPluginConfig } from '@linkcode/sdk'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePluginSettings } from '../hooks'; + +const { useDataMock, useMutationMock } = vi.hoisted(() => ({ + useDataMock: vi.fn(), + useMutationMock: vi.fn(), +})); + +vi.mock('../../../runtime/tayori', () => ({ + useData: useDataMock, + useMutation: useMutationMock, +})); + +const configMutate = vi.fn(); +const trigger = vi.fn(); + +beforeEach(() => { + configMutate.mockReset().mockResolvedValue(undefined); + trigger.mockReset().mockResolvedValue({ ok: true }); + useDataMock.mockImplementation((operation: unknown) => + operation === getPluginCatalog + ? { data: [], error: undefined, isLoading: false, mutate: vi.fn() } + : { + data: { units: [], serviceBindings: {}, connectors: [] }, + error: undefined, + isLoading: false, + mutate: configMutate, + }, + ); + useMutationMock.mockImplementation((operation: unknown) => { + expect(operation).toBe(setPluginConfig); + return { trigger, error: undefined, isMutating: false }; + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('usePluginSettings', () => { + const patch: PluginConfigSet = { units: [{ unitId: 'github-read', enabled: true }] }; + + it('revalidates config only after the host acknowledges the write', async () => { + const { result } = renderHook(() => usePluginSettings()); + + await result.current.save(patch); + + expect(trigger).toHaveBeenCalledWith({ plugins: patch }); + expect(configMutate).toHaveBeenCalledTimes(1); + expect(trigger.mock.invocationCallOrder[0]).toBeLessThan( + configMutate.mock.invocationCallOrder[0] ?? Number.NaN, + ); + }); + + it('leaves the cached config untouched when the write fails', async () => { + trigger.mockRejectedValue(new Error('request failed')); + const { result } = renderHook(() => usePluginSettings()); + + await expect(result.current.save(patch)).rejects.toThrow('request failed'); + + expect(configMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts new file mode 100644 index 000000000..9a6abacd6 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -0,0 +1,145 @@ +import type { McpPluginCatalog, PluginConfigPublic } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { MCP_APPLICABLE_AGENTS, pluginServiceViews, pluginUnitViews } from '../view'; + +const NOW = 1_000_000; + +const CATALOG: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + servers: [ + { + type: 'preset', + server: { type: 'stdio', name: 'github-cli', command: 'github-mcp-server' }, + service: 'github', + credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], + }, + { + type: 'preset', + server: { type: 'http', name: 'github-docs', url: 'https://docs.test/mcp' }, + credentialSlots: [], + }, + ], + }, +]; + +function config(overrides: Partial = {}): PluginConfigPublic { + return { + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', configured: true }, + }, + ], + ...overrides, + }; +} + +describe('pluginUnitViews', () => { + it('marks a fully satisfied composition ready and lists its distinct services', () => { + const [unit] = pluginUnitViews(CATALOG, config(), NOW); + expect(unit).toMatchObject({ + id: 'github-read', + enabled: true, + services: ['github'], + status: 'ready', + servers: [ + { name: 'github-cli', service: 'github', status: 'satisfied' }, + { name: 'github-docs', service: undefined, status: 'ready' }, + ], + }); + }); + + it('degrades to partial when one service dependency is unbound', () => { + const [unit] = pluginUnitViews(CATALOG, config({ serviceBindings: {} }), NOW); + expect(unit?.status).toBe('partial'); + expect(unit?.servers[0]?.status).toBe('unsatisfied-binding'); + expect(unit?.servers[1]?.status).toBe('ready'); + }); + + it('treats a binding to a missing or service-mismatched connector as unsatisfied', () => { + const [unit] = pluginUnitViews(CATALOG, config({ connectors: [] }), NOW); + expect(unit?.servers[0]?.status).toBe('unsatisfied-binding'); + }); + + it('flags an expired local credential without hiding the unit', () => { + const expired = config({ + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', configured: true, expiresAt: NOW - 1 }, + }, + ], + }); + const [unit] = pluginUnitViews(CATALOG, expired, NOW); + expect(unit?.servers[0]?.status).toBe('expired-credential'); + expect(unit?.status).toBe('partial'); + }); + + it('routes managed bindings and managed servers to the broker-unavailable state', () => { + const managedBinding = config({ serviceBindings: { github: { type: 'managed' } } }); + expect(pluginUnitViews(CATALOG, managedBinding, NOW)[0]?.servers[0]?.status).toBe( + 'broker-unavailable', + ); + + const managedCatalog: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], + }, + ]; + const [unit] = pluginUnitViews(managedCatalog, config(), NOW); + expect(unit?.servers[0]?.status).toBe('broker-unavailable'); + expect(unit?.status).toBe('unavailable'); + }); + + it('reports a unit absent from config as disabled', () => { + const [unit] = pluginUnitViews(CATALOG, config({ units: [] }), NOW); + expect(unit?.status).toBe('disabled'); + expect(unit?.enabled).toBe(false); + }); +}); + +describe('pluginServiceViews', () => { + it('derives one row per catalog service with its binding status and dependent units', () => { + expect(pluginServiceViews(CATALOG, config(), NOW)).toEqual([ + { + service: 'github', + status: { + kind: 'local', + connector: { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', configured: true }, + }, + expired: false, + }, + usedByUnits: ['github-read'], + }, + ]); + }); + + it('surfaces a dangling local binding as local-missing', () => { + const [row] = pluginServiceViews(CATALOG, config({ connectors: [] }), NOW); + expect(row?.status).toEqual({ kind: 'local-missing', connectorId: 'github-personal' }); + }); + + it('reports an unbound service', () => { + const [row] = pluginServiceViews(CATALOG, config({ serviceBindings: {} }), NOW); + expect(row?.status).toEqual({ kind: 'unbound' }); + }); +}); + +describe('MCP_APPLICABLE_AGENTS', () => { + it('mirrors the shared schema capability table', () => { + expect(MCP_APPLICABLE_AGENTS).toEqual(['claude-code', 'codex', 'opencode']); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/view.ts b/packages/client/workbench/src/settings/plugins/view.ts new file mode 100644 index 000000000..2c49c8d67 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/view.ts @@ -0,0 +1,155 @@ +import type { + AgentKind, + McpPluginCatalog, + McpPluginId, + McpPluginService, + PluginConfigPublic, + PluginConnectorPublic, +} from '@linkcode/schema'; +import { AGENT_MCP_CAPABLE, AgentKindSchema, mcpPluginServerName } from '@linkcode/schema'; + +/** + * Presentation-ready plugin state, derived purely from the catalog and the public config. The + * daemon does not persist resolution warnings, so the settings surface derives the same verdicts + * the resolver would reach — the reason vocabulary intentionally matches `PluginWarningReason`. + */ + +/** Agent kinds whose sessions accept plugin MCP servers (per-unit refinement is CODE-93's). */ +export const MCP_APPLICABLE_AGENTS: readonly AgentKind[] = AgentKindSchema.options.filter( + (kind) => AGENT_MCP_CAPABLE[kind], +); + +/** Credential-source state of one service, shared by every plugin server that names it. */ +export type PluginServiceStatus = + | { kind: 'unbound' } + /** Bound to the HQ broker; live connected/expired state composes from CODE-94, not here. */ + | { kind: 'managed' } + | { kind: 'local'; connector: PluginConnectorPublic; expired: boolean } + /** The binding references a connector that no longer exists or serves another service. */ + | { kind: 'local-missing'; connectorId: string }; + +export type PluginServerStatus = + /** Preset with no credential dependency — injects as-is. */ + | 'ready' + /** Local credential bound and configured. */ + | 'satisfied' + /** Non-usable states, named exactly like the `PluginWarningReason` the resolver would emit. */ + | 'expired-credential' + | 'unsatisfied-binding' + | 'broker-unavailable'; + +export interface PluginUnitServerView { + name: string; + service?: McpPluginService; + status: PluginServerStatus; +} + +export type PluginUnitStatus = 'disabled' | 'ready' | 'partial' | 'unavailable'; + +export interface PluginUnitView { + id: McpPluginId; + labelKey: McpPluginCatalog[number]['labelKey']; + descriptionKey: McpPluginCatalog[number]['descriptionKey']; + enabled: boolean; + /** Distinct services the unit's servers depend on, in server order. */ + services: McpPluginService[]; + servers: PluginUnitServerView[]; + status: PluginUnitStatus; +} + +export interface PluginServiceView { + service: McpPluginService; + status: PluginServiceStatus; + /** Catalog units with at least one server on this service — they share its binding. */ + usedByUnits: McpPluginId[]; +} + +export function pluginServiceStatus( + service: McpPluginService, + config: PluginConfigPublic, + now: number, +): PluginServiceStatus { + const binding = config.serviceBindings[service]; + if (!binding) return { kind: 'unbound' }; + if (binding.type === 'managed') return { kind: 'managed' }; + const connector = config.connectors.find( + (candidate) => candidate.id === binding.connectorId && candidate.service === service, + ); + if (!connector) return { kind: 'local-missing', connectorId: binding.connectorId }; + const expiresAt = connector.credential.expiresAt; + return { kind: 'local', connector, expired: expiresAt !== undefined && expiresAt <= now }; +} + +function serverStatus( + server: McpPluginCatalog[number]['servers'][number], + config: PluginConfigPublic, + now: number, +): PluginServerStatus { + // Managed servers only materialize through the CODE-96 broker. + if (server.type === 'managed') return 'broker-unavailable'; + if (server.credentialSlots.length === 0 || server.service === undefined) return 'ready'; + const status = pluginServiceStatus(server.service, config, now); + switch (status.kind) { + case 'local': + return status.expired ? 'expired-credential' : 'satisfied'; + case 'managed': + return 'broker-unavailable'; + default: + return 'unsatisfied-binding'; + } +} + +function unitStatus(enabled: boolean, servers: readonly PluginUnitServerView[]): PluginUnitStatus { + if (!enabled) return 'disabled'; + const usable = servers.filter( + (server) => server.status === 'ready' || server.status === 'satisfied', + ).length; + if (usable === servers.length) return 'ready'; + return usable > 0 ? 'partial' : 'unavailable'; +} + +export function pluginUnitViews( + catalog: McpPluginCatalog, + config: PluginConfigPublic, + now: number, +): PluginUnitView[] { + const enabledById = new Map(config.units.map((unit) => [unit.unitId, unit.enabled])); + return catalog.map((descriptor) => { + const servers = descriptor.servers.map((server) => ({ + name: mcpPluginServerName(server), + service: server.service, + status: serverStatus(server, config, now), + })); + const enabled = enabledById.get(descriptor.id) ?? false; + return { + id: descriptor.id, + labelKey: descriptor.labelKey, + descriptionKey: descriptor.descriptionKey, + enabled, + services: [...new Set(servers.flatMap((server) => server.service ?? []))], + servers, + status: unitStatus(enabled, servers), + }; + }); +} + +export function pluginServiceViews( + catalog: McpPluginCatalog, + config: PluginConfigPublic, + now: number, +): PluginServiceView[] { + const usedBy = new Map(); + for (const descriptor of catalog) { + for (const server of descriptor.servers) { + if (server.service === undefined) continue; + const units = usedBy.get(server.service) ?? []; + if (!units.includes(descriptor.id)) units.push(descriptor.id); + usedBy.set(server.service, units); + } + } + return [...usedBy].map(([service, usedByUnits]) => ({ + service, + status: pluginServiceStatus(service, config, now), + usedByUnits, + })); +} diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 27c365f6c..a2d1ac9cb 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -204,6 +204,80 @@ describe('dev mock transport', () => { client.dispose(); }); + it('applies a unit toggle in isolation without touching bindings or connectors', async () => { + const client = await connectedClient(); + await client.setPluginConfig({ + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, + connectorOperations: [ + { + type: 'create', + connector: { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', secret: 'github-secret' }, + }, + }, + ], + }); + + await client.setPluginConfig({ units: [{ unitId: 'github-read', enabled: false }] }); + + expect(await client.getPluginConfig()).toEqual({ + units: [{ unitId: 'github-read', enabled: false }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', configured: true }, + }, + ], + }); + client.dispose(); + }); + + it('rejects an invalid connector operation and keeps the last acknowledged state', async () => { + const client = await connectedClient(); + await client.setPluginConfig({ + connectorOperations: [ + { + type: 'create', + connector: { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', secret: 'github-secret' }, + }, + }, + ], + }); + const confirmed = await client.getPluginConfig(); + + // The daemon redacts the cause into this fixed public message; the mock mirrors that. + await expect( + client.setPluginConfig({ + connectorOperations: [ + { + type: 'create', + connector: { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', secret: 'other-secret' }, + }, + }, + ], + }), + ).rejects.toThrow('Failed to update provider config'); + await expect( + client.setPluginConfig({ + connectorOperations: [{ type: 'delete', connectorId: 'missing' }], + }), + ).rejects.toThrow('Failed to update provider config'); + + expect(await client.getPluginConfig()).toEqual(confirmed); + client.dispose(); + }); + it('advertises and handles composer directives', async () => { const client = await connectedClient(); const sessionId = await client.startSession({ From ead0b02bec6ba44e46ca9e5adf05ecebfba3278c Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 23 Jul 2026 13:35:51 +0800 Subject: [PATCH 18/23] feat(settings): present MCP plugin cards with enablement and status Card list of catalog units on a new Tools tab in both apps: localized label/description, enable switch writing PluginConfigSet.units, and per-server status badges derived from the plugin view model. --- .../src/renderer/src/settings/plugins-tab.tsx | 6 + .../renderer/src/settings/settings-view.tsx | 12 ++ .../src/renderer/src/settings/store.ts | 1 + apps/webview/src/router.tsx | 2 + apps/webview/src/routes/settings/plugins.tsx | 9 ++ .../src/routes/settings/settings-layout.tsx | 11 ++ packages/client/workbench/src/index.ts | 1 + .../src/settings/plugins/plugins-settings.tsx | 54 +++++++ .../client/workbench/src/settings/search.ts | 8 + packages/presentation/i18n/src/locales/en.ts | 21 +++ .../presentation/i18n/src/locales/zh-cn.ts | 21 +++ packages/presentation/ui/src/shell/index.ts | 1 + .../shell/plugins/plugin-settings-panel.tsx | 138 ++++++++++++++++++ 13 files changed, 285 insertions(+) create mode 100644 apps/desktop/src/renderer/src/settings/plugins-tab.tsx create mode 100644 apps/webview/src/routes/settings/plugins.tsx create mode 100644 packages/client/workbench/src/settings/plugins/plugins-settings.tsx create mode 100644 packages/presentation/ui/src/shell/plugins/plugin-settings-panel.tsx diff --git a/apps/desktop/src/renderer/src/settings/plugins-tab.tsx b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx new file mode 100644 index 000000000..c4c36c638 --- /dev/null +++ b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx @@ -0,0 +1,6 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; + +/** Daemon-backed MCP tool settings, shared with the browser client. */ +export function PluginsTab(): React.ReactNode { + return ; +} diff --git a/apps/desktop/src/renderer/src/settings/settings-view.tsx b/apps/desktop/src/renderer/src/settings/settings-view.tsx index 112028ea2..090495571 100644 --- a/apps/desktop/src/renderer/src/settings/settings-view.tsx +++ b/apps/desktop/src/renderer/src/settings/settings-view.tsx @@ -21,6 +21,7 @@ import { HistoryIcon, InfoIcon, KeyRoundIcon, + PlugIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -41,6 +42,7 @@ import { GeneralTab } from './general-tab'; import { HistoryImportTab } from './history-import-tab'; import { ImChannelTab } from './im-channel-tab'; import { NotificationsTab } from './notifications-tab'; +import { PluginsTab } from './plugins-tab'; import { ProvidersTab } from './providers-tab'; import type { SettingsCategory } from './store'; import { useDesktopSettingsStore } from './store'; @@ -147,6 +149,14 @@ export function SettingsView(): React.ReactNode { active: category === 'providers', onClick: () => setCategory('providers'), }, + { + key: 'plugins', + icon: , + label: t('tabs.plugins'), + keywords: searchKeywords.plugins, + active: category === 'plugins', + onClick: () => setCategory('plugins'), + }, { key: 'imChannel', icon: , @@ -301,6 +311,8 @@ function renderSettingsPanel( return ; case 'agents': return ; + case 'plugins': + return ; case 'imChannel': return ; case 'history-import': diff --git a/apps/desktop/src/renderer/src/settings/store.ts b/apps/desktop/src/renderer/src/settings/store.ts index 2173ba02b..da78c01c5 100644 --- a/apps/desktop/src/renderer/src/settings/store.ts +++ b/apps/desktop/src/renderer/src/settings/store.ts @@ -13,6 +13,7 @@ export type SettingsCategory = | 'about' | 'providers' | 'agents' + | 'plugins' | 'imChannel' | 'history-import'; diff --git a/apps/webview/src/router.tsx b/apps/webview/src/router.tsx index ee7e127bb..bfccf3899 100644 --- a/apps/webview/src/router.tsx +++ b/apps/webview/src/router.tsx @@ -6,6 +6,7 @@ import { DeveloperSettings } from '@webview/routes/settings/developer'; import { GeneralSettings } from '@webview/routes/settings/general'; import { MessagingSettings } from '@webview/routes/settings/messaging'; import { NotificationsSettings } from '@webview/routes/settings/notifications'; +import { PluginsSettings } from '@webview/routes/settings/plugins'; import { ProvidersSettings } from '@webview/routes/settings/providers'; import { SettingsLayout } from '@webview/routes/settings/settings-layout'; import { TerminalSettings } from '@webview/routes/settings/terminal'; @@ -32,6 +33,7 @@ export function createWebviewRouter( { path: 'developer', element: }, { path: 'notifications', element: }, { path: 'providers', element: }, + { path: 'plugins', element: }, { path: 'agents', element: }, { path: 'messaging', element: }, ], diff --git a/apps/webview/src/routes/settings/plugins.tsx b/apps/webview/src/routes/settings/plugins.tsx new file mode 100644 index 000000000..afde41355 --- /dev/null +++ b/apps/webview/src/routes/settings/plugins.tsx @@ -0,0 +1,9 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; +import { usePageTitle } from '@webview/hooks/use-page-title'; +import { useTranslations } from 'use-intl'; + +export function PluginsSettings(): React.ReactNode { + const tTabs = useTranslations('settings.tabs'); + usePageTitle(tTabs('plugins')); + return ; +} diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index d12dd56e9..8524208a0 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -5,6 +5,7 @@ import { BotIcon, CodeXmlIcon, KeyRoundIcon, + PlugIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -21,6 +22,7 @@ const SETTINGS_ROUTES: Record = { notifications: '/settings/notifications', agents: '/settings/agents', providers: '/settings/providers', + plugins: '/settings/plugins', messaging: '/settings/messaging', developer: '/settings/developer', }; @@ -92,6 +94,14 @@ export function SettingsLayout(): React.ReactNode { active: isActive(pathname, 'providers'), render: , }, + { + key: 'plugins', + icon: , + label: t('tabs.plugins'), + keywords: searchKeywords.plugins, + active: isActive(pathname, 'plugins'), + render: , + }, { key: 'messaging', icon: , @@ -163,6 +173,7 @@ function isActive( | 'developer' | 'notifications' | 'providers' + | 'plugins' | 'agents' | 'messaging', ): boolean { diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index 77b7bae9e..3390b46c0 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -44,6 +44,7 @@ export * from './settings/appearance-render-prefs'; export * from './settings/appearance-settings'; export * from './settings/appearance-store'; export * from './settings/plugins/hooks'; +export * from './settings/plugins/plugins-settings'; export * from './settings/plugins/view'; export * from './settings/providers/providers-settings'; export * from './settings/providers/store'; diff --git a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx new file mode 100644 index 000000000..79bf4d81d --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx @@ -0,0 +1,54 @@ +import type { PluginUnitCardView } from '@linkcode/ui'; +import { PluginSettingsPanel } from '@linkcode/ui'; +import { useSingleton } from 'foxact/use-singleton'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { noop } from 'foxts/noop'; +import { useTranslations } from 'use-intl'; +import { usePluginSettings } from './hooks'; +import { pluginUnitViews } from './view'; + +/** Card list of MCP capability units: enablement toggle plus derived per-server status. */ +export function PluginsSettingsPanel(): React.ReactNode { + const t = useTranslations('settings.plugins'); + const { catalog, config, error, isMutating, save } = usePluginSettings(); + // Expiry cutoff pinned per mount: precise enough for a settings page, pure for the compiler. + const now = useSingleton(() => Date.now()); + + const units: PluginUnitCardView[] | undefined = + catalog === undefined || config === undefined + ? undefined + : pluginUnitViews(catalog, config, now.current).map((unit) => ({ + id: unit.id, + label: t(unit.labelKey), + description: t(unit.descriptionKey), + enabled: unit.enabled, + status: unit.status, + servers: unit.servers.map((server) => ({ + name: server.name, + serviceLabel: + server.service === undefined ? undefined : t(`serviceName.${server.service}`), + status: server.status, + })), + })); + + const handleEnabledChange = (unitId: string, enabled: boolean): void => { + if (catalog === undefined || config === undefined) return; + const id = catalog.find((descriptor) => descriptor.id === unitId)?.id; + if (id === undefined) return; + const next = [ + ...config.units.filter((unit) => (unit.unitId as string) !== unitId), + { unitId: id, enabled }, + ]; + // A failed write surfaces through `error`; the switch reverts on the next config read. + save({ units: next }).catch(noop); + }; + + return ( + + ); +} diff --git a/packages/client/workbench/src/settings/search.ts b/packages/client/workbench/src/settings/search.ts index a9a2e54a0..6442421cf 100644 --- a/packages/client/workbench/src/settings/search.ts +++ b/packages/client/workbench/src/settings/search.ts @@ -1,3 +1,4 @@ +import { McpPluginServiceSchema } from '@linkcode/schema'; import type { SettingsSidebarNavGroup } from '@linkcode/ui'; import { useTranslations } from 'use-intl'; import { matchPaletteCommands } from '../palette/match'; @@ -26,6 +27,7 @@ export interface SettingsSearchKeywords { about: readonly string[]; agents: readonly string[]; providers: readonly string[]; + plugins: readonly string[]; imChannel: readonly string[]; historyImport: readonly string[]; } @@ -86,6 +88,12 @@ export function useSettingsSearchKeywords(): SettingsSearchKeywords { t('providers.accountModel'), ...PROVIDER_SERVICES.map((service) => t(`providers.serviceName.${service}`)), ], + plugins: [ + t('plugins.unitsTitle'), + t('plugins.units.githubRead.label'), + t('plugins.units.githubRead.description'), + ...McpPluginServiceSchema.options.map((service) => t(`plugins.serviceName.${service}`)), + ], imChannel: [t('imChannel.connectTitle'), t('imChannel.bindings'), t('imChannel.autoMirror')], historyImport: [t('historyImport.portalLabel')], }; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 79e42ff40..3a680c1cd 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -580,6 +580,7 @@ export const en = { about: 'About', providers: 'Providers', agents: 'Agents', + plugins: 'Tools', imChannel: 'Messaging', }, historyImport: { @@ -814,6 +815,26 @@ export const en = { }, }, plugins: { + hint: 'Configure optional MCP tools for your agents. Credentials stay in the local daemon.', + loadError: 'Could not load tool settings: {message}', + unitsTitle: 'Available capabilities', + enabledLabel: 'Enable {name}', + status: { + ready: 'Ready', + partial: 'Partially available', + unavailable: 'Unavailable', + disabled: 'Off', + }, + serverStatus: { + ready: 'Ready', + satisfied: 'Connected', + 'expired-credential': 'Credential expired', + 'unsatisfied-binding': 'Service not connected', + 'broker-unavailable': 'Cloud service not available yet', + }, + serviceName: { + github: 'GitHub', + }, units: { githubRead: { label: 'GitHub read-only tools', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 8ead38c31..8b1350300 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -569,6 +569,7 @@ export const zhCN = { about: '关于', providers: 'Providers', agents: '智能体', + plugins: '工具', imChannel: 'IM 渠道', }, historyImport: { @@ -793,6 +794,26 @@ export const zhCN = { }, }, plugins: { + hint: '为智能体配置可选的 MCP 工具。凭证只保存在本机 daemon 中。', + loadError: '无法加载工具设置:{message}', + unitsTitle: '可用能力', + enabledLabel: '启用{name}', + status: { + ready: '可用', + partial: '部分可用', + unavailable: '不可用', + disabled: '已停用', + }, + serverStatus: { + ready: '就绪', + satisfied: '已连接', + 'expired-credential': '凭证已过期', + 'unsatisfied-binding': '未连接服务', + 'broker-unavailable': '云端服务尚未开放', + }, + serviceName: { + github: 'GitHub', + }, units: { githubRead: { label: 'GitHub 只读能力', diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index 80c73c092..66383d4d0 100644 --- a/packages/presentation/ui/src/shell/index.ts +++ b/packages/presentation/ui/src/shell/index.ts @@ -15,6 +15,7 @@ export * from './history/sort-select'; export * from './im-channel-settings-panel'; export * from './new-session-surface'; export * from './notifications-settings-panel'; +export * from './plugins/plugin-settings-panel'; export * from './providers/account-detail'; export * from './providers/account-master-list'; export * from './service-icon'; diff --git a/packages/presentation/ui/src/shell/plugins/plugin-settings-panel.tsx b/packages/presentation/ui/src/shell/plugins/plugin-settings-panel.tsx new file mode 100644 index 000000000..a0eb8c2e3 --- /dev/null +++ b/packages/presentation/ui/src/shell/plugins/plugin-settings-panel.tsx @@ -0,0 +1,138 @@ +import { Alert, AlertDescription } from 'coss-ui/components/alert'; +import { Badge } from 'coss-ui/components/badge'; +import { Skeleton } from 'coss-ui/components/skeleton'; +import { Switch } from 'coss-ui/components/switch'; +import { TriangleAlertIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { SettingsCard, SettingsSection } from '../settings-page'; + +export type PluginUnitCardStatus = 'disabled' | 'ready' | 'partial' | 'unavailable'; +export type PluginServerCardStatus = + | 'ready' + | 'satisfied' + | 'expired-credential' + | 'unsatisfied-binding' + | 'broker-unavailable'; + +export interface PluginServerCardView { + name: string; + /** Localized service display name, when the server depends on one. */ + serviceLabel?: string; + status: PluginServerCardStatus; +} + +export interface PluginUnitCardView { + id: string; + label: string; + description: string; + enabled: boolean; + status: PluginUnitCardStatus; + servers: PluginServerCardView[]; +} + +export interface PluginSettingsPanelProps { + /** Undefined while catalog/config load — the cards render as skeletons. */ + units: PluginUnitCardView[] | undefined; + error?: string; + busy: boolean; + onEnabledChange: (unitId: string, enabled: boolean) => void; +} + +const UNIT_BADGE_VARIANT = { + ready: 'success', + partial: 'warning', + unavailable: 'warning', + disabled: 'secondary', +} as const; + +const SERVER_BADGE_VARIANT = { + ready: 'success', + satisfied: 'success', + 'expired-credential': 'warning', + 'unsatisfied-binding': 'outline', + 'broker-unavailable': 'secondary', +} as const; + +/** Pure card list for MCP capability units: label, enablement, and per-server status. */ +export function PluginSettingsPanel({ + units, + error, + busy, + onEnabledChange, +}: PluginSettingsPanelProps): React.ReactNode { + const t = useTranslations('settings.plugins'); + + return ( +
+

{t('hint')}

+ + {error === undefined ? null : ( + + + {t('loadError', { message: error })} + + )} + + + + {units === undefined ? ( + + ) : ( + units.map((unit) => ( +
+
+
+
+

{unit.label}

+ + {t(`status.${unit.status}`)} + +
+

{unit.description}

+
+ onEnabledChange(unit.id, enabled)} + /> +
+
+ {unit.servers.map((server) => ( +
+ + {server.name} + {server.serviceLabel === undefined ? '' : ` · ${server.serviceLabel}`} + + + {t(`serverStatus.${server.status}`)} + +
+ ))} +
+
+ )) + )} +
+
+
+ ); +} + +function PluginUnitSkeleton(): React.ReactNode { + return ( +
+
+
+ + +
+ +
+
+ + +
+
+ ); +} From 27f121820144d75d26ca01a79b4b18a68a4bffec Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 23 Jul 2026 14:13:25 +0800 Subject: [PATCH 19/23] feat(agent-adapter): inject StartOptions.mcpServers into agent sessions (CODE-93) claude: Options.mcpServers record (spawn-time --mcp-config; merges with the user's own MCP config). codex: thread/start|resume config.mcp_servers (no type tag, headers->http_headers; startup failures surface as recoverable errors). opencode: per-session spawn Config.mcp (local/remote, env->environment, explicit enabled). Live-verified on claude 2.1.218: an injected stdio MCP server's tool was called end to end through a LinkCode session. --- packages/host/agent-adapter/AGENTS.md | 2 +- .../src/__tests__/claude-code-mcp.test.ts | 100 +++++++++++++++ .../src/__tests__/codex-mcp.test.ts | 117 ++++++++++++++++++ .../src/__tests__/opencode-mcp.test.ts | 95 ++++++++++++++ .../agent-adapter/src/native/claude-code.ts | 22 ++++ .../agent-adapter/src/native/codex/adapter.ts | 50 +++++++- .../src/native/opencode/adapter.ts | 54 +++++++- 7 files changed, 432 insertions(+), 8 deletions(-) create mode 100644 packages/host/agent-adapter/src/__tests__/claude-code-mcp.test.ts create mode 100644 packages/host/agent-adapter/src/__tests__/codex-mcp.test.ts create mode 100644 packages/host/agent-adapter/src/__tests__/opencode-mcp.test.ts diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 67061ec5c..5251a3fb1 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -110,7 +110,7 @@ engine caches the emitted effort and replays it when the newly created session a - **Interactive login** (`login.ts` dispatcher `startAgentCliLogin`, kinds in `AGENT_LOGIN_KINDS`): claude-code drives `claude auth login --claudeai` (remote callback page; the user pastes the code back via stdin); codex drives `account/login/start {type:'chatgpt'}` on a short-lived app-server (`native/codex/login.ts`) whose OWN localhost callback completes the flow — no code hand-back (`submitCode` is a no-op), settle = `account/login/completed {success, error?}`. Auth probing: claude `auth status --json` (stdout, structured); codex `login status` (TEXT only — signed-out rides STDERR + exit 1, parse both streams; `parseCodexLoginStatus` fails open on rewording). - **Fixed bypass visibility**: Pi's in-process tools and Grok Build's headless `--permission-mode bypassPermissions` have no interactive approval path. Both advertise a single `bypassPermissions` policy with an explicit non-switchable description; this is visibility only, never a claim that approval is available. `set-approval-policy` continues to reject. - **Cancellation**: codex `turn/interrupt` (queued prompts dropped; a cancel before the turn id is known is armed and fired on arrival); pi `session.abort()`; opencode `client.session.abort({sessionID, directory})` with the wait capped at 2s (opencode has blocked the abort RPC until the running tool exits — tens of seconds on 1.14.42+; the local cancel proceeds and the abort settles server-side); claude-code `Query#interrupt()` — if no terminal frame arrives before its ack, the old Query is detached and the next prompt rebuilds from the last session id so late cancellation fallout cannot settle a new turn; grok-build kills the active headless process. After any cancel, base `send()` also calls `teardown()`. -- **MCP gap**: `StartOptions.mcpServers` is DEFINED in `schema/agent.ts` but consumed by NO native adapter (claude `query()` doesn't forward it) — a ready-but-unwired slot blocking all MCP passthrough (CODE-93 prerequisite). Pi's SDK has no `mcpServers` support at all; the other three could inject stdio MCP once wired. +- **MCP injection (CODE-93)**: `StartOptions.mcpServers` (stdio|http) reaches the three capable agents at session start, each through its own channel — claude: `Options.mcpServers` record (SDK serializes a spawn-time `--mcp-config` flag; merges with the user's own `.mcp.json`/`~/.claude.json`, `strictMcpConfig` deliberately unset); codex: `thread/start|resume` `config.mcp_servers` (no type tag — stdio-vs-http inferred from command-vs-url; `headers`→`http_headers`; deep-merges with config.toml, same-named entries masked for the session, disk untouched; startup failures surface via the `mcpServer/startupStatus/updated` notification as a recoverable error); opencode: per-session server spawn `Config.mcp` (`local`/`remote` literals, one concatenated `command` array, `env`→`environment`, explicit `enabled: true` because the user's opencode.json merges under `OPENCODE_CONFIG_CONTENT` and a same-named `enabled: false` would leak through). `AGENT_MCP_CAPABLE` (schema) is the shared gate; pi's SDK has no MCP support. In every agent a same-named server collides with the user's native config in agent-specific, mostly override-wins ways — LinkCode-catalog server names must stay distinctive (schema enforces catalog-internal uniqueness only). ## Version seams diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-mcp.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-mcp.test.ts new file mode 100644 index 000000000..0c375a6e2 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/claude-code-mcp.test.ts @@ -0,0 +1,100 @@ +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import { asyncNoop, noop } from 'foxts/noop'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ClaudeCodeAdapter } from '../native/claude-code'; + +const sdkMock = vi.hoisted(() => ({ + query: null as ((opts: unknown) => unknown) | null, +})); + +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query(opts: unknown) { + if (!sdkMock.query) throw new Error('query mock not installed'); + return sdkMock.query(opts); + }, + resolveSettings: () => Promise.resolve({ effective: {} }), +})); + +// Isolate settings reads from the developer's real ~/.claude/settings.json: every read misses. +vi.mock('node:fs/promises', () => ({ + readFile: () => Promise.reject(new Error('ENOENT')), +})); + +interface QueryInput { + prompt: AsyncIterable; + options: Record; +} + +/** Records the options the Query was created with; the message stream stays silent. */ +class FakeQuery { + readonly options: Record; + readonly setPermissionMode = vi.fn(asyncNoop); + readonly applyFlagSettings = vi.fn(asyncNoop); + readonly close = vi.fn(); + + constructor(input: QueryInput) { + this.options = input.options; + void (async () => { + for await (const _ of input.prompt) void _; + })(); + } + + // Silent message stream: the test only inspects creation options, so it pends forever. + async *[Symbol.asyncIterator](): AsyncGenerator> { + await new Promise(noop); + yield {}; + } +} + +const queries: FakeQuery[] = []; + +sdkMock.query = (opts) => { + const q = new FakeQuery(opts as QueryInput); + queries.push(q); + return q; +}; + +afterEach(() => { + queries.length = 0; +}); + +async function startedAdapter(mcpServers?: unknown): Promise { + const adapter = new ClaudeCodeAdapter(); + await adapter.start({ + kind: 'claude-code', + cwd: '/tmp/repo', + ...(mcpServers !== undefined && { mcpServers }), + } as Parameters[0]); + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'hi' }] }); + return adapter; +} + +describe('ClaudeCodeAdapter MCP injection', () => { + it('folds StartOptions.mcpServers into the SDK record keyed by server name', async () => { + await startedAdapter([ + { + type: 'stdio', + name: 'linkcode-github', + command: 'github-mcp-server', + args: ['stdio'], + env: { GITHUB_TOKEN: 'secret' }, + }, + { type: 'http', name: 'docs', url: 'https://docs.test/mcp', headers: { 'X-Key': 'k' } }, + ]); + + expect(queries[0]?.options.mcpServers).toEqual({ + 'linkcode-github': { + type: 'stdio', + command: 'github-mcp-server', + args: ['stdio'], + env: { GITHUB_TOKEN: 'secret' }, + }, + docs: { type: 'http', url: 'https://docs.test/mcp', headers: { 'X-Key': 'k' } }, + }); + }); + + it('passes no mcpServers option when the session has none', async () => { + await startedAdapter(); + expect(queries[0]?.options.mcpServers).toBeUndefined(); + }); +}); diff --git a/packages/host/agent-adapter/src/__tests__/codex-mcp.test.ts b/packages/host/agent-adapter/src/__tests__/codex-mcp.test.ts new file mode 100644 index 000000000..1a6958369 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/codex-mcp.test.ts @@ -0,0 +1,117 @@ +import type { AgentEvent } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { CodexAdapter } from '../native/codex'; +import type { CodexServerHandle } from '../native/codex/adapter'; +import type { CodexAppServerOptions } from '../native/codex/app-server'; + +/** Request log + canned thread/start reply; see codex-shell.test.ts for the full-featured fake. */ +class FakeCodexServer { + readonly requests: Array<{ method: string; params: Record }> = []; + constructor(private readonly opts: Omit) {} + request(method: string, params: unknown): Promise { + this.requests.push({ method, params: params as Record }); + if (method === 'thread/start' || method === 'thread/resume') { + return Promise.resolve({ thread: { id: 'thread-1' }, model: 'gpt-5.6-sol' }); + } + return Promise.resolve({}); + } + setRequestHandler(): void { + // Approvals are irrelevant to this suite. + } + close(): void { + // Nothing to reap. + } + notify(method: string, params: unknown): void { + this.opts.onNotification(method, params); + } +} + +class TestCodex extends CodexAdapter { + fakeServers: FakeCodexServer[] = []; + protected override startAppServer( + opts: Omit, + ): Promise { + const server = new FakeCodexServer(opts); + this.fakeServers.push(server); + return Promise.resolve(server); + } + protected override readConfiguredSandbox(): Promise { + return Promise.resolve(undefined); + } +} + +describe('CodexAdapter MCP injection', () => { + it('maps servers into thread/start config.mcp_servers alongside writable roots', async () => { + const adapter = new TestCodex(); + await adapter.start({ + kind: 'codex', + cwd: '/repo', + additionalDirectories: ['/extra'], + mcpServers: [ + { + type: 'stdio', + name: 'linkcode-github', + command: 'github-mcp-server', + args: ['stdio'], + env: { GITHUB_TOKEN: 'secret' }, + }, + { type: 'http', name: 'docs', url: 'https://docs.test/mcp', headers: { 'X-Key': 'k' } }, + ], + }); + + const started = adapter.fakeServers[0]?.requests.find((r) => r.method === 'thread/start'); + // One shared config map: MCP servers must not evict the writable-roots override. + expect(started?.params.config).toEqual({ + 'sandbox_workspace_write.writable_roots': ['/extra'], + mcp_servers: { + // codex has no type tag (stdio inferred from `command`) and names headers `http_headers`. + 'linkcode-github': { + command: 'github-mcp-server', + args: ['stdio'], + env: { GITHUB_TOKEN: 'secret' }, + }, + docs: { url: 'https://docs.test/mcp', http_headers: { 'X-Key': 'k' } }, + }, + }); + }); + + it('omits the config override entirely when nothing needs one', async () => { + const adapter = new TestCodex(); + await adapter.start({ kind: 'codex', cwd: '/repo' }); + const started = adapter.fakeServers[0]?.requests.find((r) => r.method === 'thread/start'); + expect(started?.params.config).toBeUndefined(); + }); + + it('surfaces a failed MCP server startup as a recoverable diagnostic', async () => { + const adapter = new TestCodex(); + const events: AgentEvent[] = []; + adapter.onEvent((e) => events.push(e)); + await adapter.start({ + kind: 'codex', + cwd: '/repo', + mcpServers: [{ type: 'http', name: 'docs', url: 'https://docs.test/mcp' }], + }); + + adapter.fakeServers[0]?.notify('mcpServer/startupStatus/updated', { + threadId: 'thread-1', + name: 'docs', + status: 'failed', + error: 'connect ECONNREFUSED', + }); + adapter.fakeServers[0]?.notify('mcpServer/startupStatus/updated', { + threadId: 'thread-1', + name: 'docs', + status: 'ready', + error: null, + }); + + const errors = events.filter((e) => e.type === 'error'); + expect(errors).toEqual([ + { + type: 'error', + message: 'MCP server "docs" failed to start: connect ECONNREFUSED', + recoverable: true, + }, + ]); + }); +}); diff --git a/packages/host/agent-adapter/src/__tests__/opencode-mcp.test.ts b/packages/host/agent-adapter/src/__tests__/opencode-mcp.test.ts new file mode 100644 index 000000000..ec55c3197 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/opencode-mcp.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { OpenCodeAdapter } from '../native/opencode'; +import { FakeEventStream } from './fake-event-stream'; + +const sdkMock = vi.hoisted(() => ({ + createOpencode: null as ((opts: unknown) => unknown) | null, +})); + +vi.mock('@opencode-ai/sdk/v2', () => ({ + createOpencode(opts: unknown) { + if (!sdkMock.createOpencode) throw new Error('createOpencode mock not installed'); + return sdkMock.createOpencode(opts); + }, +})); + +/** Just enough client for onStart to finish; see opencode.test.ts for the full-featured fake. */ +class FakeClient { + readonly stream = new FakeEventStream(); + readonly session = { create: vi.fn(() => ({ data: { id: 'sess-1' } })) }; + readonly command = { list: vi.fn(() => ({ data: [] as unknown[] })) }; + readonly app = { agents: vi.fn(() => ({ data: [] as unknown[] })) }; + readonly provider = { + list: vi.fn(() => ({ data: { all: [] as unknown[], default: {}, connected: [] } })), + }; + readonly event = { subscribe: vi.fn(() => ({ stream: this.stream })) }; +} + +async function startedConfig(opts: Record): Promise { + let captured: unknown; + sdkMock.createOpencode = (serverOptions: unknown) => { + captured = serverOptions; + return Promise.resolve({ + client: new FakeClient(), + server: { url: 'http://fake', close: vi.fn() }, + }); + }; + const adapter = new OpenCodeAdapter(); + await adapter.start({ kind: 'opencode', cwd: '/tmp/repo', ...opts }); + return (captured as { config?: unknown } | undefined)?.config; +} + +describe('OpenCodeAdapter MCP injection', () => { + it('maps servers into the spawn-time Config.mcp with opencode field names', async () => { + const config = await startedConfig({ + mcpServers: [ + { + type: 'stdio', + name: 'linkcode-github', + command: 'github-mcp-server', + args: ['stdio'], + env: { GITHUB_TOKEN: 'secret' }, + }, + { type: 'http', name: 'docs', url: 'https://docs.test/mcp', headers: { 'X-Key': 'k' } }, + ], + }); + + expect(config).toEqual({ + mcp: { + // stdio→local with ONE concatenated command array and env renamed `environment`; + // enabled must be explicit or a same-named user entry's `enabled: false` leaks through. + 'linkcode-github': { + type: 'local', + command: ['github-mcp-server', 'stdio'], + environment: { GITHUB_TOKEN: 'secret' }, + enabled: true, + }, + docs: { + type: 'remote', + url: 'https://docs.test/mcp', + headers: { 'X-Key': 'k' }, + enabled: true, + }, + }, + }); + }); + + it('keeps credential injection and MCP config on the same spawn config', async () => { + const config = await startedConfig({ + model: 'anthropic/claude-opus-4-8', + config: { apiKey: 'sk-live' }, + mcpServers: [{ type: 'http', name: 'docs', url: 'https://docs.test/mcp' }], + }); + + expect(config).toEqual({ + provider: { anthropic: { options: { apiKey: 'sk-live' } } }, + mcp: { + docs: { type: 'remote', url: 'https://docs.test/mcp', enabled: true }, + }, + }); + }); + + it('spawns without a config when neither credentials nor MCP servers are present', async () => { + expect(await startedConfig({})).toBeUndefined(); + }); +}); diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index addaa2d88..01533235e 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -5,6 +5,7 @@ import { env } from 'node:process'; import type { CanUseTool, HookCallback, + McpServerConfig, PermissionMode, PermissionResult, Query, @@ -35,6 +36,7 @@ import type { ApprovalPolicyState, ContentBlock, EffortLevel, + McpServer, PermissionOption, StartOptions, StopReason, @@ -84,6 +86,25 @@ type ResultMessage = Extract; /** Claude's subagent-spawning tool: `Agent` in current CLIs (verified live against the vendored * 0.3.x), `Task` in older transcripts still met by history replay. Exact match on purpose so other * adapters (e.g. opencode's lowercase `task`) opt in deliberately, not by regex accident. */ +/** Fold the wire's `McpServer[]` into the SDK's name-keyed record (CODE-93). The SDK serializes + * it into a spawn-time `--mcp-config` flag, so it merges with — never replaces — the user's own + * `.mcp.json`/`~/.claude.json` servers (`strictMcpConfig` stays unset on purpose). */ +function claudeMcpServers( + servers: McpServer[] | undefined, +): Record | undefined { + if (!servers || servers.length === 0) return undefined; + return Object.fromEntries( + servers.map((server) => + server.type === 'stdio' + ? [ + server.name, + { type: 'stdio' as const, command: server.command, args: server.args, env: server.env }, + ] + : [server.name, { type: 'http' as const, url: server.url, headers: server.headers }], + ), + ); +} + function claudeToolKind(name: string): ToolCall['kind'] { return name === 'Task' || name === 'Agent' ? 'task' : toolKindFromName(name); } @@ -679,6 +700,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { allowDangerouslySkipPermissions: true, resume, additionalDirectories: opts.additionalDirectories, + mcpServers: claudeMcpServers(opts.mcpServers), ...(credentialEnv && { env: credentialEnv }), }, }); diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index a378ebbfc..89e25c137 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -10,6 +10,7 @@ import type { ApprovalPolicyState, ContentBlock, EffortLevel, + McpServer, PermissionOption, PermissionOutcome, StartOptions, @@ -21,6 +22,7 @@ import { EffortLevelSchema, textBlock } from '@linkcode/schema'; import { appendArrayInPlace } from 'foxts/append-array-in-place'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { invariant, nullthrow } from 'foxts/guard'; +import { isObjectEmpty } from 'foxts/is-object-empty'; import { AUTH_FAILED_ERROR_CODE } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; import { codexEnv, readAgentCredential } from '../../credential'; @@ -174,6 +176,30 @@ const POLICY_PRESETS: Record { + return Object.fromEntries( + servers.map((server) => + server.type === 'stdio' + ? [ + server.name, + { + command: server.command, + ...(server.args?.length && { args: server.args }), + ...(server.env && { env: server.env }), + }, + ] + : [ + server.name, + { url: server.url, ...(server.headers && { http_headers: server.headers }) }, + ], + ), + ); +} + function isCodexPolicyId(id: string): id is CodexPolicyId { return id in POLICY_PRESETS; } @@ -592,14 +618,19 @@ export class CodexAdapter extends BaseAgentAdapter { const resume = this.resumeFrom ?? undefined; this.resumeFrom = undefined; const preset = POLICY_PRESETS[this.policyId]; + // One shared override map: separate `config` spreads would silently drop each other. + const configOverrides: Record = { + ...(opts.additionalDirectories?.length && { + 'sandbox_workspace_write.writable_roots': opts.additionalDirectories, + }), + ...(opts.mcpServers?.length && { mcp_servers: codexMcpServers(opts.mcpServers) }), + }; const params = { cwd: opts.cwd, model: this.model, approvalPolicy: preset.approvalPolicy, ...(this.sandboxOverrideAllowed() && { sandbox: preset.sandboxMode }), - ...(opts.additionalDirectories?.length && { - config: { 'sandbox_workspace_write.writable_roots': opts.additionalDirectories }, - }), + ...(!isObjectEmpty(configOverrides) && { config: configOverrides }), }; // A fresh thread's rollout holds nothing yet: announcing its history id now would trigger the // clients' transcript seed read, whose uptoSeq cut can swallow the first prompt. Hold the @@ -776,6 +807,19 @@ export class CodexAdapter extends BaseAgentAdapter { private handleNotification(method: string, params: unknown): void { if (!isRecord(params)) return; switch (method) { + // Injected MCP servers start asynchronously after thread/start; a failure would otherwise be + // invisible (the tools just never appear). Surface it as a recoverable diagnostic. + case 'mcpServer/startupStatus/updated': { + if (stringField(params, 'status') !== 'failed') break; + const name = stringField(params, 'name') ?? 'unknown'; + const detail = stringField(params, 'error'); + this.emit({ + type: 'error', + message: `MCP server "${name}" failed to start${detail ? `: ${detail}` : ''}`, + recoverable: true, + }); + break; + } case 'thread/started': { const thread = recordField(params, 'thread'); const id = thread ? stringField(thread, 'id') : undefined; diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index 402f05241..a808b6e8b 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -12,11 +12,19 @@ import type { AgentModelOption, ApprovalPolicy, ContentBlock, + McpServer, PermissionOption, Question, StartOptions, } from '@linkcode/schema'; -import type { Event, FilePartInput, Part, TextPartInput } from '@opencode-ai/sdk/v2'; +import type { + Event, + FilePartInput, + McpLocalConfig, + McpRemoteConfig, + Part, + TextPartInput, +} from '@opencode-ai/sdk/v2'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { invariant } from 'foxts/guard'; import { falseFn } from 'foxts/noop'; @@ -46,6 +54,37 @@ type SessionErrored = Extract['properties']; /** Same three-way menu as claude-code's tool asks; `allow_always` maps onto opencode's `always` * reply, which the server persists as a saved allow rule for the ask's matched pattern. */ +/** Spawn-time `Config.mcp` for our per-session server (CODE-93): schema `stdio` is opencode + * `local` with one concatenated `command` array and `env` renamed `environment`; `http` is + * `remote`. `enabled: true` is explicit — the user's own opencode.json merges UNDER + * OPENCODE_CONFIG_CONTENT per key, and a same-named entry's `enabled: false` would otherwise + * leak through and silently disable the injected server (verified live on 1.18.2). */ +function opencodeMcpConfig(servers: McpServer[]): Record { + return Object.fromEntries( + servers.map((server) => + server.type === 'stdio' + ? [ + server.name, + { + type: 'local' as const, + command: [server.command, ...(server.args ?? [])], + ...(server.env && { environment: server.env }), + enabled: true, + }, + ] + : [ + server.name, + { + type: 'remote' as const, + url: server.url, + ...(server.headers && { headers: server.headers }), + enabled: true, + }, + ], + ), + ); +} + const PERMISSION_OPTIONS: PermissionOption[] = [ { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, { optionId: 'allow_always', name: 'Always allow', kind: 'allow_always' }, @@ -201,13 +240,20 @@ export class OpenCodeAdapter extends BaseAgentAdapter { const key = cred.apiKey ?? cred.authToken; if (key) options.apiKey = key; if (cred.baseUrl) options.baseURL = cred.baseUrl; - const serverOptions = + // Credential injection and MCP servers travel through the same spawn-time config + // (delivered via OPENCODE_CONFIG_CONTENT); the per-session server keeps them session-scoped. + const providerConfig = providerID && (options.apiKey || options.baseURL) - ? { config: { provider: { [providerID]: { options } } } } + ? { provider: { [providerID]: { options } } } : undefined; + const mcpConfig = opts.mcpServers?.length + ? { mcp: opencodeMcpConfig(opts.mcpServers) } + : undefined; + const serverOptions = + providerConfig || mcpConfig ? { config: { ...providerConfig, ...mcpConfig } } : undefined; // The injection is spawn-time-only: remember which provider it scoped to so a later // set-model can refuse a cross-provider switch the running server holds no credentials for. - this.credentialProviderId = serverOptions ? (providerID ?? null) : null; + this.credentialProviderId = providerConfig ? (providerID ?? null) : null; try { // The SDK's server port is a FIXED default of 4096 (opencode's own `--port=0` does not // auto-allocate either), and this adapter spawns one server per session — without an From 248d1847c95b45b486688f6cc30a0a2628abe3d4 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 23 Jul 2026 15:46:59 +0800 Subject: [PATCH 20/23] feat(plugins): import custom user-defined MCP servers (CODE-407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a BYO MCP tier alongside the catalog: CustomMcpServer ({id, enabled, server}) in the plugin config, injected into agent sessions through the same resolved StartOptions.mcpServers path. Credentials are inline and opaque to LinkCode — the public projection masks env/header values to key lists, and a custom name colliding with a catalog server is rejected at write time. The resolver shares the capability gate and client-name precedence with catalog servers; plugin-warning gained a custom-server source. A Tools-tab section imports/toggles/removes them. Wire protocol 48. Live-verified on claude 2.1.218: a custom stdio server imported through the SDK was injected and its tool called end to end; the webview form adds a server, shows only the masked credential key, and removes it. --- apps/daemon/src/__tests__/config.test.ts | 17 +- apps/daemon/src/config.ts | 4 +- apps/daemon/src/index.ts | 2 +- packages/client/core/src/conversation.ts | 21 +- .../tests/integration/control-client.test.ts | 1 + .../workbench/src/mock/dev-mock-host.ts | 61 ++++- .../settings/plugins/__tests__/view.test.ts | 1 + .../settings/plugins/custom-server-dialog.tsx | 248 ++++++++++++++++++ .../src/settings/plugins/plugins-settings.tsx | 56 +++- .../workbench/src/settings/plugins/view.ts | 13 + .../integration/dev-mock-transport.test.ts | 55 ++++ .../schema/src/model/agent/event.ts | 7 +- .../foundation/schema/src/model/plugin.ts | 56 ++++ .../foundation/schema/src/wire/message.ts | 4 +- .../__tests__/engine-plugin-config.test.ts | 3 + .../src/__tests__/plugin-resolver.test.ts | 85 ++++++ .../src/__tests__/provider-config.test.ts | 86 ++++++ .../host/engine/src/agent/provider-config.ts | 110 +++++++- .../src/session/start-options-resolver.ts | 16 ++ packages/presentation/i18n/src/locales/en.ts | 25 ++ .../presentation/i18n/src/locales/zh-cn.ts | 23 ++ .../shell/plugins/plugin-settings-panel.tsx | 76 +++++- 22 files changed, 939 insertions(+), 31 deletions(-) create mode 100644 packages/client/workbench/src/settings/plugins/custom-server-dialog.tsx diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 38fd2b00a..c59d1f8a8 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -199,6 +199,10 @@ describe('loadConfig plugins', () => { jira: { type: 'local', connectorId: 'stale' }, }, connectors: [connector, { id: 'bad', service: 'github' }], + customServers: [ + { id: 'cs1', enabled: true, server: { type: 'stdio', name: 'local-fs', command: 'fs' } }, + { id: 'bad', enabled: true, server: { type: 'stdio', name: 'x' } }, + ], }, }); @@ -206,8 +210,11 @@ describe('loadConfig plugins', () => { units: [{ unitId: 'github-read', enabled: true }], serviceBindings: { github: { type: 'local', connectorId: connector.id } }, connectors: [connector], + customServers: [ + { id: 'cs1', enabled: true, server: { type: 'stdio', name: 'local-fs', command: 'fs' } }, + ], }); - expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(3); }); it('round-trips credentials at mode 0600 without overwriting providers or accounts', () => { @@ -216,6 +223,7 @@ describe('loadConfig plugins', () => { units: [], serviceBindings: { github: { type: 'local', connectorId: connector.id } }, connectors: [connector], + customServers: [], }; savePlugins(plugins); @@ -232,6 +240,11 @@ describe('loadConfig plugins', () => { it('defaults an older config without a plugins section to empty state', () => { writeRawConfig({ providers: {} }); - expect(loadConfig().plugins).toEqual({ units: [], serviceBindings: {}, connectors: [] }); + expect(loadConfig().plugins).toEqual({ + units: [], + serviceBindings: {}, + connectors: [], + customServers: [], + }); }); }); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 7399433ec..fb8d5fedf 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -6,6 +6,7 @@ import type { Accounts, PluginConfig, ProvidersConfig } from '@linkcode/schema'; import { AccountSchema, AgentKindSchema, + CustomMcpServerSchema, DAEMON_DEFAULT_PORT, McpPluginServiceSchema, PluginConnectorSchema, @@ -113,7 +114,7 @@ export function loadConfig(): DaemonConfig { } function parsePlugins(raw: unknown): PluginConfig { - const empty: PluginConfig = { units: [], serviceBindings: {}, connectors: [] }; + const empty: PluginConfig = { units: [], serviceBindings: {}, connectors: [], customServers: [] }; if (raw === undefined) return empty; if (!isRecord(raw)) { logger.warn({ operation: 'config.load' }, 'Invalid plugins config: expected an object'); @@ -123,6 +124,7 @@ function parsePlugins(raw: unknown): PluginConfig { units: parsePluginEntries(raw.units, PluginUnitStateSchema, 'unit'), serviceBindings: parseServiceBindings(raw.serviceBindings), connectors: parsePluginEntries(raw.connectors, PluginConnectorSchema, 'connector'), + customServers: parsePluginEntries(raw.customServers, CustomMcpServerSchema, 'custom server'), }; } diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 386d5a694..bdd28dcbf 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -173,7 +173,7 @@ async function main(): Promise { const store = createProviderConfigStore( config.providers ?? {}, config.accounts ?? [], - config.plugins ?? { units: [], serviceBindings: {}, connectors: [] }, + config.plugins ?? { units: [], serviceBindings: {}, connectors: [], customServers: [] }, ); // Managed assets (CODE-111): GC superseded versions before anything can spawn, then feed the // store into spawn resolution — managed wins over detected as soon as an install lands. diff --git a/packages/client/core/src/conversation.ts b/packages/client/core/src/conversation.ts index 8a9e3a5bd..ea02e4e96 100644 --- a/packages/client/core/src/conversation.ts +++ b/packages/client/core/src/conversation.ts @@ -33,11 +33,12 @@ export type QuestionResolution = Pick< Extract, 'outcome' | 'source' >; -/** One plugin resolution diagnostic (from `plugin-warning`): the named unit's `service` dependency - * (absent for unit-level reasons) could not be satisfied at the last session start. */ +/** One plugin resolution diagnostic (from `plugin-warning`): a catalog unit (`unitId`, optionally + * with the failed `service`) or a user-imported server (`customServerName`) could not be resolved + * at the last session start. */ export type ConversationPluginWarning = Pick< Extract, - 'unitId' | 'service' | 'reason' + 'unitId' | 'customServerName' | 'service' | 'reason' >; /** A single semantic item in the conversation timeline. `receivedAt` is the best-known time of the @@ -556,11 +557,15 @@ export function createConversationBuilder(): ConversationBuilder { break; case 'plugin-warning': - pluginWarningIndex.set(`${event.unitId}|${event.service ?? ''}|${event.reason}`, { - unitId: event.unitId, - service: event.service, - reason: event.reason, - }); + pluginWarningIndex.set( + `${event.unitId ?? ''}|${event.customServerName ?? ''}|${event.service ?? ''}|${event.reason}`, + { + unitId: event.unitId, + customServerName: event.customServerName, + service: event.service, + reason: event.reason, + }, + ); break; case 'permission-request': { diff --git a/packages/client/core/tests/integration/control-client.test.ts b/packages/client/core/tests/integration/control-client.test.ts index e36a7731e..763826837 100644 --- a/packages/client/core/tests/integration/control-client.test.ts +++ b/packages/client/core/tests/integration/control-client.test.ts @@ -107,6 +107,7 @@ describe('LinkCodeClient control API', () => { units: [{ unitId: 'github-read', enabled: true }], serviceBindings: { github: { type: 'managed' } }, connectors: [], + customServers: [], }; serverTransport.onMessage((msg) => { diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 402b72e43..3197afbd5 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -179,7 +179,12 @@ export class DevMockHost { private readonly workspaces = new Map(); private providers: ProvidersConfig = {}; private accounts: Accounts = []; - private plugins: PluginConfig = { units: [], serviceBindings: {}, connectors: [] }; + private plugins: PluginConfig = { + units: [], + serviceBindings: {}, + connectors: [], + customServers: [], + }; private readonly permissions = new Map(); private readonly questions = new Map(); private history: AgentHistorySession[] = []; @@ -1430,6 +1435,25 @@ function publicPluginConfig(config: PluginConfig): PluginConfigPublic { ? { type: credential.type, configured: true } : { type: credential.type, configured: true, expiresAt: credential.expiresAt }, })), + customServers: config.customServers.map(({ id, enabled, server }) => ({ + id, + enabled, + server: + server.type === 'stdio' + ? { + type: 'stdio', + name: server.name, + command: server.command, + ...(server.args && { args: server.args }), + envKeys: Object.keys(server.env ?? {}), + } + : { + type: 'http', + name: server.name, + url: server.url, + headerKeys: Object.keys(server.headers ?? {}), + }, + })), }; } @@ -1473,7 +1497,40 @@ function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): Pl break; } } - return { units, serviceBindings, connectors }; + let customServers = structuredClone(current.customServers); + for (const operation of patch.customServerOperations ?? []) { + switch (operation.type) { + case 'add': + if (customServers.some((entry) => entry.id === operation.server.id)) { + throw new Error(`Custom MCP server already exists: ${operation.server.id}`); + } + customServers.push(structuredClone(operation.server)); + break; + case 'update': { + const existing = customServers.find((entry) => entry.id === operation.id); + if (!existing) throw new Error(`Custom MCP server does not exist: ${operation.id}`); + customServers = customServers.map((entry) => + entry.id === operation.id + ? { + id: entry.id, + enabled: operation.enabled ?? entry.enabled, + server: operation.server ? structuredClone(operation.server) : entry.server, + } + : entry, + ); + break; + } + case 'remove': + if (!customServers.some((entry) => entry.id === operation.id)) { + throw new Error(`Custom MCP server does not exist: ${operation.id}`); + } + customServers = customServers.filter((entry) => entry.id !== operation.id); + break; + default: + break; + } + } + return { units, serviceBindings, connectors, customServers }; } function promptText(content: readonly ContentBlock[]): string { diff --git a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts index 9a6abacd6..85597793f 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -36,6 +36,7 @@ function config(overrides: Partial = {}): PluginConfigPublic credential: { type: 'auth-token', configured: true }, }, ], + customServers: [], ...overrides, }; } diff --git a/packages/client/workbench/src/settings/plugins/custom-server-dialog.tsx b/packages/client/workbench/src/settings/plugins/custom-server-dialog.tsx new file mode 100644 index 000000000..a721236ad --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/custom-server-dialog.tsx @@ -0,0 +1,248 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { McpServer } from '@linkcode/schema'; +import { Button } from 'coss-ui/components/button'; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from 'coss-ui/components/dialog'; +import { Field, FieldDescription, FieldError, FieldLabel } from 'coss-ui/components/field'; +import { Form } from 'coss-ui/components/form'; +import { Input } from 'coss-ui/components/input'; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from 'coss-ui/components/select'; +import { Textarea } from 'coss-ui/components/textarea'; +import { isObjectEmpty } from 'foxts/is-object-empty'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslations } from 'use-intl'; +import { z } from 'zod'; +import { rhfErrorsToFormErrors } from '../../lib/form'; + +const customServerDraftSchema = z + .object({ + name: z + .string() + .trim() + .regex(/^[\w-]+$/, 'name'), + transport: z.enum(['stdio', 'http']), + command: z.string().trim().optional(), + args: z.string().optional(), + env: z.string().optional(), + url: z.string().trim().optional(), + headers: z.string().optional(), + }) + .refine((draft) => draft.transport !== 'stdio' || (draft.command ?? '').length > 0, { + path: ['command'], + }) + .refine((draft) => draft.transport !== 'http' || (draft.url ?? '').length > 0, { path: ['url'] }); + +type CustomServerDraft = z.infer; + +/** Parse `KEY=VALUE` (env) / `KEY: VALUE` (headers) lines into a record; blank lines ignored. */ +function parsePairs(raw: string | undefined, separator: '=' | ':'): Record { + const pairs: Record = {}; + for (const line of (raw ?? '').split('\n')) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const at = trimmed.indexOf(separator); + if (at <= 0) continue; + pairs[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1).trim(); + } + return pairs; +} + +function draftToServer(draft: CustomServerDraft): McpServer { + if (draft.transport === 'stdio') { + const args = (draft.args ?? '').split(/\s+/).filter((part) => part.length > 0); + const env = parsePairs(draft.env, '='); + return { + type: 'stdio', + name: draft.name, + command: draft.command ?? '', + ...(args.length > 0 && { args }), + ...(!isObjectEmpty(env) && { env }), + }; + } + const headers = parsePairs(draft.headers, ':'); + return { + type: 'http', + name: draft.name, + url: draft.url ?? '', + ...(!isObjectEmpty(headers) && { headers }), + }; +} + +export interface CustomServerDialogProps { + open: boolean; + busy: boolean; + onOpenChange: (open: boolean) => void; + /** Resolves once the host acknowledges; a rejection keeps the dialog open with a root error. */ + onSubmit: (server: McpServer) => Promise; +} + +/** Add a user-imported MCP server. stdio → command/args/env; http → url/headers. */ +export function CustomServerDialog({ + open, + busy, + onOpenChange, + onSubmit, +}: CustomServerDialogProps): React.ReactNode { + const t = useTranslations('settings.plugins'); + const { + control, + register, + handleSubmit, + watch, + reset, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(customServerDraftSchema), + defaultValues: { + name: '', + transport: 'stdio', + command: '', + args: '', + env: '', + url: '', + headers: '', + }, + }); + const transport = watch('transport'); + + const submit = handleSubmit(async (draft) => { + try { + await onSubmit(draftToServer(draft)); + reset(); + onOpenChange(false); + } catch (error) { + setError('root', { message: t('customSaveError') }); + throw error; + } + }); + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + + + {t('customAddTitle')} + +
+ + {t('customForm.name')} + + {t('customForm.nameHint')} + + + ( + + {t('customForm.transport')} + + + )} + /> + {transport === 'stdio' ? ( + <> + + {t('customForm.command')} + + + + + {t('customForm.args')} + + + + {t('customForm.env')} +