diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 363d2b93f..c59d1f8a8 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, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { PluginConfig } from '@linkcode/schema'; import { noop } from 'foxts/noop'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -9,6 +10,7 @@ import { hqCredentialsPath, loadConfig, runtimeFilePath, + savePlugins, } from '../config'; import { logger } from '../logger'; import { telemetryConfigCachePath } from '../paths'; @@ -39,6 +41,12 @@ function writeAccountsConfig(accounts: unknown): void { writeFileSync(join(dir, 'config.json'), JSON.stringify({ accounts })); } +function writeRawConfig(config: unknown): void { + const dir = join(process.env.HOME ?? '', '.linkcode'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify(config)); +} + const validAccount = { id: 'acc_1', label: 'Personal key', @@ -169,3 +177,74 @@ describe('loadConfig accounts', () => { expect(errorSpy).not.toHaveBeenCalled(); }); }); + +describe('loadConfig plugins', () => { + const connector = { + id: 'github-personal', + label: 'Personal GitHub', + service: 'github', + credential: { type: 'auth-token', secret: 'github-secret' }, + } as const; + + it('keeps valid entries and drops malformed entries independently', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); + writeRawConfig({ + plugins: { + units: [ + { unitId: 'github-read', enabled: true }, + { unitId: 'unknown-unit', enabled: true }, + ], + serviceBindings: { + github: { type: 'local', connectorId: connector.id }, + jira: { type: 'local', connectorId: 'stale' }, + }, + connectors: [connector, { id: 'bad', service: 'github' }], + customServers: [ + { id: 'cs1', enabled: true, server: { type: 'stdio', name: 'local-fs', command: 'fs' } }, + { id: 'bad', enabled: true, server: { type: 'stdio', name: 'x' } }, + ], + }, + }); + + expect(loadConfig().plugins).toEqual({ + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: connector.id } }, + connectors: [connector], + customServers: [ + { id: 'cs1', enabled: true, server: { type: 'stdio', name: 'local-fs', command: 'fs' } }, + ], + }); + expect(warnSpy).toHaveBeenCalledTimes(3); + }); + + it('round-trips credentials at mode 0600 without overwriting providers or accounts', () => { + writeRawConfig({ providers: { codex: { enabled: true } }, accounts: [validAccount] }); + const plugins: PluginConfig = { + units: [], + serviceBindings: { github: { type: 'local', connectorId: connector.id } }, + connectors: [connector], + customServers: [], + }; + + savePlugins(plugins); + + expect(loadConfig()).toMatchObject({ + providers: { codex: { enabled: true } }, + accounts: [validAccount], + plugins, + }); + expect(statSync(join(process.env.HOME ?? '', '.linkcode', 'config.json')).mode & 0o777).toBe( + 0o600, + ); + }); + + it('defaults an older config without a plugins section to empty state', () => { + writeRawConfig({ providers: {} }); + expect(loadConfig().plugins).toEqual({ + units: [], + serviceBindings: {}, + connectors: [], + customServers: [], + }); + }); +}); diff --git a/apps/daemon/src/__tests__/logger.test.ts b/apps/daemon/src/__tests__/logger.test.ts index c7dbd049b..93e22df33 100644 --- a/apps/daemon/src/__tests__/logger.test.ts +++ b/apps/daemon/src/__tests__/logger.test.ts @@ -32,6 +32,9 @@ describe('daemon logger', () => { nested: { apiKey: 'nested-secret', token: 'nested-token' }, providers: { codex: { apiKey: 'provider-secret' } }, accounts: [{ credential: { key: 'account-secret' } }], + plugins: { + connectors: [{ credential: { type: 'auth-token', secret: 'plugin-secret' } }], + }, sessionId: 'session-1', }, 'Session started', @@ -48,6 +51,7 @@ describe('daemon logger', () => { nested: { apiKey: '[Redacted]', token: '[Redacted]' }, providers: { codex: { apiKey: '[Redacted]' } }, accounts: [{ credential: '[Redacted]' }], + plugins: { connectors: [{ credential: '[Redacted]' }] }, sessionId: 'session-1', }); }); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 256caa4a3..fb8d5fedf 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -1,12 +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 } from '@linkcode/schema'; +import type { Accounts, PluginConfig, ProvidersConfig } from '@linkcode/schema'; import { AccountSchema, AgentKindSchema, + CustomMcpServerSchema, DAEMON_DEFAULT_PORT, + McpPluginServiceSchema, + PluginConnectorSchema, + PluginServiceBindingSchema, + PluginUnitStateSchema, ProviderConfigSchema, } from '@linkcode/schema'; import { WORKSPACES_DIRNAME } from '@linkcode/schema/product'; @@ -29,6 +34,8 @@ export interface DaemonConfig { providers?: ProvidersConfig; /** Global account pool (data plane); undefined when nothing is configured. */ accounts?: Accounts; + /** Global plugin enablement and daemon-local connector credentials. */ + plugins?: PluginConfig; } const DEFAULT_PORT = DAEMON_DEFAULT_PORT; @@ -40,6 +47,7 @@ interface ConfigFile { listeners?: unknown; providers?: unknown; accounts?: unknown; + plugins?: unknown; } function configPath(): string { @@ -101,9 +109,66 @@ export function loadConfig(): DaemonConfig { ), providers: parseProviders(file.providers), accounts: parseAccounts(file.accounts), + plugins: parsePlugins(file.plugins), }; } +function parsePlugins(raw: unknown): PluginConfig { + const empty: PluginConfig = { units: [], serviceBindings: {}, connectors: [], customServers: [] }; + if (raw === undefined) return empty; + if (!isRecord(raw)) { + logger.warn({ operation: 'config.load' }, 'Invalid plugins config: expected an object'); + return empty; + } + return { + units: parsePluginEntries(raw.units, PluginUnitStateSchema, 'unit'), + serviceBindings: parseServiceBindings(raw.serviceBindings), + connectors: parsePluginEntries(raw.connectors, PluginConnectorSchema, 'connector'), + customServers: parsePluginEntries(raw.customServers, CustomMcpServerSchema, 'custom server'), + }; +} + +/** Parse binding by binding: an invalid entry is dropped and logged, never blanking the map. */ +function parseServiceBindings(raw: unknown): PluginConfig['serviceBindings'] { + if (raw === undefined) return {}; + if (!isRecord(raw)) { + logger.warn( + { operation: 'config.load' }, + 'Invalid plugin service bindings config: expected an object', + ); + return {}; + } + const bindings: PluginConfig['serviceBindings'] = {}; + for (const service of McpPluginServiceSchema.options) { + if (!(service in raw)) continue; + const parsed = PluginServiceBindingSchema.safeParse(raw[service]); + if (parsed.success) bindings[service] = parsed.data; + else logger.warn({ operation: 'config.load' }, 'Dropping invalid plugin service binding'); + } + return bindings; +} + +function parsePluginEntries( + raw: unknown, + schema: { safeParse(value: unknown): { success: true; data: T } | { success: false } }, + entryType: string, +): T[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + logger.warn( + { operation: 'config.load' }, + `Invalid plugin ${entryType}s config: expected an array`, + ); + return []; + } + return raw.flatMap((value) => { + const parsed = schema.safeParse(value); + if (parsed.success) return [parsed.data]; + logger.warn({ operation: 'config.load' }, `Dropping invalid plugin ${entryType} config`); + return []; + }); +} + /** * Parse element by element: an invalid account is dropped and logged, never blanking the pool — * `saveAccounts` would persist that loss on the next write. Mirrors {@link parseProviders}. @@ -166,8 +231,13 @@ export function saveAccounts(accounts: Accounts): void { writeConfigField('accounts', accounts); } +/** Persist plugin state and local credentials, preserving other config fields; `0600`. */ +export function savePlugins(plugins: PluginConfig): void { + writeConfigField('plugins', plugins); +} + /** Read-modify-write a single top-level field of config.json, preserving the rest; `0600`. */ -function writeConfigField(key: 'providers' | 'accounts', value: unknown): void { +function writeConfigField(key: 'providers' | 'accounts' | 'plugins', value: unknown): void { const path = configPath(); let file: Record = {}; try { @@ -179,6 +249,7 @@ function writeConfigField(key: 'providers' | 'accounts', value: unknown): void { file[key] = value; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); } function createDefaultSocketIoListener(file: ConfigFile): DaemonListenerConfig { diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index aeebdb60c..bdd28dcbf 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -170,7 +170,11 @@ async function main(): Promise { const EngineSubsystemLive = Layer.unwrap( Effect.gen(function* () { const { config, hub, previewRoutes } = yield* Shared; - const store = createProviderConfigStore(config.providers ?? {}, config.accounts ?? []); + const store = createProviderConfigStore( + config.providers ?? {}, + config.accounts ?? [], + config.plugins ?? { units: [], serviceBindings: {}, connectors: [], customServers: [] }, + ); // Managed assets (CODE-111): GC superseded versions before anything can spawn, then feed the // store into spawn resolution — managed wins over detected as soon as an install lands. const assets = new AssetManager(); diff --git a/apps/daemon/src/provider-store.ts b/apps/daemon/src/provider-store.ts index 0475ac5dd..84ddf27ea 100644 --- a/apps/daemon/src/provider-store.ts +++ b/apps/daemon/src/provider-store.ts @@ -1,6 +1,6 @@ import type { ProviderConfigStore } from '@linkcode/engine'; -import type { Accounts, ProvidersConfig } from '@linkcode/schema'; -import { saveAccounts, saveProviders } from './config'; +import type { Accounts, PluginConfig, ProvidersConfig } from '@linkcode/schema'; +import { saveAccounts, savePlugins, saveProviders } from './config'; /** * Daemon-backed data-plane config store: in-memory providers + account pool seeded at boot, each @@ -10,9 +10,11 @@ import { saveAccounts, saveProviders } from './config'; export function createProviderConfigStore( initialProviders: ProvidersConfig, initialAccounts: Accounts, + initialPlugins: PluginConfig, ): ProviderConfigStore { let providers = initialProviders; let accounts = initialAccounts; + let plugins = initialPlugins; return { get: () => providers, set(next) { @@ -24,5 +26,10 @@ export function createProviderConfigStore( accounts = next; saveAccounts(next); }, + getPlugins: () => plugins, + setPlugins(next) { + savePlugins(next); + plugins = next; + }, }; } diff --git a/apps/desktop/src/renderer/src/settings/plugins-tab.tsx b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx new file mode 100644 index 000000000..c4c36c638 --- /dev/null +++ b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx @@ -0,0 +1,6 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; + +/** Daemon-backed MCP tool settings, shared with the browser client. */ +export function PluginsTab(): React.ReactNode { + return ; +} diff --git a/apps/desktop/src/renderer/src/settings/settings-view.tsx b/apps/desktop/src/renderer/src/settings/settings-view.tsx index 112028ea2..090495571 100644 --- a/apps/desktop/src/renderer/src/settings/settings-view.tsx +++ b/apps/desktop/src/renderer/src/settings/settings-view.tsx @@ -21,6 +21,7 @@ import { HistoryIcon, InfoIcon, KeyRoundIcon, + PlugIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -41,6 +42,7 @@ import { GeneralTab } from './general-tab'; import { HistoryImportTab } from './history-import-tab'; import { ImChannelTab } from './im-channel-tab'; import { NotificationsTab } from './notifications-tab'; +import { PluginsTab } from './plugins-tab'; import { ProvidersTab } from './providers-tab'; import type { SettingsCategory } from './store'; import { useDesktopSettingsStore } from './store'; @@ -147,6 +149,14 @@ export function SettingsView(): React.ReactNode { active: category === 'providers', onClick: () => setCategory('providers'), }, + { + key: 'plugins', + icon: , + label: t('tabs.plugins'), + keywords: searchKeywords.plugins, + active: category === 'plugins', + onClick: () => setCategory('plugins'), + }, { key: 'imChannel', icon: , @@ -301,6 +311,8 @@ function renderSettingsPanel( return ; case 'agents': return ; + case 'plugins': + return ; case 'imChannel': return ; case 'history-import': diff --git a/apps/desktop/src/renderer/src/settings/store.ts b/apps/desktop/src/renderer/src/settings/store.ts index 2173ba02b..da78c01c5 100644 --- a/apps/desktop/src/renderer/src/settings/store.ts +++ b/apps/desktop/src/renderer/src/settings/store.ts @@ -13,6 +13,7 @@ export type SettingsCategory = | 'about' | 'providers' | 'agents' + | 'plugins' | 'imChannel' | 'history-import'; diff --git a/apps/webview/src/router.tsx b/apps/webview/src/router.tsx index ee7e127bb..bfccf3899 100644 --- a/apps/webview/src/router.tsx +++ b/apps/webview/src/router.tsx @@ -6,6 +6,7 @@ import { DeveloperSettings } from '@webview/routes/settings/developer'; import { GeneralSettings } from '@webview/routes/settings/general'; import { MessagingSettings } from '@webview/routes/settings/messaging'; import { NotificationsSettings } from '@webview/routes/settings/notifications'; +import { PluginsSettings } from '@webview/routes/settings/plugins'; import { ProvidersSettings } from '@webview/routes/settings/providers'; import { SettingsLayout } from '@webview/routes/settings/settings-layout'; import { TerminalSettings } from '@webview/routes/settings/terminal'; @@ -32,6 +33,7 @@ export function createWebviewRouter( { path: 'developer', element: }, { path: 'notifications', element: }, { path: 'providers', element: }, + { path: 'plugins', element: }, { path: 'agents', element: }, { path: 'messaging', element: }, ], diff --git a/apps/webview/src/routes/settings/plugins.tsx b/apps/webview/src/routes/settings/plugins.tsx new file mode 100644 index 000000000..afde41355 --- /dev/null +++ b/apps/webview/src/routes/settings/plugins.tsx @@ -0,0 +1,9 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; +import { usePageTitle } from '@webview/hooks/use-page-title'; +import { useTranslations } from 'use-intl'; + +export function PluginsSettings(): React.ReactNode { + const tTabs = useTranslations('settings.tabs'); + usePageTitle(tTabs('plugins')); + return ; +} diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index d12dd56e9..8524208a0 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -5,6 +5,7 @@ import { BotIcon, CodeXmlIcon, KeyRoundIcon, + PlugIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -21,6 +22,7 @@ const SETTINGS_ROUTES: Record = { notifications: '/settings/notifications', agents: '/settings/agents', providers: '/settings/providers', + plugins: '/settings/plugins', messaging: '/settings/messaging', developer: '/settings/developer', }; @@ -92,6 +94,14 @@ export function SettingsLayout(): React.ReactNode { active: isActive(pathname, 'providers'), render: , }, + { + key: 'plugins', + icon: , + label: t('tabs.plugins'), + keywords: searchKeywords.plugins, + active: isActive(pathname, 'plugins'), + render: , + }, { key: 'messaging', icon: , @@ -163,6 +173,7 @@ function isActive( | 'developer' | 'notifications' | 'providers' + | 'plugins' | 'agents' | 'messaging', ): boolean { diff --git a/packages/client/core/src/__tests__/conversation.test.ts b/packages/client/core/src/__tests__/conversation.test.ts index 4f9f08b9f..111d2eee4 100644 --- a/packages/client/core/src/__tests__/conversation.test.ts +++ b/packages/client/core/src/__tests__/conversation.test.ts @@ -793,6 +793,30 @@ describe('createConversationBuilder', () => { expect(builder.snapshot()).not.toBe(first); }); + it('dedupes replayed plugin warnings while keeping distinct reasons for one dependency', () => { + const builder = createConversationBuilder(); + const warning: AgentEvent = { + type: 'plugin-warning', + unitId: 'github-read', + service: 'github', + reason: 'unsatisfied-binding', + }; + builder.advance(warning); + // The engine replays start diagnostics on session.attach — the same warning must not stack. + builder.advance(warning); + expect(builder.snapshot().pluginWarnings).toEqual([ + { unitId: 'github-read', service: 'github', reason: 'unsatisfied-binding' }, + ]); + expect(builder.snapshot().items).toEqual([]); + + // Two composed servers on one service can fail for different reasons — both stay visible. + builder.advance({ ...warning, reason: 'broker-unavailable' }); + expect(builder.snapshot().pluginWarnings).toEqual([ + { unitId: 'github-read', service: 'github', reason: 'unsatisfied-binding' }, + { unitId: 'github-read', service: 'github', reason: 'broker-unavailable' }, + ]); + }); + it('never mutates previously returned snapshots (copy-on-write)', () => { const builder = createConversationBuilder(); for (const event of scenario.slice(0, 5)) builder.advance(event); diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 23b9b94a0..dc0fc2121 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -26,7 +26,10 @@ import type { LoopSpec, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, Schedule, @@ -296,6 +299,7 @@ export class LinkCodeClient { case 'config.get.result': // One result carries both; each resolve is a no-op unless a request awaits that reply id. this.pending.resolve('configGet', p.replyTo, p.providers); + this.pending.resolve('pluginConfigGet', p.replyTo, p.plugins); this.pending.resolve('accountsGet', p.replyTo, p.accounts); break; case 'agent-runtime.listed': @@ -304,6 +308,9 @@ export class LinkCodeClient { case 'agent.cataloged': this.pending.resolve('agentCatalog', p.replyTo, p.catalog); break; + case 'plugin.catalog.result': + this.pending.resolve('pluginCatalog', p.replyTo, p.catalog); + break; case 'agent-runtime.changed': for (const cb of this.agentRuntimesChangedSubs) cb(p.runtimes); break; @@ -451,6 +458,10 @@ export class LinkCodeClient { return this.control.getAgentCatalog(agentKind, cwd); } + getPluginCatalog(): Promise { + return this.control.getPluginCatalog(); + } + listSessions(): Promise { return this.control.listSessions(); } @@ -567,6 +578,10 @@ export class LinkCodeClient { return this.control.getProviderConfig(); } + getPluginConfig(): Promise { + return this.control.getPluginConfig(); + } + getAccounts(): Promise { return this.control.getAccounts(); } @@ -606,6 +621,10 @@ export class LinkCodeClient { return this.control.setProviderConfig(providers); } + setPluginConfig(plugins: PluginConfigSet): Promise { + return this.control.setPluginConfig(plugins); + } + setAccounts(accounts: Accounts): Promise { return this.control.setAccounts(accounts); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 2a56628c5..5b58a29fc 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -24,7 +24,10 @@ import type { LoopSpec, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, Schedule, @@ -83,6 +86,14 @@ export class ControlChannel { })); } + /** Read the daemon-owned MCP plugin catalog. */ + getPluginCatalog(): Promise { + return this.sendCorrelated('pluginCatalog', (clientReqId) => ({ + kind: 'plugin.catalog.get', + clientReqId, + })); + } + listSessions(): Promise { return this.sendCorrelated('list', (clientReqId) => ({ kind: 'session.list', clientReqId })); } @@ -331,6 +342,14 @@ export class ControlChannel { })); } + /** Read public plugin state. Connector secrets never cross this boundary. */ + getPluginConfig(): Promise { + return this.sendCorrelated('pluginConfigGet', (clientReqId) => ({ + kind: 'config.get', + clientReqId, + })); + } + /** Read the daemon-owned global account pool (data plane). */ getAccounts(): Promise { return this.sendCorrelated('accountsGet', (clientReqId) => ({ @@ -377,6 +396,15 @@ export class ControlChannel { })); } + /** Apply plugin state and daemon-local connector credential operations. */ + setPluginConfig(plugins: PluginConfigSet): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'config.set', + clientReqId, + plugins, + })); + } + /** Persist the daemon-owned global account pool (data plane). Preserves the provider config. */ setAccounts(accounts: Accounts): Promise { return this.sendCorrelated('ack', (clientReqId) => ({ diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index e326c4286..981eb232d 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -13,6 +13,8 @@ import type { LoopInspection, LoopRecord, ManagedAssetStatus, + McpPluginCatalog, + PluginConfigPublic, ProvidersConfig, Schedule, ScheduleRun, @@ -63,9 +65,11 @@ export interface PendingValueMap { historyList: AgentHistoryListResult; historyRead: AgentHistoryReadResult; configGet: ProvidersConfig; + pluginConfigGet: PluginConfigPublic; accountsGet: Accounts; agentRuntimeList: AgentRuntimes; agentCatalog: AgentStartCatalog; + pluginCatalog: McpPluginCatalog; assetList: ManagedAssetStatus[]; assetEnsure: ManagedAssetStatus; gitStatus: GitStatus; @@ -107,9 +111,11 @@ export class PendingRegistry { historyList: new Map(), historyRead: new Map(), configGet: new Map(), + pluginConfigGet: new Map(), accountsGet: new Map(), agentRuntimeList: new Map(), agentCatalog: new Map(), + pluginCatalog: new Map(), assetList: new Map(), assetEnsure: new Map(), gitStatus: new Map(), diff --git a/packages/client/core/src/conversation-store.ts b/packages/client/core/src/conversation-store.ts index 5d398140e..a221d0229 100644 --- a/packages/client/core/src/conversation-store.ts +++ b/packages/client/core/src/conversation-store.ts @@ -27,6 +27,7 @@ const EMPTY_CONVERSATION: Conversation = { stopReason: null, pendingPermissionIds: [], pendingQuestionIds: [], + pluginWarnings: [], }; /** diff --git a/packages/client/core/src/conversation.ts b/packages/client/core/src/conversation.ts index ec460b6c3..ea02e4e96 100644 --- a/packages/client/core/src/conversation.ts +++ b/packages/client/core/src/conversation.ts @@ -33,6 +33,13 @@ export type QuestionResolution = Pick< Extract, 'outcome' | 'source' >; +/** One plugin resolution diagnostic (from `plugin-warning`): a catalog unit (`unitId`, optionally + * with the failed `service`) or a user-imported server (`customServerName`) could not be resolved + * at the last session start. */ +export type ConversationPluginWarning = Pick< + Extract, + 'unitId' | 'customServerName' | 'service' | 'reason' +>; /** A single semantic item in the conversation timeline. `receivedAt` is the best-known time of the * item's latest event: the client receive time for live events (see {@link SequencedAgentEvent}), @@ -159,6 +166,10 @@ export interface ConversationViewModel { pendingPermissionIds: string[]; /** requestIds of question asks that are still open, tracked the same way as permission asks. */ pendingQuestionIds: string[]; + /** Plugin resolution diagnostics from `plugin-warning`, in first-arrival order. Deduped by + * unit+service+reason, so the attach replay of the same warnings stays idempotent while + * distinct reasons for one dependency (one per composed server) all remain visible. */ + pluginWarnings: ConversationPluginWarning[]; } export type Conversation = ConversationViewModel; @@ -212,6 +223,9 @@ export function createConversationBuilder(): ConversationBuilder { const promptResponseStatuses = new Map(); /** Every ask requestId ever folded — attach-replayed duplicates are dropped. */ const seenAskIds = new Set(); + /** unit+service+reason → diagnostic, so attach replays dedupe while distinct reasons for the + * same dependency (one per composed server) all stay visible. */ + const pluginWarningIndex = new Map(); /** parentToolCallId (undefined = main agent) → the reasoning item currently open in that scope. */ const activeReasoningByScope = new Map(); let currentTurnId: ConversationTurnId = null; @@ -542,6 +556,18 @@ export function createConversationBuilder(): ConversationBuilder { }); break; + case 'plugin-warning': + pluginWarningIndex.set( + `${event.unitId ?? ''}|${event.customServerName ?? ''}|${event.service ?? ''}|${event.reason}`, + { + unitId: event.unitId, + customServerName: event.customServerName, + service: event.service, + reason: event.reason, + }, + ); + break; + case 'permission-request': { // The engine re-broadcasts open asks on session.attach; a duplicate must not add a card. if (seenAskIds.has(event.requestId)) break; @@ -699,6 +725,7 @@ export function createConversationBuilder(): ConversationBuilder { stopReason, pendingPermissionIds: approvals.filter((requestId) => !permissionResolutions.has(requestId)), pendingQuestionIds: questionAsks.filter((requestId) => !questionResolutions.has(requestId)), + pluginWarnings: [...pluginWarningIndex.values()], }; return cached; }; diff --git a/packages/client/core/tests/integration/control-client.test.ts b/packages/client/core/tests/integration/control-client.test.ts index b8c4bf31e..763826837 100644 --- a/packages/client/core/tests/integration/control-client.test.ts +++ b/packages/client/core/tests/integration/control-client.test.ts @@ -1,8 +1,10 @@ import type { AgentEvent, AgentStartCatalog, + McpPluginCatalog, MessageId, PermissionOutcome, + PluginConfigPublic, SessionId, SessionNotification, WirePayload, @@ -88,6 +90,57 @@ describe('LinkCodeClient control API', () => { serverTransport.close(); }); + it('reads the plugin catalog and masked plugin config through correlated requests', async () => { + let requestNumber = 0; + const { client, serverTransport } = await createConnectedLocalClient({ + randomUUID: () => `plugin-${++requestNumber}`, + }); + const catalog: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], + }, + ]; + const plugins: PluginConfigPublic = { + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'managed' } }, + connectors: [], + customServers: [], + }; + + serverTransport.onMessage((msg) => { + const payload = msg.payload; + if (payload.kind === 'plugin.catalog.get') { + serverTransport.send( + createWireMessage({ + kind: 'plugin.catalog.result', + replyTo: payload.clientReqId, + catalog, + }), + ); + } + if (payload.kind === 'config.get') { + serverTransport.send( + createWireMessage({ + kind: 'config.get.result', + replyTo: payload.clientReqId, + providers: {}, + accounts: [], + plugins, + }), + ); + } + }); + + await expect(client.getPluginCatalog()).resolves.toEqual(catalog); + await expect(client.getPluginConfig()).resolves.toEqual(plugins); + + client.dispose(); + serverTransport.close(); + }); + it('waits for control acknowledgements', async () => { const { client, serverTransport } = await createConnectedLocalClient(); diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 7e066e0b0..8fb6ba4ee 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -28,7 +28,10 @@ import type { LoopSpec, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, Schedule, @@ -113,6 +116,10 @@ export class LinkCodeSdkClient { return toResult(this.raw.getAgentCatalog(agentKind, cwd)); } + getPluginCatalog(): RequestResult { + return toResult(this.raw.getPluginCatalog()); + } + stopSession(sessionId: SessionId): RequestResult<{ ok: true }> { return toResult(this.raw.stopSession(sessionId)); } @@ -203,6 +210,15 @@ export class LinkCodeSdkClient { return toResult(this.raw.getProviderConfig()); } + /** Read public plugin state. Connector secrets are represented only by configured metadata. */ + getPluginConfig(): RequestResult { + return toResult(this.raw.getPluginConfig()); + } + + setPluginConfig(plugins: PluginConfigSet): RequestResult<{ ok: true }> { + return toResult(this.raw.setPluginConfig(plugins)); + } + /** Persist the daemon-owned provider config (data plane). */ setProviderConfig(providers: ProvidersConfig): RequestResult<{ ok: true }> { return toResult(this.raw.setProviderConfig(providers)); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index cbdadfd64..672e9842e 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -22,7 +22,10 @@ import type { LoopSpec, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, PermissionOutcome, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, Schedule, @@ -57,6 +60,10 @@ export function getAgentCatalog( return resolveClient(options).getAgentCatalog(options.agentKind, options.cwd); } +export function getPluginCatalog(options?: Options): RequestResult { + return resolveClient(options).getPluginCatalog(); +} + export function stopSession( options: Options<{ sessionId: SessionId }>, ): RequestResult<{ ok: true }> { @@ -169,6 +176,16 @@ export function getProviderConfig(options?: Options): RequestResult { + return resolveClient(options).getPluginConfig(); +} + +export function setPluginConfig( + options: Options<{ plugins: PluginConfigSet }>, +): RequestResult<{ ok: true }> { + return resolveClient(options).setPluginConfig(options.plugins); +} + export function setProviderConfig( options: Options<{ providers: ProvidersConfig }>, ): RequestResult<{ ok: true }> { diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index 98cacff50..3390b46c0 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -43,6 +43,9 @@ export * from './settings/appearance-effects'; export * from './settings/appearance-render-prefs'; export * from './settings/appearance-settings'; export * from './settings/appearance-store'; +export * from './settings/plugins/hooks'; +export * from './settings/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/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index effb709dd..8a931b867 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -10,8 +10,13 @@ import type { EffortLevel, ManagedAssetId, ManagedAssetStatus, + McpPluginCatalog, + McpPluginService, MessageId, PermissionOutcome, + PluginConfig, + PluginConfigPublic, + PluginConfigSet, ProvidersConfig, QuestionOutcome, SessionId, @@ -26,7 +31,12 @@ import type { WorkspaceRecord, WorkspaceScript, } from '@linkcode/schema'; -import { AGENT_INPUT_CAPABILITIES, normalizeCwdKey, textBlock } from '@linkcode/schema'; +import { + AGENT_INPUT_CAPABILITIES, + mcpPluginServerName, + normalizeCwdKey, + textBlock, +} from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { wait } from 'foxts/wait'; @@ -99,6 +109,15 @@ const MOCK_DEFAULT_EFFORTS: Readonly>> = 'grok-build': 'high', }; +const MOCK_PLUGIN_CATALOG: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], + }, +]; + interface MockSession extends SessionInfo { /** Host-only replay state: keep it off `session.list` so the mock crosses the schema boundary. */ model?: string; @@ -165,6 +184,12 @@ export class DevMockHost { private readonly workspaces = new Map(); private providers: ProvidersConfig = {}; private accounts: Accounts = []; + private plugins: PluginConfig = { + units: [], + serviceBindings: {}, + connectors: [], + customServers: [], + }; private readonly permissions = new Map(); private readonly questions = new Map(); private history: AgentHistorySession[] = []; @@ -324,6 +349,15 @@ export class DevMockHost { replyTo: p.clientReqId, providers: this.providers, accounts: this.accounts, + plugins: publicPluginConfig(this.plugins), + }); + break; + case 'plugin.catalog.get': + await wait(CONTROL_LATENCY_MS); + this.send({ + kind: 'plugin.catalog.result', + replyTo: p.clientReqId, + catalog: MOCK_PLUGIN_CATALOG, }); break; case 'agent-runtime.list': @@ -349,6 +383,16 @@ export class DevMockHost { await wait(CONTROL_LATENCY_MS); if (p.providers !== undefined) this.providers = structuredClone(p.providers); if (p.accounts !== undefined) this.accounts = structuredClone(p.accounts); + if (p.plugins !== undefined) { + try { + this.plugins = applyPluginConfigSet(this.plugins, p.plugins); + } catch { + // Engine parity: an invalid connector operation fails the request with the same + // sanitized public message the daemon sends, leaving the acknowledged state untouched. + this.sendFailure(p.clientReqId, 'Failed to update provider config'); + break; + } + } this.sendSuccess(p.clientReqId); break; case 'workspace.list': @@ -1385,6 +1429,130 @@ export class DevMockHost { } } +function publicPluginConfig(config: PluginConfig): PluginConfigPublic { + return { + units: structuredClone(config.units), + serviceBindings: structuredClone(config.serviceBindings), + connectors: config.connectors.map(({ credential, ...connector }) => ({ + ...connector, + credential: + credential.expiresAt === undefined + ? { type: credential.type, configured: true } + : { type: credential.type, configured: true, expiresAt: credential.expiresAt }, + })), + customServers: config.customServers.map(({ id, enabled, server }) => ({ + id, + enabled, + server: + server.type === 'stdio' + ? { + type: 'stdio', + name: server.name, + command: server.command, + ...(server.args && { args: server.args }), + envKeys: Object.keys(server.env ?? {}), + } + : { + type: 'http', + name: server.name, + url: server.url, + headerKeys: Object.keys(server.headers ?? {}), + }, + })), + }; +} + +function applyPluginConfigSet(current: PluginConfig, patch: PluginConfigSet): PluginConfig { + const units = structuredClone(patch.units ?? current.units); + const serviceBindings = structuredClone(patch.serviceBindings ?? current.serviceBindings); + let connectors = structuredClone(current.connectors); + for (const operation of patch.connectorOperations ?? []) { + const connectorId = + operation.type === 'create' ? operation.connector.id : operation.connectorId; + const exists = connectors.some((connector) => connector.id === connectorId); + switch (operation.type) { + case 'create': + if (exists) throw new Error(`Plugin connector already exists: ${connectorId}`); + connectors.push(structuredClone(operation.connector)); + break; + case 'update': + if (!exists) throw new Error(`Plugin connector does not exist: ${connectorId}`); + connectors = connectors.map((connector) => { + if (connector.id !== operation.connectorId) return connector; + const label = + operation.label === undefined ? connector.label : (operation.label ?? undefined); + return { + ...connector, + ...(label === undefined ? { label: undefined } : { label }), + credential: operation.credential ?? connector.credential, + }; + }); + break; + case 'delete': + if (!exists) throw new Error(`Plugin connector does not exist: ${connectorId}`); + connectors = connectors.filter((connector) => connector.id !== operation.connectorId); + for (const service of Object.keys(serviceBindings) as McpPluginService[]) { + const binding = serviceBindings[service]; + if (binding?.type === 'local' && binding.connectorId === operation.connectorId) { + delete serviceBindings[service]; + } + } + break; + default: + break; + } + } + let customServers = structuredClone(current.customServers); + // Faithful mirror of the engine's assertServerNameAvailable: a custom server name must not + // collide with the catalog or another custom server (both are MCP injection keys). + const assertNameFree = (name: string, selfId: string): void => { + const catalogNames = MOCK_PLUGIN_CATALOG.flatMap((descriptor) => + descriptor.servers.map((server) => mcpPluginServerName(server)), + ); + if (catalogNames.includes(name)) { + throw new Error(`Custom MCP server name collides with a built-in server: ${name}`); + } + if (customServers.some((entry) => entry.id !== selfId && entry.server.name === name)) { + throw new Error(`Custom MCP server name already in use: ${name}`); + } + }; + for (const operation of patch.customServerOperations ?? []) { + switch (operation.type) { + case 'add': + if (customServers.some((entry) => entry.id === operation.server.id)) { + throw new Error(`Custom MCP server already exists: ${operation.server.id}`); + } + assertNameFree(operation.server.server.name, operation.server.id); + customServers.push(structuredClone(operation.server)); + break; + case 'update': { + const existing = customServers.find((entry) => entry.id === operation.id); + if (!existing) throw new Error(`Custom MCP server does not exist: ${operation.id}`); + if (operation.server) assertNameFree(operation.server.name, operation.id); + customServers = customServers.map((entry) => + entry.id === operation.id + ? { + id: entry.id, + enabled: operation.enabled ?? entry.enabled, + server: operation.server ? structuredClone(operation.server) : entry.server, + } + : entry, + ); + break; + } + case 'remove': + if (!customServers.some((entry) => entry.id === operation.id)) { + throw new Error(`Custom MCP server does not exist: ${operation.id}`); + } + customServers = customServers.filter((entry) => entry.id !== operation.id); + break; + default: + break; + } + } + return { units, serviceBindings, connectors, customServers }; +} + function promptText(content: readonly ContentBlock[]): string { return content .reduce((text, block) => { diff --git a/packages/client/workbench/src/settings/plugins/__tests__/custom-server-draft.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/custom-server-draft.test.ts new file mode 100644 index 000000000..664b251ee --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/custom-server-draft.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { customServerDraftSchema } from '../custom-server-dialog'; + +const schema = customServerDraftSchema((key) => key); + +const stdioDraft = { name: 'my-server', transport: 'stdio', command: 'npx' } as const; +const httpDraft = { name: 'my-server', transport: 'http', url: 'https://example.com/mcp' } as const; + +function failedPaths(draft: Record): string[] { + const result = schema.safeParse(draft); + return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.')); +} + +describe('customServerDraftSchema', () => { + it('accepts valid drafts, blank credential lines included', () => { + expect(failedPaths({ ...stdioDraft, env: 'API_TOKEN=abc\n\nREGION=us\n' })).toEqual([]); + expect(failedPaths({ ...httpDraft, headers: 'Authorization: Bearer x' })).toEqual([]); + }); + + it('rejects nonblank env lines without a separator or key instead of dropping them', () => { + expect(failedPaths({ ...stdioDraft, env: 'API_TOKEN' })).toEqual(['env']); + expect(failedPaths({ ...stdioDraft, env: '=value' })).toEqual(['env']); + }); + + it('rejects malformed header lines', () => { + expect(failedPaths({ ...httpDraft, headers: 'Authorization Bearer x' })).toEqual(['headers']); + }); + + it('ignores the inactive transport side', () => { + expect(failedPaths({ ...stdioDraft, headers: 'not a header' })).toEqual([]); + expect(failedPaths({ ...httpDraft, env: 'not an env line' })).toEqual([]); + }); + + it('keeps the deliberate agent-safe name charset (claude tool ids embed the name)', () => { + expect(failedPaths({ ...stdioDraft, name: 'my.server' })).toEqual(['name']); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/hooks.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/hooks.test.ts new file mode 100644 index 000000000..525c8b284 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/hooks.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom + +import type { PluginConfigSet } from '@linkcode/schema'; +import { getPluginCatalog, setPluginConfig } from '@linkcode/sdk'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePluginSettings } from '../hooks'; + +const { useDataMock, useMutationMock } = vi.hoisted(() => ({ + useDataMock: vi.fn(), + useMutationMock: vi.fn(), +})); + +vi.mock('../../../runtime/tayori', () => ({ + useData: useDataMock, + useMutation: useMutationMock, +})); + +const configMutate = vi.fn(); +const trigger = vi.fn(); + +beforeEach(() => { + configMutate.mockReset().mockResolvedValue(undefined); + trigger.mockReset().mockResolvedValue({ ok: true }); + useDataMock.mockImplementation((operation: unknown) => + operation === getPluginCatalog + ? { data: [], error: undefined, isLoading: false, mutate: vi.fn() } + : { + data: { units: [], serviceBindings: {}, connectors: [] }, + error: undefined, + isLoading: false, + mutate: configMutate, + }, + ); + useMutationMock.mockImplementation((operation: unknown) => { + expect(operation).toBe(setPluginConfig); + return { trigger, error: undefined, isMutating: false }; + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('usePluginSettings', () => { + const patch: PluginConfigSet = { units: [{ unitId: 'github-read', enabled: true }] }; + + it('revalidates config only after the host acknowledges the write', async () => { + const { result } = renderHook(() => usePluginSettings()); + + await result.current.save(patch); + + expect(trigger).toHaveBeenCalledWith({ plugins: patch }); + expect(configMutate).toHaveBeenCalledTimes(1); + expect(trigger.mock.invocationCallOrder[0]).toBeLessThan( + configMutate.mock.invocationCallOrder[0] ?? Number.NaN, + ); + }); + + it('leaves the cached config untouched when the write fails', async () => { + trigger.mockRejectedValue(new Error('request failed')); + const { result } = renderHook(() => usePluginSettings()); + + await expect(result.current.save(patch)).rejects.toThrow('request failed'); + + expect(configMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts new file mode 100644 index 000000000..85597793f --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -0,0 +1,146 @@ +import type { McpPluginCatalog, PluginConfigPublic } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { MCP_APPLICABLE_AGENTS, pluginServiceViews, pluginUnitViews } from '../view'; + +const NOW = 1_000_000; + +const CATALOG: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + servers: [ + { + type: 'preset', + server: { type: 'stdio', name: 'github-cli', command: 'github-mcp-server' }, + service: 'github', + credentialSlots: [{ target: 'env', name: 'GITHUB_TOKEN' }], + }, + { + type: 'preset', + server: { type: 'http', name: 'github-docs', url: 'https://docs.test/mcp' }, + credentialSlots: [], + }, + ], + }, +]; + +function config(overrides: Partial = {}): PluginConfigPublic { + return { + units: [{ unitId: 'github-read', enabled: true }], + serviceBindings: { github: { type: 'local', connectorId: 'github-personal' } }, + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', configured: true }, + }, + ], + customServers: [], + ...overrides, + }; +} + +describe('pluginUnitViews', () => { + it('marks a fully satisfied composition ready and lists its distinct services', () => { + const [unit] = pluginUnitViews(CATALOG, config(), NOW); + expect(unit).toMatchObject({ + id: 'github-read', + enabled: true, + services: ['github'], + status: 'ready', + servers: [ + { name: 'github-cli', service: 'github', status: 'satisfied' }, + { name: 'github-docs', service: undefined, status: 'ready' }, + ], + }); + }); + + it('degrades to partial when one service dependency is unbound', () => { + const [unit] = pluginUnitViews(CATALOG, config({ serviceBindings: {} }), NOW); + expect(unit?.status).toBe('partial'); + expect(unit?.servers[0]?.status).toBe('unsatisfied-binding'); + expect(unit?.servers[1]?.status).toBe('ready'); + }); + + it('treats a binding to a missing or service-mismatched connector as unsatisfied', () => { + const [unit] = pluginUnitViews(CATALOG, config({ connectors: [] }), NOW); + expect(unit?.servers[0]?.status).toBe('unsatisfied-binding'); + }); + + it('flags an expired local credential without hiding the unit', () => { + const expired = config({ + connectors: [ + { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', configured: true, expiresAt: NOW - 1 }, + }, + ], + }); + const [unit] = pluginUnitViews(CATALOG, expired, NOW); + expect(unit?.servers[0]?.status).toBe('expired-credential'); + expect(unit?.status).toBe('partial'); + }); + + it('routes managed bindings and managed servers to the broker-unavailable state', () => { + const managedBinding = config({ serviceBindings: { github: { type: 'managed' } } }); + expect(pluginUnitViews(CATALOG, managedBinding, NOW)[0]?.servers[0]?.status).toBe( + 'broker-unavailable', + ); + + const managedCatalog: McpPluginCatalog = [ + { + id: 'github-read', + labelKey: 'units.githubRead.label', + descriptionKey: 'units.githubRead.description', + servers: [{ type: 'managed', name: 'linkcode-github', service: 'github' }], + }, + ]; + const [unit] = pluginUnitViews(managedCatalog, config(), NOW); + expect(unit?.servers[0]?.status).toBe('broker-unavailable'); + expect(unit?.status).toBe('unavailable'); + }); + + it('reports a unit absent from config as disabled', () => { + const [unit] = pluginUnitViews(CATALOG, config({ units: [] }), NOW); + expect(unit?.status).toBe('disabled'); + expect(unit?.enabled).toBe(false); + }); +}); + +describe('pluginServiceViews', () => { + it('derives one row per catalog service with its binding status and dependent units', () => { + expect(pluginServiceViews(CATALOG, config(), NOW)).toEqual([ + { + service: 'github', + status: { + kind: 'local', + connector: { + id: 'github-personal', + service: 'github', + credential: { type: 'auth-token', configured: true }, + }, + expired: false, + }, + usedByUnits: ['github-read'], + }, + ]); + }); + + it('surfaces a dangling local binding as local-missing', () => { + const [row] = pluginServiceViews(CATALOG, config({ connectors: [] }), NOW); + expect(row?.status).toEqual({ kind: 'local-missing', connectorId: 'github-personal' }); + }); + + it('reports an unbound service', () => { + const [row] = pluginServiceViews(CATALOG, config({ serviceBindings: {} }), NOW); + expect(row?.status).toEqual({ kind: 'unbound' }); + }); +}); + +describe('MCP_APPLICABLE_AGENTS', () => { + it('mirrors the shared schema capability table', () => { + expect(MCP_APPLICABLE_AGENTS).toEqual(['claude-code', 'codex', 'opencode']); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/custom-server-dialog.tsx b/packages/client/workbench/src/settings/plugins/custom-server-dialog.tsx new file mode 100644 index 000000000..6ecc5b949 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/custom-server-dialog.tsx @@ -0,0 +1,273 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { McpServer } from '@linkcode/schema'; +import { Button } from 'coss-ui/components/button'; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from 'coss-ui/components/dialog'; +import { Field, FieldDescription, FieldError, FieldLabel } from 'coss-ui/components/field'; +import { Form } from 'coss-ui/components/form'; +import { Input } from 'coss-ui/components/input'; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from 'coss-ui/components/select'; +import { Textarea } from 'coss-ui/components/textarea'; +import { isObjectEmpty } from 'foxts/is-object-empty'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslations } from 'use-intl'; +import { z } from 'zod'; +import { rhfErrorsToFormErrors } from '../../lib/form'; + +/** Nonblank line missing the separator (or with an empty key) — `parsePairs` would drop it. */ +function hasMalformedPairLine(raw: string | undefined, separator: '=' | ':'): boolean { + return (raw ?? '').split('\n').some((line) => { + const trimmed = line.trim(); + return trimmed !== '' && trimmed.indexOf(separator) <= 0; + }); +} + +export function customServerDraftSchema( + t: (key: 'customForm.nameError' | 'customForm.envError' | 'customForm.headersError') => string, +) { + return z + .object({ + name: z + .string() + .trim() + .regex(/^[\w-]+$/, t('customForm.nameError')), + transport: z.enum(['stdio', 'http']), + command: z.string().trim().optional(), + args: z.string().optional(), + env: z.string().optional(), + url: z.string().trim().optional(), + headers: z.string().optional(), + }) + .refine((draft) => draft.transport !== 'stdio' || (draft.command ?? '').length > 0, { + path: ['command'], + }) + .refine((draft) => draft.transport !== 'http' || (draft.url ?? '').length > 0, { + path: ['url'], + }) + .refine((draft) => draft.transport !== 'stdio' || !hasMalformedPairLine(draft.env, '='), { + path: ['env'], + message: t('customForm.envError'), + }) + .refine((draft) => draft.transport !== 'http' || !hasMalformedPairLine(draft.headers, ':'), { + path: ['headers'], + message: t('customForm.headersError'), + }); +} + +type CustomServerDraft = z.infer>; + +/** Parse `KEY=VALUE` (env) / `KEY: VALUE` (headers) lines into a record; blank lines ignored + * (malformed lines are rejected by the draft schema before this runs). */ +function parsePairs(raw: string | undefined, separator: '=' | ':'): Record { + const pairs: Record = {}; + for (const line of (raw ?? '').split('\n')) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const at = trimmed.indexOf(separator); + if (at <= 0) continue; + pairs[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1).trim(); + } + return pairs; +} + +function draftToServer(draft: CustomServerDraft): McpServer { + if (draft.transport === 'stdio') { + const args = (draft.args ?? '').split(/\s+/).filter((part) => part.length > 0); + const env = parsePairs(draft.env, '='); + return { + type: 'stdio', + name: draft.name, + command: draft.command ?? '', + ...(args.length > 0 && { args }), + ...(!isObjectEmpty(env) && { env }), + }; + } + const headers = parsePairs(draft.headers, ':'); + return { + type: 'http', + name: draft.name, + url: draft.url ?? '', + ...(!isObjectEmpty(headers) && { headers }), + }; +} + +export interface CustomServerDialogProps { + open: boolean; + busy: boolean; + onOpenChange: (open: boolean) => void; + /** Resolves once the host acknowledges; a rejection keeps the dialog open with a root error. */ + onSubmit: (server: McpServer) => Promise; +} + +/** Add a user-imported MCP server. stdio → command/args/env; http → url/headers. */ +export function CustomServerDialog({ + open, + busy, + onOpenChange, + onSubmit, +}: CustomServerDialogProps): React.ReactNode { + const t = useTranslations('settings.plugins'); + const { + control, + register, + handleSubmit, + watch, + reset, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(customServerDraftSchema(t)), + defaultValues: { + name: '', + transport: 'stdio', + command: '', + args: '', + env: '', + url: '', + headers: '', + }, + }); + const transport = watch('transport'); + + const submit = handleSubmit(async (draft) => { + try { + await onSubmit(draftToServer(draft)); + reset(); + onOpenChange(false); + } catch (error) { + setError('root', { message: t('customSaveError') }); + throw error; + } + }); + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + + + {t('customAddTitle')} + +
+ + {t('customForm.name')} + + {t('customForm.nameHint')} + + + ( + + {t('customForm.transport')} + + + )} + /> + {transport === 'stdio' ? ( + <> + + {t('customForm.command')} + + + + + {t('customForm.args')} + + + + {t('customForm.env')} +