using System.ComponentModel; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using Microsoft.EntityFrameworkCore; using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns); public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns, string? VerifyCommand); public sealed record TaskConfigResult(bool Found, TaskConfigDto? Config); public sealed record ListConfigResult(bool Found, ListConfigDto? Config); public sealed record SetListConfigResult(bool Ok, string ListId, ListConfigDto? Config); public sealed record SetTaskConfigResult(bool Ok, string TaskId, TaskConfigDto? Config); public sealed record EffectiveModelDto(string Value, string Source); public sealed record EffectiveMaxTurnsDto(int Effective, string Source, int Requested, bool Clamped); public sealed record EffectiveAgentPathDto(string? Value, string? Source); public sealed record EffectiveSystemPromptDto(bool Set, IReadOnlyList Sources); public sealed record EffectiveRunConfigDto( string TaskId, EffectiveModelDto Model, EffectiveMaxTurnsDto MaxTurns, string Effort, string PermissionMode, EffectiveAgentPathDto AgentPath, EffectiveSystemPromptDto SystemPrompt, IReadOnlyList SkillNames); [McpServerToolType] public sealed class ConfigMcpTools { private readonly ListRepository _lists; private readonly TaskRepository _tasks; private readonly HubBroadcaster _broadcaster; private readonly IDbContextFactory _dbFactory; public ConfigMcpTools( ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster, IDbContextFactory dbFactory) { _lists = lists; _tasks = tasks; _broadcaster = broadcaster; _dbFactory = dbFactory; } [McpServerTool, Description("Read a list's default run config, including its verify command — the fallback used by tasks in this list that don't set their own overrides. Returns { found: false, config: null } if none is set.")] public async Task GetListConfig(string listId, CancellationToken cancellationToken) { var cfg = await _lists.GetConfigAsync(listId, cancellationToken); return cfg is null ? new ListConfigResult(false, null) : new ListConfigResult(true, new ListConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns, cfg.VerifyCommand)); } // clearFields (not a per-field sentinel like UpdateList's "" for workingDir) because maxTurns is an // int? — there's no blank value to overload as "clear" for a number the way there is for a string. private static readonly string[] ConfigFieldNames = { "model", "systemPrompt", "agentPath", "maxTurns" }; private static readonly string[] ListConfigFieldNames = { "model", "systemPrompt", "agentPath", "maxTurns", "verifyCommand" }; private static HashSet NormalizeClearFields(IReadOnlyList? clearFields, string[] validFields) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var field in clearFields ?? Array.Empty()) { if (!validFields.Contains(field, StringComparer.OrdinalIgnoreCase)) throw new InvalidOperationException( $"Unknown clearFields entry '{field}'. Valid values: {string.Join(", ", validFields)}."); set.Add(field); } return set; } [McpServerTool, Description( "Set a list's default model/system prompt/agent path/max turns/verify command — the fallback for tasks " + "in this list that don't override them (verify command is list-only; tasks cannot override it). Only " + "the fields you pass are changed; omitted fields keep their current value. To clear a field instead, " + "name it in clearFields; clearing all five deletes the list's config unless it also carries settings " + "this tool doesn't expose (session skills, file-scope serialization), which are always preserved.")] public async Task SetListConfig( string listId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, string? verifyCommand = null, [Description("Field names to explicitly clear: 'model', 'systemPrompt', 'agentPath', 'maxTurns', " + "'verifyCommand'. A field named here is cleared even if a value was also passed for it.")] IReadOnlyList? clearFields = null, CancellationToken cancellationToken = default) { _ = await _lists.GetByIdAsync(listId, cancellationToken) ?? throw new InvalidOperationException($"List {listId} not found."); var clear = NormalizeClearFields(clearFields, ListConfigFieldNames); var existing = await _lists.GetConfigAsync(listId, cancellationToken); var m = clear.Contains("model") ? null : model.NullIfBlank() ?? existing?.Model; var sp = clear.Contains("systemPrompt") ? null : systemPrompt.NullIfBlank() ?? existing?.SystemPrompt; var ap = clear.Contains("agentPath") ? null : agentPath.NullIfBlank() ?? existing?.AgentPath; var mt = clear.Contains("maxTurns") ? null : maxTurns ?? existing?.MaxTurns; var vc = clear.Contains("verifyCommand") ? null : verifyCommand.NullIfBlank() ?? existing?.VerifyCommand; // Fields this tool doesn't expose but that live on the same row. They must survive every // write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left // at its default would silently reset (SerializeOnFileOverlap in particular has no UI // affordance at all, so a reset is invisible until tasks stop serializing). var hasUnrelatedSettings = existing is not null && (existing.SessionSkills is not null || existing.SerializeOnFileOverlap); ListConfigDto? config; var allCleared = m is null && sp is null && ap is null && mt is null && vc is null; if (allCleared && clear.Count > 0 && !hasUnrelatedSettings) { await _lists.DeleteConfigAsync(listId, cancellationToken); config = null; } else if (allCleared && existing is null) { config = null; } else { await _lists.SetConfigAsync(new ListConfigEntity { ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = mt, VerifyCommand = vc, SessionSkills = existing?.SessionSkills, SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false, }, cancellationToken); config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc); } await _broadcaster.ListUpdated(listId); return new SetListConfigResult(true, listId, config); } [McpServerTool, Description( "Set per-task overrides for model/system prompt/agent path/max turns; these take precedence over the " + "list's default config for this one task. Only the fields you pass are changed; omitted fields keep " + "their current override. To clear a field instead, name it in clearFields.")] public async Task SetTaskConfig( string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, [Description("Field names to explicitly clear: 'model', 'systemPrompt', 'agentPath', 'maxTurns'. " + "A field named here is cleared even if a value was also passed for it.")] IReadOnlyList? clearFields = null, CancellationToken cancellationToken = default) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var clear = NormalizeClearFields(clearFields, ConfigFieldNames); var m = clear.Contains("model") ? null : model.NullIfBlank() ?? task.Model; var sp = clear.Contains("systemPrompt") ? null : systemPrompt.NullIfBlank() ?? task.SystemPrompt; var ap = clear.Contains("agentPath") ? null : agentPath.NullIfBlank() ?? task.AgentPath; var mt = clear.Contains("maxTurns") ? null : maxTurns ?? task.MaxTurns; await _tasks.UpdateAgentSettingsAsync(taskId, m, sp, ap, mt, task.SessionSkills, cancellationToken); await _broadcaster.TaskUpdated(taskId); return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, mt)); } [McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")] public async Task GetTaskConfig(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Model is null && task.SystemPrompt is null && task.AgentPath is null && task.MaxTurns is null) return new TaskConfigResult(false, null); return new TaskConfigResult(true, new TaskConfigDto(task.Model, task.SystemPrompt, task.AgentPath, task.MaxTurns)); } [McpServerTool, Description( "Report the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent " + "path, whether a system prompt is set, and skill names — each tagged with its source " + "(task/list/preset/global). Use this over get_task_config/get_app_settings when you need resolved " + "values, not raw overrides. maxTurns also reports the raw requested value and whether it was clamped " + "to the global ceiling.")] public async Task GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var listConfig = await _lists.GetConfigAsync(task.ListId, cancellationToken); AppSettingsEntity global; using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken)) global = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); var systemFile = PromptFiles.ReadOrDefault(PromptKind.System); var isImprovementChild = task.ParentTaskId is not null && task.CreatedBy == task.ParentTaskId; var improvementPrompt = isImprovementChild ? PromptFiles.ReadOrDefault(PromptKind.ImprovementChild) : null; var effective = EffectiveRunConfigResolver.Resolve(task, listConfig, global, systemFile, improvementPrompt); var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills); var skillNames = requestedSkills; if (requestedSkills.Count > 0) { List installed; using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken)) installed = (await new SessionSkillRepository(ctx).ListAsync(cancellationToken)).ToList(); var installedNames = installed.Select(s => s.Name).ToHashSet(StringComparer.Ordinal); skillNames = TaskRunner.FilterToInstalled(requestedSkills, installedNames); } return new EffectiveRunConfigDto( taskId, new EffectiveModelDto(effective.Model, effective.ModelSource), new EffectiveMaxTurnsDto(effective.MaxTurns, effective.MaxTurnsSource, effective.RequestedMaxTurns, effective.MaxTurnsClamped), effective.Effort, effective.PermissionMode, new EffectiveAgentPathDto(effective.AgentPath, effective.AgentPathSource), new EffectiveSystemPromptDto(effective.SystemPromptSet, effective.SystemPromptSources), skillNames); } }