diff --git a/MCP-LSP-IMPLEMENTATION-PLAN.md b/MCP-LSP-IMPLEMENTATION-PLAN.md new file mode 100644 index 0000000..a3b9311 --- /dev/null +++ b/MCP-LSP-IMPLEMENTATION-PLAN.md @@ -0,0 +1,628 @@ +# MCP / LSP Support Implementation Plan + +Status: implemented v1 architecture reference + +Intended execution order: + +1. Backend agent executes backend phases first. +2. Frontend agent executes frontend phases second. +3. Frontend work should begin only after the backend contracts, persistence, and diagnostics are stable. + +## Objective + +Add first-class support for MCP servers and LSP-backed project intelligence in Eryx without fighting the existing architecture. The design should preserve Eryx's current strengths: + +- Electron main as the desktop control plane +- preload + typed IPC as the renderer boundary +- shared TS contracts mirrored into the .NET sidecar +- .NET sidecar as the agent runtime +- OS keychain storage for secrets + +This plan is intentionally backend-first and split into backend/frontend task groups so different agents can execute them in order. + +The clarified product direction for v1 is: + +- users add MCPs and LSPs globally in app settings +- users enable or disable those global MCPs/LSPs per session in the right-side panel that is currently used for activity +- enabled tooling becomes available to the agent for that session + +## Current state analysis + +### Application architecture today + +- `src/main/EryxAppService.ts` + - central orchestration layer for workspace persistence, project/session lifecycle, sidecar calls, git refresh, and event emission +- `src/main/ipc/registerIpcHandlers.ts` + - single place where Electron IPC methods are registered +- `src/preload/index.ts` + - exposes the typed `eryxApi` bridge to the renderer +- `src/renderer/App.tsx` + - loads workspace + sidecar capabilities and wires subscriptions into the UI +- `src/shared/contracts/ipc.ts` + - typed renderer/main API contract +- `src/shared/contracts/sidecar.ts` + - typed main/sidecar protocol contract +- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs` + - C# mirror of the sidecar DTOs +- `sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs` + - sidecar command dispatcher and capability provider +- `sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs` + - actual agent runtime construction and activity emission + +### Signals that matter for MCP + +- `CopilotWorkflowRunner.cs` already loads `McpServerToolCallContent` via reflection and reports MCP tool names through `TryGetToolName(...)`. +- `CopilotWorkflowRunner.cs` builds a `SessionConfig` for each agent and already owns `AvailableTools`, `WorkingDirectory`, and `OnPermissionRequest`. +- Scratchpad explicitly clears `AvailableTools`, so scratchpad is currently a "no tools" product surface. + +### Signals that matter for LSP + +- `package.json` includes `typescript-language-server` and an `lsp:typescript` script, which is a good TypeScript-first bootstrap hint. +- For v1, the implemented design starts LSP processes inside the sidecar as turn-scoped tool sessions so they can surface directly as agent tools without inventing a second bridge layer. +- The current session's project root remains the source of truth for LSP initialization. + +### Persistence and secrets today + +- `src/main/persistence/workspaceRepository.ts` persists a single additive `workspace.json`. +- `src/shared/domain/workspace.ts` defines the root persisted shape. +- `src/shared/domain/session.ts` already carries session-scoped overrides such as `scratchpadConfig`, which makes it the natural place for per-session MCP/LSP enablement. +- `src/main/secrets/secretStore.ts` already wraps `keytar`, so secrets should stay in the OS keychain and never be stored in `workspace.json`. + +### Activity and diagnostics today + +- `src/shared/domain/event.ts` only exposes coarse activity today: `thinking`, `tool-calling`, `handoff`, `completed`. +- `src/renderer/components/ActivityPanel.tsx` can already show per-agent activity but not rich MCP/LSP traces. +- `src/renderer/components/CopilotStatusCard.tsx` and `SettingsPanel.tsx` already establish a diagnostics-first UX pattern worth reusing for MCP/LSP health surfaces. + +## Recommended design decisions + +### 1. Put global tooling definitions in workspace settings + +Additive, non-secret config should live in `WorkspaceState`, for example through a new `settings.tooling` section. + +Illustrative shape: + +```ts +interface WorkspaceState { + projects: ProjectRecord[]; + patterns: PatternDefinition[]; + sessions: SessionRecord[]; + selectedProjectId?: string; + selectedPatternId?: string; + selectedSessionId?: string; + lastUpdatedAt: string; + settings?: WorkspaceSettings; +} + +interface WorkspaceSettings { + tooling?: { + mcpServers?: McpServerDefinition[]; + lspProfiles?: LspProfileDefinition[]; + }; +} +``` + +Why this fits: + +- it follows the current single-workspace persistence model +- in the current architecture, that single local workspace file already behaves like the app-wide store on this computer +- it remains backward compatible because the new fields are optional +- it keeps MCP/LSP definitions reusable across future sessions + +### 2. Put enable/disable state on `SessionRecord` + +The user wants MCP/LSP enablement to be controlled per session from the right-side panel. That means the durable selection should live on `SessionRecord`, not on `ProjectRecord`. + +Illustrative shape: + +```ts +interface SessionRecord { + id: string; + projectId: string; + patternId: string; + title: string; + titleSource?: SessionTitleSource; + createdAt: string; + updatedAt: string; + status: SessionStatus; + isPinned?: boolean; + isArchived?: boolean; + messages: ChatMessageRecord[]; + lastError?: string; + scratchpadConfig?: ScratchpadSessionConfig; + tooling?: { + enabledMcpServerIds?: string[]; + enabledLspProfileIds?: string[]; + }; +} +``` + +How this should behave: + +- the settings screen owns the global registry of MCP and LSP definitions +- the session panel owns which global definitions are enabled for the current session +- the session's `projectId` provides the project root for LSP initialization when the session is project-backed +- scratchpad sessions should remain tool-free unless explicitly expanded later + +### 3. Keep secrets out of persisted JSON + +Use `src/main/secrets/secretStore.ts` for secret material. Persist only references in workspace config. + +Recommended rule set: + +- `workspace.json` stores server/profile definitions and secret references +- `keytar` stores secret values +- runtime logs, process IDs, and raw diagnostics stay out of `workspace.json` + +### 4. MCP belongs in the .NET sidecar runtime + +The sidecar already owns agent session creation, tool attachment, permission callbacks, and tool-call activity detection. MCP should build on that instead of inventing a second runtime stack in Electron or the renderer. + +Recommended flow: + +1. Electron main resolves session-enabled MCP definitions. +2. `EryxAppService.sendSessionMessage()` enriches the `run-turn` payload with runtime-ready MCP config. +3. The sidecar attaches MCP tools during `SessionConfig` creation. +4. The sidecar emits richer activity and error events back through the existing streaming channel. + +### 5. LSP process ownership should start in the sidecar for v1 + +Implemented v1 LSP ownership: + +- Electron main persists global definitions and resolves per-session selections. +- The sidecar starts the selected LSP server processes for a run, initializes them with the active project root, and exposes a focused tool surface through `AIFunction`s. +- The renderer only consumes typed config and per-session enablement state. + +Why this shipped path fits v1: + +- it keeps LSP capabilities closest to the agent tool runtime +- it avoids building a second transport bridge between Electron main and the sidecar before the tool surface is proven +- it preserves the user-facing model of "enable a tool for this session and let the agent use it" + +### 6. Prefer one user-facing tooling model + +The long-term product should not feel like "MCP over here, raw LSP over there" from a UX perspective. + +Recommended path: + +- implement direct MCP runtime support first +- implement LSP as a backend capability service second +- where feasible, expose LSP-backed actions to agents through MCP-like or tool-like abstractions instead of teaching the system two unrelated tool invocation models + +## Proposed data models + +The exact names can change, but the structure should follow these boundaries. + +### MCP definitions + +```ts +interface McpServerDefinition { + id: string; + name: string; + transport: 'stdio' | 'sse' | 'streamable-http'; + command?: string; + args?: string[]; + url?: string; + env?: McpEnvironmentBinding[]; + cwdStrategy?: 'project-root' | 'custom'; + customCwd?: string; + enabled?: boolean; +} + +interface McpEnvironmentBinding { + key: string; + valueSource: 'literal' | 'secret-ref'; + value?: string; + secretRef?: string; +} +``` + +### LSP profiles + +```ts +interface LspProfileDefinition { + id: string; + name: string; + languageIds: string[]; + transport: 'stdio'; + command: string; + args?: string[]; + initialization?: { + rootStrategy: 'project-root' | 'workspace-roots'; + initializationOptions?: Record; + }; + enabled?: boolean; +} +``` + +### Runtime diagnostics + +```ts +interface ToolingHealthSummary { + status: 'ready' | 'warning' | 'error' | 'disabled'; + summary: string; + detail?: string; + checkedAt: string; +} +``` + +Use separate summaries for: + +- workspace MCP health +- current session MCP enablement health +- LSP profile health +- current session LSP enablement health + +## Backend execution plan + +Backend must be executed first. + +### B0. Domain and persistence foundation + +Primary files: + +- `src/shared/domain/workspace.ts` +- `src/shared/domain/session.ts` +- `src/main/persistence/workspaceRepository.ts` +- `tests/shared/workspace.test.ts` +- `tests/shared/session.test.ts` + +Tasks: + +- add optional workspace-level tooling settings +- add optional session-level tooling enablement +- keep persisted migration additive and backward compatible +- ensure legacy workspaces load without mutation errors +- do not persist any secret values + +Acceptance: + +- old workspaces still load +- new fields round-trip through persistence +- tests cover legacy and new shapes + +### B1. Main-process tooling services and IPC + +Primary files: + +- `src/main/EryxAppService.ts` +- `src/shared/contracts/ipc.ts` +- `src/shared/contracts/channels.ts` +- `src/main/ipc/registerIpcHandlers.ts` +- `src/preload/index.ts` + +Recommended additions: + +- CRUD for MCP server definitions +- CRUD for LSP profiles +- session enable/disable methods for both systems +- diagnostics/status methods +- secret-reference save/delete flows that route through `SecretStore` + +Acceptance: + +- renderer gets a typed, renderer-safe API +- all config changes persist through `WorkspaceRepository` +- diagnostics can be queried without starting a session +- session enablement can be updated without mutating global definitions + +### B2. Sidecar protocol expansion for MCP + +Primary files: + +- `src/shared/contracts/sidecar.ts` +- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs` +- `sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs` +- `src/main/sidecar/sidecarProcess.ts` +- `sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs` + +Tasks: + +- extend capabilities with MCP availability/diagnostics +- extend `run-turn` with selected MCP runtime bindings +- optionally add dedicated MCP health validation commands if needed +- keep the protocol additive to avoid breaking existing clients + +Acceptance: + +- TS and C# contracts remain in sync +- protocol tests cover the new shapes +- existing capabilities and run-turn behavior still work without MCP config + +### B3. MCP runtime attachment in the sidecar + +Primary files: + +- `sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs` +- new helper services under `sidecar/src/Eryx.AgentHost/Services/` +- `sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs` + +Tasks: + +- translate main-process MCP definitions into runtime-attached tools +- attach tools during `SessionConfig` creation +- handle startup failures and unsupported transports cleanly +- emit richer activity when tool/server identity matters +- revisit the current blanket `ApprovePermissionAsync(...)` behavior before enabling arbitrary external tools + +Acceptance: + +- project-backed runs can invoke enabled MCP tools +- tool failures surface clearly to the app +- scratchpad behavior remains intentionally restricted unless product scope changes + +### B4. LSP tool sessions in the sidecar + +Primary files: + +- `sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs` +- `sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs` +- `package.json` +- `sidecar/tests/Eryx.AgentHost.Tests/*` + +Tasks: + +- start selected LSP server processes inside the sidecar for the active run +- initialize them against the current session project root +- expose a focused read-oriented tool surface to agents +- keep lifecycle cleanup bound to the run/session bundle +- start with TypeScript-first support + +Acceptance: + +- a project-backed session can initialize at least one supported LSP profile +- agents can call the exposed LSP tools without any renderer-managed process lifecycle +- no raw LSP JSON-RPC is exposed to the renderer + +### B5. LSP bridge into agent runtime + +Primary files: + +- `src/main/EryxAppService.ts` +- `src/shared/contracts/sidecar.ts` +- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs` +- `sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs` +- `sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs` + +Tasks: + +- compose selected LSP profiles into the `run-turn` payload from Electron main +- translate those profiles into sidecar-local tool sessions +- expose a stable subset of LSP-backed operations to agents +- avoid turning the renderer into a second IDE protocol stack + +Recommended direction: + +- use sidecar-local `AIFunction` tools for agent consumption +- keep the exposed operations narrow and project-oriented + +Acceptance: + +- agents can request useful LSP-backed project intelligence during project runs +- the contract is observable and testable from the app side + +### B6. Backend hardening and validation + +Primary files: + +- impacted TS and C# tests +- `package.json` + +Validation commands: + +- `bun test` +- `bun run sidecar:test` +- `bun run build` + +Hardening checklist: + +- additive migration coverage +- bad configuration/error reporting coverage +- environment sanitization review for spawned tools/servers +- sidecar protocol regression coverage + +## Frontend execution plan + +Frontend should start only after backend APIs and diagnostics are stable. + +### F1. Read-only diagnostics surfaces + +Primary files: + +- `src/renderer/App.tsx` +- `src/renderer/components/SettingsPanel.tsx` +- `src/renderer/components/CopilotStatusCard.tsx` + +Tasks: + +- add MCP and LSP sections in settings +- surface backend-reported health, readiness, and counts +- reuse the current diagnostics-card style before introducing complex editors + +Acceptance: + +- users can see whether MCP/LSP are configured and healthy +- failures are understandable without opening logs + +### F2. Global configuration editors + +Primary files: + +- `src/renderer/components/SettingsPanel.tsx` +- new renderer components for MCP/LSP editors +- `src/renderer/App.tsx` + +Tasks: + +- create/edit/delete MCP server definitions +- create/edit/delete LSP profiles +- manage secret references safely +- do not mix global definition editing with per-session toggles +- allow secret references without ever echoing raw secrets back into the renderer state + +Acceptance: + +- all backend CRUD flows are reachable through the UI +- global definitions are understandable and recoverable + +### F3. Session tooling panel controls + +Primary files: + +- `src/renderer/components/ActivityPanel.tsx` +- `src/renderer/App.tsx` +- supporting renderer state/helpers + +Tasks: + +- extend the current right-side activity panel with MCP/LSP enable/disable controls for the selected session +- show which global tools are available vs enabled for the session +- disable LSP/MCP controls when the selected session cannot use them, such as scratchpad if that restriction remains +- surface lightweight health/state next to each toggle + +Acceptance: + +- users can enable or disable global MCPs/LSPs per session from the right-side panel +- session toggles are persisted on the session record and survive app reloads + +### F4. Runtime visibility in chat/activity surfaces + +Primary files: + +- `src/renderer/components/ActivityPanel.tsx` +- `src/renderer/lib/sessionActivity.ts` +- `src/shared/domain/event.ts` +- possibly `src/renderer/components/ChatPane.tsx` + +Tasks: + +- show tool server name + tool name clearly +- distinguish MCP activity from LSP-backed activity if both appear +- show meaningful failure summaries +- leave room for a future richer timeline without blocking this phase + +Acceptance: + +- users can tell what tool/server was used during a run +- failures do not collapse into generic "tool-calling" noise + +### F5. Session-level polish and troubleshooting UX + +Primary files: + +- `src/renderer/components/ActivityPanel.tsx` +- `src/renderer/components/ChatPane.tsx` +- any supporting session detail surfaces + +Tasks: + +- show current session tooling status clearly +- add "why unavailable?" and "fix configuration" entry points +- optionally add compact badges or chips for active MCP/LSP selections + +Acceptance: + +- session-scoped tooling is visible without leaving the main workflow +- troubleshooting paths are discoverable + +## Handoff between backend and frontend agents + +### Backend agent handoff deliverables + +The backend pass should hand off: + +- finalized shared domain shapes +- finalized IPC contract +- finalized sidecar contract changes +- clear diagnostics payloads +- scratchpad/tool policy behavior +- finalized session enablement state shape +- test coverage for persistence, protocol, and runtime behavior + +### Frontend agent prerequisites + +The frontend agent should not start until it has: + +- stable backend CRUD endpoints +- stable health/diagnostic payloads +- stable activity event shapes +- at least one working MCP path and one working LSP profile path to design against +- stable session enable/disable semantics for the right-side panel + +## Risks and decisions to settle early + +### A. Define what "LSP support" means in v1 + +This is the biggest scope decision. Options include: + +- agent-facing project intelligence only +- agent-facing intelligence plus light read-only diagnostics in the UI +- a much larger editor-like in-app LSP experience + +Recommended default: + +- agent-facing project intelligence first +- diagnostics UI second +- editor-like UX later + +### B. Decide the scratchpad policy + +Current behavior is strongly tool-free. Keep that default unless product scope explicitly changes. + +### C. Decide the default session behavior + +Need to decide whether new sessions start with: + +- no MCPs/LSPs enabled by default +- a safe recommended subset enabled by default +- the same enablement as the previously selected session + +Recommended default: + +- start disabled by default until a session explicitly enables a tool + +### D. Decide provisioning and trust + +Need explicit decisions for: + +- bundled vs user-installed MCP/LSP servers +- which transports are allowed in v1 +- whether arbitrary commands are allowed +- whether write-capable tools need approval +- whether LSP starts TypeScript-only first + +### E. Decide the approval/safety model + +`ApprovePermissionAsync(...)` currently auto-approves. That is not a sufficient trust model for arbitrary MCP tooling. A v1 safety story is needed before enabling broad external tool execution. + +### F. Decide observability depth + +Current activity events are coarse. MCP/LSP may require richer activity or trace events to stay understandable. + +## Recommended sequencing summary + +1. B0: persisted vocabulary and migration-safe shapes +2. B1: main-process API and diagnostics +3. B2: sidecar protocol expansion +4. B3: MCP runtime support +5. B4: Electron-main LSP service +6. B5: LSP bridge into agent runtime +7. F1-F5: frontend diagnostics, global editors, session toggles, visibility, and polish + +## Out of scope for the first implementation pass + +- a full IDE/editor inside Eryx +- raw LSP transport in the renderer +- persisted secret values in workspace files +- broad packaging/install automation for every possible LSP/MCP ecosystem on day one + +## Suggested validation gate before frontend begins + +Before handing to the frontend agent, the backend agent should be able to demonstrate: + +- additive workspace persistence for MCP/LSP config +- at least one working MCP-backed agent flow in a project session +- one initialized, health-reporting LSP profile in Electron main +- per-session enable/disable persistence for selected MCPs/LSPs +- stable diagnostics payloads exposed to the renderer +- passing `bun test`, `bun run sidecar:test`, and `bun run build` diff --git a/README.md b/README.md index 4f5c7a1..46dc188 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ Eryx supports several ways of working: - **Handoff** for agent-to-agent delegation - **Group chat** for collaborative multi-agent discussion +### Add global MCPs and LSPs + +You can define MCP servers and LSP profiles once in **Settings**, then enable the ones you want for each project-backed session from the right-side **Activity** panel. + +This keeps machine-wide tooling reusable while still letting each session decide which external tools the agent can use. + ### Watch runs as they happen You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand. @@ -57,6 +63,7 @@ To use Eryx comfortably, make sure you have: - **GitHub Copilot CLI** installed and available as `copilot` - an active **GitHub Copilot sign-in** - a local folder or git repository ready to connect if you want project-aware help +- any MCP servers or language servers you want to use installed and reachable from your machine Eryx includes connection status in the app so you can quickly tell whether Copilot is ready before you start a session. @@ -74,7 +81,10 @@ Eryx includes connection status in the app so you can quickly tell whether Copil 4. **Pick a pattern** Use a simple single-agent setup to begin, or choose a saved multi-agent pattern when you want a more structured workflow. -5. **Start working** +5. **Configure optional tooling** + If you want MCP or LSP support, add the global definitions in settings and then enable the ones you want for the current session from the Activity panel. + +6. **Start working** Ask a question, describe a task, or explore a project. As the run progresses, you can watch the participating agents and keep the session for later. ## When Eryx feels most useful diff --git a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs index 2f17f5a..710c35b 100644 --- a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs @@ -113,6 +113,36 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope public string WorkspaceKind { get; init; } = "project"; public PatternDefinitionDto Pattern { get; init; } = new(); public IReadOnlyList Messages { get; init; } = []; + public RunTurnToolingConfigDto? Tooling { get; init; } +} + +public sealed class RunTurnToolingConfigDto +{ + public IReadOnlyList McpServers { get; init; } = []; + public IReadOnlyList LspProfiles { get; init; } = []; +} + +public sealed class RunTurnMcpServerConfigDto +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Transport { get; init; } = "local"; + public IReadOnlyList Tools { get; init; } = []; + public int? TimeoutMs { get; init; } + public string? Command { get; init; } + public IReadOnlyList? Args { get; init; } + public string? Cwd { get; init; } + public string? Url { get; init; } +} + +public sealed class RunTurnLspProfileConfigDto +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Command { get; init; } = string.Empty; + public IReadOnlyList Args { get; init; } = []; + public string LanguageId { get; init; } = string.Empty; + public IReadOnlyList FileExtensions { get; init; } = []; } public abstract class SidecarEventDto diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs index fa71d3d..db12f4e 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -507,6 +507,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner List agents = []; CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(); bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase); + SessionToolingBundle? toolingBundle = isScratchpad + ? null + : await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken) + .ConfigureAwait(false); + + if (toolingBundle is not null) + { + disposables.Add(toolingBundle); + } foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index))) { @@ -530,6 +539,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { sessionConfig.AvailableTools = []; } + else if (toolingBundle is not null) + { + if (toolingBundle.McpServers.Count > 0) + { + sessionConfig.McpServers = toolingBundle.McpServers; + } + + if (toolingBundle.Tools.Count > 0) + { + sessionConfig.Tools = toolingBundle.Tools.ToList(); + } + } GitHubCopilotAgent agent = new( client, diff --git a/sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs b/sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs new file mode 100644 index 0000000..188020b --- /dev/null +++ b/sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs @@ -0,0 +1,681 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Eryx.AgentHost.Contracts; +using Microsoft.Extensions.AI; + +namespace Eryx.AgentHost.Services; + +internal sealed class LspToolSession : IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + private readonly RunTurnLspProfileConfigDto _profile; + private readonly string _projectPath; + private readonly Process _process; + private readonly Stream _stdin; + private readonly Stream _stdout; + private readonly CancellationTokenSource _cts = new(); + private readonly ConcurrentDictionary> _pending = + new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly SemaphoreSlim _documentLock = new(1, 1); + private readonly HashSet _openedDocumentUris = new(StringComparer.OrdinalIgnoreCase); + private readonly Task _stdoutReaderTask; + private readonly Task _stderrReaderTask; + private int _nextRequestId; + + private LspToolSession( + RunTurnLspProfileConfigDto profile, + string projectPath, + Process process) + { + _profile = profile; + _projectPath = Path.GetFullPath(projectPath); + _process = process; + _stdin = process.StandardInput.BaseStream; + _stdout = process.StandardOutput.BaseStream; + _stdoutReaderTask = Task.Run(() => ReadLoopAsync(_cts.Token)); + _stderrReaderTask = Task.Run(() => ReadErrorLoopAsync(_cts.Token)); + Tools = + [ + CreateTool( + methodName: nameof(WorkspaceSymbolsToolAsync), + toolName: $"{BuildToolPrefix(_profile.Id)}_workspace_symbols", + description: $"Search workspace symbols using the {_profile.Name} language server. Use this when you know a symbol name or partial symbol name."), + CreateTool( + methodName: nameof(DocumentSymbolsToolAsync), + toolName: $"{BuildToolPrefix(_profile.Id)}_document_symbols", + description: $"List document symbols for a file using the {_profile.Name} language server. Pass a path relative to the current project root."), + CreateTool( + methodName: nameof(DefinitionToolAsync), + toolName: $"{BuildToolPrefix(_profile.Id)}_definition", + description: $"Resolve the definition location for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."), + CreateTool( + methodName: nameof(HoverToolAsync), + toolName: $"{BuildToolPrefix(_profile.Id)}_hover", + description: $"Fetch hover information for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."), + CreateTool( + methodName: nameof(ReferencesToolAsync), + toolName: $"{BuildToolPrefix(_profile.Id)}_references", + description: $"Find references for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."), + ]; + } + + public IReadOnlyList Tools { get; } + + public static async Task StartAsync( + RunTurnLspProfileConfigDto profile, + string projectPath, + CancellationToken cancellationToken) + { + ProcessStartInfo startInfo = new() + { + FileName = profile.Command, + WorkingDirectory = projectPath, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (string arg in profile.Args) + { + startInfo.ArgumentList.Add(arg); + } + + Process? process; + try + { + process = Process.Start(startInfo); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Could not start LSP profile \"{profile.Name}\" using command \"{profile.Command}\".", + ex); + } + + if (process is null) + { + throw new InvalidOperationException( + $"Could not start LSP profile \"{profile.Name}\" using command \"{profile.Command}\"."); + } + + LspToolSession session = new(profile, projectPath, process); + await session.InitializeAsync(cancellationToken).ConfigureAwait(false); + return session; + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + } + catch + { + } + + try + { + await _stdoutReaderTask.ConfigureAwait(false); + } + catch + { + } + + try + { + await _stderrReaderTask.ConfigureAwait(false); + } + catch + { + } + + _writeLock.Dispose(); + _documentLock.Dispose(); + _stdin.Dispose(); + _stdout.Dispose(); + _process.Dispose(); + _cts.Dispose(); + } + + private async Task InitializeAsync(CancellationToken cancellationToken) + { + string rootUri = ToFileUri(_projectPath); + await SendRequestAsync( + "initialize", + new + { + processId = Environment.ProcessId, + rootUri, + capabilities = new { }, + workspaceFolders = new[] + { + new + { + uri = rootUri, + name = Path.GetFileName(_projectPath), + }, + }, + clientInfo = new + { + name = "Eryx", + version = "1.0.0", + }, + }, + cancellationToken).ConfigureAwait(false); + + await SendNotificationAsync("initialized", new { }, cancellationToken).ConfigureAwait(false); + } + + private AIFunction CreateTool(string methodName, string toolName, string description) + { + MethodInfo method = GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"LSP tool method \"{methodName}\" was not found."); + + return AIFunctionFactory.Create( + method, + this, + new AIFunctionFactoryOptions + { + Name = toolName, + Description = description, + SerializerOptions = JsonOptions, + }); + } + + private async Task WorkspaceSymbolsToolAsync(string query, int limit = 20) + { + JsonElement? result = await SendRequestAsync( + "workspace/symbol", + new + { + query, + }, + CancellationToken.None).ConfigureAwait(false); + + return SerializeResult(result, limit); + } + + private async Task DocumentSymbolsToolAsync(string relativePath) + { + string documentPath = ResolveProjectPath(relativePath); + string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None) + .ConfigureAwait(false); + + JsonElement? result = await SendRequestAsync( + "textDocument/documentSymbol", + new + { + textDocument = new + { + uri = documentUri, + }, + }, + CancellationToken.None).ConfigureAwait(false); + + return SerializeResult(result); + } + + private async Task DefinitionToolAsync(string relativePath, int line, int character) + { + string documentPath = ResolveProjectPath(relativePath); + string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None) + .ConfigureAwait(false); + + JsonElement? result = await SendRequestAsync( + "textDocument/definition", + new + { + textDocument = new + { + uri = documentUri, + }, + position = new + { + line = NormalizePosition(line), + character = NormalizePosition(character), + }, + }, + CancellationToken.None).ConfigureAwait(false); + + return SerializeResult(result); + } + + private async Task HoverToolAsync(string relativePath, int line, int character) + { + string documentPath = ResolveProjectPath(relativePath); + string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None) + .ConfigureAwait(false); + + JsonElement? result = await SendRequestAsync( + "textDocument/hover", + new + { + textDocument = new + { + uri = documentUri, + }, + position = new + { + line = NormalizePosition(line), + character = NormalizePosition(character), + }, + }, + CancellationToken.None).ConfigureAwait(false); + + return SerializeResult(result); + } + + private async Task ReferencesToolAsync(string relativePath, int line, int character) + { + string documentPath = ResolveProjectPath(relativePath); + string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None) + .ConfigureAwait(false); + + JsonElement? result = await SendRequestAsync( + "textDocument/references", + new + { + textDocument = new + { + uri = documentUri, + }, + position = new + { + line = NormalizePosition(line), + character = NormalizePosition(character), + }, + context = new + { + includeDeclaration = true, + }, + }, + CancellationToken.None).ConfigureAwait(false); + + return SerializeResult(result); + } + + private async Task EnsureDocumentOpenedAsync(string documentPath, CancellationToken cancellationToken) + { + string documentUri = ToFileUri(documentPath); + + if (_openedDocumentUris.Contains(documentUri)) + { + return documentUri; + } + + await _documentLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_openedDocumentUris.Contains(documentUri)) + { + return documentUri; + } + + string text = await File.ReadAllTextAsync(documentPath, cancellationToken).ConfigureAwait(false); + await SendNotificationAsync( + "textDocument/didOpen", + new + { + textDocument = new + { + uri = documentUri, + languageId = ResolveLanguageId(documentPath), + version = 1, + text, + }, + }, + cancellationToken).ConfigureAwait(false); + + _openedDocumentUris.Add(documentUri); + return documentUri; + } + finally + { + _documentLock.Release(); + } + } + + private async Task SendRequestAsync( + string method, + object? parameters, + CancellationToken cancellationToken) + { + int requestId = Interlocked.Increment(ref _nextRequestId); + TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[requestId] = tcs; + + await WriteMessageAsync( + new + { + jsonrpc = "2.0", + id = requestId, + method, + @params = parameters, + }, + cancellationToken).ConfigureAwait(false); + + using CancellationTokenRegistration registration = cancellationToken.Register( + static state => + { + ((TaskCompletionSource)state!).TrySetCanceled(); + }, + tcs); + + try + { + return await tcs.Task.ConfigureAwait(false); + } + finally + { + _pending.TryRemove(requestId, out _); + } + } + + private Task SendNotificationAsync(string method, object? parameters, CancellationToken cancellationToken) + { + return WriteMessageAsync( + new + { + jsonrpc = "2.0", + method, + @params = parameters, + }, + cancellationToken); + } + + private async Task WriteMessageAsync(object payload, CancellationToken cancellationToken) + { + string json = JsonSerializer.Serialize(payload, JsonOptions); + byte[] body = Encoding.UTF8.GetBytes(json); + byte[] header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _stdin.WriteAsync(header, cancellationToken).ConfigureAwait(false); + await _stdin.WriteAsync(body, cancellationToken).ConfigureAwait(false); + await _stdin.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + private async Task ReadLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + Dictionary? headers = await ReadHeadersAsync(_stdout, cancellationToken) + .ConfigureAwait(false); + if (headers is null) + { + break; + } + + if (!headers.TryGetValue("Content-Length", out string? contentLengthHeader) + || !int.TryParse(contentLengthHeader, out int contentLength)) + { + throw new InvalidOperationException("LSP response was missing a valid Content-Length header."); + } + + byte[] body = await ReadExactAsync(_stdout, contentLength, cancellationToken).ConfigureAwait(false); + using JsonDocument document = JsonDocument.Parse(body); + JsonElement root = document.RootElement; + + if (root.TryGetProperty("id", out JsonElement idElement) + && TryReadRequestId(idElement, out int requestId) + && _pending.TryGetValue(requestId, out TaskCompletionSource? tcs)) + { + if (root.TryGetProperty("error", out JsonElement errorElement)) + { + string message = errorElement.TryGetProperty("message", out JsonElement messageElement) + ? messageElement.GetString() ?? "Unknown LSP error." + : "Unknown LSP error."; + tcs.TrySetException(new InvalidOperationException(message)); + } + else if (root.TryGetProperty("result", out JsonElement resultElement)) + { + tcs.TrySetResult(resultElement.Clone()); + } + else + { + tcs.TrySetResult(null); + } + } + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + FailPendingRequests(ex); + } + finally + { + FailPendingRequests(new InvalidOperationException( + $"LSP profile \"{_profile.Name}\" stopped before a pending request completed.")); + } + } + + private async Task ReadErrorLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + string? line = await _process.StandardError.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) + { + break; + } + + if (!string.IsNullOrWhiteSpace(line)) + { + Console.Error.WriteLine($"[eryx lsp:{_profile.Id}] {line}"); + } + } + } + catch (OperationCanceledException) + { + } + } + + private static async Task?> ReadHeadersAsync( + Stream stream, + CancellationToken cancellationToken) + { + List bytes = []; + byte[] buffer = new byte[1]; + + while (true) + { + int read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + return bytes.Count == 0 + ? null + : throw new EndOfStreamException("LSP stream ended mid-header."); + } + + bytes.Add(buffer[0]); + int count = bytes.Count; + if (count >= 4 + && bytes[count - 4] == '\r' + && bytes[count - 3] == '\n' + && bytes[count - 2] == '\r' + && bytes[count - 1] == '\n') + { + break; + } + } + + string headerText = Encoding.ASCII.GetString(bytes.Take(bytes.Count - 4).ToArray()); + Dictionary headers = new(StringComparer.OrdinalIgnoreCase); + foreach (string line in headerText.Split("\r\n", StringSplitOptions.RemoveEmptyEntries)) + { + int separatorIndex = line.IndexOf(':'); + if (separatorIndex < 0) + { + continue; + } + + string key = line[..separatorIndex].Trim(); + string value = line[(separatorIndex + 1)..].Trim(); + headers[key] = value; + } + + return headers; + } + + private static async Task ReadExactAsync( + Stream stream, + int length, + CancellationToken cancellationToken) + { + byte[] buffer = new byte[length]; + int offset = 0; + + while (offset < length) + { + int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + throw new EndOfStreamException("LSP stream ended mid-message."); + } + + offset += read; + } + + return buffer; + } + + private static bool TryReadRequestId(JsonElement idElement, out int requestId) + { + requestId = default; + return idElement.ValueKind switch + { + JsonValueKind.Number => idElement.TryGetInt32(out requestId), + JsonValueKind.String => int.TryParse(idElement.GetString(), out requestId), + _ => false, + }; + } + + private void FailPendingRequests(Exception exception) + { + foreach ((_, TaskCompletionSource tcs) in _pending.ToArray()) + { + tcs.TrySetException(exception); + } + + _pending.Clear(); + } + + private string ResolveProjectPath(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new InvalidOperationException("A project-relative path is required."); + } + + string fullPath = Path.IsPathRooted(relativePath) + ? Path.GetFullPath(relativePath) + : Path.GetFullPath(Path.Combine(_projectPath, relativePath)); + + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException($"Could not find \"{relativePath}\" in the current project.", fullPath); + } + + string normalizedProjectPath = Path.TrimEndingDirectorySeparator(_projectPath); + if (!fullPath.StartsWith(normalizedProjectPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("LSP tools can only access files inside the current project."); + } + + return fullPath; + } + + private string ResolveLanguageId(string documentPath) + { + string extension = Path.GetExtension(documentPath); + if (_profile.FileExtensions.Any(candidate => + string.Equals(candidate, extension, StringComparison.OrdinalIgnoreCase))) + { + return _profile.LanguageId; + } + + return _profile.LanguageId; + } + + private static string BuildToolPrefix(string value) + { + StringBuilder builder = new(); + foreach (char ch in value) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(char.ToLowerInvariant(ch)); + continue; + } + + if (builder.Length == 0 || builder[^1] == '_') + { + continue; + } + + builder.Append('_'); + } + + string prefix = builder.ToString().Trim('_'); + return string.IsNullOrWhiteSpace(prefix) ? "lsp" : $"lsp_{prefix}"; + } + + private static int NormalizePosition(int value) + { + return Math.Max(value - 1, 0); + } + + private static string ToFileUri(string path) + { + return new Uri(path).AbsoluteUri; + } + + private static string SerializeResult(JsonElement? result, int? maxItems = null) + { + if (result is null) + { + return "null"; + } + + JsonElement output = result.Value; + if (maxItems.HasValue && output.ValueKind == JsonValueKind.Array) + { + JsonElement[] limited = output.EnumerateArray().Take(Math.Max(maxItems.Value, 0)).ToArray(); + return JsonSerializer.Serialize(limited, JsonOptions); + } + + return JsonSerializer.Serialize(output, JsonOptions); + } +} diff --git a/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs b/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs new file mode 100644 index 0000000..cb185bc --- /dev/null +++ b/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs @@ -0,0 +1,98 @@ +using GitHub.Copilot.SDK; +using Eryx.AgentHost.Contracts; +using Microsoft.Extensions.AI; + +namespace Eryx.AgentHost.Services; + +internal sealed class SessionToolingBundle : IAsyncDisposable +{ + private readonly List _disposables = []; + + private SessionToolingBundle( + Dictionary mcpServers, + IReadOnlyList tools) + { + McpServers = mcpServers; + Tools = tools; + } + + public Dictionary McpServers { get; } + + public IReadOnlyList Tools { get; } + + public static async Task CreateAsync( + RunTurnToolingConfigDto? tooling, + string projectPath, + CancellationToken cancellationToken) + { + Dictionary mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []); + List disposables = []; + List tools = []; + + foreach (RunTurnLspProfileConfigDto profile in tooling?.LspProfiles ?? []) + { + LspToolSession lspSession = await LspToolSession.StartAsync(profile, projectPath, cancellationToken) + .ConfigureAwait(false); + disposables.Add(lspSession); + tools.AddRange(lspSession.Tools); + } + + SessionToolingBundle bundle = new(mcpServers, tools); + bundle._disposables.AddRange(disposables); + return bundle; + } + + public async ValueTask DisposeAsync() + { + foreach (IAsyncDisposable disposable in _disposables) + { + await disposable.DisposeAsync().ConfigureAwait(false); + } + } + + internal static Dictionary BuildMcpServerConfigurations( + IReadOnlyList servers) + { + Dictionary configurations = new(StringComparer.OrdinalIgnoreCase); + + foreach (RunTurnMcpServerConfigDto server in servers) + { + string serverName = string.IsNullOrWhiteSpace(server.Name) ? server.Id : server.Name.Trim(); + List tools = server.Tools.Count == 0 ? ["*"] : server.Tools.ToList(); + + if (string.Equals(server.Transport, "local", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(server.Command)) + { + throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a command."); + } + + configurations[serverName] = new McpLocalServerConfig + { + Type = "local", + Timeout = server.TimeoutMs, + Command = server.Command, + Args = server.Args?.ToList() ?? [], + Cwd = string.IsNullOrWhiteSpace(server.Cwd) ? null : server.Cwd, + Tools = tools, + }; + continue; + } + + if (string.IsNullOrWhiteSpace(server.Url)) + { + throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a URL."); + } + + configurations[serverName] = new McpRemoteServerConfig + { + Type = server.Transport, + Timeout = server.TimeoutMs, + Url = server.Url, + Tools = tools, + }; + } + + return configurations; + } +} diff --git a/sidecar/tests/Eryx.AgentHost.Tests/SessionToolingBundleTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/SessionToolingBundleTests.cs new file mode 100644 index 0000000..db34bec --- /dev/null +++ b/sidecar/tests/Eryx.AgentHost.Tests/SessionToolingBundleTests.cs @@ -0,0 +1,98 @@ +using Eryx.AgentHost.Contracts; +using Eryx.AgentHost.Services; +using GitHub.Copilot.SDK; + +namespace Eryx.AgentHost.Tests; + +public sealed class SessionToolingBundleTests +{ + [Fact] + public void BuildMcpServerConfigurations_MapsLocalAndRemoteServers() + { + IReadOnlyList servers = + [ + new() + { + Id = "mcp-local", + Name = "Local MCP", + Transport = "local", + Command = "node", + Args = ["server.js", "--stdio"], + Cwd = @"C:\workspace\repo", + Tools = ["git.status"], + TimeoutMs = 1500, + }, + new() + { + Id = "mcp-remote", + Name = "Remote MCP", + Transport = "http", + Url = "https://example.com/mcp", + Tools = ["*"], + }, + ]; + + Dictionary configurations = SessionToolingBundle.BuildMcpServerConfigurations(servers); + + McpLocalServerConfig localConfig = Assert.IsType(configurations["Local MCP"]); + Assert.Equal("local", localConfig.Type); + Assert.Equal("node", localConfig.Command); + Assert.Equal(["server.js", "--stdio"], localConfig.Args); + Assert.Equal(@"C:\workspace\repo", localConfig.Cwd); + Assert.Equal(["git.status"], localConfig.Tools); + Assert.Equal(1500, localConfig.Timeout); + + McpRemoteServerConfig remoteConfig = Assert.IsType(configurations["Remote MCP"]); + Assert.Equal("http", remoteConfig.Type); + Assert.Equal("https://example.com/mcp", remoteConfig.Url); + Assert.Equal(["*"], remoteConfig.Tools); + } + + [Fact] + public void BuildMcpServerConfigurations_DefaultsMissingToolsToWildcard() + { + IReadOnlyList servers = + [ + new() + { + Id = "mcp-local", + Transport = "local", + Command = "node", + Tools = [], + }, + ]; + + Dictionary configurations = SessionToolingBundle.BuildMcpServerConfigurations(servers); + McpLocalServerConfig localConfig = Assert.IsType(configurations["mcp-local"]); + + Assert.Equal(["*"], localConfig.Tools); + } + + [Fact] + public void BuildMcpServerConfigurations_RejectsMissingTransportTargets() + { + InvalidOperationException localError = Assert.Throws( + () => SessionToolingBundle.BuildMcpServerConfigurations( + [ + new RunTurnMcpServerConfigDto + { + Id = "mcp-local", + Name = "Local MCP", + Transport = "local", + }, + ])); + Assert.Contains("missing a command", localError.Message); + + InvalidOperationException remoteError = Assert.Throws( + () => SessionToolingBundle.BuildMcpServerConfigurations( + [ + new RunTurnMcpServerConfigDto + { + Id = "mcp-remote", + Name = "Remote MCP", + Transport = "sse", + }, + ])); + Assert.Contains("missing a URL", remoteError.Message); + } +} diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 47a7c05..76490b4 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -5,6 +5,9 @@ import { dialog } from 'electron'; import type { AgentActivityEvent, + RunTurnLspProfileConfig, + RunTurnMcpServerConfig, + RunTurnToolingConfig, SidecarCapabilities, TurnDeltaEvent, } from '@shared/contracts/sidecar'; @@ -31,11 +34,22 @@ import { import type { SessionEventRecord } from '@shared/domain/event'; import { applyScratchpadSessionConfig, + resolveSessionToolingSelection, createScratchpadSessionConfig, resolveSessionTitle, type ChatMessageRecord, type SessionRecord, } from '@shared/domain/session'; +import { + createSessionToolingSelection, + type LspProfileDefinition, + type McpServerDefinition, + normalizeLspProfileDefinition, + normalizeMcpServerDefinition, + normalizeSessionToolingSelection, + validateLspProfileDefinition, + validateMcpServerDefinition, +} from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; import { createId, nowIso } from '@shared/utils/ids'; import { mergeStreamingText } from '@shared/utils/streamingText'; @@ -193,6 +207,96 @@ export class EryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async saveMcpServer(server: McpServerDefinition): Promise { + const workspace = await this.loadWorkspace(); + const existingIndex = workspace.settings.tooling.mcpServers.findIndex( + (current) => current.id === server.id, + ); + const timestamp = nowIso(); + const candidate = normalizeMcpServerDefinition({ + ...server, + createdAt: + existingIndex >= 0 + ? workspace.settings.tooling.mcpServers[existingIndex].createdAt + : timestamp, + updatedAt: timestamp, + }); + const issue = validateMcpServerDefinition(candidate); + if (issue) { + throw new Error(issue); + } + + if (existingIndex >= 0) { + workspace.settings.tooling.mcpServers[existingIndex] = candidate; + } else { + workspace.settings.tooling.mcpServers.push(candidate); + } + + return this.persistAndBroadcast(workspace); + } + + async deleteMcpServer(serverId: string): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.tooling.mcpServers = workspace.settings.tooling.mcpServers.filter( + (server) => server.id !== serverId, + ); + + for (const session of workspace.sessions) { + const selection = resolveSessionToolingSelection(session); + session.tooling = { + ...selection, + enabledMcpServerIds: selection.enabledMcpServerIds.filter((id) => id !== serverId), + }; + } + + return this.persistAndBroadcast(workspace); + } + + async saveLspProfile(profile: LspProfileDefinition): Promise { + const workspace = await this.loadWorkspace(); + const existingIndex = workspace.settings.tooling.lspProfiles.findIndex( + (current) => current.id === profile.id, + ); + const timestamp = nowIso(); + const candidate = normalizeLspProfileDefinition({ + ...profile, + createdAt: + existingIndex >= 0 + ? workspace.settings.tooling.lspProfiles[existingIndex].createdAt + : timestamp, + updatedAt: timestamp, + }); + const issue = validateLspProfileDefinition(candidate); + if (issue) { + throw new Error(issue); + } + + if (existingIndex >= 0) { + workspace.settings.tooling.lspProfiles[existingIndex] = candidate; + } else { + workspace.settings.tooling.lspProfiles.push(candidate); + } + + return this.persistAndBroadcast(workspace); + } + + async deleteLspProfile(profileId: string): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.tooling.lspProfiles = workspace.settings.tooling.lspProfiles.filter( + (profile) => profile.id !== profileId, + ); + + for (const session of workspace.sessions) { + const selection = resolveSessionToolingSelection(session); + session.tooling = { + ...selection, + enabledLspProfileIds: selection.enabledLspProfileIds.filter((id) => id !== profileId), + }; + } + + return this.persistAndBroadcast(workspace); + } + async createSession(projectId: string, patternId: string): Promise { const workspace = await this.loadWorkspace(); const project = this.requireProject(workspace, projectId); @@ -213,6 +317,7 @@ export class EryxAppService extends EventEmitter { scratchpadConfig: isScratchpadProject(project) ? createScratchpadSessionConfig(normalizedPattern) : undefined, + tooling: createSessionToolingSelection(), }; workspace.sessions.unshift(session); @@ -303,6 +408,7 @@ export class EryxAppService extends EventEmitter { workspaceKind, pattern: effectivePattern, messages: session.messages, + tooling: this.buildRunTurnToolingConfig(workspace, project, session), }, async (event) => { await this.applyTurnDelta(workspace, session.id, event); @@ -366,6 +472,57 @@ export class EryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async updateSessionTooling( + sessionId: string, + enabledMcpServerIds: string[], + enabledLspProfileIds: string[], + ): Promise { + const workspace = await this.loadWorkspace(); + const session = this.requireSession(workspace, sessionId); + const project = this.requireProject(workspace, session.projectId); + + if (session.status === 'running') { + throw new Error('Wait for the current response to finish before changing session tools.'); + } + + const selection = normalizeSessionToolingSelection({ + enabledMcpServerIds, + enabledLspProfileIds, + }); + + if ( + isScratchpadProject(project) + && (selection.enabledMcpServerIds.length > 0 || selection.enabledLspProfileIds.length > 0) + ) { + throw new Error('Scratchpad sessions do not support MCP or LSP tools.'); + } + + const knownMcpServerIds = new Set( + workspace.settings.tooling.mcpServers.map((server) => server.id), + ); + const knownLspProfileIds = new Set( + workspace.settings.tooling.lspProfiles.map((profile) => profile.id), + ); + + const unknownMcpServerIds = selection.enabledMcpServerIds.filter( + (id) => !knownMcpServerIds.has(id), + ); + if (unknownMcpServerIds.length > 0) { + throw new Error(`Unknown MCP server "${unknownMcpServerIds[0]}".`); + } + + const unknownLspProfileIds = selection.enabledLspProfileIds.filter( + (id) => !knownLspProfileIds.has(id), + ); + if (unknownLspProfileIds.length > 0) { + throw new Error(`Unknown LSP profile "${unknownLspProfileIds[0]}".`); + } + + session.tooling = selection; + session.updatedAt = nowIso(); + return this.persistAndBroadcast(workspace); + } + async querySessions(input: QuerySessionsInput): Promise { const workspace = await this.loadWorkspace(); return queryWorkspaceSessions(workspace, input); @@ -590,6 +747,86 @@ export class EryxAppService extends EventEmitter { return normalizePatternModels(patternWithSessionConfig, modelCatalog); } + private buildRunTurnToolingConfig( + workspace: WorkspaceState, + project: ProjectRecord, + session: SessionRecord, + ): RunTurnToolingConfig | undefined { + if (isScratchpadProject(project)) { + return undefined; + } + + const selection = resolveSessionToolingSelection(session); + const mcpServersById = new Map( + workspace.settings.tooling.mcpServers.map((server) => [server.id, server]), + ); + const lspProfilesById = new Map( + workspace.settings.tooling.lspProfiles.map((profile) => [profile.id, profile]), + ); + + const mcpServers = selection.enabledMcpServerIds.flatMap((id): RunTurnMcpServerConfig[] => { + const server = mcpServersById.get(id); + if (!server) { + return []; + } + + if (server.transport === 'local') { + return [ + { + id: server.id, + name: server.name, + transport: 'local', + tools: [...server.tools], + timeoutMs: server.timeoutMs, + command: server.command, + args: [...server.args], + cwd: server.cwd, + }, + ]; + } + + return [ + { + id: server.id, + name: server.name, + transport: server.transport, + tools: [...server.tools], + timeoutMs: server.timeoutMs, + url: server.url, + }, + ]; + }); + + const lspProfiles = selection.enabledLspProfileIds.flatMap( + (id): RunTurnLspProfileConfig[] => { + const profile = lspProfilesById.get(id); + if (!profile) { + return []; + } + + return [ + { + id: profile.id, + name: profile.name, + command: profile.command, + args: [...profile.args], + languageId: profile.languageId, + fileExtensions: [...profile.fileExtensions], + }, + ]; + }, + ); + + if (mcpServers.length === 0 && lspProfiles.length === 0) { + return undefined; + } + + return { + mcpServers, + lspProfiles, + }; + } + private emitSessionEvent(event: SessionEventRecord): void { this.emit('session-event', event); } diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 9390945..ab3317c 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -5,11 +5,14 @@ import type { CreateSessionInput, DuplicateSessionInput, RenameSessionInput, + SaveLspProfileInput, + SaveMcpServerInput, SavePatternInput, SendSessionMessageInput, SetPatternFavoriteInput, SetSessionArchivedInput, SetSessionPinnedInput, + UpdateSessionToolingInput, UpdateScratchpadSessionConfigInput, } from '@shared/contracts/ipc'; import type { QuerySessionsInput } from '@shared/domain/sessionLibrary'; @@ -30,6 +33,25 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) => service.setPatternFavorite(input.patternId, input.isFavorite), ); + ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) => + service.saveMcpServer(input.server), + ); + ipcMain.handle(ipcChannels.deleteMcpServer, (_event, serverId: string) => + service.deleteMcpServer(serverId), + ); + ipcMain.handle(ipcChannels.saveLspProfile, (_event, input: SaveLspProfileInput) => + service.saveLspProfile(input.profile), + ); + ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) => + service.deleteLspProfile(profileId), + ); + ipcMain.handle(ipcChannels.updateSessionTooling, (_event, input: UpdateSessionToolingInput) => + service.updateSessionTooling( + input.sessionId, + input.enabledMcpServerIds, + input.enabledLspProfileIds, + ), + ); ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) => service.createSession(input.projectId, input.patternId), ); diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index e60db58..8c0cac4 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises'; import { createBuiltinPatterns } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern'; import { mergeScratchpadProject } from '@shared/domain/project'; +import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling'; import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; import { nowIso } from '@shared/utils/ids'; @@ -59,7 +60,11 @@ export class WorkspaceRepository { ...stored, patterns: mergePatterns(stored.patterns ?? []), projects, - sessions: stored.sessions ?? [], + sessions: (stored.sessions ?? []).map((session) => ({ + ...session, + tooling: normalizeSessionToolingSelection(session.tooling), + })), + settings: normalizeWorkspaceSettings(stored.settings), selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId) ? stored.selectedProjectId : projects[0]?.id, diff --git a/src/preload/index.ts b/src/preload/index.ts index cde35f6..27ea71d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,11 @@ const api: ElectronApi = { savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input), deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId), setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input), + saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input), + deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId), + saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input), + deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId), + updateSessionTooling: (input) => ipcRenderer.invoke(ipcChannels.updateSessionTooling, input), createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input), duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input), renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3ca2a8f..1f01e20 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -24,6 +24,7 @@ import { import type { PatternDefinition } from '@shared/domain/pattern'; import { isScratchpadProject } from '@shared/domain/project'; import { applyScratchpadSessionConfig } from '@shared/domain/session'; +import type { LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; import { createId, nowIso } from '@shared/utils/ids'; @@ -51,6 +52,34 @@ function createDraftPattern(defaultModelId: string, defaultReasoningEffort: Patt }; } +function createDraftMcpServer(): McpServerDefinition { + const timestamp = nowIso(); + return { + id: createId('mcp'), + name: 'New MCP Server', + transport: 'local', + command: '', + args: [], + tools: ['*'], + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +function createDraftLspProfile(): LspProfileDefinition { + const timestamp = nowIso(); + return { + id: createId('lsp'), + name: 'New LSP Profile', + command: '', + args: [], + languageId: 'typescript', + fileExtensions: ['.ts', '.tsx'], + createdAt: timestamp, + updatedAt: timestamp, + }; +} + export default function App() { const api = getElectronApi(); const [workspace, setWorkspace] = useState(); @@ -195,7 +224,17 @@ export default function App() { detailPanel = ( { + void api.updateSessionTooling({ + sessionId: selectedSession.id, + enabledMcpServerIds: selection.enabledMcpServerIds, + enabledLspProfileIds: selection.enabledLspProfileIds, + }); + }} pattern={patternForSession} + projectIsScratchpad={isScratchpadProject(projectForSession)} session={selectedSession} /> ); @@ -216,24 +255,39 @@ export default function App() { availableModels={availableModels} isRefreshingCapabilities={isRefreshingCapabilities} onClose={() => setShowSettings(false)} - onDeletePattern={async (id) => { - await api.deletePattern(id); - }} - onNewPattern={() => { - const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4'); + onDeleteLspProfile={async (id) => { + await api.deleteLspProfile(id); + }} + onDeleteMcpServer={async (id) => { + await api.deleteMcpServer(id); + }} + onDeletePattern={async (id) => { + await api.deletePattern(id); + }} + onNewLspProfile={createDraftLspProfile} + onNewMcpServer={createDraftMcpServer} + onNewPattern={() => { + const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4'); return createDraftPattern( defaultModel?.id ?? 'gpt-5.4', resolveReasoningEffort(defaultModel, 'high'), ); }} - onRefreshCapabilities={refreshCapabilities} - onSavePattern={async (pattern) => { - await api.savePattern({ pattern }); - }} - patterns={workspace.patterns} - sidecarCapabilities={sidecarCapabilities} - /> + onRefreshCapabilities={refreshCapabilities} + onSaveLspProfile={async (profile) => { + await api.saveLspProfile({ profile }); + }} + onSaveMcpServer={async (server) => { + await api.saveMcpServer({ server }); + }} + onSavePattern={async (pattern) => { + await api.savePattern({ pattern }); + }} + patterns={workspace.patterns} + sidecarCapabilities={sidecarCapabilities} + toolingSettings={workspace.settings.tooling} + /> ) : null; return ( diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index 23d6967..773e201 100644 --- a/src/renderer/components/ActivityPanel.tsx +++ b/src/renderer/components/ActivityPanel.tsx @@ -10,7 +10,15 @@ import { } from '@renderer/lib/sessionActivity'; import { inferProvider } from '@shared/domain/models'; import type { PatternDefinition } from '@shared/domain/pattern'; -import type { SessionRecord } from '@shared/domain/session'; +import { + resolveSessionToolingSelection, + type SessionRecord, +} from '@shared/domain/session'; +import type { + LspProfileDefinition, + McpServerDefinition, + SessionToolingSelection, +} from '@shared/domain/tooling'; import { ProviderIcon } from './ProviderIcons'; function formatModel(model: string): string { @@ -30,17 +38,31 @@ function formatEffort(effort: string | undefined): string | undefined { interface ActivityPanelProps { activity?: SessionActivityState; + lspProfiles: LspProfileDefinition[]; + mcpServers: McpServerDefinition[]; + onUpdateSessionTooling: (selection: SessionToolingSelection) => void; pattern: PatternDefinition; + projectIsScratchpad: boolean; session: SessionRecord; } -export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps) { +export function ActivityPanel({ + activity, + lspProfiles, + mcpServers, + onUpdateSessionTooling, + pattern, + projectIsScratchpad, + session, +}: ActivityPanelProps) { const activityRows = useMemo( () => buildAgentActivityRows(activity, pattern.agents), [activity, pattern.agents], ); + const selection = useMemo(() => resolveSessionToolingSelection(session), [session]); const isBusy = session.status === 'running'; + const toolsDisabled = isBusy || projectIsScratchpad; return (
@@ -57,7 +79,71 @@ export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps {/* Agent cards */}
-
+
+
+
+
+

