From d08a71c6dc41337ca2bcf0d62ff276afa62d03a3 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 14:03:28 +0800 Subject: [PATCH 01/24] feat(schema): plugin discovery, management, and custom MCP wire contracts (CODE-487) --- .../workbench/src/mock/dev-mock-host.ts | 1 + .../src/model/__tests__/custom-mcp.test.ts | 107 ++++++++++++++++++ .../foundation/schema/src/model/custom-mcp.ts | 102 +++++++++++++++++ packages/foundation/schema/src/model/index.ts | 1 + .../foundation/schema/src/model/plugin.ts | 32 ++++++ packages/foundation/schema/src/wire/config.ts | 7 +- .../foundation/schema/src/wire/message.ts | 4 +- .../foundation/schema/src/wire/payload.ts | 2 + packages/foundation/schema/src/wire/plugin.ts | 45 ++++++++ .../foundation/schema/src/wire/session.ts | 4 + .../schema/tests/contract/wire/config.test.ts | 91 +++++++++++++++ .../schema/tests/contract/wire/plugin.test.ts | 100 ++++++++++++++++ .../host/engine/src/agent/request-handler.ts | 2 + 13 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 packages/foundation/schema/src/model/__tests__/custom-mcp.test.ts create mode 100644 packages/foundation/schema/src/model/custom-mcp.ts create mode 100644 packages/foundation/schema/src/wire/plugin.ts create mode 100644 packages/foundation/schema/tests/contract/wire/config.test.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 838e3cc98..c37446ce0 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -335,6 +335,7 @@ export class DevMockHost { replyTo: p.clientReqId, providers: this.providers, accounts: this.accounts, + customMcpServers: [], }); break; case 'agent-runtime.list': diff --git a/packages/foundation/schema/src/model/__tests__/custom-mcp.test.ts b/packages/foundation/schema/src/model/__tests__/custom-mcp.test.ts new file mode 100644 index 000000000..7ddbcb58c --- /dev/null +++ b/packages/foundation/schema/src/model/__tests__/custom-mcp.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { + CustomMcpServerPatchOpSchema, + CustomMcpServerPublicSchema, + CustomMcpServerSchema, +} from '../custom-mcp'; + +describe('CustomMcpServerSchema', () => { + it('accepts a stdio server with inline secrets', () => { + const parsed = CustomMcpServerSchema.safeParse({ + id: 'custom-1', + enabled: true, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + env: { GITHUB_TOKEN: 'secret' }, + }, + createdAt: 1, + }); + expect(parsed.success).toBe(true); + }); +}); + +describe('CustomMcpServerPublicSchema', () => { + it('carries only key lists where the full model carries values', () => { + const parsed = CustomMcpServerPublicSchema.safeParse({ + id: 'custom-1', + enabled: true, + server: { type: 'stdio', name: 'github', command: 'gh-mcp', envKeys: ['GITHUB_TOKEN'] }, + createdAt: 1, + }); + expect(parsed.success).toBe(true); + }); + + it('rejects a projection that still contains secret values', () => { + const parsed = CustomMcpServerPublicSchema.safeParse({ + id: 'custom-1', + enabled: true, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + env: { GITHUB_TOKEN: 'secret' }, + }, + createdAt: 1, + }); + expect(parsed.success).toBe(false); + }); +}); + +describe('CustomMcpServerPatchOpSchema', () => { + it('accepts an add op with a full plaintext server', () => { + const parsed = CustomMcpServerPatchOpSchema.safeParse({ + op: 'add', + server: { + id: 'custom-1', + enabled: true, + server: { type: 'http', name: 'search', url: 'https://mcp.example', headers: { A: 'b' } }, + createdAt: 1, + }, + }); + expect(parsed.success).toBe(true); + }); + + it('accepts an enabled-only update that never mentions secrets', () => { + const parsed = CustomMcpServerPatchOpSchema.safeParse({ + op: 'update', + id: 'custom-1', + enabled: false, + }); + expect(parsed.success).toBe(true); + }); + + it('expresses secret edits per key, not as whole-value replacement', () => { + const parsed = CustomMcpServerPatchOpSchema.safeParse({ + op: 'update', + id: 'custom-1', + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + env: { set: { GITHUB_TOKEN: 'rotated' }, remove: ['STALE_VAR'] }, + }, + }); + expect(parsed.success).toBe(true); + }); + + it('rejects an update whose env is a plain value map', () => { + const parsed = CustomMcpServerPatchOpSchema.safeParse({ + op: 'update', + id: 'custom-1', + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + env: { GITHUB_TOKEN: 'value' }, + }, + }); + expect(parsed.success).toBe(false); + }); + + it('rejects unknown ops', () => { + const parsed = CustomMcpServerPatchOpSchema.safeParse({ op: 'replace-all', servers: [] }); + expect(parsed.success).toBe(false); + }); +}); diff --git a/packages/foundation/schema/src/model/custom-mcp.ts b/packages/foundation/schema/src/model/custom-mcp.ts new file mode 100644 index 000000000..c356bd3ab --- /dev/null +++ b/packages/foundation/schema/src/model/custom-mcp.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +import { McpServerSchema } from './agent'; +import { TimestampSchema } from './primitives'; + +/** + * A LinkCode-owned ("bring your own") MCP server, independent of any provider plugin. It is + * persisted in the daemon config and injected into MCP-capable sessions' StartOptions at start — + * never written into a provider's own config file (codex has its own `codex mcp` store). + */ +export const CustomMcpServerSchema = z.object({ + /** Client-minted, stable across edits (mirrors the Account.id precedent). */ + id: z.string().min(1), + enabled: z.boolean(), + server: McpServerSchema, + createdAt: TimestampSchema, +}); +export type CustomMcpServer = z.infer; + +/** + * Masked read projection: env/header values may hold secrets, so only their keys leave the + * daemon. A client can show "3 env vars configured" but can never echo a value back. + */ +export const McpServerPublicSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('stdio'), + name: z.string(), + command: z.string(), + args: z.array(z.string()).optional(), + envKeys: z.array(z.string()), + }), + z.object({ + type: z.literal('http'), + name: z.string(), + url: z.string(), + headerKeys: z.array(z.string()), + }), +]); +export type McpServerPublic = z.infer; + +export const CustomMcpServerPublicSchema = z.object({ + id: z.string().min(1), + enabled: z.boolean(), + server: McpServerPublicSchema, + createdAt: TimestampSchema, +}); +export type CustomMcpServerPublic = z.infer; + +/** Per-key secret edit: values in `set` are written, keys in `remove` are deleted, every other + * stored key is preserved. This is what makes "leave blank to keep" sound when the client only + * ever holds the masked projection. Strict, so a plain `{KEY: value}` map is rejected loudly + * instead of stripping to an empty patch and silently dropping the caller's values. */ +export const McpSecretPatchSchema = z.strictObject({ + set: z.record(z.string().min(1), z.string()).optional(), + remove: z.array(z.string().min(1)).optional(), +}); +export type McpSecretPatch = z.infer; + +/** + * Update payload for one stored server. Non-secret fields replace wholesale; secrets patch + * per key. The transport `type` must match the stored server — switching transport is expressed + * as `remove` + `add`, which naturally forces secret re-entry. + */ +export const McpServerUpdateSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('stdio'), + name: z.string(), + command: z.string(), + args: z.array(z.string()).optional(), + env: McpSecretPatchSchema.optional(), + }), + z.object({ + type: z.literal('http'), + name: z.string(), + url: z.string(), + headers: McpSecretPatchSchema.optional(), + }), +]); +export type McpServerUpdate = z.infer; + +/** Patch-op writes (config.set). A full-array replace is deliberately impossible: the client + * only sees the masked projection, so resending whole servers would clobber stored secrets. */ +export const CustomMcpServerPatchOpSchema = z.discriminatedUnion('op', [ + z.object({ op: z.literal('add'), server: CustomMcpServerSchema }), + z.object({ + op: z.literal('update'), + id: z.string().min(1), + enabled: z.boolean().optional(), + server: McpServerUpdateSchema.optional(), + }), + z.object({ op: z.literal('remove'), id: z.string().min(1) }), +]); +export type CustomMcpServerPatchOp = z.infer; + +/** Session-start advisory about custom MCP injection, carried on `session.started`. */ +export const McpWarningReasonSchema = z.enum(['agent-unsupported', 'name-conflict']); +export type McpWarningReason = z.infer; + +export const McpWarningSchema = z.object({ + serverName: z.string().min(1), + reason: McpWarningReasonSchema, +}); +export type McpWarning = z.infer; diff --git a/packages/foundation/schema/src/model/index.ts b/packages/foundation/schema/src/model/index.ts index 6ad95a602..4cb71ed74 100644 --- a/packages/foundation/schema/src/model/index.ts +++ b/packages/foundation/schema/src/model/index.ts @@ -4,6 +4,7 @@ export * from './agent-runtime'; export * from './artifact'; export * from './browser'; export * from './content'; +export * from './custom-mcp'; export * from './daemon-discovery'; export * from './file'; export * from './git'; diff --git a/packages/foundation/schema/src/model/plugin.ts b/packages/foundation/schema/src/model/plugin.ts index c3ecb7003..fdc4dc4fb 100644 --- a/packages/foundation/schema/src/model/plugin.ts +++ b/packages/foundation/schema/src/model/plugin.ts @@ -158,3 +158,35 @@ export const PluginSchema = z.discriminatedUnion('provider', [ z.object({ provider: z.literal('codex'), ...pluginFields }), ]); export type Plugin = z.infer; + +export const StandaloneSkillScopeSchema = z.enum(['user', 'project']); +export type StandaloneSkillScope = z.infer; + +/** + * A skill directory that exists outside any plugin package (e.g. Claude's personal/project + * `.claude/skills/*`). Distinct from a `PluginComponent` because it has no owning plugin id, + * marketplace, or installation record. + */ +export const StandaloneSkillSchema = z.object({ + provider: PluginProviderSchema, + /** Provider-stable within one scope (claude-code: the skill directory name). */ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + scope: StandaloneSkillScopeSchema, + /** Absolute path on the daemon host. */ + path: z.string().min(1), + /** Whether the owning adapter can toggle it. Clients gate the affordance on this flag, + * never on `provider`. */ + toggleable: z.boolean(), +}); +export type StandaloneSkill = z.infer; + +/** Per-provider discovery outcome, so an empty catalog is distinguishable from a failed CLI. */ +export const PluginProviderStatusSchema = z.object({ + provider: PluginProviderSchema, + ok: z.boolean(), + /** Short human-readable failure cause; absent when `ok`. */ + reason: z.string().min(1).optional(), +}); +export type PluginProviderStatus = z.infer; diff --git a/packages/foundation/schema/src/wire/config.ts b/packages/foundation/schema/src/wire/config.ts index 845b72e4e..11421480d 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; import { AccountsSchema } from '../model/account'; +import { CustomMcpServerPatchOpSchema, CustomMcpServerPublicSchema } from '../model/custom-mcp'; 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. */ + * round-trips the whole editable config. Custom MCP servers ride the same pair but read as a + * masked projection and write as patch ops (custom-mcp.ts). */ export const configWireVariants = [ z.object({ kind: z.literal('config.get'), clientReqId: WireRequestIdSchema }), z.object({ @@ -13,6 +15,7 @@ export const configWireVariants = [ replyTo: WireRequestIdSchema, providers: ProvidersConfigSchema, accounts: AccountsSchema, + customMcpServers: z.array(CustomMcpServerPublicSchema), }), z.object({ kind: z.literal('config.set'), @@ -21,5 +24,7 @@ export const configWireVariants = [ providers: ProvidersConfigSchema.optional(), /** The global account pool; omitted by a client editing only provider settings. */ accounts: AccountsSchema.optional(), + /** Patch ops against the stored custom MCP servers; omitted when untouched. */ + customMcpServers: z.array(CustomMcpServerPatchOpSchema).optional(), }), ] as const; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index cfb1c016a..a02075d4c 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -28,7 +28,9 @@ import { WirePayloadSchema } from './payload'; // 60 adds provider/model-specific reasoning-effort metadata and Codex's distinct `ultra` level. // 61 adds the browser broker variants (CODE-267). // 62 carries project cwd on history reads so provider-local environment resolution stays coherent. -export const WIRE_PROTOCOL_VERSION = 62 as const; +// 63 adds plugin discovery/enablement (plugin.*), custom MCP servers on config.get/set, and +// session.started mcpWarnings (CODE-487). +export const WIRE_PROTOCOL_VERSION = 63 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 8c38e8b99..ed4bd794d 100644 --- a/packages/foundation/schema/src/wire/payload.ts +++ b/packages/foundation/schema/src/wire/payload.ts @@ -12,6 +12,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'; @@ -30,6 +31,7 @@ export const WirePayloadSchema = z.discriminatedUnion('kind', [ ...agentCatalogWireVariants, ...agentLoginWireVariants, ...managedAssetWireVariants, + ...pluginWireVariants, ...workspaceWireVariants, ...gitWireVariants, ...fileWireVariants, diff --git a/packages/foundation/schema/src/wire/plugin.ts b/packages/foundation/schema/src/wire/plugin.ts new file mode 100644 index 000000000..17f31de26 --- /dev/null +++ b/packages/foundation/schema/src/wire/plugin.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import { + PluginProviderSchema, + PluginProviderStatusSchema, + PluginSchema, + PluginScopeSchema, + StandaloneSkillSchema, +} from '../model/plugin'; +import { WireRequestIdSchema } from './request'; + +/** Plugin discovery + management wire variants. Discovery shells out to provider CLIs, so it is + * client-triggered (no server-side cache, no push invalidation); `cwd` scopes project-level + * marketplaces and skills. */ +export const pluginWireVariants = [ + z.object({ + kind: z.literal('plugin.list.get'), + clientReqId: WireRequestIdSchema, + cwd: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal('plugin.list.result'), + replyTo: WireRequestIdSchema, + plugins: z.array(PluginSchema), + standaloneSkills: z.array(StandaloneSkillSchema), + /** One entry per known provider, so "no plugins" and "CLI failed" stay distinguishable. */ + providerStatus: z.array(PluginProviderStatusSchema), + }), + /** Plugin-level enable/disable. No component-level variant exists: no provider implements one. */ + z.object({ + kind: z.literal('plugin.set-enabled'), + clientReqId: WireRequestIdSchema, + provider: PluginProviderSchema, + id: z.string().min(1), + enabled: z.boolean(), + scope: PluginScopeSchema.optional(), + cwd: z.string().min(1).optional(), + }), + /** Success reply carrying the full updated plugin, so clients patch one cache entry instead of + * re-running the expensive discovery. Failures use the shared request.failed reply. */ + z.object({ + kind: z.literal('plugin.updated'), + replyTo: WireRequestIdSchema, + plugin: PluginSchema, + }), +] as const; diff --git a/packages/foundation/schema/src/wire/session.ts b/packages/foundation/schema/src/wire/session.ts index b0b732c95..7e1a40a91 100644 --- a/packages/foundation/schema/src/wire/session.ts +++ b/packages/foundation/schema/src/wire/session.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { StartOptionsSchema } from '../model/agent'; +import { McpWarningSchema } from '../model/custom-mcp'; import { AgentHistoryIdSchema, AgentKindSchema, SessionIdSchema } from '../model/primitives'; import { SessionInfoSchema, @@ -25,6 +26,9 @@ export const sessionWireVariants = [ kind: z.literal('session.started'), replyTo: WireRequestIdSchema, sessionId: SessionIdSchema, + /** Custom-MCP injection advisories for this start/resume. Delivered only in this reply (the + * one client that acted); deliberately not an agent event and not replayed on attach. */ + mcpWarnings: z.array(McpWarningSchema).optional(), }), z.object({ kind: z.literal('session.stop'), diff --git a/packages/foundation/schema/tests/contract/wire/config.test.ts b/packages/foundation/schema/tests/contract/wire/config.test.ts new file mode 100644 index 000000000..7a48006b9 --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/config.test.ts @@ -0,0 +1,91 @@ +import { parseWireMessage, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +function envelope(payload: unknown) { + return { v: WIRE_PROTOCOL_VERSION, id: 'message-1', ts: 1, payload }; +} + +describe('config wire schema — custom MCP servers', () => { + it('round-trips a masked read projection on config.get.result', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'config.get.result', + replyTo: 'request-1', + providers: {}, + accounts: [], + customMcpServers: [ + { + id: 'custom-1', + enabled: true, + server: { type: 'http', name: 'search', url: 'https://mcp.example', headerKeys: [] }, + createdAt: 1, + }, + ], + }), + ); + expect(parsed.success).toBe(true); + }); + + it('rejects a read projection carrying secret values instead of key lists', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'config.get.result', + replyTo: 'request-1', + providers: {}, + accounts: [], + customMcpServers: [ + { + id: 'custom-1', + enabled: true, + server: { + type: 'http', + name: 'search', + url: 'https://mcp.example', + headers: { Authorization: 'Bearer x' }, + }, + createdAt: 1, + }, + ], + }), + ); + expect(parsed.success).toBe(false); + }); + + it('accepts patch ops on config.set and leaves them optional', () => { + expect( + parseWireMessage(envelope({ kind: 'config.set', clientReqId: 'request-1' })).success, + ).toBe(true); + const parsed = parseWireMessage( + envelope({ + kind: 'config.set', + clientReqId: 'request-1', + customMcpServers: [{ op: 'remove', id: 'custom-1' }], + }), + ); + expect(parsed.success).toBe(true); + }); + + it('carries optional mcp warnings on session.started', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'session.started', + replyTo: 'request-1', + sessionId: 'session-1', + mcpWarnings: [{ serverName: 'github', reason: 'agent-unsupported' }], + }), + ); + expect(parsed.success).toBe(true); + }); + + it('rejects an mcp warning outside the closed reason set', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'session.started', + replyTo: 'request-1', + sessionId: 'session-1', + mcpWarnings: [{ serverName: 'github', reason: 'broker-unavailable' }], + }), + ); + expect(parsed.success).toBe(false); + }); +}); 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..dd5e7a202 --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/plugin.test.ts @@ -0,0 +1,100 @@ +import { parseWireMessage, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +function envelope(payload: unknown) { + return { v: WIRE_PROTOCOL_VERSION, id: 'message-1', ts: 1, payload }; +} + +const plugin = { + provider: 'claude-code', + id: 'formatter@marketplace', + name: 'formatter', + keywords: [], + availability: 'available', + installations: [{ enabled: true, scope: 'user' }], + components: [{ kind: 'skill', name: 'render' }], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: true, + disable: true, + }, +}; + +describe('plugin wire schema', () => { + it('round-trips a discovery request with an optional cwd', () => { + expect( + parseWireMessage(envelope({ kind: 'plugin.list.get', clientReqId: 'request-1' })).success, + ).toBe(true); + expect( + parseWireMessage( + envelope({ kind: 'plugin.list.get', clientReqId: 'request-1', cwd: '/repo' }), + ).success, + ).toBe(true); + }); + + it('round-trips a discovery result with plugins, standalone skills, and provider status', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'plugin.list.result', + replyTo: 'request-1', + plugins: [plugin], + standaloneSkills: [ + { + provider: 'claude-code', + id: 'docx', + name: 'docx', + scope: 'user', + path: '/home/user/.claude/skills/docx', + toggleable: false, + }, + ], + providerStatus: [ + { provider: 'claude-code', ok: true }, + { provider: 'codex', ok: false, reason: 'binary not found' }, + ], + }), + ); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.payload.kind !== 'plugin.list.result') return; + expect(parsed.data.payload.providerStatus).toHaveLength(2); + }); + + it('accepts a plugin-level enable request with an explicit scope', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'plugin.set-enabled', + clientReqId: 'request-1', + provider: 'claude-code', + id: 'formatter@marketplace', + enabled: false, + scope: 'user', + }), + ); + expect(parsed.success).toBe(true); + }); + + it('rejects an enable request for an unknown provider', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'plugin.set-enabled', + clientReqId: 'request-1', + provider: 'opencode', + id: 'formatter@marketplace', + enabled: true, + }), + ); + expect(parsed.success).toBe(false); + }); + + it('round-trips the updated reply carrying the full plugin', () => { + const parsed = parseWireMessage( + envelope({ kind: 'plugin.updated', replyTo: 'request-1', plugin }), + ); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.payload.kind !== 'plugin.updated') return; + expect(parsed.data.payload.plugin.id).toBe('formatter@marketplace'); + }); +}); diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index b62f37afd..7f27109da 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -98,6 +98,8 @@ export class AgentRequestHandler { replyTo: payload.clientReqId, providers: this.providers.get(), accounts: this.providers.getAccounts(), + // Real masked projection lands with the custom-MCP config plane (CODE-490). + customMcpServers: [], }), ), catch: (cause) => From 5b0a24ca7dd2f01ec1d2c862ac27da3a1e9fbf47 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 14:15:44 +0800 Subject: [PATCH 02/24] feat(agent-adapter): discover standalone skills alongside plugin catalogs (CODE-488) --- .../src/__tests__/claude-code-plugins.test.ts | 53 +++++++++ .../src/__tests__/codex-plugins.test.ts | 77 +++++++++++++ .../host/agent-adapter/src/plugins/adapter.ts | 7 +- .../agent-adapter/src/plugins/claude-code.ts | 90 ++++++++++++++- .../host/agent-adapter/src/plugins/codex.ts | 105 +++++++++++++++--- .../src/__tests__/plugin-service.test.ts | 4 + 6 files changed, 314 insertions(+), 22 deletions(-) diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts index c317775c8..208cdf3d1 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts @@ -134,4 +134,57 @@ describe('ClaudeCodePluginAdapter', () => { await expect(new ClaudeCodePluginAdapter(command).list()).rejects.toThrow(); }); + + it('discovers standalone skills from the user and project skill directories', async () => { + const userRoot = await mkdtemp(join(tmpdir(), 'linkcode-claude-skills-user-')); + const projectRoot = await mkdtemp(join(tmpdir(), 'linkcode-claude-skills-project-')); + tempRoots.push(userRoot, projectRoot); + const projectSkills = join(projectRoot, '.claude', 'skills'); + await Promise.all([ + mkdir(join(userRoot, 'docx'), { recursive: true }), + mkdir(join(userRoot, 'no-manifest'), { recursive: true }), + mkdir(join(projectSkills, 'deploy'), { recursive: true }), + ]); + await Promise.all([ + writeFile( + join(userRoot, 'docx', 'SKILL.md'), + '---\nname: "Word documents"\ndescription: \'Create .docx files\'\nlicense: MIT\n---\nBody', + ), + writeFile(join(userRoot, 'stray-file.md'), 'not a skill directory'), + writeFile(join(projectSkills, 'deploy', 'SKILL.md'), '---\ndescription: Ship it\n---'), + ]); + const command: ClaudePluginCommand = () => Promise.reject(new Error('not used')); + + const skills = await new ClaudeCodePluginAdapter(command, userRoot).listStandaloneSkills({ + cwd: projectRoot, + }); + + expect(skills).toEqual([ + { + provider: 'claude-code', + id: 'docx', + name: 'Word documents', + description: 'Create .docx files', + scope: 'user', + path: join(userRoot, 'docx'), + toggleable: false, + }, + { + provider: 'claude-code', + id: 'deploy', + name: 'deploy', + description: 'Ship it', + scope: 'project', + path: join(projectSkills, 'deploy'), + toggleable: false, + }, + ]); + }); + + it('returns no standalone skills when the skill roots do not exist', async () => { + const command: ClaudePluginCommand = () => Promise.reject(new Error('not used')); + const adapter = new ClaudeCodePluginAdapter(command, '/nonexistent/claude/skills'); + + await expect(adapter.listStandaloneSkills()).resolves.toEqual([]); + }); }); diff --git a/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts b/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts index 62c537fb7..b30593b61 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts @@ -211,4 +211,81 @@ describe('CodexPluginAdapter', () => { await rejection; }); + + it('lists bare-named skills as standalone and filters plugin-qualified ones', async () => { + const close = vi.fn(); + const request = vi.fn((method: string, params: unknown) => { + expect(method).toBe('skills/list'); + expect(params).toEqual({ cwds: ['/workspace'], forceReload: false }); + return Promise.resolve({ + data: [ + { + cwd: '/workspace', + skills: [ + { + name: 'linear', + description: 'Linear workflow', + path: '/workspace/.agents/skills/linear/SKILL.md', + scope: 'repo', + enabled: true, + }, + { + name: 'browser:control-in-app-browser', + description: 'Bundled plugin skill', + path: '/home/user/.codex/plugins/cache/browser/skills/control/SKILL.md', + scope: 'user', + enabled: true, + }, + { + name: 'better-skill-creator', + description: '', + path: '/home/user/skills/better-skill-creator/SKILL.md', + scope: 'user', + enabled: true, + }, + { malformed: true }, + ], + }, + { + skills: [ + { + name: 'better-skill-creator', + description: 'duplicate path entry', + path: '/home/user/skills/better-skill-creator/SKILL.md', + scope: 'user', + enabled: true, + }, + ], + }, + ], + }); + }); + const server: CodexPluginServer = { request, close }; + + const skills = await new CodexPluginAdapter(() => Promise.resolve(server)).listStandaloneSkills( + { cwd: '/workspace' }, + ); + + expect(skills).toEqual([ + { + provider: 'codex', + id: 'better-skill-creator', + name: 'better-skill-creator', + description: undefined, + scope: 'user', + path: '/home/user/skills/better-skill-creator/SKILL.md', + toggleable: false, + }, + { + provider: 'codex', + id: 'linear', + name: 'linear', + description: 'Linear workflow', + scope: 'project', + path: '/workspace/.agents/skills/linear/SKILL.md', + toggleable: false, + }, + ]); + expect(close).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/host/agent-adapter/src/plugins/adapter.ts b/packages/host/agent-adapter/src/plugins/adapter.ts index 70f8b5396..cddf1499c 100644 --- a/packages/host/agent-adapter/src/plugins/adapter.ts +++ b/packages/host/agent-adapter/src/plugins/adapter.ts @@ -1,14 +1,17 @@ -import type { Plugin, PluginProvider } from '@linkcode/schema'; +import type { Plugin, PluginProvider, StandaloneSkill } from '@linkcode/schema'; export interface PluginDiscoveryOptions { /** Project root used by providers that expose repository-scoped marketplaces. */ cwd?: string; } -/** Read-only provider boundary for discovering native plugin catalogs. */ +/** Provider boundary for discovering native plugin catalogs and standalone skills. */ export interface PluginProviderAdapter { readonly provider: PluginProvider; list(opts?: PluginDiscoveryOptions): Promise; + /** Skills living outside any plugin package (e.g. `~/.claude/skills/*`). Plugin-bundled + * skills stay on `Plugin.components` and never appear here. */ + listStandaloneSkills(opts?: PluginDiscoveryOptions): Promise; } export type PluginProviderAdapterFactory = (provider: PluginProvider) => PluginProviderAdapter; diff --git a/packages/host/agent-adapter/src/plugins/claude-code.ts b/packages/host/agent-adapter/src/plugins/claude-code.ts index 5fc09938d..4f625cd57 100644 --- a/packages/host/agent-adapter/src/plugins/claude-code.ts +++ b/packages/host/agent-adapter/src/plugins/claude-code.ts @@ -1,8 +1,9 @@ import { execFile } from 'node:child_process'; import { readdir, readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; import { promisify } from 'node:util'; -import type { Plugin, PluginComponent, PluginSource } from '@linkcode/schema'; +import type { Plugin, PluginComponent, PluginSource, StandaloneSkill } from '@linkcode/schema'; import { PluginSchema } from '@linkcode/schema'; import { z } from 'zod'; import { agentRuntimeProber, ClaudeCodeProbe } from '../probe'; @@ -92,7 +93,10 @@ const CLAUDE_MANAGEMENT_CAPABILITIES = { export class ClaudeCodePluginAdapter implements PluginProviderAdapter { readonly provider = 'claude-code' as const; - constructor(private readonly command: ClaudePluginCommand = runClaudePluginCommand) {} + constructor( + private readonly command: ClaudePluginCommand = runClaudePluginCommand, + private readonly userSkillsDir: string = join(homedir(), '.claude', 'skills'), + ) {} async list(opts: PluginDiscoveryOptions = {}): Promise { const [pluginsValue, marketplacesValue] = await Promise.all([ @@ -103,6 +107,88 @@ export class ClaudeCodePluginAdapter implements PluginProviderAdapter { const marketplaces = z.array(ClaudeMarketplaceSchema).parse(marketplacesValue); return normalizeClaudePlugins(catalog, marketplaces); } + + /** Personal/project Agent Skills are bare `SKILL.md` directories that `claude plugin list` + * never surfaces (verified on CLI 2.1.220) — a plain filesystem read is the only source. + * No CLI toggle exists for them, so `toggleable` is always false. */ + async listStandaloneSkills(opts: PluginDiscoveryOptions = {}): Promise { + const roots: { dir: string; scope: StandaloneSkill['scope'] }[] = [ + { dir: this.userSkillsDir, scope: 'user' }, + ]; + if (opts.cwd) roots.push({ dir: join(opts.cwd, '.claude', 'skills'), scope: 'project' }); + const groups = await Promise.all( + roots.map(({ dir, scope }) => readClaudeSkillsDirectory(dir, scope)), + ); + return groups.flat(); + } +} + +async function readClaudeSkillsDirectory( + dir: string, + scope: StandaloneSkill['scope'], +): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const reads: Array> = []; + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + reads.push(readClaudeSkill(dir, entry.name, scope)); + } + const skills = await Promise.all(reads); + return skills + .filter((skill) => skill !== undefined) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +async function readClaudeSkill( + dir: string, + id: string, + scope: StandaloneSkill['scope'], +): Promise { + const path = join(dir, id); + let frontmatter; + try { + frontmatter = parseSkillFrontmatter(await readFile(join(path, 'SKILL.md'), 'utf8')); + } catch { + return undefined; + } + return { + provider: 'claude-code', + id, + name: frontmatter.name ?? id, + description: frontmatter.description, + scope, + path, + toggleable: false, + }; +} + +const RE_SKILL_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/; +const RE_LINE_BREAK = /\r?\n/; + +/** SKILL.md frontmatter is a flat two-key block (name/description, optionally quoted) — a full + * YAML parser is unwarranted and would be a new dependency. */ +function parseSkillFrontmatter(content: string): { name?: string; description?: string } { + const match = RE_SKILL_FRONTMATTER.exec(content); + if (!match) return {}; + const fields: { name?: string; description?: string } = {}; + for (const line of match[1].split(RE_LINE_BREAK)) { + const separator = line.indexOf(':'); + if (separator <= 0) continue; + const key = line.slice(0, separator).trim(); + if (key !== 'name' && key !== 'description') continue; + const raw = line.slice(separator + 1).trim(); + const unquoted = + (raw[0] === '"' && raw.endsWith('"')) || (raw[0] === "'" && raw.endsWith("'")) + ? raw.slice(1, -1) + : raw; + if (unquoted) fields[key] = unquoted; + } + return fields; } async function runClaudePluginCommand( diff --git a/packages/host/agent-adapter/src/plugins/codex.ts b/packages/host/agent-adapter/src/plugins/codex.ts index 4d5ad5b29..bbe81e62f 100644 --- a/packages/host/agent-adapter/src/plugins/codex.ts +++ b/packages/host/agent-adapter/src/plugins/codex.ts @@ -1,4 +1,10 @@ -import type { Plugin, PluginComponent, PluginLinks, PluginSource } from '@linkcode/schema'; +import type { + Plugin, + PluginComponent, + PluginLinks, + PluginSource, + StandaloneSkill, +} from '@linkcode/schema'; import { PluginSchema } from '@linkcode/schema'; import { never } from 'foxts/guard'; import { noop } from 'foxts/noop'; @@ -101,6 +107,25 @@ const CodexPluginDetailSchema = z.object({ const CodexPluginReadSchema = z.object({ plugin: CodexPluginDetailSchema }); +/** `skills/list` entry shape observed live on 0.144.1; tolerant per-entry so one malformed + * skill never hides the rest. */ +const CodexSkillEntrySchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + path: z.string().min(1), + scope: z.enum(['repo', 'user']), + enabled: z.boolean(), +}); + +const CodexSkillListSchema = z.object({ + data: z.array( + z.object({ + cwd: z.string().optional(), + skills: z.array(z.unknown()), + }), + ), +}); + type CodexMarketplace = z.infer; type CodexPluginSummary = z.infer; type CodexPluginDetail = z.infer; @@ -123,22 +148,8 @@ export class CodexPluginAdapter implements PluginProviderAdapter { constructor(private readonly startServer: StartCodexPluginServer = startCodexPluginServer) {} async list(opts: PluginDiscoveryOptions = {}): Promise { - const controller = new AbortController(); - let server: CodexPluginServer | undefined; - let closed = false; - const closeOnce = (): void => { - if (closed || !server) return; - closed = true; - server.close(); - }; - const timeout = setTimeout(() => { - controller.abort(new Error('codex: plugin discovery timed out')); - closeOnce(); - }, DISCOVERY_TIMEOUT_MS); - try { - const activeServer = await this.startServer(controller.signal); - server = activeServer; - const value = await activeServer.request('plugin/list', { + return this.withDiscoveryServer(async (server) => { + const value = await server.request('plugin/list', { cwds: opts.cwd ? [opts.cwd] : undefined, }); const catalog = CodexPluginListSchema.parse(value); @@ -150,11 +161,45 @@ export class CodexPluginAdapter implements PluginProviderAdapter { normalizeCodexPlugin( marketplace, summary, - await readCodexPluginDetail(activeServer, marketplace, summary), + await readCodexPluginDetail(server, marketplace, summary), ), ), ); return plugins.sort((left, right) => left.id.localeCompare(right.id)); + }); + } + + /** `skills/list` works pre-thread on a discovery-only app-server, accepts omitted `cwds`, and + * tags every entry with an explicit `scope` (`repo`/`user`) — all verified live on 0.144.1. + * Plugin-bundled skills arrive in the same response under `plugin:skill` qualified names, so + * a bare (colon-free) name is what marks a skill standalone. */ + async listStandaloneSkills(opts: PluginDiscoveryOptions = {}): Promise { + return this.withDiscoveryServer(async (server) => { + const value = await server.request('skills/list', { + cwds: opts.cwd ? [opts.cwd] : undefined, + forceReload: false, + }); + return normalizeCodexStandaloneSkills(value); + }); + } + + private async withDiscoveryServer(run: (server: CodexPluginServer) => Promise): Promise { + const controller = new AbortController(); + let server: CodexPluginServer | undefined; + let closed = false; + const closeOnce = (): void => { + if (closed || !server) return; + closed = true; + server.close(); + }; + const timeout = setTimeout(() => { + controller.abort(new Error('codex: plugin discovery timed out')); + closeOnce(); + }, DISCOVERY_TIMEOUT_MS); + try { + const activeServer = await this.startServer(controller.signal); + server = activeServer; + return await run(activeServer); } finally { clearTimeout(timeout); closeOnce(); @@ -183,6 +228,30 @@ async function readCodexPluginDetail( } } +function normalizeCodexStandaloneSkills(value: unknown): StandaloneSkill[] { + const parsed = CodexSkillListSchema.parse(value); + const skills = new Map(); + for (const group of parsed.data) { + for (const raw of group.skills) { + const entry = CodexSkillEntrySchema.safeParse(raw); + if (!entry.success) continue; + const { name, description, path, scope } = entry.data; + // Plugin-qualified names (`plugin:skill`) belong to Plugin.components, not this list. + if (name.includes(':') || skills.has(path)) continue; + skills.set(path, { + provider: 'codex', + id: name, + name, + description: description || undefined, + scope: scope === 'repo' ? 'project' : 'user', + path, + toggleable: false, + }); + } + } + return [...skills.values()].sort((left, right) => left.id.localeCompare(right.id)); +} + async function startCodexPluginServer(signal: AbortSignal): Promise { const binaryPath = agentRuntimeProber.resolveBinary('codex') ?? resolveCodexBinaryPath(); return CodexAppServer.start({ diff --git a/packages/host/engine/src/__tests__/plugin-service.test.ts b/packages/host/engine/src/__tests__/plugin-service.test.ts index 5021b13f6..2e8cd4728 100644 --- a/packages/host/engine/src/__tests__/plugin-service.test.ts +++ b/packages/host/engine/src/__tests__/plugin-service.test.ts @@ -36,6 +36,7 @@ describe('PluginService', () => { const options = new Map(); const factory: PluginProviderAdapterFactory = (provider) => ({ provider, + listStandaloneSkills: () => Promise.resolve([]), list(opts) { options.set(provider, opts); @@ -61,6 +62,7 @@ describe('PluginService', () => { it('keeps healthy provider results when another provider fails', async () => { const factory: PluginProviderAdapterFactory = (provider) => ({ provider, + listStandaloneSkills: () => Promise.resolve([]), list: () => provider === 'claude-code' ? Promise.reject(new Error('native discovery failed')) @@ -75,6 +77,7 @@ describe('PluginService', () => { it('returns an empty catalog when every provider fails', async () => { const factory: PluginProviderAdapterFactory = (provider) => ({ provider, + listStandaloneSkills: () => Promise.resolve([]), list: () => Promise.reject(new Error(`${provider} discovery failed`)), }); @@ -86,6 +89,7 @@ describe('PluginService', () => { it('exposes the injected provider aggregation through the Engine runtime', async () => { const factory: PluginProviderAdapterFactory = (provider) => ({ provider, + listStandaloneSkills: () => Promise.resolve([]), list: () => Promise.resolve([plugin(provider, 'runtime')]), }); const transport: Transport = { From 470be4eb78dda6db943e525f1b77f64969135f2f Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 14:24:11 +0800 Subject: [PATCH 03/24] fix(agent-adapter): report real claude plugin management capabilities and add enable/disable (CODE-492) --- .../src/__tests__/claude-code-plugins.test.ts | 32 +++++++++++++- .../src/__tests__/codex-plugins.test.ts | 9 ++++ .../host/agent-adapter/src/plugins/adapter.ts | 11 ++++- .../agent-adapter/src/plugins/claude-code.ts | 44 ++++++++++++++++--- 4 files changed, 87 insertions(+), 9 deletions(-) diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts index 208cdf3d1..801415e17 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { asyncNoop } from 'foxts/noop'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ClaudePluginCommand } from '../plugins/claude-code'; import { ClaudeCodePluginAdapter } from '../plugins/claude-code'; @@ -118,8 +119,8 @@ describe('ClaudeCodePluginAdapter', () => { install: false, uninstall: false, update: false, - enable: false, - disable: false, + enable: true, + disable: true, }, }), ]); @@ -187,4 +188,31 @@ describe('ClaudeCodePluginAdapter', () => { await expect(adapter.listStandaloneSkills()).resolves.toEqual([]); }); + + it('toggles a plugin with an explicit CLI scope and omits untoggleable scopes', async () => { + const command: ClaudePluginCommand = () => Promise.reject(new Error('not used')); + const action = vi.fn(asyncNoop); + const adapter = new ClaudeCodePluginAdapter(command, '/nonexistent', action); + + await adapter.setPluginEnabled('latex@team-tools', true, { + scope: 'project', + cwd: '/workspace/demo', + }); + await adapter.setPluginEnabled('latex@team-tools', false, { scope: 'managed' }); + await adapter.setPluginEnabled('latex@team-tools', false); + + expect(action.mock.calls).toEqual([ + [['plugin', 'enable', 'latex@team-tools', '-s', 'project'], { cwd: '/workspace/demo' }], + [['plugin', 'disable', 'latex@team-tools'], { cwd: undefined }], + [['plugin', 'disable', 'latex@team-tools'], { cwd: undefined }], + ]); + }); + + it('propagates a toggle failure from the CLI', async () => { + const command: ClaudePluginCommand = () => Promise.reject(new Error('not used')); + const action = vi.fn(() => Promise.reject(new Error('exit 1'))); + const adapter = new ClaudeCodePluginAdapter(command, '/nonexistent', action); + + await expect(adapter.setPluginEnabled('latex@team-tools', true)).rejects.toThrow('exit 1'); + }); }); diff --git a/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts b/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts index b30593b61..dc8644054 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts @@ -1,5 +1,6 @@ import { noop } from 'foxts/noop'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { PluginProviderAdapter } from '../plugins/adapter'; import type { CodexPluginServer, StartCodexPluginServer } from '../plugins/codex'; import { CodexPluginAdapter } from '../plugins/codex'; @@ -288,4 +289,12 @@ describe('CodexPluginAdapter', () => { ]); expect(close).toHaveBeenCalledOnce(); }); + + it('honestly reports no plugin toggle support', () => { + const adapter: PluginProviderAdapter = new CodexPluginAdapter(() => + Promise.reject(new Error('not used')), + ); + + expect('setPluginEnabled' in adapter).toBe(false); + }); }); diff --git a/packages/host/agent-adapter/src/plugins/adapter.ts b/packages/host/agent-adapter/src/plugins/adapter.ts index cddf1499c..de98aeca5 100644 --- a/packages/host/agent-adapter/src/plugins/adapter.ts +++ b/packages/host/agent-adapter/src/plugins/adapter.ts @@ -1,10 +1,16 @@ -import type { Plugin, PluginProvider, StandaloneSkill } from '@linkcode/schema'; +import type { Plugin, PluginProvider, PluginScope, StandaloneSkill } from '@linkcode/schema'; export interface PluginDiscoveryOptions { /** Project root used by providers that expose repository-scoped marketplaces. */ cwd?: string; } +export interface PluginToggleOptions extends PluginDiscoveryOptions { + /** The install record being toggled. Passed to the provider explicitly whenever the provider + * supports it — auto-detection is never relied on for multi-scope installs. */ + scope?: PluginScope; +} + /** Provider boundary for discovering native plugin catalogs and standalone skills. */ export interface PluginProviderAdapter { readonly provider: PluginProvider; @@ -12,6 +18,9 @@ export interface PluginProviderAdapter { /** Skills living outside any plugin package (e.g. `~/.claude/skills/*`). Plugin-bundled * skills stay on `Plugin.components` and never appear here. */ listStandaloneSkills(opts?: PluginDiscoveryOptions): Promise; + /** Plugin-level enable/disable. Left undefined by providers with no native toggle (codex) — + * callers gate on presence, and the reported `managementCapabilities` must agree. */ + setPluginEnabled?(id: string, enabled: boolean, opts?: PluginToggleOptions): Promise; } export type PluginProviderAdapterFactory = (provider: PluginProvider) => PluginProviderAdapter; diff --git a/packages/host/agent-adapter/src/plugins/claude-code.ts b/packages/host/agent-adapter/src/plugins/claude-code.ts index 4f625cd57..8a1eb91a9 100644 --- a/packages/host/agent-adapter/src/plugins/claude-code.ts +++ b/packages/host/agent-adapter/src/plugins/claude-code.ts @@ -7,7 +7,7 @@ import type { Plugin, PluginComponent, PluginSource, StandaloneSkill } from '@li import { PluginSchema } from '@linkcode/schema'; import { z } from 'zod'; import { agentRuntimeProber, ClaudeCodeProbe } from '../probe'; -import type { PluginDiscoveryOptions, PluginProviderAdapter } from './adapter'; +import type { PluginDiscoveryOptions, PluginProviderAdapter, PluginToggleOptions } from './adapter'; const execFileAsync = promisify(execFile); const GIT_SOURCE_RE = /^(?:https?:\/\/|git@)/; @@ -66,6 +66,10 @@ export type ClaudePluginCommand = ( opts: PluginDiscoveryOptions, ) => Promise; +/** Runner for `claude plugin` subcommands with human-text output (no `--json` exists on + * enable/disable) — success is exit 0, stdout is discarded. */ +export type ClaudePluginAction = (args: string[], opts: PluginDiscoveryOptions) => Promise; + interface ClaudePluginRecord { id: string; available?: ClaudeAvailablePlugin; @@ -77,14 +81,19 @@ interface ClaudePackageMetadata { components: PluginComponent[]; } +/** enable/disable verified live on CLI 2.1.220 (`claude plugin enable|disable [-s scope]`); + * install/uninstall/update stay unimplemented by this adapter, not unsupported by the CLI. */ const CLAUDE_MANAGEMENT_CAPABILITIES = { install: false, uninstall: false, update: false, - enable: false, - disable: false, + enable: true, + disable: true, } as const; +/** `-s` values the CLI accepts (2.1.220); `managed` installs have no toggle flag. */ +const CLAUDE_TOGGLE_SCOPES = new Set(['user', 'project', 'local']); + /** * Claude's verified machine-readable plugin surface (CLI 2.1.212): `plugin list --available * --json` returns installed/available records and `plugin marketplace list --json` supplies their @@ -96,8 +105,24 @@ export class ClaudeCodePluginAdapter implements PluginProviderAdapter { constructor( private readonly command: ClaudePluginCommand = runClaudePluginCommand, private readonly userSkillsDir: string = join(homedir(), '.claude', 'skills'), + private readonly action: ClaudePluginAction = runClaudePluginAction, ) {} + /** + * Trap verified live on 2.1.220: enable/disable exit 0 even for a plugin that does not exist + * (the CLI blind-writes `enabledPlugins`), so exit code alone never proves the toggle landed on + * a real install — the engine re-lists after this call and that readback is the success check. + */ + async setPluginEnabled( + id: string, + enabled: boolean, + opts: PluginToggleOptions = {}, + ): Promise { + const args = ['plugin', enabled ? 'enable' : 'disable', id]; + if (opts.scope && CLAUDE_TOGGLE_SCOPES.has(opts.scope)) args.push('-s', opts.scope); + await this.action(args, { cwd: opts.cwd }); + } + async list(opts: PluginDiscoveryOptions = {}): Promise { const [pluginsValue, marketplacesValue] = await Promise.all([ this.command(['plugin', 'list', '--available', '--json'], opts), @@ -195,18 +220,25 @@ async function runClaudePluginCommand( args: string[], opts: PluginDiscoveryOptions, ): Promise { + const { stdout } = await execClaudeCli(args, opts); + return JSON.parse(stdout) as unknown; +} + +async function runClaudePluginAction(args: string[], opts: PluginDiscoveryOptions): Promise { + await execClaudeCli(args, opts); +} + +function execClaudeCli(args: string[], opts: PluginDiscoveryOptions) { const binaryPath = agentRuntimeProber.resolveBinary('claude-code') ?? new ClaudeCodeProbe().sdkPlatformBinaryPath(); if (!binaryPath) throw new Error('claude-code: CLI is not available'); - const { stdout } = await execFileAsync(binaryPath, args, { + return execFileAsync(binaryPath, args, { cwd: opts.cwd, maxBuffer: 10 * 1024 * 1024, timeout: 30000, windowsHide: true, }); - - return JSON.parse(stdout) as unknown; } async function normalizeClaudePlugins( From db0951a7537646b8ef7369d9bd0124e7b3e0eee7 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 15:00:28 +0800 Subject: [PATCH 04/24] feat(engine): serve plugin discovery and enablement over the wire (CODE-493) --- .../host/agent-adapter/src/plugins/index.ts | 1 + .../src/__tests__/plugin-service.test.ts | 126 ++++++++++++--- packages/host/engine/src/engine.ts | 8 +- packages/host/engine/src/failure.ts | 1 + .../host/engine/src/plugin/request-handler.ts | 65 ++++++++ packages/host/engine/src/plugin/service.ts | 146 ++++++++++++++++-- packages/host/engine/src/service.ts | 5 +- .../host/engine/src/wire/request-router.ts | 6 + 8 files changed, 316 insertions(+), 42 deletions(-) create mode 100644 packages/host/engine/src/plugin/request-handler.ts diff --git a/packages/host/agent-adapter/src/plugins/index.ts b/packages/host/agent-adapter/src/plugins/index.ts index 994715263..6016f29df 100644 --- a/packages/host/agent-adapter/src/plugins/index.ts +++ b/packages/host/agent-adapter/src/plugins/index.ts @@ -8,6 +8,7 @@ export type { PluginDiscoveryOptions, PluginProviderAdapter, PluginProviderAdapterFactory, + PluginToggleOptions, } from './adapter'; export { ClaudeCodePluginAdapter } from './claude-code'; export { CodexPluginAdapter } from './codex'; diff --git a/packages/host/engine/src/__tests__/plugin-service.test.ts b/packages/host/engine/src/__tests__/plugin-service.test.ts index 2e8cd4728..6041d930f 100644 --- a/packages/host/engine/src/__tests__/plugin-service.test.ts +++ b/packages/host/engine/src/__tests__/plugin-service.test.ts @@ -1,15 +1,15 @@ import type { PluginDiscoveryOptions, PluginProviderAdapterFactory } from '@linkcode/agent-adapter'; -import type { PluginProvider } from '@linkcode/schema'; +import type { PluginProvider, StandaloneSkill } from '@linkcode/schema'; import { PluginSchema } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { Effect } from 'effect'; -import { noop } from 'foxts/noop'; -import { describe, expect, it } from 'vitest'; +import { asyncNoop, noop } from 'foxts/noop'; +import { describe, expect, it, vi } from 'vitest'; import { createEngineRuntime } from '../engine'; import { PluginService } from '../plugin/service'; -function plugin(provider: PluginProvider, id: string) { +function plugin(provider: PluginProvider, id: string, enabled = true) { return PluginSchema.parse({ id, name: id, @@ -18,25 +18,35 @@ function plugin(provider: PluginProvider, id: string) { keywords: [], source: { type: 'local', path: `/plugins/${id}` }, availability: 'available', - installations: [], + installations: [{ enabled, scope: 'user' }], components: [], assets: [], managementCapabilities: { install: false, uninstall: false, update: false, - enable: false, - disable: false, + enable: provider === 'claude-code', + disable: provider === 'claude-code', }, }); } +function skill(provider: PluginProvider, id: string): StandaloneSkill { + return { + provider, + id, + name: id, + scope: 'user', + path: `/skills/${id}`, + toggleable: false, + }; +} + describe('PluginService', () => { - it('aggregates provider catalogs in stable provider and id order', async () => { + it('aggregates catalogs and standalone skills in stable provider and id order', async () => { const options = new Map(); const factory: PluginProviderAdapterFactory = (provider) => ({ provider, - listStandaloneSkills: () => Promise.resolve([]), list(opts) { options.set(provider, opts); @@ -46,51 +56,123 @@ describe('PluginService', () => { : [plugin(provider, 'beta')], ); }, + listStandaloneSkills: () => + Promise.resolve(provider === 'codex' ? [skill(provider, 'linear')] : []), }); - const plugins = await Effect.runPromise(new PluginService(factory).list({ cwd: '/workspace' })); + const result = await Effect.runPromise(new PluginService(factory).list({ cwd: '/workspace' })); - expect(plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual([ + expect(result.plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual([ 'claude-code:alpha', 'claude-code:zeta', 'codex:beta', ]); + expect(result.standaloneSkills).toEqual([skill('codex', 'linear')]); + expect(result.providerStatus).toEqual([ + { provider: 'claude-code', ok: true }, + { provider: 'codex', ok: true }, + ]); expect(options.get('claude-code')).toEqual({ cwd: '/workspace' }); expect(options.get('codex')).toEqual({ cwd: '/workspace' }); }); - it('keeps healthy provider results when another provider fails', async () => { + it('reports a failed provider without hiding healthy results', async () => { const factory: PluginProviderAdapterFactory = (provider) => ({ provider, - listStandaloneSkills: () => Promise.resolve([]), list: () => provider === 'claude-code' ? Promise.reject(new Error('native discovery failed')) : Promise.resolve([plugin(provider, 'working')]), + listStandaloneSkills: () => Promise.resolve([]), }); - const plugins = await Effect.runPromise(new PluginService(factory).list()); + const result = await Effect.runPromise(new PluginService(factory).list()); - expect(plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual(['codex:working']); + expect(result.plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual([ + 'codex:working', + ]); + expect(result.providerStatus).toEqual([ + { provider: 'claude-code', ok: false, reason: 'Failed to discover claude-code plugins' }, + { provider: 'codex', ok: true }, + ]); }); - it('returns an empty catalog when every provider fails', async () => { + it('returns an empty catalog with per-provider failures when every provider fails', async () => { const factory: PluginProviderAdapterFactory = (provider) => ({ provider, - listStandaloneSkills: () => Promise.resolve([]), list: () => Promise.reject(new Error(`${provider} discovery failed`)), + listStandaloneSkills: () => Promise.reject(new Error(`${provider} skills failed`)), }); - const plugins = await Effect.runPromise(new PluginService(factory).list()); + const result = await Effect.runPromise(new PluginService(factory).list()); - expect(plugins).toEqual([]); + expect(result.plugins).toEqual([]); + expect(result.standaloneSkills).toEqual([]); + expect(result.providerStatus.every((status) => !status.ok)).toBe(true); }); - it('exposes the injected provider aggregation through the Engine runtime', async () => { + it('toggles through the adapter and returns the re-listed plugin', async () => { + const setPluginEnabled = vi.fn(asyncNoop); const factory: PluginProviderAdapterFactory = (provider) => ({ provider, + list: () => Promise.resolve([plugin(provider, 'latex@team-tools', false)]), listStandaloneSkills: () => Promise.resolve([]), + ...(provider === 'claude-code' && { setPluginEnabled }), + }); + + const updated = await Effect.runPromise( + new PluginService(factory).setPluginEnabled('claude-code', 'latex@team-tools', false, { + scope: 'user', + cwd: '/workspace', + }), + ); + + expect(setPluginEnabled).toHaveBeenCalledWith('latex@team-tools', false, { + scope: 'user', + cwd: '/workspace', + }); + expect(updated.id).toBe('latex@team-tools'); + }); + + it('fails with unsupported before touching an adapter without a toggle', async () => { + const list = vi.fn(); + const factory: PluginProviderAdapterFactory = (provider) => ({ + provider, + list, + listStandaloneSkills: () => Promise.resolve([]), + }); + + const outcome = await Effect.runPromise( + Effect.flip(new PluginService(factory).setPluginEnabled('codex', 'any', true)), + ); + + expect(outcome).toMatchObject({ _tag: 'RequestError', code: 'unsupported' }); + expect(list).not.toHaveBeenCalled(); + }); + + it('fails with not_found when the toggled plugin never shows up in the readback', async () => { + // claude's enable/disable exits 0 even for unknown plugins — the readback is the real check. + const factory: PluginProviderAdapterFactory = (provider) => ({ + provider, + list: () => Promise.resolve([]), + listStandaloneSkills: () => Promise.resolve([]), + setPluginEnabled: asyncNoop, + }); + + const outcome = await Effect.runPromise( + Effect.flip( + new PluginService(factory).setPluginEnabled('claude-code', 'ghost@nowhere', true), + ), + ); + + expect(outcome).toMatchObject({ _tag: 'RequestError', code: 'not_found' }); + }); + + it('exposes the injected provider aggregation through the Engine runtime', async () => { + const factory: PluginProviderAdapterFactory = (provider) => ({ + provider, list: () => Promise.resolve([plugin(provider, 'runtime')]), + listStandaloneSkills: () => Promise.resolve([]), }); const transport: Transport = { connect: () => Promise.resolve(), @@ -100,7 +182,7 @@ describe('PluginService', () => { close: noop, }; - const plugins = await Effect.runPromise( + const result = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const engine = yield* createEngineRuntime(transport, { pluginFactory: factory }); @@ -109,7 +191,7 @@ describe('PluginService', () => { ), ); - expect(plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual([ + expect(result.plugins.map(({ provider, id }) => `${provider}:${id}`)).toEqual([ 'claude-code:runtime', 'codex:runtime', ]); diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 92886b58d..dbd32e6eb 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -1,6 +1,6 @@ import type { PluginDiscoveryOptions } from '@linkcode/agent-adapter'; import { createAdapter, createPluginProviderAdapter } from '@linkcode/agent-adapter'; -import type { Plugin, WorkspaceRecord } from '@linkcode/schema'; +import type { WorkspaceRecord } from '@linkcode/schema'; import type { Transport, Unsubscribe } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import type { Scope } from 'effect'; @@ -25,6 +25,8 @@ import type { EngineFailure, OperationSubsystem } from './failure'; import { toOperationFailure } from './failure'; import { GitService } from './git/git-service'; import { GitRequestHandler } from './git/request-handler'; +import { PluginRequestHandler } from './plugin/request-handler'; +import type { PluginDiscoveryResult } from './plugin/service'; import { PluginService } from './plugin/service'; import { ArtifactHostService } from './preview/artifact-host-service'; import { FileHostService } from './preview/file-host-service'; @@ -61,7 +63,7 @@ import { InMemoryWorkspaceStore } from './workspace/workspace-store'; export interface EngineRuntime { readonly start: Effect.Effect; readonly ensureChatWorkspace: (cwd: string) => Effect.Effect; - readonly listPlugins: (opts?: PluginDiscoveryOptions) => Effect.Effect; + readonly listPlugins: (opts?: PluginDiscoveryOptions) => Effect.Effect; readonly stop: Effect.Effect; } @@ -202,6 +204,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( factory, ); const browserRequests = new BrowserRequestHandler(transport, browserBroker); + const pluginRequests = new PluginRequestHandler(transport, plugins, responder); const requests = new WireRequestRouter(transport, { session: sessionRequests, history: historyRequests, @@ -209,6 +212,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( asset: assets, workspace: workspaceRequests, git: gitRequests, + plugin: pluginRequests, file: fileRequests, script: scriptRequests, artifact: artifactRequests, diff --git a/packages/host/engine/src/failure.ts b/packages/host/engine/src/failure.ts index 85f83acba..63dbe8f8f 100644 --- a/packages/host/engine/src/failure.ts +++ b/packages/host/engine/src/failure.ts @@ -20,6 +20,7 @@ export type OperationSubsystem = | 'asset' | 'filesystem' | 'git' + | 'plugin' | 'preview' | 'pty' | 'runtime-probe' diff --git a/packages/host/engine/src/plugin/request-handler.ts b/packages/host/engine/src/plugin/request-handler.ts new file mode 100644 index 000000000..f93f701d5 --- /dev/null +++ b/packages/host/engine/src/plugin/request-handler.ts @@ -0,0 +1,65 @@ +import type { WirePayload } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { Effect } from 'effect'; +import type { WireResponder } from '../wire/responder'; +import type { PluginService } from './service'; + +type PluginRequest = Extract; + +/** Serves plugin discovery and plugin-level enablement over the wire. */ +export class PluginRequestHandler { + constructor( + private readonly transport: Transport, + private readonly plugins: PluginService, + private readonly responder: WireResponder, + ) {} + + handle(payload: PluginRequest): Effect.Effect { + switch (payload.kind) { + case 'plugin.list.get': + return this.responder.reply( + payload.clientReqId, + this.plugins.list({ cwd: payload.cwd }).pipe( + Effect.flatMap((result) => + Effect.sync(() => + this.transport.send( + createWireMessage({ + kind: 'plugin.list.result', + replyTo: payload.clientReqId, + plugins: result.plugins, + standaloneSkills: result.standaloneSkills, + providerStatus: result.providerStatus, + }), + ), + ), + ), + ), + ); + case 'plugin.set-enabled': + return this.responder.reply( + payload.clientReqId, + this.plugins + .setPluginEnabled(payload.provider, payload.id, payload.enabled, { + scope: payload.scope, + cwd: payload.cwd, + }) + .pipe( + Effect.flatMap((plugin) => + Effect.sync(() => + this.transport.send( + createWireMessage({ + kind: 'plugin.updated', + replyTo: payload.clientReqId, + plugin, + }), + ), + ), + ), + ), + ); + default: + return Effect.void; + } + } +} diff --git a/packages/host/engine/src/plugin/service.ts b/packages/host/engine/src/plugin/service.ts index 2738db314..8d7120dbc 100644 --- a/packages/host/engine/src/plugin/service.ts +++ b/packages/host/engine/src/plugin/service.ts @@ -1,48 +1,162 @@ -import type { PluginDiscoveryOptions, PluginProviderAdapterFactory } from '@linkcode/agent-adapter'; -import type { Plugin, PluginProvider } from '@linkcode/schema'; +import type { + PluginDiscoveryOptions, + PluginProviderAdapterFactory, + PluginToggleOptions, +} from '@linkcode/agent-adapter'; +import type { + Plugin, + PluginProvider, + PluginProviderStatus, + StandaloneSkill, +} from '@linkcode/schema'; import { PluginProviderSchema } from '@linkcode/schema'; import { Effect } from 'effect'; -import { OperationError } from '../failure'; +import { OperationError, RequestError } from '../failure'; + +export interface PluginDiscoveryResult { + readonly plugins: Plugin[]; + readonly standaloneSkills: StandaloneSkill[]; + /** Per-provider outcome so "no plugins" and "CLI missing/failed" stay distinguishable. */ + readonly providerStatus: PluginProviderStatus[]; +} + +interface ProviderDiscovery { + readonly plugins: Plugin[]; + readonly standaloneSkills: StandaloneSkill[]; + readonly status: PluginProviderStatus; +} /** Aggregates provider-native catalogs while keeping one unavailable CLI from hiding the rest. */ export class PluginService { constructor(private readonly factory: PluginProviderAdapterFactory) {} - list(opts: PluginDiscoveryOptions = {}): Effect.Effect { + list(opts: PluginDiscoveryOptions = {}): Effect.Effect { return Effect.all( - PluginProviderSchema.options.map((provider) => this.listProvider(provider, opts)), + PluginProviderSchema.options.map((provider) => this.discoverProvider(provider, opts)), { concurrency: 'unbounded' }, ).pipe( - Effect.map((catalogs) => - catalogs.flat().sort((left, right) => { - const provider = left.provider.localeCompare(right.provider); - return provider === 0 ? left.id.localeCompare(right.id) : provider; + Effect.map((results) => ({ + plugins: results + .flatMap((result) => result.plugins) + .sort((left, right) => comparePluginOrder(left, right)), + standaloneSkills: results + .flatMap((result) => result.standaloneSkills) + .sort((left, right) => comparePluginOrder(left, right)), + providerStatus: results.map((result) => result.status), + })), + ); + } + + /** + * Plugin-level toggle. The post-toggle re-list is the real success check, not an optimization: + * claude's enable/disable exits 0 even for a plugin that does not exist (verified on 2.1.220), + * so only the readback proves the toggle landed on a real install. + */ + setPluginEnabled( + provider: PluginProvider, + id: string, + enabled: boolean, + opts: PluginToggleOptions = {}, + ): Effect.Effect { + const adapter = this.factory(provider); + const toggle = adapter.setPluginEnabled?.bind(adapter); + if (!toggle) { + return Effect.fail( + new RequestError({ + code: 'unsupported', + message: `${provider}: plugin management is not supported`, + }), + ); + } + return Effect.tryPromise({ + try: () => toggle(id, enabled, opts), + catch: (cause) => + new OperationError({ + subsystem: 'plugin', + operation: `plugin.set-enabled.${provider}`, + publicMessage: `Failed to ${enabled ? 'enable' : 'disable'} the plugin`, + cause, + }), + }).pipe( + Effect.andThen( + Effect.tryPromise({ + try: () => adapter.list({ cwd: opts.cwd }), + catch: (cause) => + new OperationError({ + subsystem: 'plugin', + operation: `plugin.reload.${provider}`, + publicMessage: 'Failed to reload the plugin after the update', + cause, + }), }), ), + Effect.flatMap((plugins) => { + const updated = plugins.find((plugin) => plugin.id === id); + return updated + ? Effect.succeed(updated) + : Effect.fail( + new RequestError({ code: 'not_found', message: `Plugin not found: ${id}` }), + ); + }), ); } - private listProvider( + private discoverProvider( provider: PluginProvider, opts: PluginDiscoveryOptions, - ): Effect.Effect { + ): Effect.Effect { + const adapter = this.factory(provider); + return Effect.all( + [ + this.discoveryCall(provider, 'plugins', () => adapter.list(opts)), + this.discoveryCall(provider, 'skills', () => adapter.listStandaloneSkills(opts)), + ], + { concurrency: 'unbounded' }, + ).pipe( + Effect.map(([plugins, standaloneSkills]) => { + const reason = plugins.reason ?? standaloneSkills.reason; + return { + plugins: plugins.values, + standaloneSkills: standaloneSkills.values, + status: { provider, ok: reason === undefined, ...(reason && { reason }) }, + }; + }), + ); + } + + private discoveryCall( + provider: PluginProvider, + what: 'plugins' | 'skills', + run: () => Promise, + ): Effect.Effect<{ values: T[]; reason?: string }> { return Effect.tryPromise({ - try: () => this.factory(provider).list(opts), + try: run, catch: (cause) => new OperationError({ - subsystem: 'agent', - operation: `plugin.list.${provider}`, - publicMessage: `Failed to discover ${provider} plugins`, + subsystem: 'plugin', + operation: `plugin.list.${what}.${provider}`, + publicMessage: `Failed to discover ${provider} ${what}`, cause, }), }).pipe( + Effect.map((values) => ({ values })), Effect.catch((error) => Effect.logWarning( error.publicMessage, { operation: error.operation, provider, subsystem: error.subsystem }, error.cause, - ).pipe(Effect.andThen(Effect.succeed(new Array()))), + ).pipe( + Effect.andThen(Effect.succeed({ values: new Array(), reason: error.publicMessage })), + ), ), ); } } + +function comparePluginOrder( + left: { provider: string; id: string }, + right: { provider: string; id: string }, +): number { + const provider = left.provider.localeCompare(right.provider); + return provider === 0 ? left.id.localeCompare(right.id) : provider; +} diff --git a/packages/host/engine/src/service.ts b/packages/host/engine/src/service.ts index dd88a91ed..2cbacb3e7 100644 --- a/packages/host/engine/src/service.ts +++ b/packages/host/engine/src/service.ts @@ -1,16 +1,17 @@ import type { PluginDiscoveryOptions } from '@linkcode/agent-adapter'; -import type { Plugin, WorkspaceRecord } from '@linkcode/schema'; +import type { WorkspaceRecord } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { Context, Effect, Layer } from 'effect'; import type { EngineDeps } from './deps'; import { createEngineRuntime } from './engine'; import type { EngineFailure } from './failure'; +import type { PluginDiscoveryResult } from './plugin/service'; export class EngineService extends Context.Service< EngineService, { readonly ensureChatWorkspace: (cwd: string) => Effect.Effect; - readonly listPlugins: (opts?: PluginDiscoveryOptions) => Effect.Effect; + readonly listPlugins: (opts?: PluginDiscoveryOptions) => Effect.Effect; } >()('@linkcode/engine/Engine') {} diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index 7fade564a..eacdc6d58 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -8,6 +8,7 @@ import type { AutomationRequestHandler } from '../automation/request-handler'; import type { BrowserRequestHandler } from '../browser/request-handler'; import type { GitRequestHandler } from '../git/request-handler'; import { observeRequest } from '../observability'; +import type { PluginRequestHandler } from '../plugin/request-handler'; import type { ArtifactRequestHandler } from '../preview/request-handler'; import type { ScriptRequestHandler } from '../scripts/request-handler'; import type { HistoryRequestHandler } from '../session/history-request-handler'; @@ -24,6 +25,7 @@ interface RequestHandlers { readonly asset: ManagedAssetService; readonly workspace: WorkspaceRequestHandler; readonly git: GitRequestHandler; + readonly plugin: PluginRequestHandler; readonly file: FileRequestHandler; readonly script: ScriptRequestHandler; readonly artifact: ArtifactRequestHandler; @@ -91,6 +93,10 @@ export class WireRequestRouter { case 'git.diff.get': { return this.handlers.git.handle(p); } + case 'plugin.list.get': + case 'plugin.set-enabled': { + return this.handlers.plugin.handle(p); + } case 'file.read': case 'file.list': case 'file.suggest': From ca13a7abeb1ebd7f0e440e4d82043f886808aca0 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 15:14:08 +0800 Subject: [PATCH 05/24] feat(engine,daemon): custom MCP server config plane (CODE-490) --- apps/daemon/src/__tests__/config.test.ts | 47 +++- apps/daemon/src/config.ts | 46 +++- apps/daemon/src/index.ts | 6 +- apps/daemon/src/provider-store.ts | 18 +- .../src/__tests__/custom-mcp-service.test.ts | 183 ++++++++++++++++ .../engine/src/agent/custom-mcp-service.ts | 204 ++++++++++++++++++ .../host/engine/src/agent/provider-config.ts | 13 ++ .../host/engine/src/agent/request-handler.ts | 11 +- packages/host/engine/src/engine.ts | 3 + 9 files changed, 519 insertions(+), 12 deletions(-) create mode 100644 packages/host/engine/src/__tests__/custom-mcp-service.test.ts create mode 100644 packages/host/engine/src/agent/custom-mcp-service.ts diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 69368a1da..1229e2c9a 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -1,6 +1,7 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { CustomMcpServer } from '@linkcode/schema'; import { DAEMON_DEFAULT_PORT, DAEMON_PORT_HUNT_SPAN, daemonBasePort } from '@linkcode/schema'; import { noop } from 'foxts/noop'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -10,6 +11,7 @@ import { hqCredentialsPath, loadConfig, runtimeFilePath, + saveCustomMcpServers, } from '../config'; import { logger } from '../logger'; import { daemonChannel, telemetryConfigCachePath } from '../paths'; @@ -241,3 +243,46 @@ describe('loadConfig accounts', () => { expect(errorSpy).not.toHaveBeenCalled(); }); }); + +describe('loadConfig custom MCP servers', () => { + const validServer = { + id: 'custom-1', + enabled: true, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + env: { GITHUB_TOKEN: 'secret' }, + }, + createdAt: 1, + } as const satisfies CustomMcpServer; + + function writeCustomMcpConfig(customMcpServers: unknown): void { + const dir = join(process.env.HOME ?? '', '.linkcode'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify({ customMcpServers })); + } + + it('keeps valid servers and drops an invalid one without blanking the rest', () => { + const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); + writeCustomMcpConfig([validServer, { id: 'broken', server: { type: 'stdio' } }]); + + const config = loadConfig(); + + expect(config.customMcpServers).toEqual([validServer]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('round-trips through saveCustomMcpServers preserving other fields at mode 0600', () => { + writeCustomMcpConfig([]); + const path = join(process.env.HOME ?? '', '.linkcode', 'config.json'); + writeFileSync(path, JSON.stringify({ providers: {}, customMcpServers: [] })); + + saveCustomMcpServers([validServer]); + + const written: unknown = JSON.parse(readFileSync(path, 'utf8')); + expect(written).toEqual({ providers: {}, customMcpServers: [validServer] }); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(loadConfig().customMcpServers).toEqual([validServer]); + }); +}); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 3416d379b..2bd58698a 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -1,11 +1,17 @@ -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, SimulatorConsentState } from '@linkcode/schema'; +import type { + Accounts, + CustomMcpServer, + ProvidersConfig, + SimulatorConsentState, +} from '@linkcode/schema'; import { AccountSchema, AgentKindSchema, + CustomMcpServerSchema, daemonBasePort, ProviderConfigSchema, SimulatorConsentStateSchema, @@ -30,6 +36,8 @@ export interface DaemonConfig { providers?: ProvidersConfig; /** Global account pool (data plane); undefined when nothing is configured. */ accounts?: Accounts; + /** LinkCode-owned custom MCP servers (data plane); undefined when nothing is configured. */ + customMcpServers?: CustomMcpServer[]; /** Which simulators agents may drive, plus the global agent-tools switch (CODE-420). */ simulatorConsent: SimulatorConsentState; } @@ -42,6 +50,7 @@ interface ConfigFile { listeners?: unknown; providers?: unknown; accounts?: unknown; + customMcpServers?: unknown; simulatorConsent?: unknown; } @@ -104,6 +113,7 @@ export function loadConfig(): DaemonConfig { ), providers: parseProviders(file.providers), accounts: parseAccounts(file.accounts), + customMcpServers: parseCustomMcpServers(file.customMcpServers), simulatorConsent: parseSimulatorConsent(file.simulatorConsent), }; } @@ -150,6 +160,28 @@ function parseAccounts(raw: unknown): Accounts { return accounts; } +/** + * Parse element by element like {@link parseAccounts}: one invalid server is dropped and logged, + * never blanking the rest. + */ +function parseCustomMcpServers(raw: unknown): CustomMcpServer[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + logger.warn({ operation: 'config.load' }, 'Invalid custom MCP config: expected an array'); + return []; + } + const servers: CustomMcpServer[] = []; + for (const value of raw) { + const server = CustomMcpServerSchema.safeParse(value); + if (!server.success) { + logger.warn({ operation: 'config.load' }, 'Dropping invalid custom MCP server config'); + continue; + } + servers.push(server.data); + } + return servers; +} + /** * Parse field by field: an invalid entry is dropped and logged, never blanking the other entries — * `saveProviders` would persist that loss on the next write. @@ -190,9 +222,14 @@ export function saveAccounts(accounts: Accounts): void { writeConfigField('accounts', accounts); } +/** Persist custom MCP servers, preserving other fields; `0600` (env/headers may hold secrets). */ +export function saveCustomMcpServers(servers: CustomMcpServer[]): void { + writeConfigField('customMcpServers', servers); +} + /** Read-modify-write a single top-level field of config.json, preserving the rest; `0600`. */ function writeConfigField( - key: 'providers' | 'accounts' | 'simulatorConsent', + key: 'providers' | 'accounts' | 'customMcpServers' | 'simulatorConsent', value: unknown, ): void { const path = configPath(); @@ -206,6 +243,9 @@ function writeConfigField( file[key] = value; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + // writeFileSync's mode only applies at creation — re-assert on a pre-existing file, which may + // have been created loose by hand and now holds secrets (accounts, custom MCP env/headers). + chmodSync(path, 0o600); } function createDefaultSocketIoListener(file: ConfigFile): DaemonListenerConfig { diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index c0437645f..668698300 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -196,7 +196,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.customMcpServers ?? [], + ); // 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..8dd95abbb 100644 --- a/apps/daemon/src/provider-store.ts +++ b/apps/daemon/src/provider-store.ts @@ -1,18 +1,21 @@ import type { ProviderConfigStore } from '@linkcode/engine'; -import type { Accounts, ProvidersConfig } from '@linkcode/schema'; -import { saveAccounts, saveProviders } from './config'; +import type { Accounts, CustomMcpServer, ProvidersConfig } from '@linkcode/schema'; +import { saveAccounts, saveCustomMcpServers, saveProviders } from './config'; /** - * Daemon-backed data-plane config store: in-memory providers + account pool seeded at boot, each - * persisted to `~/.linkcode/config.json` on write. Injected into the Engine so `config.get` / - * `config.set` and per-session provider defaults read and write the same persisted values. + * Daemon-backed data-plane config store: in-memory providers + account pool + custom MCP servers + * seeded at boot, each persisted to `~/.linkcode/config.json` on write. Injected into the Engine + * so `config.get` / `config.set` and per-session provider defaults read and write the same + * persisted values. */ export function createProviderConfigStore( initialProviders: ProvidersConfig, initialAccounts: Accounts, + initialCustomMcpServers: CustomMcpServer[] = [], ): ProviderConfigStore { let providers = initialProviders; let accounts = initialAccounts; + let customMcpServers = initialCustomMcpServers; return { get: () => providers, set(next) { @@ -24,5 +27,10 @@ export function createProviderConfigStore( accounts = next; saveAccounts(next); }, + getCustomMcpServers: () => customMcpServers, + setCustomMcpServers(next) { + customMcpServers = next; + saveCustomMcpServers(next); + }, }; } diff --git a/packages/host/engine/src/__tests__/custom-mcp-service.test.ts b/packages/host/engine/src/__tests__/custom-mcp-service.test.ts new file mode 100644 index 000000000..bd2a872fe --- /dev/null +++ b/packages/host/engine/src/__tests__/custom-mcp-service.test.ts @@ -0,0 +1,183 @@ +import type { CustomMcpServer } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { describe, expect, it } from 'vitest'; +import { CustomMcpServerService } from '../agent/custom-mcp-service'; +import { InMemoryProviderConfigStore } from '../agent/provider-config'; + +function stdioServer(overrides: Partial = {}): CustomMcpServer { + return { + id: 'custom-1', + enabled: true, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + env: { GITHUB_TOKEN: 'secret', LOG_LEVEL: 'info' }, + }, + createdAt: 1, + ...overrides, + }; +} + +function seeded(...servers: CustomMcpServer[]) { + const store = new InMemoryProviderConfigStore(); + store.setCustomMcpServers(servers); + return { store, service: new CustomMcpServerService(store) }; +} + +describe('CustomMcpServerService', () => { + it('adds, masks, and removes servers through patches', async () => { + const { store, service } = seeded(); + + await Effect.runPromise(service.applyPatch([{ op: 'add', server: stdioServer() }])); + + expect(service.listPublic()).toEqual([ + { + id: 'custom-1', + enabled: true, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + envKeys: ['GITHUB_TOKEN', 'LOG_LEVEL'], + }, + createdAt: 1, + }, + ]); + + await Effect.runPromise(service.applyPatch([{ op: 'remove', id: 'custom-1' }])); + expect(store.getCustomMcpServers()).toEqual([]); + }); + + it('flips enabled without touching stored secrets', async () => { + const { store, service } = seeded(stdioServer()); + + await Effect.runPromise(service.applyPatch([{ op: 'update', id: 'custom-1', enabled: false }])); + + const [entry] = store.getCustomMcpServers(); + expect(entry.enabled).toBe(false); + expect(entry.server).toEqual(stdioServer().server); + }); + + it('applies per-key secret set/remove and preserves untouched keys', async () => { + const { store, service } = seeded(stdioServer()); + + await Effect.runPromise( + service.applyPatch([ + { + op: 'update', + id: 'custom-1', + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp-v2', + env: { set: { GITHUB_TOKEN: 'rotated' }, remove: ['LOG_LEVEL'] }, + }, + }, + ]), + ); + + const [entry] = store.getCustomMcpServers(); + expect(entry.server).toEqual({ + type: 'stdio', + name: 'github', + command: 'gh-mcp-v2', + args: undefined, + env: { GITHUB_TOKEN: 'rotated' }, + }); + }); + + it('keeps stored secrets when the update carries no secret patch', async () => { + const { store, service } = seeded(stdioServer()); + + await Effect.runPromise( + service.applyPatch([ + { + op: 'update', + id: 'custom-1', + server: { type: 'stdio', name: 'github-renamed', command: 'gh-mcp' }, + }, + ]), + ); + + const [entry] = store.getCustomMcpServers(); + expect(entry.server.name).toBe('github-renamed'); + expect(entry.server.type === 'stdio' && entry.server.env).toEqual({ + GITHUB_TOKEN: 'secret', + LOG_LEVEL: 'info', + }); + }); + + it('rejects a transport type change on update', async () => { + const { service } = seeded(stdioServer()); + + const outcome = await Effect.runPromise( + Effect.flip( + service.applyPatch([ + { + op: 'update', + id: 'custom-1', + server: { type: 'http', name: 'github', url: 'https://mcp.example' }, + }, + ]), + ), + ); + + expect(outcome).toMatchObject({ _tag: 'RequestError', code: 'invalid_request' }); + }); + + it('rejects duplicate names, duplicate ids, and reserved names', async () => { + const { service } = seeded(stdioServer()); + + const duplicateName = await Effect.runPromise( + Effect.flip( + service.applyPatch([ + { + op: 'add', + server: stdioServer({ + id: 'custom-2', + server: { type: 'http', name: 'github', url: 'https://mcp.example' }, + }), + }, + ]), + ), + ); + const duplicateId = await Effect.runPromise( + Effect.flip(service.applyPatch([{ op: 'add', server: stdioServer() }])), + ); + const reserved = await Effect.runPromise( + Effect.flip( + service.applyPatch([ + { + op: 'add', + server: stdioServer({ + id: 'custom-3', + server: { type: 'http', name: 'linkcode-sim', url: 'https://mcp.example' }, + }), + }, + ]), + ), + ); + + expect(duplicateName).toMatchObject({ _tag: 'RequestError', code: 'conflict' }); + expect(duplicateId).toMatchObject({ _tag: 'RequestError', code: 'conflict' }); + expect(reserved).toMatchObject({ _tag: 'RequestError', code: 'conflict' }); + }); + + it('leaves the store untouched when any op in the patch fails', async () => { + const { store, service } = seeded(stdioServer()); + + await Effect.runPromise( + Effect.flip( + service.applyPatch([ + { op: 'update', id: 'custom-1', enabled: false }, + { op: 'update', id: 'missing', enabled: true }, + ]), + ), + ); + + expect(store.getCustomMcpServers()).toEqual([stdioServer()]); + }); +}); diff --git a/packages/host/engine/src/agent/custom-mcp-service.ts b/packages/host/engine/src/agent/custom-mcp-service.ts new file mode 100644 index 000000000..bcdc8201e --- /dev/null +++ b/packages/host/engine/src/agent/custom-mcp-service.ts @@ -0,0 +1,204 @@ +import type { + CustomMcpServer, + CustomMcpServerPatchOp, + CustomMcpServerPublic, + McpSecretPatch, + McpServer, + McpServerPublic, + McpServerUpdate, +} from '@linkcode/schema'; +import { Effect } from 'effect'; +import { never } from 'foxts/guard'; +import { isObjectEmpty } from 'foxts/is-object-empty'; +import { OperationError, RequestError } from '../failure'; +import type { ProviderConfigStore } from './provider-config'; + +/** Names the daemon injects itself (simulator endpoint, claude browser tools); a custom server + * must never claim one or the session-start merge would silently shadow a built-in. */ +export const RESERVED_MCP_SERVER_NAMES: ReadonlySet = new Set([ + 'linkcode-sim', + 'linkcode_browser', +]); + +/** + * The LinkCode-owned ("bring your own") MCP server config plane: masked reads, patch-op writes. + * Secrets live only in the store; the public projection carries key lists, never values, which is + * why writes are patches — a client holding the masked view could not legally resend full servers. + */ +export class CustomMcpServerService { + constructor(private readonly store: ProviderConfigStore) {} + + list(): CustomMcpServer[] { + return this.store.getCustomMcpServers(); + } + + listEnabled(): CustomMcpServer[] { + return this.list().filter((entry) => entry.enabled); + } + + listPublic(): CustomMcpServerPublic[] { + return this.list().map((entry) => maskCustomMcpServer(entry)); + } + + applyPatch(ops: CustomMcpServerPatchOp[]): Effect.Effect { + return Effect.suspend((): Effect.Effect => { + let next = [...this.store.getCustomMcpServers()]; + for (const op of ops) { + switch (op.op) { + case 'add': { + if (next.some((entry) => entry.id === op.server.id)) { + return Effect.fail( + new RequestError({ + code: 'conflict', + message: `Duplicate custom MCP server id: ${op.server.id}`, + }), + ); + } + const conflict = validateName(op.server.server.name, next, undefined); + if (conflict) return Effect.fail(conflict); + next.push(op.server); + break; + } + case 'update': { + const index = next.findIndex((entry) => entry.id === op.id); + if (index < 0) { + return Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown custom MCP server: ${op.id}`, + }), + ); + } + const current = next[index]; + let server = current.server; + if (op.server) { + const updated = applyServerUpdate(current.server, op.server, next, op.id); + if (updated instanceof RequestError) return Effect.fail(updated); + server = updated; + } + next[index] = { + ...current, + ...(op.enabled !== undefined && { enabled: op.enabled }), + server, + }; + break; + } + case 'remove': { + next = next.filter((entry) => entry.id !== op.id); + break; + } + default: + return never(op, 'custom MCP patch op'); + } + } + return Effect.tryPromise({ + try: async () => { + await this.store.setCustomMcpServers(next); + }, + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'config.set-custom-mcp', + publicMessage: 'Failed to persist the MCP server config', + cause, + }), + }); + }); + } +} + +export function maskCustomMcpServer(entry: CustomMcpServer): CustomMcpServerPublic { + return { + id: entry.id, + enabled: entry.enabled, + server: maskMcpServer(entry.server), + createdAt: entry.createdAt, + }; +} + +function maskMcpServer(server: McpServer): McpServerPublic { + if (server.type === 'stdio') { + return { + type: 'stdio', + name: server.name, + command: server.command, + args: server.args, + envKeys: Object.keys(server.env ?? {}), + }; + } + return { + type: 'http', + name: server.name, + url: server.url, + headerKeys: Object.keys(server.headers ?? {}), + }; +} + +/** Non-secret fields replace wholesale; env/headers patch per key ("blank = keep"). A transport + * change is expressed as remove + add by the client, so a mismatched type is a bad request. */ +function applyServerUpdate( + current: McpServer, + update: McpServerUpdate, + siblings: CustomMcpServer[], + selfId: string, +): McpServer | RequestError { + if (current.type !== update.type) { + return new RequestError({ + code: 'invalid_request', + message: 'The transport type cannot change on update; remove the server and re-add it', + }); + } + const conflict = validateName(update.name, siblings, selfId); + if (conflict) return conflict; + if (current.type === 'stdio' && update.type === 'stdio') { + return { + type: 'stdio', + name: update.name, + command: update.command, + args: update.args, + env: applySecretPatch(current.env, update.env), + }; + } + if (current.type === 'http' && update.type === 'http') { + return { + type: 'http', + name: update.name, + url: update.url, + headers: applySecretPatch(current.headers, update.headers), + }; + } + return new RequestError({ code: 'invalid_request', message: 'Malformed server update' }); +} + +function applySecretPatch( + current: Record | undefined, + patch: McpSecretPatch | undefined, +): Record | undefined { + if (!patch) return current; + const removed = new Set(patch.remove); + const next: Record = {}; + for (const [key, value] of Object.entries({ ...current, ...patch.set })) { + if (!removed.has(key)) next[key] = value; + } + return isObjectEmpty(next) ? undefined : next; +} + +function validateName( + name: string, + existing: CustomMcpServer[], + excludeId: string | undefined, +): RequestError | undefined { + if (RESERVED_MCP_SERVER_NAMES.has(name)) { + return new RequestError({ + code: 'conflict', + message: `"${name}" is a reserved MCP server name`, + }); + } + if (existing.some((entry) => entry.id !== excludeId && entry.server.name === name)) { + return new RequestError({ + code: 'conflict', + message: `MCP server name already in use: ${name}`, + }); + } + return undefined; +} diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 4db3696b0..8d41bd5a0 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -1,6 +1,7 @@ import type { Account, Accounts, + CustomMcpServer, ProviderConfig, ProvidersConfig, StartOptions, @@ -17,11 +18,15 @@ export interface ProviderConfigStore { /** The global account pool bound by `providers[kind].activeAccountId`. */ getAccounts(): Accounts; setAccounts(next: Accounts): void | Promise; + /** LinkCode-owned custom MCP servers (full plaintext — masking is the data plane's job). */ + getCustomMcpServers(): CustomMcpServer[]; + setCustomMcpServers(next: CustomMcpServer[]): void | Promise; } export class InMemoryProviderConfigStore implements ProviderConfigStore { private providers: ProvidersConfig = {}; private accounts: Accounts = []; + private customMcpServers: CustomMcpServer[] = []; get(): ProvidersConfig { return this.providers; @@ -38,6 +43,14 @@ export class InMemoryProviderConfigStore implements ProviderConfigStore { setAccounts(next: Accounts): void { this.accounts = next; } + + getCustomMcpServers(): CustomMcpServer[] { + return this.customMcpServers; + } + + setCustomMcpServers(next: CustomMcpServer[]): void { + this.customMcpServers = next; + } } /** diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index 7f27109da..a46a592bd 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -5,6 +5,7 @@ import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; import { OperationError, RequestError } from '../failure'; import type { WireResponder } from '../wire/responder'; +import type { CustomMcpServerService } from './custom-mcp-service'; import type { AgentLoginService } from './login-service'; import type { ProviderConfigStore } from './provider-config'; import { applyProviderDefaults } from './provider-config'; @@ -30,6 +31,7 @@ export class AgentRequestHandler { private readonly transport: Transport, private readonly runtimes: AgentRuntimeService, private readonly providers: ProviderConfigStore, + private readonly customMcp: CustomMcpServerService, private readonly logins: AgentLoginService | undefined, private readonly responder: WireResponder, private readonly factory: AdapterFactory, @@ -98,8 +100,7 @@ export class AgentRequestHandler { replyTo: payload.clientReqId, providers: this.providers.get(), accounts: this.providers.getAccounts(), - // Real masked projection lands with the custom-MCP config plane (CODE-490). - customMcpServers: [], + customMcpServers: this.customMcp.listPublic(), }), ), catch: (cause) => @@ -109,6 +110,7 @@ export class AgentRequestHandler { case 'config.set': { const providers = payload.providers; const accounts = payload.accounts; + const customMcpServers = payload.customMcpServers; return this.responder.reply( payload.clientReqId, Effect.andThen( @@ -121,6 +123,11 @@ export class AgentRequestHandler { this.providers.setAccounts(accounts), ), ).pipe( + Effect.andThen( + customMcpServers === undefined + ? Effect.void + : this.customMcp.applyPatch(customMcpServers), + ), Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), ), ); diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index dbd32e6eb..5641c493c 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -5,6 +5,7 @@ import type { Transport, Unsubscribe } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import type { Scope } from 'effect'; import { Cause, Effect, FiberSet } from 'effect'; +import { CustomMcpServerService } from './agent/custom-mcp-service'; import { AgentLoginService } from './agent/login-service'; import { InMemoryProviderConfigStore } from './agent/provider-config'; import { AgentRequestHandler } from './agent/request-handler'; @@ -195,10 +196,12 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( runtimes.refresh(); }) : undefined; + const customMcp = new CustomMcpServerService(providerStore); const agentRequests = new AgentRequestHandler( transport, runtimes, providerStore, + customMcp, logins, responder, factory, From 1745941807a7e28ff484aad53deb2d73cd19a977 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 15:20:16 +0800 Subject: [PATCH 06/24] feat(engine): inject enabled custom MCP servers at session start with warnings (CODE-494) --- apps/daemon/src/sim/mcp-endpoint.ts | 3 +- .../src/__tests__/start-options-mcp.test.ts | 90 +++++++++++++++++-- .../engine/src/agent/custom-mcp-service.ts | 3 +- packages/host/engine/src/engine.ts | 3 +- packages/host/engine/src/index.ts | 2 +- .../engine/src/session/lifecycle-service.ts | 37 +++++--- .../host/engine/src/session/mcp-capability.ts | 16 ++++ .../host/engine/src/session/orchestrator.ts | 11 ++- .../src/session/start-options-resolver.ts | 61 ++++++++++--- packages/host/engine/src/simulator/mcp.ts | 13 +-- 10 files changed, 194 insertions(+), 45 deletions(-) create mode 100644 packages/host/engine/src/session/mcp-capability.ts diff --git a/apps/daemon/src/sim/mcp-endpoint.ts b/apps/daemon/src/sim/mcp-endpoint.ts index 3494b348a..313a3780a 100644 --- a/apps/daemon/src/sim/mcp-endpoint.ts +++ b/apps/daemon/src/sim/mcp-endpoint.ts @@ -6,6 +6,7 @@ import type { SimulatorMcpProvider, SimulatorService, } from '@linkcode/engine'; +import { SIMULATOR_MCP_SERVER_NAME } from '@linkcode/engine'; import type { McpServer as McpServerEntry, SessionId } from '@linkcode/schema'; // eslint-disable-next-line import-x/no-unresolved -- the SDK's exports-map subpaths (./server/*.js) defeat the resolver; tsc resolves them fine import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -36,7 +37,7 @@ export interface SimulatorMcpNotifications { devicesChanged?: (devices: Awaited>) => void; } -const SERVER_NAME = 'linkcode-sim'; +const SERVER_NAME = SIMULATOR_MCP_SERVER_NAME; const RE_MCP_PATH = /^\/mcp\/([\w-]+)$/; /** HID keyboard usages (page 7) for the keys an agent cannot express as text. Decimal because diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index 9a3a9529f..c4ea3595c 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -1,7 +1,8 @@ -import type { McpServer, SessionId } from '@linkcode/schema'; +import type { CustomMcpServer, McpServer, SessionId } from '@linkcode/schema'; import { Effect } from 'effect'; import { noop } from 'foxts/noop'; import { describe, expect, it } from 'vitest'; +import { CustomMcpServerService } from '../agent/custom-mcp-service'; import { InMemoryProviderConfigStore } from '../agent/provider-config'; import { SessionStartOptionsResolver } from '../session/start-options-resolver'; import type { SimulatorMcpProvider } from '../simulator/mcp'; @@ -20,6 +21,21 @@ function provider(endpoint: McpServer | undefined): SimulatorMcpProvider { }; } +function customService(...servers: CustomMcpServer[]): CustomMcpServerService { + const store = new InMemoryProviderConfigStore(); + store.setCustomMcpServers(servers); + return new CustomMcpServerService(store); +} + +function customEntry(name: string, enabled = true): CustomMcpServer { + return { + id: `custom-${name}`, + enabled, + server: { type: 'stdio', name, command: `${name}-mcp`, env: { TOKEN: 'secret' } }, + createdAt: 1, + }; +} + describe('simulator MCP injection at session start', () => { it('appends the session endpoint for MCP-capable agents', async () => { const resolver = new SessionStartOptionsResolver( @@ -27,10 +43,11 @@ describe('simulator MCP injection at session start', () => { undefined, provider(ENDPOINT), ); - const resolved = await Effect.runPromise( + const { options: resolved, warnings } = await Effect.runPromise( resolver.resolve({ kind: 'claude-code', cwd: '/repo' }, SESSION), ); expect(resolved.mcpServers).toEqual([ENDPOINT]); + expect(warnings).toEqual([]); }); it('preserves explicitly requested servers ahead of the injected one', async () => { @@ -40,7 +57,7 @@ describe('simulator MCP injection at session start', () => { undefined, provider(ENDPOINT), ); - const resolved = await Effect.runPromise( + const { options: resolved } = await Effect.runPromise( resolver.resolve({ kind: 'opencode', cwd: '/repo', mcpServers: [explicit] }, SESSION), ); expect(resolved.mcpServers).toEqual([explicit, ENDPOINT]); @@ -57,7 +74,7 @@ describe('simulator MCP injection at session start', () => { undefined, provider(ENDPOINT), ); - const resolved = await Effect.runPromise( + const { options: resolved } = await Effect.runPromise( resolver.resolve({ kind: 'claude-code', cwd: '/repo', mcpServers: [userOwned] }, SESSION), ); // The user's server keeps the name; ours is not appended over it. @@ -71,16 +88,77 @@ describe('simulator MCP injection at session start', () => { provider(ENDPOINT), ); const pi = await Effect.runPromise(withProvider.resolve({ kind: 'pi', cwd: '/repo' }, SESSION)); - expect(pi.mcpServers).toBeUndefined(); + expect(pi.options.mcpServers).toBeUndefined(); const unavailable = new SessionStartOptionsResolver( new InMemoryProviderConfigStore(), undefined, provider(undefined), ); - const resolved = await Effect.runPromise( + const { options: resolved } = await Effect.runPromise( unavailable.resolve({ kind: 'claude-code', cwd: '/repo' }, SESSION), ); expect(resolved.mcpServers).toBeUndefined(); }); }); + +describe('custom MCP injection at session start', () => { + it('folds enabled custom servers in for claude-code, codex, and opencode', async () => { + for (const kind of ['claude-code', 'codex', 'opencode'] as const) { + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + customService(customEntry('github'), customEntry('disabled-one', false)), + ); + const { options: resolved, warnings } = await Effect.runPromise( + resolver.resolve({ kind, cwd: '/repo' }, SESSION), + ); + expect(resolved.mcpServers).toEqual([customEntry('github').server]); + expect(warnings).toEqual([]); + } + }); + + it('warns instead of injecting for an MCP-incapable agent', async () => { + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + customService(customEntry('github')), + ); + const { options: resolved, warnings } = await Effect.runPromise( + resolver.resolve({ kind: 'pi', cwd: '/repo' }, SESSION), + ); + expect(resolved.mcpServers).toBeUndefined(); + expect(warnings).toEqual([{ serverName: 'github', reason: 'agent-unsupported' }]); + }); + + it('skips a caller-claimed name with a name-conflict warning', async () => { + const explicit: McpServer = { type: 'http', name: 'github', url: 'http://127.0.0.1:9/x' }; + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + customService(customEntry('github'), customEntry('search')), + ); + const { options: resolved, warnings } = await Effect.runPromise( + resolver.resolve({ kind: 'claude-code', cwd: '/repo', mcpServers: [explicit] }, SESSION), + ); + expect(resolved.mcpServers).toEqual([explicit, customEntry('search').server]); + expect(warnings).toEqual([{ serverName: 'github', reason: 'name-conflict' }]); + }); + + it('injects custom servers before the simulator endpoint without disturbing it', async () => { + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + provider(ENDPOINT), + customService(customEntry('github')), + ); + const { options: resolved, warnings } = await Effect.runPromise( + resolver.resolve({ kind: 'claude-code', cwd: '/repo' }, SESSION), + ); + expect(resolved.mcpServers).toEqual([customEntry('github').server, ENDPOINT]); + expect(warnings).toEqual([]); + }); +}); diff --git a/packages/host/engine/src/agent/custom-mcp-service.ts b/packages/host/engine/src/agent/custom-mcp-service.ts index bcdc8201e..d5f501cb5 100644 --- a/packages/host/engine/src/agent/custom-mcp-service.ts +++ b/packages/host/engine/src/agent/custom-mcp-service.ts @@ -11,12 +11,13 @@ import { Effect } from 'effect'; import { never } from 'foxts/guard'; import { isObjectEmpty } from 'foxts/is-object-empty'; import { OperationError, RequestError } from '../failure'; +import { SIMULATOR_MCP_SERVER_NAME } from '../session/mcp-capability'; import type { ProviderConfigStore } from './provider-config'; /** Names the daemon injects itself (simulator endpoint, claude browser tools); a custom server * must never claim one or the session-start merge would silently shadow a built-in. */ export const RESERVED_MCP_SERVER_NAMES: ReadonlySet = new Set([ - 'linkcode-sim', + SIMULATOR_MCP_SERVER_NAME, 'linkcode_browser', ]); diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 5641c493c..41cca6dad 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -79,6 +79,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const runEffect = yield* FiberSet.runtimePromise(taskSet)(); const factory = deps.factory ?? createAdapter; const providerStore = deps.providerStore ?? new InMemoryProviderConfigStore(); + const customMcp = new CustomMcpServerService(providerStore); const records = new SessionRecordRegistry(deps.sessionStore ?? new InMemorySessionStore()); const history = new HistoryService(factory); const plugins = new PluginService(deps.pluginFactory ?? createPluginProviderAdapter); @@ -152,6 +153,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( providerStore, translator, deps.simulatorMcp, + customMcp, ); const sessionLifecycle = new SessionLifecycleService( sessions, @@ -196,7 +198,6 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( runtimes.refresh(); }) : undefined; - const customMcp = new CustomMcpServerService(providerStore); const agentRequests = new AgentRequestHandler( transport, runtimes, diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 650d3fe9e..fb010836e 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -17,6 +17,7 @@ export { makeEngineInfrastructureLayer, makeEngineLayer, } from './service'; +export { MCP_CAPABLE_AGENT_KINDS, SIMULATOR_MCP_SERVER_NAME } from './session/mcp-capability'; export type { SessionStore } from './session/session-store'; export type { SimulatorBackend, @@ -27,7 +28,6 @@ export type { export type { SimulatorConsentStore } from './simulator/consent'; export { InMemorySimulatorConsentStore, SimulatorConsentService } from './simulator/consent'; export type { SimulatorMcpProvider } from './simulator/mcp'; -export { MCP_CAPABLE_AGENT_KINDS } from './simulator/mcp'; export { SimulatorService } from './simulator/service'; export type { PtyBackend, PtyOpenOptions, PtyProcess } from './terminal/pty-backend'; export type { WorkspaceStore } from './workspace/workspace-store'; diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 035dce0e2..47c36f848 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, sessionId); + const { options: resolved, warnings } = yield* startOptions.resolve(options, sessionId); 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,10 @@ 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 }, sessionId); + const { options: startOptions, warnings } = yield* resolver.resolve( + { ...options, kind }, + sessionId, + ); const now = Date.now(); const record: SessionRecord = { sessionId, @@ -134,8 +140,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,7 +174,7 @@ 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( + const { options: startOptions, warnings } = yield* resolver.resolve( { kind: record.kind, cwd: record.cwd }, sessionId, ); @@ -173,10 +182,14 @@ export class SessionLifecycleService { // `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, ); }); }); @@ -192,7 +205,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 } = yield* resolver.resolve( { kind: options.kind, cwd: options.cwd, model: options.model }, sessionId, ); diff --git a/packages/host/engine/src/session/mcp-capability.ts b/packages/host/engine/src/session/mcp-capability.ts new file mode 100644 index 000000000..046f9915a --- /dev/null +++ b/packages/host/engine/src/session/mcp-capability.ts @@ -0,0 +1,16 @@ +import type { AgentKind } from '@linkcode/schema'; + +/** + * Agent kinds whose SDKs accept MCP server configuration. pi runs in-process with no MCP support + * at all, and grok-build's headless CLI exposes none — the engine never injects for those, and + * their adapters loudly reject explicit `mcpServers` rather than silently dropping them. + */ +export const MCP_CAPABLE_AGENT_KINDS: ReadonlySet = new Set([ + 'claude-code', + 'codex', + 'opencode', +]); + +/** The daemon's built-in simulator MCP endpoint name — shared by the daemon endpoint host, the + * session resolver's injection, and custom-MCP reserved-name validation. */ +export const SIMULATOR_MCP_SERVER_NAME = 'linkcode-sim'; diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index e0625152b..217c1e857 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -3,6 +3,7 @@ import { nextMessageId } from '@linkcode/agent-adapter'; import type { AgentInput, ContentBlock, + McpWarning, SessionId, SessionInfo, SessionRecord, @@ -161,6 +162,7 @@ export class SessionOrchestrator { replyTo: string | undefined, record: SessionRecord, startAdapter: (adapter: AgentAdapter) => Effect.Effect, + mcpWarnings: readonly McpWarning[] = [], ): Effect.Effect { const { events, factory, records, runtimes, scope: parentScope, sessions, transport } = this; const { browserTools } = this; @@ -186,7 +188,14 @@ export class SessionOrchestrator { yield* startAdapter(adapter); if (sessions.get(sessionId) !== session) return yield* Effect.interrupt; if (replyTo !== undefined) { - transport.send(createWireMessage({ kind: 'session.started', replyTo, sessionId })); + transport.send( + createWireMessage({ + kind: 'session.started', + replyTo, + sessionId, + ...(mcpWarnings.length > 0 && { mcpWarnings: [...mcpWarnings] }), + }), + ); } }); yield* session.run(start).pipe(Effect.tapError(() => discardFailedStart(session))); diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index f091d7214..dc9f8caa0 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -1,32 +1,43 @@ -import type { SessionId, StartOptions } from '@linkcode/schema'; +import type { McpWarning, SessionId, StartOptions } from '@linkcode/schema'; import { Effect } from 'effect'; +import type { CustomMcpServerService } from '../agent/custom-mcp-service'; 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 type { SimulatorMcpProvider } from '../simulator/mcp'; -import { MCP_CAPABLE_AGENT_KINDS } from '../simulator/mcp'; +import { MCP_CAPABLE_AGENT_KINDS } from './mcp-capability'; -/** Resolves daemon-owned provider defaults, the optional cross-protocol translation endpoint, - * and the per-session simulator MCP injection for MCP-capable agents. */ +export interface ResolvedStartOptions { + readonly options: StartOptions; + /** Custom-MCP injection advisories, delivered on the `session.started` reply. */ + readonly warnings: McpWarning[]; +} + +/** Resolves daemon-owned provider defaults, enabled custom MCP servers, the optional + * cross-protocol translation endpoint, and the per-session simulator MCP injection. */ export class SessionStartOptionsResolver { constructor( private readonly providers: ProviderConfigStore, private readonly translator: TranslatorService | undefined, private readonly simulatorMcp?: SimulatorMcpProvider, + private readonly customMcp?: CustomMcpServerService, ) {} resolve( options: StartOptions, sessionId: SessionId, - ): Effect.Effect { - const resolved = this.withSimulatorMcp( - applyProviderDefaults(options, this.providers.get(), this.providers.getAccounts()), - sessionId, + ): Effect.Effect { + const defaults = applyProviderDefaults( + options, + this.providers.get(), + this.providers.getAccounts(), ); + const custom = this.withCustomMcpServers(defaults); + const resolved = this.withSimulatorMcp(custom.options, sessionId); const upstream = translationUpstream(resolved); - if (!upstream) return Effect.succeed(resolved); + if (!upstream) return Effect.succeed({ options: resolved, warnings: custom.warnings }); if (!this.translator) { return Effect.fail( new RequestError({ @@ -45,7 +56,37 @@ export class SessionStartOptionsResolver { publicMessage: 'Failed to start cross-protocol translation', cause, }), - }).pipe(Effect.map((url) => withTranslatorEndpoint(resolved, url))); + }).pipe( + Effect.map((url) => ({ + options: withTranslatorEndpoint(resolved, url), + warnings: custom.warnings, + })), + ); + } + + /** Fold enabled custom MCP servers into the session's server list, warning instead of + * silently dropping: unsupported agent kinds and name collisions are user-visible facts. */ + private withCustomMcpServers(options: StartOptions): ResolvedStartOptions { + const warnings: McpWarning[] = []; + const enabled = this.customMcp?.listEnabled() ?? []; + if (enabled.length === 0) return { options, warnings }; + if (!MCP_CAPABLE_AGENT_KINDS.has(options.kind)) { + for (const entry of enabled) { + warnings.push({ serverName: entry.server.name, reason: 'agent-unsupported' }); + } + return { options, warnings }; + } + const servers = [...(options.mcpServers ?? [])]; + for (const entry of enabled) { + // Same posture as the simulator injection below: a caller-supplied server of the same name + // wins, because most SDKs key servers by name and let the last one silently replace. + if (servers.some((server) => server.name === entry.server.name)) { + warnings.push({ serverName: entry.server.name, reason: 'name-conflict' }); + continue; + } + servers.push(entry.server); + } + return { options: { ...options, mcpServers: servers }, warnings }; } /** Append the session's simulator MCP endpoint for agents whose SDK can consume it. */ diff --git a/packages/host/engine/src/simulator/mcp.ts b/packages/host/engine/src/simulator/mcp.ts index 32018bfe6..0187acdaf 100644 --- a/packages/host/engine/src/simulator/mcp.ts +++ b/packages/host/engine/src/simulator/mcp.ts @@ -1,15 +1,4 @@ -import type { AgentKind, McpServer, SessionId } from '@linkcode/schema'; - -/** - * Agent kinds whose SDKs accept MCP server configuration. pi runs in-process with no MCP support - * at all, and grok-build's headless CLI exposes none — the engine never injects for those, and - * their adapters loudly reject explicit `mcpServers` rather than silently dropping them. - */ -export const MCP_CAPABLE_AGENT_KINDS: ReadonlySet = new Set([ - 'claude-code', - 'codex', - 'opencode', -]); +import type { McpServer, SessionId } from '@linkcode/schema'; /** * Daemon-owned provider of the per-session simulator MCP endpoint (CODE-395). The daemon mints a From 2dc5edb219bf96dd03a655fbc3883eee224849d0 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 15:43:45 +0800 Subject: [PATCH 07/24] feat(client): plugin and custom MCP data plane (CODE-491) --- packages/client/core/src/client.ts | 47 ++++- .../client/core/src/client/control-channel.ts | 48 ++++- .../core/src/client/pending-registry.ts | 18 ++ packages/client/sdk/src/client.ts | 32 +++ packages/client/sdk/src/operations.ts | 42 +++- .../client/workbench/src/mock/data/plugins.ts | 108 ++++++++++ .../workbench/src/mock/dev-mock-host.ts | 116 +++++++++- .../__tests__/custom-mcp-patch.test.ts | 148 +++++++++++++ .../settings/plugins/__tests__/view.test.ts | 165 +++++++++++++++ .../src/settings/plugins/custom-mcp-patch.ts | 155 ++++++++++++++ .../workbench/src/settings/plugins/hooks.ts | 33 +++ .../workbench/src/settings/plugins/view.ts | 199 ++++++++++++++++++ 12 files changed, 1106 insertions(+), 5 deletions(-) create mode 100644 packages/client/workbench/src/mock/data/plugins.ts create mode 100644 packages/client/workbench/src/settings/plugins/__tests__/custom-mcp-patch.test.ts create mode 100644 packages/client/workbench/src/settings/plugins/__tests__/view.test.ts create mode 100644 packages/client/workbench/src/settings/plugins/custom-mcp-patch.ts create mode 100644 packages/client/workbench/src/settings/plugins/hooks.ts create mode 100644 packages/client/workbench/src/settings/plugins/view.ts diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index d9898c58c..dbfbdff3e 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -9,6 +9,8 @@ import type { AgentRuntimes, AgentStartCatalog, ContentBlock, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, FileSuggestion, GitDiff, @@ -27,6 +29,9 @@ import type { ManagedAssetId, ManagedAssetStatus, PermissionOutcome, + Plugin, + PluginProvider, + PluginScope, ProvidersConfig, QuestionOutcome, Schedule, @@ -72,7 +77,7 @@ import { ControlChannel } from './client/control-channel'; import type { SequencedAgentEvent } from './client/event-buffer'; import { EventBuffer } from './client/event-buffer'; import { LoopLogBuffer } from './client/loop-log-buffer'; -import type { RandomUUID, RequestAck } from './client/pending-registry'; +import type { PluginList, RandomUUID, RequestAck } from './client/pending-registry'; import { PendingRegistry, resolveRandomUUID } from './client/pending-registry'; import { TerminalChannel } from './client/terminal-channel'; @@ -80,6 +85,7 @@ export type { AgentLoginHandlers, AgentLoginSettled } from './client/agent-login export type { BrowserCommandExecutor } from './client/browser-host-channel'; export type { HistoryListClientOptions, HistoryReadClientOptions } from './client/control-channel'; export type { SequencedAgentEvent } from './client/event-buffer'; +export type { PluginList } from './client/pending-registry'; type EventCb = (event: AgentEvent, seq: number) => void; type TerminalOutputCb = (data: string) => void; @@ -339,9 +345,20 @@ export class LinkCodeClient { this.pending.resolve('historyRead', p.replyTo, p.result); break; case 'config.get.result': - // One result carries both; each resolve is a no-op unless a request awaits that reply id. + // One result carries all three; each resolve is a no-op unless a request awaits that reply id. this.pending.resolve('configGet', p.replyTo, p.providers); this.pending.resolve('accountsGet', p.replyTo, p.accounts); + this.pending.resolve('customMcpGet', p.replyTo, p.customMcpServers); + break; + case 'plugin.list.result': + this.pending.resolve('pluginList', p.replyTo, { + plugins: p.plugins, + standaloneSkills: p.standaloneSkills, + providerStatus: p.providerStatus, + }); + break; + case 'plugin.updated': + this.pending.resolve('pluginSetEnabled', p.replyTo, p.plugin); break; case 'agent-runtime.listed': this.pending.resolve('agentRuntimeList', p.replyTo, p.runtimes); @@ -697,6 +714,32 @@ export class LinkCodeClient { return this.control.getAccounts(); } + /** Masked custom MCP servers (env/header keys only — the daemon never returns values). */ + getCustomMcpServers(): Promise { + return this.control.getCustomMcpServers(); + } + + /** Apply custom-MCP patch ops; the masked read model makes whole-array writes unsound. */ + setCustomMcpServers(patches: CustomMcpServerPatchOp[]): Promise { + return this.control.setCustomMcpServers(patches); + } + + /** Discover provider plugins + standalone skills (slow: a CLI shell-out on the daemon). */ + listPlugins(cwd?: string): Promise { + return this.control.listPlugins(cwd); + } + + /** Toggle a plugin; resolves with the re-listed plugin for single-entry cache patching. */ + setPluginEnabled(params: { + provider: PluginProvider; + id: string; + enabled: boolean; + scope?: PluginScope; + cwd?: string; + }): Promise { + return this.control.setPluginEnabled(params); + } + listAgentRuntimes(): Promise { return this.control.listAgentRuntimes(); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 609f6bef1..828027f18 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -10,6 +10,8 @@ import type { AgentRuntimes, AgentStartCatalog, ContentBlock, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, FileSuggestion, GitDiff, @@ -25,6 +27,9 @@ import type { ManagedAssetId, ManagedAssetStatus, PermissionOutcome, + Plugin, + PluginProvider, + PluginScope, ProvidersConfig, QuestionOutcome, Schedule, @@ -56,7 +61,7 @@ import type { } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; -import type { PendingRegistry, PendingValueMap, RequestAck } from './pending-registry'; +import type { PendingRegistry, PendingValueMap, PluginList, RequestAck } from './pending-registry'; import { sendCorrelated } from './pending-registry'; export type HistoryListClientOptions = AgentHistoryListOptions & { @@ -368,6 +373,47 @@ export class ControlChannel { })); } + /** Read the daemon-owned custom MCP servers (masked projection — never carries a secret). */ + getCustomMcpServers(): Promise { + return this.sendCorrelated('customMcpGet', (clientReqId) => ({ + kind: 'config.get', + clientReqId, + })); + } + + /** Apply custom-MCP patch ops (add / per-key secret update / remove). Preserves other config. */ + setCustomMcpServers(patches: CustomMcpServerPatchOp[]): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'config.set', + clientReqId, + customMcpServers: patches, + })); + } + + /** Discover provider plugins and standalone skills (a real CLI shell-out on the daemon). */ + listPlugins(cwd?: string): Promise { + return this.sendCorrelated('pluginList', (clientReqId) => ({ + kind: 'plugin.list.get', + clientReqId, + cwd, + })); + } + + /** Toggle a plugin through its provider; resolves with the re-listed, updated plugin. */ + setPluginEnabled(params: { + provider: PluginProvider; + id: string; + enabled: boolean; + scope?: PluginScope; + cwd?: string; + }): Promise { + return this.sendCorrelated('pluginSetEnabled', (clientReqId) => ({ + kind: 'plugin.set-enabled', + clientReqId, + ...params, + })); + } + /** Which agent CLIs the host can actually spawn (probed once at daemon boot). */ listAgentRuntimes(): Promise { return this.sendCorrelated('agentRuntimeList', (clientReqId) => ({ diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index 75f33fbba..50430e3ed 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -4,6 +4,7 @@ import type { AgentHistoryReadResult, AgentRuntimes, AgentStartCatalog, + CustomMcpServerPublic, FileSuggestion, GitDiff, GitPullRequestStatus, @@ -13,6 +14,8 @@ import type { LoopInspection, LoopRecord, ManagedAssetStatus, + Plugin, + PluginProviderStatus, ProvidersConfig, Schedule, ScheduleRun, @@ -25,6 +28,7 @@ import type { SimulatorImageFormat, SimulatorStatus, SimulatorStreamCodec, + StandaloneSkill, TerminalMetadata, WirePayload, WorkspaceFile, @@ -45,6 +49,14 @@ export interface RequestAck { ok: true; } +/** The `plugin.list.result` payload as one value: catalogs, standalone skills, and per-provider + * discovery outcomes travel together so the UI can tell "empty" from "provider CLI failed". */ +export interface PluginList { + plugins: Plugin[]; + standaloneSkills: StandaloneSkill[]; + providerStatus: PluginProviderStatus[]; +} + export type RandomUUID = () => string; export function resolveRandomUUID(provider?: RandomUUID): RandomUUID { @@ -70,6 +82,9 @@ export interface PendingValueMap { historyRead: AgentHistoryReadResult; configGet: ProvidersConfig; accountsGet: Accounts; + customMcpGet: CustomMcpServerPublic[]; + pluginList: PluginList; + pluginSetEnabled: Plugin; agentRuntimeList: AgentRuntimes; agentCatalog: AgentStartCatalog; assetList: ManagedAssetStatus[]; @@ -122,6 +137,9 @@ export class PendingRegistry { historyRead: new Map(), configGet: new Map(), accountsGet: new Map(), + customMcpGet: new Map(), + pluginList: new Map(), + pluginSetEnabled: new Map(), agentRuntimeList: new Map(), agentCatalog: new Map(), assetList: new Map(), diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 7e066e0b0..8dcd5430d 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -3,6 +3,7 @@ import type { AssetSettledEvent, HistoryListClientOptions, HistoryReadClientOptions, + PluginList, } from '@linkcode/client-core'; import { LinkCodeClient } from '@linkcode/client-core'; import type { @@ -14,6 +15,8 @@ import type { AgentKind, AgentRuntimes, AgentStartCatalog, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, FileSuggestion, GitDiff, @@ -29,6 +32,9 @@ import type { ManagedAssetId, ManagedAssetStatus, PermissionOutcome, + Plugin, + PluginProvider, + PluginScope, ProvidersConfig, QuestionOutcome, Schedule, @@ -218,6 +224,32 @@ export class LinkCodeSdkClient { return toResult(this.raw.setAccounts(accounts)); } + /** Masked custom MCP servers (data plane) — env/header keys only, never a secret value. */ + getCustomMcpServers(): RequestResult { + return toResult(this.raw.getCustomMcpServers()); + } + + /** Apply custom-MCP patch ops (add / per-key secret update / remove). */ + setCustomMcpServers(patches: CustomMcpServerPatchOp[]): RequestResult<{ ok: true }> { + return toResult(this.raw.setCustomMcpServers(patches)); + } + + /** Discover provider plugins + standalone skills (slow: a CLI shell-out on the daemon). */ + listPlugins(cwd?: string): RequestResult { + return toResult(this.raw.listPlugins(cwd)); + } + + /** Toggle a plugin; resolves with the re-listed plugin for single-entry cache patching. */ + setPluginEnabled(params: { + provider: PluginProvider; + id: string; + enabled: boolean; + scope?: PluginScope; + cwd?: string; + }): RequestResult { + return toResult(this.raw.setPluginEnabled(params)); + } + /** Which agent CLIs the host can actually spawn (probed once at daemon boot). */ listAgentRuntimes(): RequestResult { return toResult(this.raw.listAgentRuntimes()); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index cbdadfd64..2cce9d072 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -1,4 +1,8 @@ -import type { HistoryListClientOptions, HistoryReadClientOptions } from '@linkcode/client-core'; +import type { + HistoryListClientOptions, + HistoryReadClientOptions, + PluginList, +} from '@linkcode/client-core'; import type { Accounts, AgentHistoryId, @@ -8,6 +12,8 @@ import type { AgentKind, AgentRuntimes, AgentStartCatalog, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, FileSuggestion, GitDiff, @@ -23,6 +29,9 @@ import type { ManagedAssetId, ManagedAssetStatus, PermissionOutcome, + Plugin, + PluginProvider, + PluginScope, ProvidersConfig, QuestionOutcome, Schedule, @@ -183,6 +192,37 @@ export function setAccounts(options: Options<{ accounts: Accounts }>): RequestRe return resolveClient(options).setAccounts(options.accounts); } +/** Masked custom MCP servers — env/header keys only, never a secret value. */ +export function getCustomMcpServers(options?: Options): RequestResult { + return resolveClient(options).getCustomMcpServers(); +} + +export function setCustomMcpServers( + options: Options<{ patches: CustomMcpServerPatchOp[] }>, +): RequestResult<{ ok: true }> { + return resolveClient(options).setCustomMcpServers(options.patches); +} + +/** Discover provider plugins + standalone skills. Slow (a CLI shell-out on the daemon) — + * consumers refresh manually, never on focus. */ +export function getPlugins(options?: Options<{ cwd?: string }>): RequestResult { + return resolveClient(options).listPlugins(options?.cwd); +} + +/** Toggle a plugin; resolves with the re-listed plugin so callers patch one cache entry. */ +export function setPluginEnabled( + options: Options<{ + provider: PluginProvider; + id: string; + enabled: boolean; + scope?: PluginScope; + cwd?: string; + }>, +): RequestResult { + const { provider, id, enabled, scope, cwd } = options; + return resolveClient(options).setPluginEnabled({ provider, id, enabled, scope, cwd }); +} + /** Which agent CLIs the host can actually spawn (probed once at daemon boot). */ export function listAgentRuntimes(options?: Options): RequestResult { return resolveClient(options).listAgentRuntimes(); diff --git a/packages/client/workbench/src/mock/data/plugins.ts b/packages/client/workbench/src/mock/data/plugins.ts new file mode 100644 index 000000000..b2167906a --- /dev/null +++ b/packages/client/workbench/src/mock/data/plugins.ts @@ -0,0 +1,108 @@ +import type { Plugin, PluginProviderStatus, StandaloneSkill } from '@linkcode/schema'; + +/** Canned plugin discovery covering the shapes the settings page must render: an installed and + * enabled claude plugin with skills + an MCP server, a blocked codex plugin, a marketplace-only + * (not installed) listing, and a multi-scope install. */ +export const SEED_PLUGINS: Plugin[] = [ + { + provider: 'claude-code', + id: 'latex@team-tools', + name: 'latex', + displayName: 'LaTeX Toolkit', + description: 'Compile LaTeX documents and preview build artifacts.', + version: '1.2.3', + keywords: ['latex', 'pdf'], + marketplace: { name: 'team-tools', displayName: 'Team Tools' }, + source: { type: 'local', path: '/marketplaces/team-tools/plugins/latex' }, + availability: 'available', + installations: [ + { enabled: true, version: '1.2.3', scope: 'user' }, + { enabled: false, version: '1.2.0', scope: 'project' }, + ], + components: [ + { kind: 'skill', name: 'compile-latex', description: 'Compile a LaTeX project to PDF' }, + { kind: 'skill', name: 'bibtex-cleanup', description: 'Normalize BibTeX entries' }, + { kind: 'command', name: 'render' }, + { kind: 'mcp-server', name: 'latex-tools' }, + ], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: true, + disable: true, + }, + }, + { + provider: 'claude-code', + id: 'reviewer@claude-plugins-official', + name: 'reviewer', + description: 'Marketplace listing that has not been installed yet.', + version: '0.9.0', + keywords: ['review'], + marketplace: { name: 'claude-plugins-official' }, + source: { type: 'remote' }, + availability: 'available', + installations: [], + components: [{ kind: 'agent', name: 'reviewer' }], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: true, + disable: true, + }, + }, + { + provider: 'codex', + id: 'search@openai-bundled', + name: 'search', + displayName: 'Web Search', + description: 'Bundled search tools; management is provider-owned.', + keywords: [], + marketplace: { name: 'openai-bundled', displayName: 'OpenAI Bundled' }, + source: { type: 'remote' }, + availability: 'blocked', + installations: [{ enabled: true }], + components: [ + { kind: 'skill', name: 'web-search', enabled: true }, + { kind: 'mcp-server', name: 'search-tools' }, + ], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: false, + disable: false, + }, + }, +]; + +export const SEED_STANDALONE_SKILLS: StandaloneSkill[] = [ + { + provider: 'claude-code', + id: 'docx', + name: 'docx', + description: 'Create and edit Word documents.', + scope: 'user', + path: '/home/user/.claude/skills/docx', + toggleable: false, + }, + { + provider: 'codex', + id: 'linear', + name: 'linear', + description: 'Linear workflow conventions.', + scope: 'project', + path: '/workspace/.agents/skills/linear/SKILL.md', + toggleable: false, + }, +]; + +export const SEED_PLUGIN_PROVIDER_STATUS: PluginProviderStatus[] = [ + { provider: 'claude-code', ok: true }, + { provider: 'codex', ok: true }, +]; diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index c37446ce0..4af4b804c 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -7,12 +7,16 @@ import type { AgentKind, AgentRuntimes, ContentBlock, + CustomMcpServer, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, ManagedAssetId, ManagedAssetKey, ManagedAssetStatus, MessageId, PermissionOutcome, + Plugin, ProvidersConfig, QuestionOutcome, SessionId, @@ -44,6 +48,7 @@ import { MOCK_WORKSPACE_FILES, mockFileFixture } from './data/files'; import { gitFixtureFor } from './data/git'; import { SEED_HISTORY } from './data/history'; import { SEED_MODEL_CATALOGS } from './data/models'; +import { SEED_PLUGIN_PROVIDER_STATUS, SEED_PLUGINS, SEED_STANDALONE_SKILLS } from './data/plugins'; import { CHUNK_LATENCY_MS, CONTROL_LATENCY_MS, @@ -174,6 +179,9 @@ export class DevMockHost { private readonly workspaces = new Map(); private providers: ProvidersConfig = {}; private accounts: Accounts = []; + /** Stored with full secrets like the daemon; config.get serves the masked projection. */ + private customMcpServers: CustomMcpServer[] = []; + private readonly plugins: Plugin[] = structuredClone(SEED_PLUGINS); private readonly permissions = new Map(); private readonly questions = new Map(); private history: AgentHistorySession[] = []; @@ -335,7 +343,7 @@ export class DevMockHost { replyTo: p.clientReqId, providers: this.providers, accounts: this.accounts, - customMcpServers: [], + customMcpServers: this.customMcpServers.map((entry) => maskCustomMcpServer(entry)), }); break; case 'agent-runtime.list': @@ -361,8 +369,38 @@ 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.customMcpServers !== undefined) { + this.customMcpServers = applyCustomMcpPatches(this.customMcpServers, p.customMcpServers); + } this.sendSuccess(p.clientReqId); break; + case 'plugin.list.get': + await wait(CONTROL_LATENCY_MS); + this.send({ + kind: 'plugin.list.result', + replyTo: p.clientReqId, + plugins: this.plugins, + standaloneSkills: SEED_STANDALONE_SKILLS, + providerStatus: SEED_PLUGIN_PROVIDER_STATUS, + }); + break; + case 'plugin.set-enabled': { + await wait(CONTROL_LATENCY_MS); + const plugin = this.plugins.find( + (entry) => entry.provider === p.provider && entry.id === p.id, + ); + if (!plugin?.managementCapabilities.enable) { + this.sendFailure(p.clientReqId, 'plugin management is not supported'); + break; + } + for (const installation of plugin.installations) { + if (p.scope === undefined || installation.scope === p.scope) { + installation.enabled = p.enabled; + } + } + this.send({ kind: 'plugin.updated', replyTo: p.clientReqId, plugin }); + break; + } case 'workspace.list': await wait(CONTROL_LATENCY_MS); this.send({ @@ -1433,3 +1471,79 @@ const PATH_SEPARATORS_RE = /[/\\]+/; function lastPathSegment(cwd: string): string { return cwd.split(PATH_SEPARATORS_RE).findLast((part) => part.length > 0) ?? cwd; } + +/** Mirror of the daemon's masked projection: env/header values never reach the client. */ +function maskCustomMcpServer(entry: CustomMcpServer): CustomMcpServerPublic { + const { server } = entry; + return { + id: entry.id, + enabled: entry.enabled, + createdAt: entry.createdAt, + server: + server.type === 'stdio' + ? { + type: 'stdio', + name: server.name, + command: server.command, + args: server.args, + envKeys: Object.keys(server.env ?? {}), + } + : { + type: 'http', + name: server.name, + url: server.url, + headerKeys: Object.keys(server.headers ?? {}), + }, + }; +} + +/** Mirror of the daemon's patch semantics: add / enabled flip / per-key secret set-remove / + * remove. Validation (name uniqueness etc.) is deliberately not replicated in the mock. */ +function applyCustomMcpPatches( + current: CustomMcpServer[], + ops: readonly CustomMcpServerPatchOp[], +): CustomMcpServer[] { + let next = structuredClone(current); + for (const op of ops) { + switch (op.op) { + case 'add': + next.push(structuredClone(op.server)); + break; + case 'update': { + const entry = next.find((candidate) => candidate.id === op.id); + if (!entry) break; + if (op.enabled !== undefined) entry.enabled = op.enabled; + if (op.server?.type !== entry.server.type) break; + entry.server.name = op.server.name; + if (entry.server.type === 'stdio' && op.server.type === 'stdio') { + entry.server.command = op.server.command; + entry.server.args = op.server.args; + entry.server.env = applyMockSecretPatch(entry.server.env, op.server.env); + } else if (entry.server.type === 'http' && op.server.type === 'http') { + entry.server.url = op.server.url; + entry.server.headers = applyMockSecretPatch(entry.server.headers, op.server.headers); + } + break; + } + case 'remove': + next = next.filter((candidate) => candidate.id !== op.id); + break; + default: + break; + } + } + return next; +} + +function applyMockSecretPatch( + current: Record | undefined, + patch: { set?: Record; remove?: string[] } | undefined, +): Record | undefined { + if (!patch) return current; + const removed = new Set(patch.remove); + const next: Record = {}; + for (const [key, value] of Object.entries({ ...current, ...patch.set })) { + if (!removed.has(key)) next[key] = value; + } + return next; +} diff --git a/packages/client/workbench/src/settings/plugins/__tests__/custom-mcp-patch.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/custom-mcp-patch.test.ts new file mode 100644 index 000000000..f2ae7f171 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/custom-mcp-patch.test.ts @@ -0,0 +1,148 @@ +import type { CustomMcpServerPublic } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import type { CustomMcpServerDraft } from '../custom-mcp-patch'; +import { buildCustomMcpPatch } from '../custom-mcp-patch'; + +const MINT = { id: 'custom-new', createdAt: 100 }; + +const previousStdio: CustomMcpServerPublic = { + id: 'custom-1', + enabled: true, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + envKeys: ['GITHUB_TOKEN', 'LOG_LEVEL'], + }, + createdAt: 1, +}; + +function stdioDraft(overrides: Partial> = {}) { + return { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + secrets: [ + { key: 'GITHUB_TOKEN', value: '' }, + { key: 'LOG_LEVEL', value: '' }, + ], + ...overrides, + } satisfies CustomMcpServerDraft; +} + +describe('buildCustomMcpPatch', () => { + it('creates a full-plaintext add op for a new server', () => { + const ops = buildCustomMcpPatch( + undefined, + stdioDraft({ secrets: [{ key: 'GITHUB_TOKEN', value: 'secret' }] }), + MINT, + ); + + expect(ops).toEqual([ + { + op: 'add', + server: { + id: 'custom-new', + enabled: true, + createdAt: 100, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + env: { GITHUB_TOKEN: 'secret' }, + }, + }, + }, + ]); + }); + + it('returns no ops when every secret is left blank and nothing else changed', () => { + expect(buildCustomMcpPatch(previousStdio, stdioDraft(), MINT)).toEqual([]); + }); + + it('never resends untouched secrets: blank = keep, typed = set, flagged = remove', () => { + const ops = buildCustomMcpPatch( + previousStdio, + stdioDraft({ + secrets: [ + { key: 'GITHUB_TOKEN', value: 'rotated' }, + { key: 'LOG_LEVEL', value: '', remove: true }, + { key: 'NEW_VAR', value: 'added' }, + { key: 'BLANK_NEW', value: '' }, + ], + }), + MINT, + ); + + expect(ops).toEqual([ + { + op: 'update', + id: 'custom-1', + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + env: { set: { GITHUB_TOKEN: 'rotated', NEW_VAR: 'added' }, remove: ['LOG_LEVEL'] }, + }, + }, + ]); + }); + + it('emits a secret-free update when only non-secret fields changed', () => { + const ops = buildCustomMcpPatch(previousStdio, stdioDraft({ command: 'gh-mcp-v2' }), MINT); + + expect(ops).toEqual([ + { + op: 'update', + id: 'custom-1', + server: { type: 'stdio', name: 'github', command: 'gh-mcp-v2', args: ['--stdio'] }, + }, + ]); + }); + + it('ignores a remove flag for a key the server never had', () => { + const ops = buildCustomMcpPatch( + previousStdio, + stdioDraft({ secrets: [{ key: 'NEVER_EXISTED', value: '', remove: true }] }), + MINT, + ); + + expect(ops).toEqual([]); + }); + + it('expresses a transport switch as remove + add, preserving enablement', () => { + const disabledPrevious = { ...previousStdio, enabled: false }; + const ops = buildCustomMcpPatch( + disabledPrevious, + { + type: 'http', + name: 'github', + url: 'https://mcp.example', + secrets: [{ key: 'Authorization', value: 'Bearer x' }], + }, + MINT, + ); + + expect(ops).toEqual([ + { op: 'remove', id: 'custom-1' }, + { + op: 'add', + server: { + id: 'custom-new', + enabled: false, + createdAt: 100, + server: { + type: 'http', + name: 'github', + url: 'https://mcp.example', + headers: { Authorization: 'Bearer x' }, + }, + }, + }, + ]); + }); +}); 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..66545bd0f --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -0,0 +1,165 @@ +import type { PluginList } from '@linkcode/client-core'; +import type { Plugin } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { + filterPluginCards, + pluginCardView, + pluginMcpServerRows, + pluginProviderGroups, + skillRows, +} from '../view'; + +function plugin(overrides: Partial = {}): Plugin { + return { + provider: 'claude-code', + id: 'latex@team-tools', + name: 'latex', + displayName: 'LaTeX Toolkit', + description: 'Compile LaTeX documents.', + version: '2.0.0', + keywords: ['latex'], + marketplace: { name: 'team-tools', displayName: 'Team Tools' }, + availability: 'available', + installations: [{ enabled: true, version: '1.2.3', scope: 'user' }], + components: [ + { kind: 'skill', name: 'compile-latex', description: 'Compile to PDF' }, + { kind: 'skill', name: 'bibtex-cleanup' }, + { kind: 'mcp-server', name: 'latex-tools' }, + ], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: true, + disable: true, + }, + ...overrides, + }; +} + +function list(overrides: Partial = {}): PluginList { + return { + plugins: [plugin()], + standaloneSkills: [], + providerStatus: [ + { provider: 'claude-code', ok: true }, + { provider: 'codex', ok: false, reason: 'binary not found' }, + ], + ...overrides, + }; +} + +describe('pluginCardView', () => { + it('derives title, install state, capability gating, and component counts', () => { + const card = pluginCardView(plugin()); + + expect(card).toMatchObject({ + key: 'claude-code:latex@team-tools', + title: 'LaTeX Toolkit', + version: '1.2.3', + marketplaceLabel: 'Team Tools', + installed: true, + installations: [{ scope: 'user', enabled: true, canToggle: true }], + componentCounts: { skill: 2, 'mcp-server': 1 }, + }); + }); + + it('gates the toggle off when the provider reports no management capability', () => { + const card = pluginCardView( + plugin({ + provider: 'codex', + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: false, + disable: false, + }, + }), + ); + + expect(card.installations[0].canToggle).toBe(false); + }); +}); + +describe('pluginProviderGroups', () => { + it('keeps failed discovery distinguishable from an empty catalog', () => { + const groups = pluginProviderGroups(list()); + + expect(groups).toHaveLength(2); + expect(groups[0]).toMatchObject({ provider: 'claude-code', discoveryFailed: false }); + expect(groups[0].plugins).toHaveLength(1); + expect(groups[1]).toMatchObject({ + provider: 'codex', + discoveryFailed: true, + failureReason: 'binary not found', + plugins: [], + }); + }); +}); + +describe('filterPluginCards', () => { + it('matches id, title, description, keywords, and component names', () => { + const cards = [pluginCardView(plugin())]; + + expect(filterPluginCards(cards, 'bibtex')).toHaveLength(1); + expect(filterPluginCards(cards, 'TOOLKIT')).toHaveLength(1); + expect(filterPluginCards(cards, 'nonexistent')).toHaveLength(0); + expect(filterPluginCards(cards, ' ')).toHaveLength(1); + }); +}); + +describe('skillRows', () => { + it('groups plugin skills with sibling counts and appends standalone skills', () => { + const rows = skillRows( + list({ + standaloneSkills: [ + { + provider: 'codex', + id: 'linear', + name: 'linear', + scope: 'project', + path: '/x', + toggleable: false, + }, + ], + }), + ); + + expect(rows.map((row) => row.name)).toEqual(['compile-latex', 'bibtex-cleanup', 'linear']); + expect(rows[0]).toMatchObject({ + pluginTitle: 'LaTeX Toolkit', + canToggle: true, + siblingSkillCount: 2, + standaloneScope: undefined, + }); + expect(rows[2]).toMatchObject({ + pluginKey: undefined, + canToggle: false, + standaloneScope: 'project', + }); + }); + + it('excludes skills from plugins that are not installed', () => { + const rows = skillRows(list({ plugins: [plugin({ installations: [] })] })); + + expect(rows).toEqual([]); + }); +}); + +describe('pluginMcpServerRows', () => { + it('projects installed plugins’ mcp-server components read-only', () => { + const rows = pluginMcpServerRows([plugin(), plugin({ id: 'x@y', installations: [] })]); + + expect(rows).toEqual([ + { + key: 'claude-code:latex@team-tools:latex-tools', + provider: 'claude-code', + pluginTitle: 'LaTeX Toolkit', + serverName: 'latex-tools', + enabled: true, + }, + ]); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/custom-mcp-patch.ts b/packages/client/workbench/src/settings/plugins/custom-mcp-patch.ts new file mode 100644 index 000000000..fc3b09ec5 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/custom-mcp-patch.ts @@ -0,0 +1,155 @@ +import type { + CustomMcpServerPatchOp, + CustomMcpServerPublic, + McpSecretPatch, + McpServer, +} from '@linkcode/schema'; +import { isObjectEmpty } from 'foxts/is-object-empty'; + +/** + * The add/edit dialog's normalized output. Secret rows follow the masked-edit contract: an + * existing key renders with an EMPTY value ("configured" placeholder) — empty means keep, a typed + * value means replace, `remove` stages deletion. New rows with empty values are dropped. + */ +export interface CustomMcpSecretRow { + key: string; + value: string; + remove?: boolean; +} + +export type CustomMcpServerDraft = + | { + type: 'stdio'; + name: string; + command: string; + args: string[]; + secrets: CustomMcpSecretRow[]; + } + | { + type: 'http'; + name: string; + url: string; + secrets: CustomMcpSecretRow[]; + }; + +export interface CustomMcpMint { + id: string; + createdAt: number; +} + +/** + * Diff a dialog draft against the masked previous state into config.set patch ops. This is the + * single place secret-clobber bugs could hide: the client never holds secret values, so it must + * express edits per key and never resend whole servers. + * + * - no previous → one `add` (mint supplies id/createdAt); + * - transport changed → `remove` + `add` (secrets must be re-entered — nothing carries over); + * - same transport → one `update` with per-key `{set, remove}`, or `[]` when nothing changed. + */ +export function buildCustomMcpPatch( + previous: CustomMcpServerPublic | undefined, + draft: CustomMcpServerDraft, + mint: CustomMcpMint, +): CustomMcpServerPatchOp[] { + if (!previous) return [{ op: 'add', server: mintedServer(draft, mint, true) }]; + if (previous.server.type !== draft.type) { + return [ + { op: 'remove', id: previous.id }, + { op: 'add', server: mintedServer(draft, mint, previous.enabled) }, + ]; + } + const previousKeys = new Set( + previous.server.type === 'stdio' ? previous.server.envKeys : previous.server.headerKeys, + ); + const secrets = diffSecrets(draft.secrets, previousKeys); + const nonSecretChanged = + previous.server.name !== draft.name || + (previous.server.type === 'stdio' && draft.type === 'stdio' + ? previous.server.command !== draft.command || + !sameArgs(previous.server.args ?? [], draft.args) + : previous.server.type === 'http' && + draft.type === 'http' && + previous.server.url !== draft.url); + if (!nonSecretChanged && !secrets) return []; + return [ + { + op: 'update', + id: previous.id, + server: + draft.type === 'stdio' + ? { + type: 'stdio', + name: draft.name, + command: draft.command, + args: draft.args.length > 0 ? draft.args : undefined, + ...(secrets && { env: secrets }), + } + : { + type: 'http', + name: draft.name, + url: draft.url, + ...(secrets && { headers: secrets }), + }, + }, + ]; +} + +/** Empty value on an existing key = keep (no entry at all); typed value = set; remove = remove. + * A brand-new key needs a value to count. */ +function diffSecrets( + rows: readonly CustomMcpSecretRow[], + previousKeys: ReadonlySet, +): McpSecretPatch | undefined { + const set: Record = {}; + const remove: string[] = []; + let touched = false; + for (const row of rows) { + const key = row.key.trim(); + if (!key) continue; + if (row.remove) { + if (previousKeys.has(key)) { + remove.push(key); + touched = true; + } + continue; + } + if (row.value !== '') { + set[key] = row.value; + touched = true; + } + } + if (!touched) return undefined; + return { + ...(!isObjectEmpty(set) && { set }), + ...(remove.length > 0 && { remove }), + }; +} + +function mintedServer(draft: CustomMcpServerDraft, mint: CustomMcpMint, enabled: boolean) { + const secrets: Record = {}; + for (const row of draft.secrets) { + const key = row.key.trim(); + if (key && !row.remove && row.value !== '') secrets[key] = row.value; + } + const hasSecrets = !isObjectEmpty(secrets); + const server: McpServer = + draft.type === 'stdio' + ? { + type: 'stdio', + name: draft.name, + command: draft.command, + args: draft.args.length > 0 ? draft.args : undefined, + env: hasSecrets ? secrets : undefined, + } + : { + type: 'http', + name: draft.name, + url: draft.url, + headers: hasSecrets ? secrets : undefined, + }; + return { id: mint.id, enabled, server, createdAt: mint.createdAt }; +} + +function sameArgs(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} 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..9eb49f10a --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/hooks.ts @@ -0,0 +1,33 @@ +import { + getCustomMcpServers, + getPlugins, + setCustomMcpServers, + setPluginEnabled, +} from '@linkcode/sdk'; +import { useData, useMutation } from '../../runtime/tayori'; + +/** + * Provider-plugin + standalone-skill discovery. The daemon shells out to the agent CLIs (codex + * bounds one pass at 30s), so this deliberately never revalidates on focus/reconnect — the page + * offers a manual refresh (`mutate()`) instead, and a toggle patches its single entry via + * `mutate(map, { revalidate: false })` rather than re-running discovery. + * + * Settings is a global surface with no active-workspace concept, so discovery runs without a + * `cwd`; project-scoped marketplaces and skills intentionally don't appear here. + */ +export function usePlugins() { + return useData(getPlugins, {}, { revalidateOnFocus: false, revalidateOnReconnect: false }); +} + +export function useSetPluginEnabled() { + return useMutation(setPluginEnabled); +} + +/** Masked custom MCP servers; cheap config read, normal trigger-then-revalidate rhythm. */ +export function useCustomMcpServers() { + return useData(getCustomMcpServers, {}); +} + +export function useSetCustomMcpServers() { + return useMutation(setCustomMcpServers); +} 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..6eecdbf89 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/view.ts @@ -0,0 +1,199 @@ +import type { PluginList } from '@linkcode/client-core'; +import type { + Plugin, + PluginAvailability, + PluginComponentKind, + PluginProvider, + PluginScope, + StandaloneSkill, + StandaloneSkillScope, +} from '@linkcode/schema'; + +/** Pure projections from the discovery result to presentation view-models. No React, no I/O. */ + +export interface PluginInstallationRow { + scope: PluginScope | undefined; + enabled: boolean; + version: string | undefined; + /** Per-plugin management capability; the switch renders only when true. */ + canToggle: boolean; +} + +export interface PluginCardView { + /** `${provider}:${id}` — stable across re-sorts and refreshes. */ + key: string; + provider: PluginProvider; + id: string; + title: string; + description: string | undefined; + version: string | undefined; + marketplaceLabel: string | undefined; + availability: PluginAvailability; + installed: boolean; + installations: PluginInstallationRow[]; + componentCounts: Partial>; + /** Precomputed lowercase haystack for the client-side filter. */ + searchText: string; +} + +export interface PluginProviderGroup { + provider: PluginProvider; + /** Discovery for this provider failed (CLI missing/broken) — not the same as zero plugins. */ + discoveryFailed: boolean; + failureReason: string | undefined; + plugins: PluginCardView[]; +} + +export interface SkillRowView { + key: string; + provider: PluginProvider; + /** Present for plugin-bundled skills; undefined for standalone ones. */ + pluginKey: string | undefined; + pluginTitle: string | undefined; + name: string; + description: string | undefined; + enabled: boolean; + /** Toggling acts on the whole plugin (no provider has per-skill toggling). */ + canToggle: boolean; + /** How many skills the owning plugin bundles — >1 shows the "toggles together" note. */ + siblingSkillCount: number; + standaloneScope: StandaloneSkillScope | undefined; + searchText: string; +} + +export interface PluginMcpServerRow { + key: string; + provider: PluginProvider; + pluginTitle: string; + serverName: string; + enabled: boolean; +} + +export function pluginCardView(plugin: Plugin): PluginCardView { + const title = plugin.displayName ?? plugin.name; + const componentCounts: Partial> = {}; + for (const component of plugin.components) { + componentCounts[component.kind] = (componentCounts[component.kind] ?? 0) + 1; + } + const canToggle = plugin.managementCapabilities.enable && plugin.managementCapabilities.disable; + return { + key: `${plugin.provider}:${plugin.id}`, + provider: plugin.provider, + id: plugin.id, + title, + description: plugin.description, + version: plugin.installations.find((entry) => entry.version)?.version ?? plugin.version, + marketplaceLabel: plugin.marketplace?.displayName ?? plugin.marketplace?.name, + availability: plugin.availability, + installed: plugin.installations.length > 0, + installations: plugin.installations.map((entry) => ({ + scope: entry.scope, + enabled: entry.enabled, + version: entry.version, + canToggle, + })), + componentCounts, + searchText: [ + plugin.id, + title, + plugin.description ?? '', + ...plugin.keywords, + ...plugin.components.map((component) => component.name), + ] + .join('\n') + .toLowerCase(), + }; +} + +/** Group cards by provider in the discovery result's provider order, folding in per-provider + * failure state so an empty group renders "discovery failed", not a bare empty state. */ +export function pluginProviderGroups(list: PluginList): PluginProviderGroup[] { + return list.providerStatus.map((status) => { + const plugins: PluginCardView[] = []; + for (const plugin of list.plugins) { + if (plugin.provider === status.provider) plugins.push(pluginCardView(plugin)); + } + return { + provider: status.provider, + discoveryFailed: !status.ok, + failureReason: status.reason, + plugins, + }; + }); +} + +export function filterPluginCards( + cards: readonly PluginCardView[], + query: string, +): PluginCardView[] { + const needle = query.trim().toLowerCase(); + if (!needle) return [...cards]; + return cards.filter((card) => card.searchText.includes(needle)); +} + +/** Plugin-bundled skills (installed plugins only) followed by standalone skills. */ +export function skillRows(list: PluginList): SkillRowView[] { + const rows: SkillRowView[] = []; + for (const plugin of list.plugins) { + if (plugin.installations.length === 0) continue; + const card = pluginCardView(plugin); + const skills = plugin.components.filter((component) => component.kind === 'skill'); + const pluginEnabled = plugin.installations.some((entry) => entry.enabled); + for (const skill of skills) { + rows.push({ + key: `${card.key}:${skill.name}`, + provider: plugin.provider, + pluginKey: card.key, + pluginTitle: card.title, + name: skill.name, + description: skill.description, + enabled: skill.enabled ?? pluginEnabled, + canToggle: plugin.managementCapabilities.enable && plugin.managementCapabilities.disable, + siblingSkillCount: skills.length, + standaloneScope: undefined, + searchText: `${skill.name}\n${skill.description ?? ''}\n${card.title}`.toLowerCase(), + }); + } + } + for (const skill of list.standaloneSkills) { + rows.push(standaloneSkillRow(skill)); + } + return rows; +} + +function standaloneSkillRow(skill: StandaloneSkill): SkillRowView { + return { + key: `${skill.provider}:standalone:${skill.scope}:${skill.id}`, + provider: skill.provider, + pluginKey: undefined, + pluginTitle: undefined, + name: skill.name, + description: skill.description, + enabled: true, + canToggle: skill.toggleable, + siblingSkillCount: 1, + standaloneScope: skill.scope, + searchText: `${skill.name}\n${skill.description ?? ''}`.toLowerCase(), + }; +} + +/** Read-only projection of plugin-provided MCP servers for the MCP tab's lower section. */ +export function pluginMcpServerRows(plugins: readonly Plugin[]): PluginMcpServerRow[] { + const rows: PluginMcpServerRow[] = []; + for (const plugin of plugins) { + if (plugin.installations.length === 0) continue; + const title = plugin.displayName ?? plugin.name; + const pluginEnabled = plugin.installations.some((entry) => entry.enabled); + for (const component of plugin.components) { + if (component.kind !== 'mcp-server') continue; + rows.push({ + key: `${plugin.provider}:${plugin.id}:${component.name}`, + provider: plugin.provider, + pluginTitle: title, + serverName: component.name, + enabled: component.enabled ?? pluginEnabled, + }); + } + } + return rows; +} From d46ab60e58ccaee768b19d76dbdc5efa00d66c95 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 15:57:37 +0800 Subject: [PATCH 08/24] feat(settings): Plugins tab with provider-grouped plugin cards (CODE-495) --- .../src/renderer/src/settings/plugins-tab.tsx | 7 + .../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 | 10 ++ .../src/routes/settings/settings-layout.tsx | 11 ++ packages/client/workbench/src/index.ts | 4 + .../src/settings/plugins/plugins-settings.tsx | 79 +++++++++++ .../workbench/src/settings/plugins/view.ts | 72 +--------- .../client/workbench/src/settings/search.ts | 9 ++ packages/presentation/i18n/src/locales/en.ts | 42 ++++++ .../presentation/i18n/src/locales/zh-cn.ts | 42 ++++++ packages/presentation/ui/src/shell/index.ts | 1 + .../ui/src/shell/plugins/index.ts | 4 + .../ui/src/shell/plugins/plugin-card.tsx | 127 ++++++++++++++++++ .../ui/src/shell/plugins/plugins-shell.tsx | 66 +++++++++ .../ui/src/shell/plugins/plugins-tab.tsx | 94 +++++++++++++ .../ui/src/shell/plugins/types.ts | 78 +++++++++++ 18 files changed, 596 insertions(+), 65 deletions(-) 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/index.ts create mode 100644 packages/presentation/ui/src/shell/plugins/plugin-card.tsx create mode 100644 packages/presentation/ui/src/shell/plugins/plugins-shell.tsx create mode 100644 packages/presentation/ui/src/shell/plugins/plugins-tab.tsx create mode 100644 packages/presentation/ui/src/shell/plugins/types.ts 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..0d0880460 --- /dev/null +++ b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx @@ -0,0 +1,7 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; + +// A transport-backed workbench container: reachable above the connection gate, degrading to +// loading while the daemon is unreachable — same posture as the providers tab. +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..6d71ada49 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, + PuzzleIcon, 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: , @@ -299,6 +309,8 @@ function renderSettingsPanel( return ; case 'providers': return ; + case 'plugins': + return ; case 'agents': return ; case 'imChannel': diff --git a/apps/desktop/src/renderer/src/settings/store.ts b/apps/desktop/src/renderer/src/settings/store.ts index 2173ba02b..453d989e1 100644 --- a/apps/desktop/src/renderer/src/settings/store.ts +++ b/apps/desktop/src/renderer/src/settings/store.ts @@ -14,6 +14,7 @@ export type SettingsCategory = | 'providers' | 'agents' | 'imChannel' + | 'plugins' | '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..c0a442883 --- /dev/null +++ b/apps/webview/src/routes/settings/plugins.tsx @@ -0,0 +1,10 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; +import { usePageTitle } from '@webview/hooks/use-page-title'; +import { useTranslations } from 'use-intl'; + +/** The shared plugins page lives in `@linkcode/workbench`; webview only adds the page title. */ +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..4f022b84e 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, + PuzzleIcon, 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 cf0fc9296..5f6ac21e0 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -44,6 +44,10 @@ 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/custom-mcp-patch'; +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'; 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..0f1fa5d6a --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx @@ -0,0 +1,79 @@ +import type { Plugin, PluginScope } from '@linkcode/schema'; +import type { PluginCardView } from '@linkcode/ui'; +import { PluginsShell, PluginsTab } from '@linkcode/ui'; +import { useState } from 'react'; +import { useAgentRuntimes } from '../../agent-runtime/hooks'; +import { usePlugins, useSetPluginEnabled } from './hooks'; +import { filterPluginCards, pluginProviderGroups } from './view'; + +/** + * The plugins/MCP/skills settings page container. Transport-backed — it must render inside + * `WorkbenchProviders`, but may sit above the connection gate, degrading to loading while the + * daemon is unreachable. Discovery is a CLI shell-out, so refresh is manual only. + */ +export function PluginsSettingsPanel(): React.ReactNode { + const { data, isLoading, isValidating, mutate } = usePlugins(); + const { data: runtimes } = useAgentRuntimes(); + const toggle = useSetPluginEnabled(); + const [searchQuery, setSearchQuery] = useState(''); + + const missingRuntimes = new Set(); + for (const [kind, runtime] of Object.entries(runtimes ?? {})) { + if (runtime.status === 'missing') missingRuntimes.add(kind); + } + + const groups = + data === undefined + ? undefined + : pluginProviderGroups(data).map((group) => ({ + ...group, + plugins: filterPluginCards(group.plugins, searchQuery), + })); + + const onToggleInstallation = async ( + card: PluginCardView, + scope: PluginScope | undefined, + enabled: boolean, + ): Promise => { + const updated = await toggle.trigger({ provider: card.provider, id: card.id, enabled, scope }); + if (updated === undefined) return; + // Fold the single re-listed plugin into the cache instead of revalidating: a full mutate() + // would re-run the expensive CLI discovery for one switch flip. + void mutate( + (current) => + current && { + ...current, + plugins: current.plugins.map((plugin) => replaceIfSame(plugin, updated)), + }, + { revalidate: false }, + ); + }; + + return ( + { + void mutate(); + }} + refreshing={isLoading || isValidating} + pluginsTab={ + { + void onToggleInstallation(card, scope, enabled); + }} + /> + } + mcpTab={null} + skillsTab={null} + /> + ); +} + +function replaceIfSame(plugin: Plugin, updated: Plugin): Plugin { + return plugin.provider === updated.provider && plugin.id === updated.id ? updated : plugin; +} diff --git a/packages/client/workbench/src/settings/plugins/view.ts b/packages/client/workbench/src/settings/plugins/view.ts index 6eecdbf89..5ee8291ec 100644 --- a/packages/client/workbench/src/settings/plugins/view.ts +++ b/packages/client/workbench/src/settings/plugins/view.ts @@ -1,73 +1,15 @@ import type { PluginList } from '@linkcode/client-core'; +import type { Plugin, PluginComponentKind, StandaloneSkill } from '@linkcode/schema'; import type { - Plugin, - PluginAvailability, - PluginComponentKind, - PluginProvider, - PluginScope, - StandaloneSkill, - StandaloneSkillScope, -} from '@linkcode/schema'; + PluginCardView, + PluginMcpServerRow, + PluginProviderGroup, + SkillRowView, +} from '@linkcode/ui'; /** Pure projections from the discovery result to presentation view-models. No React, no I/O. */ -export interface PluginInstallationRow { - scope: PluginScope | undefined; - enabled: boolean; - version: string | undefined; - /** Per-plugin management capability; the switch renders only when true. */ - canToggle: boolean; -} - -export interface PluginCardView { - /** `${provider}:${id}` — stable across re-sorts and refreshes. */ - key: string; - provider: PluginProvider; - id: string; - title: string; - description: string | undefined; - version: string | undefined; - marketplaceLabel: string | undefined; - availability: PluginAvailability; - installed: boolean; - installations: PluginInstallationRow[]; - componentCounts: Partial>; - /** Precomputed lowercase haystack for the client-side filter. */ - searchText: string; -} - -export interface PluginProviderGroup { - provider: PluginProvider; - /** Discovery for this provider failed (CLI missing/broken) — not the same as zero plugins. */ - discoveryFailed: boolean; - failureReason: string | undefined; - plugins: PluginCardView[]; -} - -export interface SkillRowView { - key: string; - provider: PluginProvider; - /** Present for plugin-bundled skills; undefined for standalone ones. */ - pluginKey: string | undefined; - pluginTitle: string | undefined; - name: string; - description: string | undefined; - enabled: boolean; - /** Toggling acts on the whole plugin (no provider has per-skill toggling). */ - canToggle: boolean; - /** How many skills the owning plugin bundles — >1 shows the "toggles together" note. */ - siblingSkillCount: number; - standaloneScope: StandaloneSkillScope | undefined; - searchText: string; -} - -export interface PluginMcpServerRow { - key: string; - provider: PluginProvider; - pluginTitle: string; - serverName: string; - enabled: boolean; -} +export type { PluginCardView, PluginMcpServerRow, PluginProviderGroup, SkillRowView }; export function pluginCardView(plugin: Plugin): PluginCardView { const title = plugin.displayName ?? plugin.name; diff --git a/packages/client/workbench/src/settings/search.ts b/packages/client/workbench/src/settings/search.ts index 9ff7faf18..973af6234 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[]; } @@ -90,6 +91,14 @@ export function useSettingsSearchKeywords(): SettingsSearchKeywords { ...PROVIDER_SERVICES.map((service) => t(`providers.serviceName.${service}`)), ], imChannel: [t('imChannel.connectTitle'), t('imChannel.bindings'), t('imChannel.autoMirror')], + plugins: [ + t('plugins.title'), + t('plugins.tabPlugins'), + t('plugins.tabMcp'), + t('plugins.tabSkills'), + t('plugins.componentKind.mcp-server'), + t('plugins.componentKind.skill'), + ], historyImport: [t('historyImport.portalLabel')], }; } diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index f659da5f5..021059486 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -666,6 +666,48 @@ export const en = { providers: 'Providers', agents: 'Agents', imChannel: 'Messaging', + plugins: 'Plugins & Skills', + }, + plugins: { + title: 'Plugins & Skills', + hint: "Plugins come from Claude Code's and Codex's own marketplaces and may bundle skills, commands, or MCP tool servers; whether they can be toggled here depends on the agent.", + searchPlaceholder: 'Search…', + refresh: 'Rescan', + tabPlugins: 'Plugins', + tabMcp: 'MCP', + tabSkills: 'Skills', + discoveryFailed: 'Could not read {provider} plugins: {reason}', + discoveryFailedUnknown: 'discovery failed', + runtimeMissing: '{provider} was not detected; install it to manage its plugins here.', + emptyHint: 'No plugins discovered for this agent yet.', + noSearchResults: 'No matching plugins.', + notInstalled: 'Not installed', + availability: { + blocked: 'Blocked', + unknown: 'Unknown', + }, + enabledReadOnly: 'Enabled · managed by the agent', + disabledReadOnly: 'Disabled · managed by the agent', + scope: { + user: 'User', + project: 'Project', + local: 'Local', + managed: 'Managed', + unknown: 'Default', + }, + componentCount: '{count} {kind}', + componentKind: { + skill: 'skills', + command: 'commands', + agent: 'agents', + hook: 'hooks', + 'mcp-server': 'MCP servers', + 'lsp-server': 'LSP servers', + 'output-style': 'output styles', + channel: 'channels', + app: 'apps', + 'app-template': 'app templates', + }, }, historyImport: { portalLabel: 'Import chat history', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 75cd62813..580af97d8 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -654,6 +654,48 @@ export const zhCN = { providers: 'Providers', agents: '智能体', imChannel: 'IM 渠道', + plugins: '插件与技能', + }, + plugins: { + title: '插件与技能', + hint: '插件来自 Claude Code / Codex 自带的插件市场,可能包含技能、指令或 MCP 工具服务;能否在这里启停取决于所用智能体是否支持。', + searchPlaceholder: '搜索…', + refresh: '重新扫描', + tabPlugins: '插件', + tabMcp: 'MCP', + tabSkills: '技能', + discoveryFailed: '无法读取 {provider} 的插件:{reason}', + discoveryFailedUnknown: '扫描失败', + runtimeMissing: '未检测到 {provider},安装后即可在这里管理它的插件。', + emptyHint: '该智能体还没有发现任何插件。', + noSearchResults: '没有匹配的插件。', + notInstalled: '未安装', + availability: { + blocked: '已被限制', + unknown: '状态未知', + }, + enabledReadOnly: '已启用 · 由智能体管理', + disabledReadOnly: '已禁用 · 由智能体管理', + scope: { + user: '用户', + project: '项目', + local: '本地', + managed: '托管', + unknown: '默认', + }, + componentCount: '{count} 个{kind}', + componentKind: { + skill: '技能', + command: '指令', + agent: '子智能体', + hook: '钩子', + 'mcp-server': 'MCP 服务', + 'lsp-server': 'LSP 服务', + 'output-style': '输出风格', + channel: '渠道', + app: '应用', + 'app-template': '应用模板', + }, }, historyImport: { portalLabel: '导入聊天历史', diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index 14658c8ea..79130ee54 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'; export * from './providers/account-detail'; export * from './providers/account-master-list'; export * from './service-icon'; diff --git a/packages/presentation/ui/src/shell/plugins/index.ts b/packages/presentation/ui/src/shell/plugins/index.ts new file mode 100644 index 000000000..d60316057 --- /dev/null +++ b/packages/presentation/ui/src/shell/plugins/index.ts @@ -0,0 +1,4 @@ +export * from './plugin-card'; +export * from './plugins-shell'; +export * from './plugins-tab'; +export type * from './types'; diff --git a/packages/presentation/ui/src/shell/plugins/plugin-card.tsx b/packages/presentation/ui/src/shell/plugins/plugin-card.tsx new file mode 100644 index 000000000..6d145d4ff --- /dev/null +++ b/packages/presentation/ui/src/shell/plugins/plugin-card.tsx @@ -0,0 +1,127 @@ +import type { PluginScope } from '@linkcode/schema'; +import { Badge } from 'coss-ui/components/badge'; +import { Card } from 'coss-ui/components/card'; +import { Switch } from 'coss-ui/components/switch'; +import { useTranslations } from 'use-intl'; +import { AgentIcon } from '../../chat/agent-icon'; +import type { PluginCardView, PluginInstallationRow } from './types'; + +export interface PluginCardProps { + card: PluginCardView; + busy: boolean; + onToggleInstallation: ( + card: PluginCardView, + scope: PluginScope | undefined, + enabled: boolean, + ) => void; +} + +/** One provider plugin: identity, capability summary, and per-installation enablement. */ +export function PluginCard({ card, busy, onToggleInstallation }: PluginCardProps): React.ReactNode { + const t = useTranslations('settings.plugins'); + return ( + +
+
+ +
+
+ {card.title} + {card.version === undefined ? null : ( + v{card.version} + )} + {card.availability === 'available' ? null : ( + {t(`availability.${card.availability}`)} + )} + {card.installed ? null : {t('notInstalled')}} +
+ {card.description === undefined ? null : ( +

{card.description}

+ )} +
+ {card.marketplaceLabel === undefined ? null : {card.marketplaceLabel}} + +
+
+
+
+ {card.installations.length === 0 ? null : ( +
+ {card.installations.map((installation) => ( + onToggleInstallation(card, installation.scope, enabled)} + /> + ))} +
+ )} +
+ ); +} + +function InstallationRow({ + installation, + busy, + onToggle, +}: { + installation: PluginInstallationRow; + busy: boolean; + onToggle: (enabled: boolean) => void; +}): React.ReactNode { + const t = useTranslations('settings.plugins'); + return ( +
+
+ + {installation.scope === undefined ? t('scope.unknown') : t(`scope.${installation.scope}`)} + + {installation.version === undefined ? null : ( + v{installation.version} + )} +
+ {installation.canToggle ? ( + onToggle(checked)} + /> + ) : ( + + {installation.enabled ? t('enabledReadOnly') : t('disabledReadOnly')} + + )} +
+ ); +} + +const COMPONENT_KIND_ORDER = [ + 'skill', + 'command', + 'agent', + 'hook', + 'mcp-server', + 'lsp-server', + 'output-style', + 'channel', + 'app', + 'app-template', +] as const; + +function ComponentSummary({ + counts, +}: { + counts: PluginCardView['componentCounts']; +}): React.ReactNode { + const t = useTranslations('settings.plugins'); + const parts: string[] = []; + for (const kind of COMPONENT_KIND_ORDER) { + const count = counts[kind]; + if (count !== undefined && count > 0) { + parts.push(t('componentCount', { count, kind: t(`componentKind.${kind}`) })); + } + } + if (parts.length === 0) return null; + return {parts.join(' · ')}; +} diff --git a/packages/presentation/ui/src/shell/plugins/plugins-shell.tsx b/packages/presentation/ui/src/shell/plugins/plugins-shell.tsx new file mode 100644 index 000000000..32904ddee --- /dev/null +++ b/packages/presentation/ui/src/shell/plugins/plugins-shell.tsx @@ -0,0 +1,66 @@ +import { Button } from 'coss-ui/components/button'; +import { Input } from 'coss-ui/components/input'; +import { Tabs, TabsList, TabsPanel, TabsTab } from 'coss-ui/components/tabs'; +import { RefreshCwIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { cn } from '../../lib/cn'; +import { SettingsPageTitle } from '../settings-page'; + +export interface PluginsShellProps { + searchQuery: string; + onSearchChange: (query: string) => void; + /** Discovery re-run; disabled while one is in flight (a real CLI shell-out on the daemon). */ + onRefresh: () => void; + refreshing: boolean; + pluginsTab: React.ReactNode; + mcpTab: React.ReactNode; + skillsTab: React.ReactNode; +} + +/** The plugins/MCP/skills settings page frame: title, search, manual refresh, three tabs. */ +export function PluginsShell({ + searchQuery, + onSearchChange, + onRefresh, + refreshing, + pluginsTab, + mcpTab, + skillsTab, +}: PluginsShellProps): React.ReactNode { + const t = useTranslations('settings.plugins'); + return ( +
+ {t('title')} +

{t('hint')}

+ +
+ + {t('tabPlugins')} + {t('tabMcp')} + {t('tabSkills')} + +
+ onSearchChange(event.target.value)} + /> + +
+
+ {pluginsTab} + {mcpTab} + {skillsTab} +
+
+ ); +} diff --git a/packages/presentation/ui/src/shell/plugins/plugins-tab.tsx b/packages/presentation/ui/src/shell/plugins/plugins-tab.tsx new file mode 100644 index 000000000..cbe4f9de2 --- /dev/null +++ b/packages/presentation/ui/src/shell/plugins/plugins-tab.tsx @@ -0,0 +1,94 @@ +import type { PluginScope } from '@linkcode/schema'; +import { Card } from 'coss-ui/components/card'; +import { Skeleton } from 'coss-ui/components/skeleton'; +import { createFixedArray } from 'foxact/create-fixed-array'; +import { useTranslations } from 'use-intl'; +import { AGENT_LABELS } from '../../chat/agent-icon'; +import { SettingsSection } from '../settings-page'; +import { PluginCard } from './plugin-card'; +import type { PluginCardView, PluginProviderGroup } from './types'; + +const SKELETON_ROWS = createFixedArray(3); + +export interface PluginsTabProps { + /** Undefined while the first discovery is loading (skeletons). */ + groups: PluginProviderGroup[] | undefined; + /** Providers whose runtime the host has not detected at all. */ + missingRuntimes: ReadonlySet; + searchQuery: string; + busy: boolean; + onToggleInstallation: ( + card: PluginCardView, + scope: PluginScope | undefined, + enabled: boolean, + ) => void; +} + +/** Provider-grouped plugin cards with honest empty/failed/missing states. */ +export function PluginsTab({ + groups, + missingRuntimes, + searchQuery, + busy, + onToggleInstallation, +}: PluginsTabProps): React.ReactNode { + const t = useTranslations('settings.plugins'); + if (groups === undefined) { + return ( +
+ {SKELETON_ROWS.map((index) => ( + + ))} +
+ ); + } + const filtering = searchQuery.trim().length > 0; + return ( +
+ {groups.map((group) => { + const label = AGENT_LABELS[group.provider]; + return ( + + {group.discoveryFailed ? ( + + ) : group.plugins.length === 0 ? ( + + ) : ( +
+ {group.plugins.map((card) => ( + + ))} +
+ )} +
+ ); + })} +
+ ); +} + +function EmptyRow({ text }: { text: string }): React.ReactNode { + return ( + +

{text}

+
+ ); +} diff --git a/packages/presentation/ui/src/shell/plugins/types.ts b/packages/presentation/ui/src/shell/plugins/types.ts new file mode 100644 index 000000000..69a2dd58a --- /dev/null +++ b/packages/presentation/ui/src/shell/plugins/types.ts @@ -0,0 +1,78 @@ +import type { + PluginAvailability, + PluginComponentKind, + PluginProvider, + PluginScope, + StandaloneSkillScope, +} from '@linkcode/schema'; + +/** View-models for the plugins/MCP/skills settings page. Derived by the workbench data plane + * (`@linkcode/workbench` settings/plugins/view.ts); presentation renders them verbatim. */ + +export interface PluginInstallationRow { + scope: PluginScope | undefined; + enabled: boolean; + version: string | undefined; + /** Per-plugin management capability; the switch renders only when true. */ + canToggle: boolean; +} + +export interface PluginCardView { + /** `${provider}:${id}` — stable across re-sorts and refreshes. */ + key: string; + provider: PluginProvider; + id: string; + title: string; + description: string | undefined; + version: string | undefined; + marketplaceLabel: string | undefined; + availability: PluginAvailability; + installed: boolean; + installations: PluginInstallationRow[]; + componentCounts: Partial>; + /** Precomputed lowercase haystack for the client-side filter. */ + searchText: string; +} + +export interface PluginProviderGroup { + provider: PluginProvider; + /** Discovery for this provider failed (CLI missing/broken) — not the same as zero plugins. */ + discoveryFailed: boolean; + failureReason: string | undefined; + plugins: PluginCardView[]; +} + +export interface SkillRowView { + key: string; + provider: PluginProvider; + /** Present for plugin-bundled skills; undefined for standalone ones. */ + pluginKey: string | undefined; + pluginTitle: string | undefined; + name: string; + description: string | undefined; + enabled: boolean; + /** Toggling acts on the whole plugin (no provider has per-skill toggling). */ + canToggle: boolean; + /** How many skills the owning plugin bundles — >1 shows the "toggles together" note. */ + siblingSkillCount: number; + standaloneScope: StandaloneSkillScope | undefined; + searchText: string; +} + +export interface PluginMcpServerRow { + key: string; + provider: PluginProvider; + pluginTitle: string; + serverName: string; + enabled: boolean; +} + +export interface CustomMcpServerRow { + id: string; + name: string; + transport: 'stdio' | 'http'; + /** Command (stdio) or URL (http) — the row's secondary line. */ + detail: string; + enabled: boolean; + secretKeys: string[]; +} From 029aa9fa00d8cde35003e6f7c7ec0076fcf1e128 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Thu, 30 Jul 2026 16:04:54 +0800 Subject: [PATCH 09/24] =?UTF-8?q?feat(settings):=20MCP=20tab=20=E2=80=94?= =?UTF-8?q?=20custom=20server=20management=20and=20plugin-provided=20view?= =?UTF-8?q?=20(CODE-496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/settings/plugins/mcp-settings.tsx | 328 ++++++++++++++++++ .../src/settings/plugins/plugins-settings.tsx | 5 +- packages/presentation/i18n/src/locales/en.ts | 33 ++ .../presentation/i18n/src/locales/zh-cn.ts | 32 ++ .../src/shell/plugins/custom-server-list.tsx | 119 +++++++ .../ui/src/shell/plugins/index.ts | 1 + 6 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 packages/client/workbench/src/settings/plugins/mcp-settings.tsx create mode 100644 packages/presentation/ui/src/shell/plugins/custom-server-list.tsx diff --git a/packages/client/workbench/src/settings/plugins/mcp-settings.tsx b/packages/client/workbench/src/settings/plugins/mcp-settings.tsx new file mode 100644 index 000000000..fd814ab47 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/mcp-settings.tsx @@ -0,0 +1,328 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { CustomMcpServerPublic } from '@linkcode/schema'; +import type { CustomMcpServerRow, PluginMcpServerRow } from '@linkcode/ui'; +import { CustomServerList, PluginProvidedServers } from '@linkcode/ui'; +import { Button } from 'coss-ui/components/button'; +import { + Dialog, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from 'coss-ui/components/dialog'; +import { Field, FieldLabel } from 'coss-ui/components/field'; +import { Input } from 'coss-ui/components/input'; +import { RadioGroup, RadioGroupItem } from 'coss-ui/components/radio-group'; +import { Textarea } from 'coss-ui/components/textarea'; +import { Trash2Icon, UndoIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { useTranslations } from 'use-intl'; +import { z } from 'zod'; +import type { CustomMcpServerDraft } from './custom-mcp-patch'; +import { buildCustomMcpPatch } from './custom-mcp-patch'; +import { useCustomMcpServers, useSetCustomMcpServers } from './hooks'; + +/** Client-minted identity for a new server (module scope: `Date.now` must not run in render). */ +function mintCustomServer(): { id: string; createdAt: number } { + return { id: `custom_${crypto.randomUUID()}`, createdAt: Date.now() }; +} + +const SecretRowSchema = z.object({ + key: z.string(), + value: z.string(), + remove: z.boolean(), +}); + +const McpFormSchema = z + .object({ + name: z.string().min(1), + transport: z.enum(['stdio', 'http']), + command: z.string(), + args: z.string(), + url: z.string(), + secrets: z.array(SecretRowSchema), + }) + .superRefine((form, ctx) => { + if (form.transport === 'stdio' && form.command.trim() === '') { + ctx.addIssue({ code: 'custom', path: ['command'], message: 'required' }); + } + if (form.transport === 'http' && form.url.trim() === '') { + ctx.addIssue({ code: 'custom', path: ['url'], message: 'required' }); + } + }); + +type McpForm = z.infer; + +type DialogState = { mode: 'closed' } | { mode: 'add' } | { mode: 'edit'; id: string }; + +export interface McpTabProps { + pluginRows: PluginMcpServerRow[]; +} + +/** The MCP tab: editable LinkCode-owned servers on top, plugin-provided servers read-only below. */ +export function McpTab({ pluginRows }: McpTabProps): React.ReactNode { + const { data: servers, mutate } = useCustomMcpServers(); + const save = useSetCustomMcpServers(); + const [dialog, setDialog] = useState({ mode: 'closed' }); + + const rows = servers?.map((server) => customServerRow(server)); + const serversById = new Map((servers ?? []).map((server) => [server.id, server])); + const editing = dialog.mode === 'edit' ? serversById.get(dialog.id) : undefined; + + const apply = async (patches: ReturnType): Promise => { + if (patches.length > 0) { + await save.trigger({ patches }); + void mutate(); + } + setDialog({ mode: 'closed' }); + }; + + return ( +
+ setDialog({ mode: 'add' })} + onEdit={(id) => setDialog({ mode: 'edit', id })} + onRemove={(id) => { + void apply([{ op: 'remove', id }]); + }} + onToggle={(id, enabled) => { + void apply([{ op: 'update', id, enabled }]); + }} + /> + + {dialog.mode === 'closed' ? null : ( + setDialog({ mode: 'closed' })} + onSubmit={(draft) => { + void apply(buildCustomMcpPatch(maskOf(editing), draft, mintCustomServer())); + }} + /> + )} +
+ ); +} + +/** The dialog edits against the masked projection — the same view the wire exposes. */ +function maskOf(server: CustomMcpServerPublic | undefined): CustomMcpServerPublic | undefined { + return server; +} + +function customServerRow(server: CustomMcpServerPublic): CustomMcpServerRow { + return { + id: server.id, + name: server.server.name, + transport: server.server.type, + detail: server.server.type === 'stdio' ? server.server.command : server.server.url, + enabled: server.enabled, + secretKeys: server.server.type === 'stdio' ? server.server.envKeys : server.server.headerKeys, + }; +} + +function formDefaults(previous: CustomMcpServerPublic | undefined): McpForm { + if (!previous) { + return { name: '', transport: 'stdio', command: '', args: '', url: '', secrets: [] }; + } + const { server } = previous; + return { + name: server.name, + transport: server.type, + command: server.type === 'stdio' ? server.command : '', + args: server.type === 'stdio' ? (server.args ?? []).join('\n') : '', + url: server.type === 'http' ? server.url : '', + // Existing keys render with an EMPTY value: blank = keep, typed = replace, remove = delete. + secrets: (server.type === 'stdio' ? server.envKeys : server.headerKeys).map((key) => ({ + key, + value: '', + remove: false, + })), + }; +} + +function CustomServerDialog({ + previous, + busy, + onClose, + onSubmit, +}: { + previous: CustomMcpServerPublic | undefined; + busy: boolean; + onClose: () => void; + onSubmit: (draft: CustomMcpServerDraft) => void; +}): React.ReactNode { + const t = useTranslations('settings.plugins.mcp'); + const { control, register, handleSubmit } = useForm({ + resolver: zodResolver(McpFormSchema), + defaultValues: formDefaults(previous), + }); + const secrets = useFieldArray({ control, name: 'secrets' }); + const transport = useWatch({ control, name: 'transport' }); + const existingKeyCount = + previous === undefined + ? 0 + : previous.server.type === 'stdio' + ? previous.server.envKeys.length + : previous.server.headerKeys.length; + + const submit = handleSubmit((form) => { + const secretRows: { key: string; value: string; remove: boolean }[] = []; + for (const row of form.secrets) { + const key = row.key.trim(); + if (key !== '') secretRows.push({ key, value: row.value, remove: row.remove }); + } + const args: string[] = []; + for (const line of form.args.split('\n')) { + const trimmed = line.trim(); + if (trimmed !== '') args.push(trimmed); + } + onSubmit( + form.transport === 'stdio' + ? { + type: 'stdio', + name: form.name.trim(), + command: form.command.trim(), + args, + secrets: secretRows, + } + : { type: 'http', name: form.name.trim(), url: form.url.trim(), secrets: secretRows }, + ); + }); + + return ( + open || onClose()}> + + + {previous === undefined ? t('add') : t('edit')} + + + + {t('form.name')} + + + + {t('form.transport')} + {previous === undefined ? ( + ( + + {(['stdio', 'http'] as const).map((value) => ( + + ))} + + )} + /> + ) : ( +

{t('form.transportLocked')}

+ )} +
+ {transport === 'stdio' ? ( + <> + + {t('form.command')} + + + + {t('form.args')} +