feat: add MCP and LSP tooling support

Add global MCP/LSP settings, per-session Activity toggles, sidecar runtime integration, tests, and documentation updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-23 22:51:03 +01:00
co-authored by Copilot
parent 50a8e5dfbe
commit efa5c44e07
26 changed files with 3196 additions and 47 deletions
+628
View File
@@ -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<string, unknown>;
};
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`
+11 -1
View File
@@ -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
@@ -113,6 +113,36 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
public string WorkspaceKind { get; init; } = "project";
public PatternDefinitionDto Pattern { get; init; } = new();
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
public RunTurnToolingConfigDto? Tooling { get; init; }
}
public sealed class RunTurnToolingConfigDto
{
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
public IReadOnlyList<RunTurnLspProfileConfigDto> 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<string> Tools { get; init; } = [];
public int? TimeoutMs { get; init; }
public string? Command { get; init; }
public IReadOnlyList<string>? 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<string> Args { get; init; } = [];
public string LanguageId { get; init; } = string.Empty;
public IReadOnlyList<string> FileExtensions { get; init; } = [];
}
public abstract class SidecarEventDto
@@ -507,6 +507,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
List<AIAgent> 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,
@@ -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<int, TaskCompletionSource<JsonElement?>> _pending =
new();
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly SemaphoreSlim _documentLock = new(1, 1);
private readonly HashSet<string> _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<AIFunction> Tools { get; }
public static async Task<LspToolSession> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<JsonElement?> SendRequestAsync(
string method,
object? parameters,
CancellationToken cancellationToken)
{
int requestId = Interlocked.Increment(ref _nextRequestId);
TaskCompletionSource<JsonElement?> 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<JsonElement?>)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<string, string>? 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<JsonElement?>? 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<Dictionary<string, string>?> ReadHeadersAsync(
Stream stream,
CancellationToken cancellationToken)
{
List<byte> 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<string, string> 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<byte[]> 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<JsonElement?> 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);
}
}
@@ -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<IAsyncDisposable> _disposables = [];
private SessionToolingBundle(
Dictionary<string, object> mcpServers,
IReadOnlyList<AIFunction> tools)
{
McpServers = mcpServers;
Tools = tools;
}
public Dictionary<string, object> McpServers { get; }
public IReadOnlyList<AIFunction> Tools { get; }
public static async Task<SessionToolingBundle> CreateAsync(
RunTurnToolingConfigDto? tooling,
string projectPath,
CancellationToken cancellationToken)
{
Dictionary<string, object> mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []);
List<IAsyncDisposable> disposables = [];
List<AIFunction> 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<string, object> BuildMcpServerConfigurations(
IReadOnlyList<RunTurnMcpServerConfigDto> servers)
{
Dictionary<string, object> configurations = new(StringComparer.OrdinalIgnoreCase);
foreach (RunTurnMcpServerConfigDto server in servers)
{
string serverName = string.IsNullOrWhiteSpace(server.Name) ? server.Id : server.Name.Trim();
List<string> 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;
}
}
@@ -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<RunTurnMcpServerConfigDto> 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<string, object> configurations = SessionToolingBundle.BuildMcpServerConfigurations(servers);
McpLocalServerConfig localConfig = Assert.IsType<McpLocalServerConfig>(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<McpRemoteServerConfig>(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<RunTurnMcpServerConfigDto> servers =
[
new()
{
Id = "mcp-local",
Transport = "local",
Command = "node",
Tools = [],
},
];
Dictionary<string, object> configurations = SessionToolingBundle.BuildMcpServerConfigurations(servers);
McpLocalServerConfig localConfig = Assert.IsType<McpLocalServerConfig>(configurations["mcp-local"]);
Assert.Equal(["*"], localConfig.Tools);
}
[Fact]
public void BuildMcpServerConfigurations_RejectsMissingTransportTargets()
{
InvalidOperationException localError = Assert.Throws<InvalidOperationException>(
() => SessionToolingBundle.BuildMcpServerConfigurations(
[
new RunTurnMcpServerConfigDto
{
Id = "mcp-local",
Name = "Local MCP",
Transport = "local",
},
]));
Assert.Contains("missing a command", localError.Message);
InvalidOperationException remoteError = Assert.Throws<InvalidOperationException>(
() => SessionToolingBundle.BuildMcpServerConfigurations(
[
new RunTurnMcpServerConfigDto
{
Id = "mcp-remote",
Name = "Remote MCP",
Transport = "sse",
},
]));
Assert.Contains("missing a URL", remoteError.Message);
}
}
+237
View File
@@ -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<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
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<WorkspaceState> {
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<WorkspaceState> {
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<WorkspaceState> {
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<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
@@ -213,6 +317,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
scratchpadConfig: isScratchpadProject(project)
? createScratchpadSessionConfig(normalizedPattern)
: undefined,
tooling: createSessionToolingSelection(),
};
workspace.sessions.unshift(session);
@@ -303,6 +408,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
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<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async updateSessionTooling(
sessionId: string,
enabledMcpServerIds: string[],
enabledLspProfileIds: string[],
): Promise<WorkspaceState> {
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<SessionQueryResult[]> {
const workspace = await this.loadWorkspace();
return queryWorkspaceSessions(workspace, input);
@@ -590,6 +747,86 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
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<string, McpServerDefinition>(
workspace.settings.tooling.mcpServers.map((server) => [server.id, server]),
);
const lspProfilesById = new Map<string, LspProfileDefinition>(
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);
}
+22
View File
@@ -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),
);
+6 -1
View File
@@ -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,
+5
View File
@@ -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),
+66 -12
View File
@@ -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<WorkspaceState>();
@@ -195,7 +224,17 @@ export default function App() {
detailPanel = (
<ActivityPanel
activity={activityForSession}
lspProfiles={workspace.settings.tooling.lspProfiles}
mcpServers={workspace.settings.tooling.mcpServers}
onUpdateSessionTooling={(selection) => {
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 (
+162 -3
View File
@@ -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 (
<div className="flex h-full flex-col">
@@ -57,7 +79,71 @@ export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps
{/* Agent cards */}
<div className="flex-1 overflow-y-auto px-3 py-3">
<div className="space-y-2">
<div className="space-y-3">
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<h3 className="text-[12px] font-semibold text-zinc-200">Session tools</h3>
<p className="mt-0.5 text-[11px] text-zinc-500">
Enable globally configured MCPs and LSPs for this session.
</p>
</div>
{toolsDisabled && (
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium text-zinc-400">
{projectIsScratchpad ? 'Scratchpad disabled' : 'Locked while running'}
</span>
)}
</div>
{projectIsScratchpad ? (
<p className="mt-3 text-[11px] leading-relaxed text-zinc-500">
Scratchpad stays tool-free. Start a project-backed session to use MCPs or LSPs.
</p>
) : (
<div className="mt-3 space-y-3">
<ToolToggleGroup
description="Globally configured MCP servers"
emptyMessage="No MCP servers configured in Settings."
enabledIds={selection.enabledMcpServerIds}
items={mcpServers.map((server) => ({
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}
/>
<ToolToggleGroup
description="Globally configured LSP profiles"
emptyMessage="No LSP profiles configured in Settings."
enabledIds={selection.enabledLspProfileIds}
items={lspProfiles.map((profile) => ({
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}
/>
</div>
)}
</div>
{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 (
<div>
<div className="mb-2">
<h4 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-zinc-500">
{title}
</h4>
<p className="mt-0.5 text-[11px] text-zinc-600">{description}</p>
</div>
{items.length === 0 ? (
<p className="text-[11px] text-zinc-600">{emptyMessage}</p>
) : (
<div className="space-y-1.5">
{items.map((item) => {
const enabled = enabledIds.includes(item.id);
return (
<button
className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition ${
enabled
? 'border-blue-500/30 bg-blue-500/5'
: 'border-zinc-800 bg-zinc-900/30'
} ${disabled ? 'cursor-not-allowed opacity-60' : 'hover:border-zinc-700 hover:bg-zinc-900/60'}`}
disabled={disabled}
key={item.id}
onClick={() => onToggle(item.id)}
type="button"
>
<div className="min-w-0">
<div className="text-[12px] font-medium text-zinc-200">{item.label}</div>
{item.detail && (
<div className="truncate text-[11px] text-zinc-500">{item.detail}</div>
)}
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
enabled
? 'bg-blue-500/10 text-blue-300'
: 'bg-zinc-800 text-zinc-500'
}`}
>
{enabled ? 'Enabled' : 'Disabled'}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
function toggleId(current: string[], id: string): string[] {
return current.includes(id)
? current.filter((currentId) => currentId !== id)
: [...current, id];
}
+694 -30
View File
@@ -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<ModelDefinition>;
patterns: PatternDefinition[];
sidecarCapabilities?: SidecarCapabilities;
toolingSettings: WorkspaceToolingSettings;
isRefreshingCapabilities: boolean;
onRefreshCapabilities: () => void;
onClose: () => void;
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
onDeletePattern: (patternId: string) => Promise<void>;
onNewPattern: () => PatternDefinition;
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
onDeleteMcpServer: (serverId: string) => Promise<void>;
onNewMcpServer: () => McpServerDefinition;
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
onDeleteLspProfile: (profileId: string) => Promise<void>;
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: <Workflow className="size-3.5" /> },
],
},
{
label: 'Tooling',
items: [
{ id: 'mcp-servers', label: 'MCP Servers', icon: <Layers className="size-3.5" /> },
{ id: 'lsp-profiles', label: 'LSP Profiles', icon: <Cpu className="size-3.5" /> },
],
},
];
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<SettingsSection>('connection');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(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 (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<McpServerEditor
onBack={() => setEditingMcpServer(null)}
onChange={setEditingMcpServer}
onDelete={
exists
? async () => {
await onDeleteMcpServer(editingMcpServer.id);
setEditingMcpServer(null);
}
: undefined
}
onSave={async () => {
await onSaveMcpServer(normalizeMcpServerDefinition(editingMcpServer));
setEditingMcpServer(null);
}}
server={editingMcpServer}
/>
</div>
);
}
if (editingLspProfile) {
const exists = toolingSettings.lspProfiles.some((profile) => profile.id === editingLspProfile.id);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<LspProfileEditor
onBack={() => setEditingLspProfile(null)}
onChange={setEditingLspProfile}
onDelete={
exists
? async () => {
await onDeleteLspProfile(editingLspProfile.id);
setEditingLspProfile(null);
}
: undefined
}
onSave={async () => {
await onSaveLspProfile(normalizeLspProfileDefinition(editingLspProfile));
setEditingLspProfile(null);
}}
profile={editingLspProfile}
/>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
{/* Header */}
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-12">
<button
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
@@ -108,9 +189,7 @@ export function SettingsPanel({
<h2 className="text-sm font-semibold text-zinc-100">Settings</h2>
</div>
{/* Two-column layout */}
<div className="flex min-h-0 flex-1">
{/* Left navigation */}
<nav className="w-52 shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
<div className="space-y-4">
{navGroups.map((group) => (
@@ -143,7 +222,6 @@ export function SettingsPanel({
</div>
</nav>
{/* Content area */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl px-8 py-6">
{activeSection === 'connection' && (
@@ -156,11 +234,25 @@ export function SettingsPanel({
)}
{activeSection === 'patterns' && (
<PatternsSection
onEditPattern={(p) => setEditingPattern(structuredClone(p))}
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
onNewPattern={() => setEditingPattern(onNewPattern())}
patterns={patterns}
/>
)}
{activeSection === 'mcp-servers' && (
<McpServersSection
onEditServer={(server) => setEditingMcpServer(structuredClone(server))}
onNewServer={() => setEditingMcpServer(onNewMcpServer())}
servers={toolingSettings.mcpServers}
/>
)}
{activeSection === 'lsp-profiles' && (
<LspProfilesSection
onEditProfile={(profile) => setEditingLspProfile(structuredClone(profile))}
onNewProfile={() => setEditingLspProfile(onNewLspProfile())}
profiles={toolingSettings.lspProfiles}
/>
)}
</div>
</div>
</div>
@@ -168,8 +260,6 @@ export function SettingsPanel({
);
}
/* ---------- Section components ---------- */
function ConnectionSection({
connection,
modelCount,
@@ -212,22 +302,12 @@ function PatternsSection({
}) {
return (
<div>
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-zinc-200">Orchestration Patterns</h3>
<p className="mt-0.5 text-[12px] text-zinc-500">
Define reusable agent configurations for your sessions
</p>
</div>
<button
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
onClick={onNewPattern}
type="button"
>
<Plus className="size-3.5" />
New Pattern
</button>
</div>
<SectionHeader
description="Define reusable agent configurations for your sessions"
title="Orchestration Patterns"
>
<SectionAction label="New Pattern" onClick={onNewPattern} />
</SectionHeader>
<div className="space-y-1">
{patterns.map((pattern) => (
@@ -258,3 +338,587 @@ function PatternsSection({
</div>
);
}
function McpServersSection({
servers,
onEditServer,
onNewServer,
}: {
servers: McpServerDefinition[];
onEditServer: (server: McpServerDefinition) => void;
onNewServer: () => void;
}) {
return (
<div>
<SectionHeader
description="Define machine-wide MCP servers that sessions can enable from the Activity panel."
title="MCP Servers"
>
<SectionAction label="New MCP Server" onClick={onNewServer} />
</SectionHeader>
<div className="space-y-1">
{servers.length === 0 && (
<EmptyState>
No MCP servers configured yet. Add one here, then enable it per session from the Activity panel.
</EmptyState>
)}
{servers.map((server) => (
<ToolingListButton
detail={
server.transport === 'local'
? `${server.command || 'No command'} · ${server.tools.length} tool filter${server.tools.length === 1 ? '' : 's'}`
: `${server.url} · ${server.transport.toUpperCase()}`
}
key={server.id}
label={server.name}
meta={server.transport.toUpperCase()}
onClick={() => onEditServer(server)}
/>
))}
</div>
</div>
);
}
function LspProfilesSection({
profiles,
onEditProfile,
onNewProfile,
}: {
profiles: LspProfileDefinition[];
onEditProfile: (profile: LspProfileDefinition) => void;
onNewProfile: () => void;
}) {
return (
<div>
<SectionHeader
description="Define machine-wide LSP commands that sessions can enable from the Activity panel."
title="LSP Profiles"
>
<SectionAction label="New LSP Profile" onClick={onNewProfile} />
</SectionHeader>
<div className="space-y-1">
{profiles.length === 0 && (
<EmptyState>
No LSP profiles configured yet. Add one here, then enable it per session from the Activity panel.
</EmptyState>
)}
{profiles.map((profile) => (
<ToolingListButton
detail={`${profile.languageId} · ${profile.command || 'No command'}`}
key={profile.id}
label={profile.name}
meta={profile.fileExtensions.join(', ')}
onClick={() => onEditProfile(profile)}
/>
))}
</div>
</div>
);
}
function McpServerEditor({
server,
onChange,
onBack,
onSave,
onDelete,
}: {
server: McpServerDefinition;
onChange: (server: McpServerDefinition) => void;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
}) {
const validationError = validateMcpServerDefinition(server);
return (
<ToolingEditorShell
description="Configure a machine-wide MCP server. Sessions can opt into this server from the Activity panel."
disableSave={Boolean(validationError)}
error={validationError}
onBack={onBack}
onDelete={onDelete}
onSave={onSave}
title="MCP Server"
>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Name" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { name: value }))}
value={server.name}
/>
</FormField>
<FormField label="Transport" required>
<SelectInput
onChange={(value) => onChange(changeMcpTransport(server, value as McpServerDefinition['transport']))}
options={[
{ value: 'local', label: 'Local process' },
{ value: 'http', label: 'HTTP' },
{ value: 'sse', label: 'SSE' },
]}
value={server.transport}
/>
</FormField>
</div>
{server.transport === 'local' ? (
<div className="grid gap-4 md:grid-cols-2">
<FormField className="md:col-span-2" label="Command" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { command: value }))}
placeholder="node"
value={server.command}
/>
</FormField>
<FormField className="md:col-span-2" label="Arguments">
<TextareaInput
onChange={(value) => onChange(updateMcpServer(server, { args: splitMultiline(value) }))}
placeholder="One argument per line"
rows={4}
value={joinMultiline(server.args)}
/>
</FormField>
<FormField className="md:col-span-2" label="Working directory">
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { cwd: value || undefined }))}
placeholder="Optional"
value={server.cwd ?? ''}
/>
</FormField>
</div>
) : (
<FormField label="Server URL" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { url: value }))}
placeholder="https://example.com/mcp"
value={server.url}
/>
</FormField>
)}
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Allowed tools">
<TextareaInput
onChange={(value) => onChange(updateMcpServer(server, { tools: splitTokens(value) }))}
placeholder="Use * for all tools, or list one tool per line"
rows={4}
value={joinMultiline(server.tools)}
/>
</FormField>
<FormField label="Timeout (ms)">
<TextInput
inputMode="numeric"
onChange={(value) =>
onChange(
updateMcpServer(server, {
timeoutMs: value.trim() ? Number(value) : undefined,
}),
)
}
placeholder="Optional"
value={server.timeoutMs?.toString() ?? ''}
/>
</FormField>
</div>
<InfoCallout>
Keep secrets out of this form. Use commands or endpoints that authenticate through the OS or external tooling.
</InfoCallout>
</ToolingEditorShell>
);
}
function LspProfileEditor({
profile,
onChange,
onBack,
onSave,
onDelete,
}: {
profile: LspProfileDefinition;
onChange: (profile: LspProfileDefinition) => void;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
}) {
const validationError = validateLspProfileDefinition(profile);
return (
<ToolingEditorShell
description="Configure a machine-wide LSP command. Sessions can opt into this profile from the Activity panel."
disableSave={Boolean(validationError)}
error={validationError}
onBack={onBack}
onDelete={onDelete}
onSave={onSave}
title="LSP Profile"
>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Name" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { name: value }))}
value={profile.name}
/>
</FormField>
<FormField label="Language ID" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { languageId: value }))}
placeholder="typescript"
value={profile.languageId}
/>
</FormField>
</div>
<FormField label="Command" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { command: value }))}
placeholder="typescript-language-server"
value={profile.command}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Arguments">
<TextareaInput
onChange={(value) => onChange(updateLspProfile(profile, { args: splitMultiline(value) }))}
placeholder="One argument per line"
rows={4}
value={joinMultiline(profile.args)}
/>
</FormField>
<FormField label="File extensions" required>
<TextareaInput
onChange={(value) => onChange(updateLspProfile(profile, { fileExtensions: splitTokens(value) }))}
placeholder={'.ts\n.tsx'}
rows={4}
value={joinMultiline(profile.fileExtensions)}
/>
</FormField>
</div>
<InfoCallout>
Profiles are global definitions only. Project root resolution still comes from the active session's project.
</InfoCallout>
</ToolingEditorShell>
);
}
function SectionHeader({
title,
description,
children,
}: {
title: string;
description: string;
children?: ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-zinc-200">{title}</h3>
<p className="mt-0.5 text-[12px] text-zinc-500">{description}</p>
</div>
{children}
</div>
);
}
function SectionAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
onClick={onClick}
type="button"
>
<Plus className="size-3.5" />
{label}
</button>
);
}
function ToolingListButton({
label,
detail,
meta,
onClick,
}: {
label: string;
detail: string;
meta: string;
onClick: () => void;
}) {
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition hover:border-zinc-800 hover:bg-zinc-900"
onClick={onClick}
type="button"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-zinc-200">{label}</span>
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-400">
{meta}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-zinc-500">{detail}</p>
</div>
<ChevronRight className="size-4 text-zinc-700 transition group-hover:text-zinc-500" />
</button>
);
}
function EmptyState({ children }: { children: ReactNode }) {
return (
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/30 px-4 py-6 text-[12px] leading-relaxed text-zinc-500">
{children}
</div>
);
}
function ToolingEditorShell({
title,
description,
error,
disableSave,
onBack,
onSave,
onDelete,
children,
}: {
title: string;
description: string;
error?: string;
disableSave: boolean;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
children: ReactNode;
}) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-12">
<div className="flex items-center gap-3">
<button
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
onClick={onBack}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<div>
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
<p className="mt-0.5 text-[12px] text-zinc-500">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{onDelete && (
<button
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-[13px] font-medium text-zinc-300 transition hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-300"
onClick={() => void onDelete()}
type="button"
>
Delete
</button>
)}
<button
className="rounded-lg bg-zinc-100 px-3 py-1.5 text-[13px] font-semibold text-zinc-900 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={disableSave}
onClick={() => void onSave()}
type="button"
>
Save
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-5 px-8 py-6">
{error && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-[12px] text-amber-200">
{error}
</div>
)}
{children}
</div>
</div>
</div>
);
}
function FormField({
label,
required,
className,
children,
}: {
label: string;
required?: boolean;
className?: string;
children: ReactNode;
}) {
return (
<label className={`block ${className ?? ''}`}>
<span className="mb-1.5 block text-[12px] font-medium text-zinc-300">
{label}
{required && <span className="ml-1 text-amber-300">*</span>}
</span>
{children}
</label>
);
}
function TextInput({
value,
onChange,
placeholder,
inputMode,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode'];
}) {
return (
<input
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition placeholder:text-zinc-600 focus:border-zinc-600"
inputMode={inputMode}
onChange={(event) => 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 (
<textarea
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition placeholder:text-zinc-600 focus:border-zinc-600"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
rows={rows}
value={value}
/>
);
}
function SelectInput({
value,
options,
onChange,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
}) {
return (
<select
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition focus:border-zinc-600"
onChange={(event) => onChange(event.target.value)}
value={value}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
function InfoCallout({ children }: { children: ReactNode }) {
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/30 px-4 py-3 text-[12px] leading-relaxed text-zinc-500">
{children}
</div>
);
}
function updateMcpServer(
server: McpServerDefinition,
patch: Partial<McpServerDefinition>,
): McpServerDefinition;
function updateMcpServer<T extends McpServerDefinition>(server: T, patch: Partial<T>): T;
function updateMcpServer<T extends McpServerDefinition>(server: T, patch: Partial<T>): T {
return {
...server,
...patch,
updatedAt: nowIso(),
};
}
function changeMcpTransport(
server: McpServerDefinition,
transport: McpServerDefinition['transport'],
): McpServerDefinition {
if (transport === server.transport) {
return server;
}
if (transport === 'local') {
return {
id: server.id,
name: server.name,
transport: 'local',
command: '',
args: [],
cwd: undefined,
tools: server.tools,
timeoutMs: server.timeoutMs,
createdAt: server.createdAt,
updatedAt: nowIso(),
};
}
return {
id: server.id,
name: server.name,
transport,
url: server.transport === 'local' ? '' : server.url,
tools: server.tools,
timeoutMs: server.timeoutMs,
createdAt: server.createdAt,
updatedAt: nowIso(),
};
}
function updateLspProfile(
profile: LspProfileDefinition,
patch: Partial<LspProfileDefinition>,
): LspProfileDefinition {
return {
...profile,
...patch,
updatedAt: nowIso(),
};
}
function splitMultiline(value: string): string[] {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function splitTokens(value: string): string[] {
return value
.split(/[\r\n,]+/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function joinMultiline(value: string[]): string {
return value.join('\n');
}
+5
View File
@@ -8,6 +8,11 @@ export const ipcChannels = {
savePattern: 'patterns:save',
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
saveMcpServer: 'tooling:mcp:save',
deleteMcpServer: 'tooling:mcp:delete',
saveLspProfile: 'tooling:lsp:save',
deleteLspProfile: 'tooling:lsp:delete',
updateSessionTooling: 'sessions:update-tooling',
createSession: 'sessions:create',
duplicateSession: 'sessions:duplicate',
renameSession: 'sessions:rename',
+22
View File
@@ -3,6 +3,11 @@ import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'
import type { ProjectRecord } from '@shared/domain/project';
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
import type { SessionEventRecord } from '@shared/domain/event';
import type {
LspProfileDefinition,
McpServerDefinition,
SessionToolingSelection,
} from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
export interface CreateSessionInput {
@@ -49,6 +54,18 @@ export interface SetPatternFavoriteInput {
isFavorite: boolean;
}
export interface SaveMcpServerInput {
server: McpServerDefinition;
}
export interface SaveLspProfileInput {
profile: LspProfileDefinition;
}
export interface UpdateSessionToolingInput extends SessionToolingSelection {
sessionId: string;
}
export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
@@ -58,6 +75,11 @@ export interface ElectronApi {
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
deletePattern(patternId: string): Promise<WorkspaceState>;
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
deleteLspProfile(profileId: string): Promise<WorkspaceState>;
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
+37
View File
@@ -73,10 +73,47 @@ export interface RunTurnCommand {
workspaceKind?: 'project' | 'scratchpad';
pattern: PatternDefinition;
messages: ChatMessageRecord[];
tooling?: RunTurnToolingConfig;
}
export type SidecarCommand = DescribeCapabilitiesCommand | ValidatePatternCommand | RunTurnCommand;
export interface RunTurnLocalMcpServerConfig {
id: string;
name: string;
transport: 'local';
tools: string[];
timeoutMs?: number;
command: string;
args: string[];
cwd?: string;
}
export interface RunTurnRemoteMcpServerConfig {
id: string;
name: string;
transport: 'http' | 'sse';
tools: string[];
timeoutMs?: number;
url: string;
}
export type RunTurnMcpServerConfig = RunTurnLocalMcpServerConfig | RunTurnRemoteMcpServerConfig;
export interface RunTurnLspProfileConfig {
id: string;
name: string;
command: string;
args: string[];
languageId: string;
fileExtensions: string[];
}
export interface RunTurnToolingConfig {
mcpServers: RunTurnMcpServerConfig[];
lspProfiles: RunTurnLspProfileConfig[];
}
export interface CapabilitiesEvent {
type: 'capabilities';
requestId: string;
+12
View File
@@ -1,4 +1,9 @@
import { buildSessionTitle, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
import {
createSessionToolingSelection,
normalizeSessionToolingSelection,
type SessionToolingSelection,
} from '@shared/domain/tooling';
export type ChatRole = 'system' | 'user' | 'assistant';
export type SessionStatus = 'idle' | 'running' | 'error';
@@ -32,6 +37,7 @@ export interface SessionRecord {
messages: ChatMessageRecord[];
lastError?: string;
scratchpadConfig?: ScratchpadSessionConfig;
tooling?: SessionToolingSelection;
}
export function resolveSessionTitle(
@@ -60,6 +66,12 @@ export function createScratchpadSessionConfig(
};
}
export function resolveSessionToolingSelection(
session: Pick<SessionRecord, 'tooling'>,
): SessionToolingSelection {
return normalizeSessionToolingSelection(session.tooling ?? createSessionToolingSelection());
}
export function resolveScratchpadSessionConfig(
session: SessionRecord,
pattern: PatternDefinition,
+6
View File
@@ -175,6 +175,12 @@ export function duplicateSessionRecord(
isArchived: false,
lastError: undefined,
scratchpadConfig: session.scratchpadConfig ? { ...session.scratchpadConfig } : undefined,
tooling: session.tooling
? {
enabledMcpServerIds: [...session.tooling.enabledMcpServerIds],
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
}
: undefined,
messages: session.messages.map((message): ChatMessageRecord => ({
...message,
pending: false,
+177
View File
@@ -0,0 +1,177 @@
export type McpServerTransport = 'local' | 'http' | 'sse';
export interface BaseMcpServerDefinition {
id: string;
name: string;
transport: McpServerTransport;
tools: string[];
timeoutMs?: number;
createdAt: string;
updatedAt: string;
}
export interface LocalMcpServerDefinition extends BaseMcpServerDefinition {
transport: 'local';
command: string;
args: string[];
cwd?: string;
}
export interface RemoteMcpServerDefinition extends BaseMcpServerDefinition {
transport: 'http' | 'sse';
url: string;
}
export type McpServerDefinition = LocalMcpServerDefinition | RemoteMcpServerDefinition;
export interface LspProfileDefinition {
id: string;
name: string;
command: string;
args: string[];
languageId: string;
fileExtensions: string[];
createdAt: string;
updatedAt: string;
}
export interface WorkspaceToolingSettings {
mcpServers: McpServerDefinition[];
lspProfiles: LspProfileDefinition[];
}
export interface WorkspaceSettings {
tooling: WorkspaceToolingSettings;
}
export interface SessionToolingSelection {
enabledMcpServerIds: string[];
enabledLspProfileIds: string[];
}
export function createWorkspaceSettings(): WorkspaceSettings {
return {
tooling: {
mcpServers: [],
lspProfiles: [],
},
};
}
export function createSessionToolingSelection(): SessionToolingSelection {
return {
enabledMcpServerIds: [],
enabledLspProfileIds: [],
};
}
export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>): WorkspaceSettings {
return {
tooling: {
mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition),
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
},
};
}
export function normalizeSessionToolingSelection(
selection?: Partial<SessionToolingSelection>,
): SessionToolingSelection {
return {
enabledMcpServerIds: normalizeStringArray(selection?.enabledMcpServerIds),
enabledLspProfileIds: normalizeStringArray(selection?.enabledLspProfileIds),
};
}
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
if (!server.name.trim()) {
return 'MCP server name is required.';
}
if (server.transport === 'local') {
if (!server.command.trim()) {
return `MCP server "${server.name}" needs a command.`;
}
return undefined;
}
if (!server.url.trim()) {
return `MCP server "${server.name}" needs a URL.`;
}
try {
new URL(server.url);
} catch {
return `MCP server "${server.name}" has an invalid URL.`;
}
return undefined;
}
export function validateLspProfileDefinition(profile: LspProfileDefinition): string | undefined {
if (!profile.name.trim()) {
return 'LSP profile name is required.';
}
if (!profile.command.trim()) {
return `LSP profile "${profile.name}" needs a command.`;
}
if (!profile.languageId.trim()) {
return `LSP profile "${profile.name}" needs a language ID.`;
}
if (normalizeStringArray(profile.fileExtensions).length === 0) {
return `LSP profile "${profile.name}" needs at least one file extension.`;
}
return undefined;
}
export function normalizeMcpServerDefinition(server: McpServerDefinition): McpServerDefinition {
const base = {
...server,
name: server.name.trim(),
tools: normalizeStringArray(server.tools),
};
if (server.transport === 'local') {
return {
...base,
transport: 'local',
command: server.command.trim(),
args: normalizeStringArray(server.args),
cwd: server.cwd?.trim() || undefined,
};
}
return {
...base,
transport: server.transport,
url: server.url.trim(),
};
}
export function normalizeLspProfileDefinition(profile: LspProfileDefinition): LspProfileDefinition {
return {
...profile,
name: profile.name.trim(),
command: profile.command.trim(),
args: normalizeStringArray(profile.args),
languageId: profile.languageId.trim(),
fileExtensions: normalizeFileExtensions(profile.fileExtensions),
};
}
function normalizeFileExtensions(fileExtensions: string[]): string[] {
return normalizeStringArray(fileExtensions).map((value) => (value.startsWith('.') ? value : `.${value}`));
}
function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
if (!values) {
return [];
}
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
+3
View File
@@ -2,12 +2,14 @@ import type { PatternDefinition } from '@shared/domain/pattern';
import { createBuiltinPatterns } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
import { nowIso } from '@shared/utils/ids';
export interface WorkspaceState {
projects: ProjectRecord[];
patterns: PatternDefinition[];
sessions: SessionRecord[];
settings: WorkspaceSettings;
selectedProjectId?: string;
selectedPatternId?: string;
selectedSessionId?: string;
@@ -20,6 +22,7 @@ export function createWorkspaceSeed(): WorkspaceState {
projects: [],
patterns: createBuiltinPatterns(timestamp),
sessions: [],
settings: createWorkspaceSettings(),
lastUpdatedAt: timestamp,
};
}
+2
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
import type { SessionEventRecord } from '@shared/domain/event';
import { createWorkspaceSettings } from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
describe('session workspace helpers', () => {
@@ -9,6 +10,7 @@ describe('session workspace helpers', () => {
return {
projects: [],
patterns: [],
settings: createWorkspaceSettings(),
sessions: [
{
id: 'session-1',
+24
View File
@@ -4,6 +4,7 @@ import type { PatternDefinition } from '@shared/domain/pattern';
import {
applyScratchpadSessionConfig,
createScratchpadSessionConfig,
resolveSessionToolingSelection,
resolveSessionTitle,
resolveScratchpadSessionConfig,
type SessionRecord,
@@ -118,3 +119,26 @@ describe('session title helpers', () => {
).toBe('Release readiness review');
});
});
describe('session tooling helpers', () => {
test('normalizes missing or duplicated tooling selections into stable arrays', () => {
expect(resolveSessionToolingSelection(createSession())).toEqual({
enabledMcpServerIds: [],
enabledLspProfileIds: [],
});
expect(
resolveSessionToolingSelection(
createSession({
tooling: {
enabledMcpServerIds: ['mcp-git', ' mcp-git ', ''],
enabledLspProfileIds: ['ts', ' ts ', ''],
},
}),
),
).toEqual({
enabledMcpServerIds: ['mcp-git'],
enabledLspProfileIds: ['ts'],
});
});
});
+2
View File
@@ -4,6 +4,7 @@ import { querySessions, duplicateSessionRecord, renameSessionRecord } from '@sha
import type { PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSettings } from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
function createPattern(overrides?: Partial<PatternDefinition>): PatternDefinition {
@@ -66,6 +67,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
return {
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
settings: createWorkspaceSettings(),
sessions: [
createSession(),
createSession({
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, test } from 'bun:test';
import {
normalizeWorkspaceSettings,
validateLspProfileDefinition,
validateMcpServerDefinition,
type LspProfileDefinition,
type McpServerDefinition,
} from '@shared/domain/tooling';
const TIMESTAMP = '2026-03-23T00:00:00.000Z';
describe('tooling settings helpers', () => {
test('normalizes persisted MCP and LSP definitions into trimmed stable settings', () => {
const workspaceSettings = normalizeWorkspaceSettings({
tooling: {
mcpServers: [
{
id: 'mcp-local',
name: ' Local MCP ',
transport: 'local',
command: ' node ',
args: [' --stdio ', ' --stdio ', ''],
cwd: ' C:\\workspace\\repo ',
tools: [' git.status ', '', ' git.status '],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
{
id: 'mcp-remote',
name: ' Remote MCP ',
transport: 'http',
url: ' https://example.com/mcp ',
tools: [],
timeoutMs: 1000,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
],
lspProfiles: [
{
id: 'lsp-ts',
name: ' TypeScript ',
command: ' typescript-language-server ',
args: [' --stdio ', ' --stdio ', ''],
languageId: ' typescript ',
fileExtensions: ['ts', ' .tsx ', ''],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
],
},
});
expect(workspaceSettings).toEqual({
tooling: {
mcpServers: [
{
id: 'mcp-local',
name: 'Local MCP',
transport: 'local',
command: 'node',
args: ['--stdio'],
cwd: 'C:\\workspace\\repo',
tools: ['git.status'],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
{
id: 'mcp-remote',
name: 'Remote MCP',
transport: 'http',
url: 'https://example.com/mcp',
tools: [],
timeoutMs: 1000,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
],
lspProfiles: [
{
id: 'lsp-ts',
name: 'TypeScript',
command: 'typescript-language-server',
args: ['--stdio'],
languageId: 'typescript',
fileExtensions: ['.ts', '.tsx'],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
],
},
});
});
test('validates required MCP transport settings', () => {
const localServer: McpServerDefinition = {
id: 'mcp-local',
name: 'Local MCP',
transport: 'local',
command: '',
args: [],
tools: ['*'],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
};
const remoteServer: McpServerDefinition = {
id: 'mcp-remote',
name: 'Remote MCP',
transport: 'sse',
url: 'not-a-url',
tools: ['*'],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
};
expect(validateMcpServerDefinition(localServer)).toBe('MCP server "Local MCP" needs a command.');
expect(validateMcpServerDefinition(remoteServer)).toBe('MCP server "Remote MCP" has an invalid URL.');
});
test('validates required LSP profile settings', () => {
const profile: LspProfileDefinition = {
id: 'lsp-empty',
name: 'TypeScript',
command: '',
args: [],
languageId: 'typescript',
fileExtensions: [],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
};
expect(validateLspProfileDefinition(profile)).toBe('LSP profile "TypeScript" needs a command.');
expect(
validateLspProfileDefinition({
...profile,
command: 'typescript-language-server',
}),
).toBe('LSP profile "TypeScript" needs at least one file extension.');
});
});
+6
View File
@@ -8,6 +8,12 @@ describe('workspace seed', () => {
expect(workspace.projects).toEqual([]);
expect(workspace.sessions).toEqual([]);
expect(workspace.settings).toEqual({
tooling: {
mcpServers: [],
lspProfiles: [],
},
});
expect(workspace.selectedProjectId).toBeUndefined();
expect(workspace.selectedPatternId).toBeUndefined();
expect(workspace.selectedSessionId).toBeUndefined();