Session tools

+

+ Enable globally configured MCPs and LSPs for this session. +

+
+ {toolsDisabled && ( + + {projectIsScratchpad ? 'Scratchpad disabled' : 'Locked while running'} + + )} +
+ + {projectIsScratchpad ? ( +

+ Scratchpad stays tool-free. Start a project-backed session to use MCPs or LSPs. +

+ ) : ( +
+ ({ + id: server.id, + label: server.name, + detail: + server.transport === 'local' + ? server.command + : server.url, + }))} + onToggle={(id) => + onUpdateSessionTooling({ + ...selection, + enabledMcpServerIds: toggleId(selection.enabledMcpServerIds, id), + }) + } + title="MCP servers" + disabled={toolsDisabled} + /> + ({ + id: profile.id, + label: profile.name, + detail: `${profile.languageId} · ${profile.command}`, + }))} + onToggle={(id) => + onUpdateSessionTooling({ + ...selection, + enabledLspProfileIds: toggleId(selection.enabledLspProfileIds, id), + }) + } + title="LSP profiles" + disabled={toolsDisabled} + /> +
+ )} +
+ {activityRows.map((row, index) => { const agent = pattern.agents[index]; const isActive = isAgentActivityActive(row.activity); @@ -149,3 +235,76 @@ export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps ); } +function ToolToggleGroup({ + title, + description, + items, + enabledIds, + onToggle, + emptyMessage, + disabled, +}: { + title: string; + description: string; + items: Array<{ id: string; label: string; detail?: string }>; + enabledIds: string[]; + onToggle: (id: string) => void; + emptyMessage: string; + disabled: boolean; +}) { + return ( +
+
+

+ {title} +

+

{description}

+
+ + {items.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
+ {items.map((item) => { + const enabled = enabledIds.includes(item.id); + return ( + + ); + })} +
+ )} +
+ ); +} + +function toggleId(current: string[], id: string): string[] { + return current.includes(id) + ? current.filter((currentId) => currentId !== id) + : [...current, id]; +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 5b9386a..db66f0e 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,30 +1,47 @@ -import { useState } from 'react'; +import { useState, type HTMLAttributes, type ReactNode } from 'react'; import { ChevronLeft, ChevronRight, Cpu, Layers, Plus, Workflow } from 'lucide-react'; -import type { ModelDefinition } from '@shared/domain/models'; -import type { PatternDefinition } from '@shared/domain/pattern'; -import type { SidecarCapabilities } from '@shared/contracts/sidecar'; import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { PatternEditor } from '@renderer/components/PatternEditor'; +import type { SidecarCapabilities } from '@shared/contracts/sidecar'; +import type { ModelDefinition } from '@shared/domain/models'; +import type { PatternDefinition } from '@shared/domain/pattern'; +import { + normalizeLspProfileDefinition, + normalizeMcpServerDefinition, + type LspProfileDefinition, + type McpServerDefinition, + type WorkspaceToolingSettings, + validateLspProfileDefinition, + validateMcpServerDefinition, +} from '@shared/domain/tooling'; +import { nowIso } from '@shared/utils/ids'; interface SettingsPanelProps { availableModels: ReadonlyArray; patterns: PatternDefinition[]; sidecarCapabilities?: SidecarCapabilities; + toolingSettings: WorkspaceToolingSettings; isRefreshingCapabilities: boolean; onRefreshCapabilities: () => void; onClose: () => void; onSavePattern: (pattern: PatternDefinition) => Promise; onDeletePattern: (patternId: string) => Promise; onNewPattern: () => PatternDefinition; + onSaveMcpServer: (server: McpServerDefinition) => Promise; + onDeleteMcpServer: (serverId: string) => Promise; + onNewMcpServer: () => McpServerDefinition; + onSaveLspProfile: (profile: LspProfileDefinition) => Promise; + onDeleteLspProfile: (profileId: string) => Promise; + onNewLspProfile: () => LspProfileDefinition; } -type SettingsSection = 'connection' | 'patterns'; +type SettingsSection = 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles'; interface NavItem { id: SettingsSection; label: string; - icon: React.ReactNode; + icon: ReactNode; } interface NavGroup { @@ -45,6 +62,13 @@ const navGroups: NavGroup[] = [ { id: 'patterns', label: 'Patterns', icon: }, ], }, + { + label: 'Tooling', + items: [ + { id: 'mcp-servers', label: 'MCP Servers', icon: }, + { id: 'lsp-profiles', label: 'LSP Profiles', icon: }, + ], + }, ]; function modeBadgeClasses(pattern: PatternDefinition) { @@ -56,17 +80,25 @@ export function SettingsPanel({ availableModels, patterns, sidecarCapabilities, + toolingSettings, isRefreshingCapabilities, onRefreshCapabilities, onClose, onSavePattern, onDeletePattern, onNewPattern, + onSaveMcpServer, + onDeleteMcpServer, + onNewMcpServer, + onSaveLspProfile, + onDeleteLspProfile, + onNewLspProfile, }: SettingsPanelProps) { const [activeSection, setActiveSection] = useState('connection'); const [editingPattern, setEditingPattern] = useState(null); + const [editingMcpServer, setEditingMcpServer] = useState(null); + const [editingLspProfile, setEditingLspProfile] = useState(null); - // Pattern editor sub-view if (editingPattern) { const isBuiltin = editingPattern.id.startsWith('pattern-'); return ( @@ -94,9 +126,58 @@ export function SettingsPanel({ ); } + if (editingMcpServer) { + const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id); + return ( +
+ setEditingMcpServer(null)} + onChange={setEditingMcpServer} + onDelete={ + exists + ? async () => { + await onDeleteMcpServer(editingMcpServer.id); + setEditingMcpServer(null); + } + : undefined + } + onSave={async () => { + await onSaveMcpServer(normalizeMcpServerDefinition(editingMcpServer)); + setEditingMcpServer(null); + }} + server={editingMcpServer} + /> +
+ ); + } + + if (editingLspProfile) { + const exists = toolingSettings.lspProfiles.some((profile) => profile.id === editingLspProfile.id); + return ( +
+ setEditingLspProfile(null)} + onChange={setEditingLspProfile} + onDelete={ + exists + ? async () => { + await onDeleteLspProfile(editingLspProfile.id); + setEditingLspProfile(null); + } + : undefined + } + onSave={async () => { + await onSaveLspProfile(normalizeLspProfileDefinition(editingLspProfile)); + setEditingLspProfile(null); + }} + profile={editingLspProfile} + /> +
+ ); + } + return (
- {/* Header */}
- {/* Two-column layout */}
- {/* Left navigation */} - {/* Content area */}
{activeSection === 'connection' && ( @@ -156,11 +234,25 @@ export function SettingsPanel({ )} {activeSection === 'patterns' && ( setEditingPattern(structuredClone(p))} + onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))} onNewPattern={() => setEditingPattern(onNewPattern())} patterns={patterns} /> )} + {activeSection === 'mcp-servers' && ( + setEditingMcpServer(structuredClone(server))} + onNewServer={() => setEditingMcpServer(onNewMcpServer())} + servers={toolingSettings.mcpServers} + /> + )} + {activeSection === 'lsp-profiles' && ( + setEditingLspProfile(structuredClone(profile))} + onNewProfile={() => setEditingLspProfile(onNewLspProfile())} + profiles={toolingSettings.lspProfiles} + /> + )}
@@ -168,8 +260,6 @@ export function SettingsPanel({ ); } -/* ---------- Section components ---------- */ - function ConnectionSection({ connection, modelCount, @@ -212,22 +302,12 @@ function PatternsSection({ }) { return (
-
-
-

Orchestration Patterns

-

- Define reusable agent configurations for your sessions -

-
- -
+ + +
{patterns.map((pattern) => ( @@ -258,3 +338,587 @@ function PatternsSection({
); } + +function McpServersSection({ + servers, + onEditServer, + onNewServer, +}: { + servers: McpServerDefinition[]; + onEditServer: (server: McpServerDefinition) => void; + onNewServer: () => void; +}) { + return ( +
+ + + + +
+ {servers.length === 0 && ( + + No MCP servers configured yet. Add one here, then enable it per session from the Activity panel. + + )} + {servers.map((server) => ( + onEditServer(server)} + /> + ))} +
+
+ ); +} + +function LspProfilesSection({ + profiles, + onEditProfile, + onNewProfile, +}: { + profiles: LspProfileDefinition[]; + onEditProfile: (profile: LspProfileDefinition) => void; + onNewProfile: () => void; +}) { + return ( +
+ + + + +
+ {profiles.length === 0 && ( + + No LSP profiles configured yet. Add one here, then enable it per session from the Activity panel. + + )} + {profiles.map((profile) => ( + onEditProfile(profile)} + /> + ))} +
+
+ ); +} + +function McpServerEditor({ + server, + onChange, + onBack, + onSave, + onDelete, +}: { + server: McpServerDefinition; + onChange: (server: McpServerDefinition) => void; + onBack: () => void; + onSave: () => Promise; + onDelete?: () => Promise; +}) { + const validationError = validateMcpServerDefinition(server); + + return ( + +
+ + onChange(updateMcpServer(server, { name: value }))} + value={server.name} + /> + + + onChange(changeMcpTransport(server, value as McpServerDefinition['transport']))} + options={[ + { value: 'local', label: 'Local process' }, + { value: 'http', label: 'HTTP' }, + { value: 'sse', label: 'SSE' }, + ]} + value={server.transport} + /> + +
+ + {server.transport === 'local' ? ( +
+ + onChange(updateMcpServer(server, { command: value }))} + placeholder="node" + value={server.command} + /> + + + onChange(updateMcpServer(server, { args: splitMultiline(value) }))} + placeholder="One argument per line" + rows={4} + value={joinMultiline(server.args)} + /> + + + onChange(updateMcpServer(server, { cwd: value || undefined }))} + placeholder="Optional" + value={server.cwd ?? ''} + /> + +
+ ) : ( + + onChange(updateMcpServer(server, { url: value }))} + placeholder="https://example.com/mcp" + value={server.url} + /> + + )} + +
+ + onChange(updateMcpServer(server, { tools: splitTokens(value) }))} + placeholder="Use * for all tools, or list one tool per line" + rows={4} + value={joinMultiline(server.tools)} + /> + + + + onChange( + updateMcpServer(server, { + timeoutMs: value.trim() ? Number(value) : undefined, + }), + ) + } + placeholder="Optional" + value={server.timeoutMs?.toString() ?? ''} + /> + +
+ + + Keep secrets out of this form. Use commands or endpoints that authenticate through the OS or external tooling. + +
+ ); +} + +function LspProfileEditor({ + profile, + onChange, + onBack, + onSave, + onDelete, +}: { + profile: LspProfileDefinition; + onChange: (profile: LspProfileDefinition) => void; + onBack: () => void; + onSave: () => Promise; + onDelete?: () => Promise; +}) { + const validationError = validateLspProfileDefinition(profile); + + return ( + +
+ + onChange(updateLspProfile(profile, { name: value }))} + value={profile.name} + /> + + + onChange(updateLspProfile(profile, { languageId: value }))} + placeholder="typescript" + value={profile.languageId} + /> + +
+ + + onChange(updateLspProfile(profile, { command: value }))} + placeholder="typescript-language-server" + value={profile.command} + /> + + +
+ + onChange(updateLspProfile(profile, { args: splitMultiline(value) }))} + placeholder="One argument per line" + rows={4} + value={joinMultiline(profile.args)} + /> + + + onChange(updateLspProfile(profile, { fileExtensions: splitTokens(value) }))} + placeholder={'.ts\n.tsx'} + rows={4} + value={joinMultiline(profile.fileExtensions)} + /> + +
+ + + Profiles are global definitions only. Project root resolution still comes from the active session's project. + +
+ ); +} + +function SectionHeader({ + title, + description, + children, +}: { + title: string; + description: string; + children?: ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} + +function SectionAction({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +function ToolingListButton({ + label, + detail, + meta, + onClick, +}: { + label: string; + detail: string; + meta: string; + onClick: () => void; +}) { + return ( + + ); +} + +function EmptyState({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ToolingEditorShell({ + title, + description, + error, + disableSave, + onBack, + onSave, + onDelete, + children, +}: { + title: string; + description: string; + error?: string; + disableSave: boolean; + onBack: () => void; + onSave: () => Promise; + onDelete?: () => Promise; + children: ReactNode; +}) { + return ( +
+
+
+ +
+

{title}

+

{description}

+
+
+
+ {onDelete && ( + + )} + +
+
+ +
+
+ {error && ( +
+ {error} +
+ )} + {children} +
+
+
+ ); +} + +function FormField({ + label, + required, + className, + children, +}: { + label: string; + required?: boolean; + className?: string; + children: ReactNode; +}) { + return ( + + ); +} + +function TextInput({ + value, + onChange, + placeholder, + inputMode, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + inputMode?: HTMLAttributes['inputMode']; +}) { + return ( + onChange(event.target.value)} + placeholder={placeholder} + value={value} + /> + ); +} + +function TextareaInput({ + value, + onChange, + placeholder, + rows, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows: number; +}) { + return ( +