From 40013e9010b78b86ce46a1ff6dcf77f504fdc74a Mon Sep 17 00:00:00 2001 From: viamu Date: Sat, 28 Feb 2026 19:55:08 -0300 Subject: [PATCH] Add description and parameters metadata to MCP server config MCP servers now support `description` and `parameters` fields in YAML. These are injected into the system prompt via McpContextBuilder so the LLM knows when and how to use each tool. The fields are YAML-only metadata and are not written to the --mcp-config JSON. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 163 +++++++++++++++++ .../Config/McpContextBuilderTests.cs | 170 ++++++++++++++++++ .../Config/McpServerConfigTests.cs | 78 ++++++++ .../Config/PipelineConfigLoaderTests.cs | 42 +++++ .../Config/McpContextBuilder.cs | 64 +++++++ CodeGenesis.Engine/Config/McpServerConfig.cs | 44 ++++- CodeGenesis.Engine/Steps/StepBuilder.cs | 12 ++ README.md | 21 +++ 8 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 CodeGenesis.Engine.Tests/Config/McpContextBuilderTests.cs create mode 100644 CodeGenesis.Engine/Config/McpContextBuilder.cs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8000766 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,163 @@ +# CLAUDE.md — CodeGenesis Developer Context + +## What is CodeGenesis? + +A .NET 10 CLI engine that orchestrates multi-step AI pipelines using Claude Code CLI as the execution backend. Define pipelines in YAML, compose agents with Markdown bundles, and Claude handles execution. + +## Quick Commands + +```bash +dotnet build # Build +dotnet test # Run tests +dotnet run --project CodeGenesis.Engine -- run-pipeline examples/hello-world.yml +dotnet run --project CodeGenesis.Engine -- run "Some task description" +``` + +## Project Structure + +``` +Solution.slnx # .NET 10, slnx format +CodeGenesis.Engine/ # Main executable + Program.cs # Entry point, DI setup (MS DI + Spectre.Console.Cli) + Claude/ # Claude Code CLI integration + IClaudeRunner.cs # Interface: RunAsync(ClaudeRequest) -> ClaudeResponse + ClaudeCliRunner.cs # Spawns `claude --print --verbose --output-format stream-json` + ClaudeRequest.cs # Record: prompt, system prompt, model, max_turns, MCP servers + ClaudeResponse.cs # Record: success, result text, tokens, cost, FailureKind enum + ClaudeCliOptions.cs # Config: CliPath, DefaultModel, TimeoutSeconds, MaxTurnsDefault + ClaudeProgressEvent.cs # Record: thinking/tool_use events from NDJSON stream + Pipeline/ # Execution engine + IPipelineStep.cs # Interface: Name, Description, ExecuteAsync(context, ct) + IStepExecutor.cs # Runs a list of steps (used by composite steps to recurse) + PipelineExecutor.cs # Main orchestrator, implements IStepExecutor + PipelineContext.cs # Shared mutable state: StepOutputs, metrics, StatusUpdate callback + StepResult.cs # Record: Success/Failed/Skipped + output + duration + tokens + RetryPolicy.cs # Record: MaxRetries, backoff, rate-limit pauses + CollectionParser.cs # Parses step output into lists for foreach + Steps/ # Step type implementations + DynamicStep.cs # Workhorse for YAML steps (retries, fail_if, optional, MCP) + PlanStep.cs # Hardcoded "architect" prompt (for `run` command) + ExecuteStep.cs # Hardcoded "engineer" prompt (for `run` command) + ValidateStep.cs # Hardcoded "reviewer" prompt (for `run` command) + ForeachStep.cs # Sequential iteration over collections + ParallelStep.cs # Concurrent named branches (Task.WhenAll + SemaphoreSlim) + ParallelForeachStep.cs # Parallel iteration (clones DynamicStep for thread safety) + ApprovalStep.cs # Interactive console pause + StepBuilder.cs # Recursive factory: StepEntry -> IPipelineStep tree + Config/ # Configuration & loading + PipelineConfig.cs # Full YAML deserialization model (YamlDotNet, snake_case aliases) + PipelineConfigLoader.cs # Load YAML, resolve {{templates}}, validate + ContextBundleLoader.cs # Load Markdown agent bundles (Claude Code-style or legacy) + MarkdownFrontmatterParser.cs # Parse YAML frontmatter from .md files + AgentDefinition.cs # Agent config from bundles (model, tools, maxTurns) + McpServerConfig.cs # MCP server definition (command, args, env, description, parameters) + McpContextBuilder.cs # Builds "Available MCP Tools" Markdown section for system prompt + Cli/ # CLI commands (Spectre.Console.Cli) + RunCommand.cs # `run ` — hardcoded Plan->Execute->Validate + RunPipelineCommand.cs # `run-pipeline ` — YAML-driven pipeline + RunCommandSettings.cs # Settings for `run` + RunPipelineCommandSettings.cs # Settings for `run-pipeline` + TypeRegistrar.cs # Bridges MS DI into Spectre.Console.Cli + UI/ # Terminal rendering + PipelineRenderer.cs # Spectre.Console output (spinners, panels, AsyncLocal depth) + ConsoleTheme.cs # Colors (purple/blue/green/red/yellow) and symbols +CodeGenesis.Engine.Tests/ # xUnit test project + Claude/ClaudeResponseTests.cs + Config/McpServerConfigTests.cs + Config/McpContextBuilderTests.cs + Config/PipelineConfigLoaderTests.cs + Config/StepEntryTests.cs + Pipeline/CollectionParserTests.cs + Pipeline/PipelineContextTests.cs + Pipeline/RetryPolicyTests.cs + Pipeline/StepResultTests.cs + Steps/DynamicStepTests.cs +examples/ # Example YAML pipelines +``` + +## Architecture: Two Execution Paths + +**`run `** — Hardcoded 3-step pipeline: PlanStep -> ExecuteStep -> ValidateStep (--skip-validate to skip last) + +**`run-pipeline `** — YAML-driven pipeline: +1. `PipelineConfigLoader.LoadFromFile()` deserializes YAML + resolves static `{{input}}` templates +2. `StepBuilder.BuildAll()` creates recursive `IPipelineStep` tree +3. `PipelineExecutor.RunAsync()` executes steps; `onBeforeStep` callback re-resolves `{{steps.xxx}}` templates with latest outputs + +## Template System + +- `{{variable}}` for pipeline inputs +- `{{steps.}}` for outputs from previous steps +- Inside foreach: `{{loop.item}}`, `{{loop.index}}`, `{{itemVar}}` +- Resolution happens twice: at build time (static inputs) and before each step (dynamic outputs) +- Unresolved placeholders are left as-is (not an error) + +## Key Patterns & Conventions + +- **C# 12+**: primary constructors, `sealed` classes by default, `record` types for value objects +- **Nullable enabled** throughout, `required` keyword where needed +- **Namespaces**: `CodeGenesis.Engine.` (Claude, Pipeline, Steps, Config, Cli, UI) +- **YAML mapping**: snake_case in YAML -> PascalCase in C# via `[YamlMember(Alias = "snake_case")]` +- **Test naming**: `MethodName_Scenario_ExpectedBehavior` +- **Test stack**: xUnit + NSubstitute + FluentAssertions +- **Global usings**: `global using Xunit;` in test project + +## Thread Safety + +- `DynamicStep` is mutable (`UpdateResolvedPrompt()`). Sequential `ForeachStep` mutates shared instances safely. +- `ParallelForeachStep` **must clone** via `DynamicStep.Clone()` before dispatching to threads. +- `PipelineRenderer` uses `AsyncLocal` for depth and `AsyncLocal` for suppression in parallel branches. + +## Retry / Rate Limit Policy + +- **Rate limit** (429/overloaded): pause `RateLimitPauseSeconds`, retry up to `MaxRateLimitPauses` (doesn't count as retry) +- **Timeout**: retry up to `MaxRetries` with exponential backoff +- **Other failures**: fail immediately +- Policy cascade: step-level > global pipeline settings > hardcoded defaults + +## MCP Server Documentation + +MCP servers support `description` and `parameters` fields (YAML-only metadata, not sent to Claude CLI JSON): + +```yaml +mcp_servers: + jira: + command: "npx" + args: ["-y", "@anthropic/mcp-jira"] + description: "Search and manage Jira tickets" + parameters: + project_key: + description: "The Jira project key" + example: "PROJ-123" +``` + +`McpContextBuilder.Build()` generates a `## Available MCP Tools` Markdown section that is appended to the system prompt in `StepBuilder.BuildSimple()`. Servers without description or parameters are silently skipped. + +## Key Behaviors + +- `optional: true` converts `Failed` -> `Skipped` (pipeline continues) +- `fail_if:` is a post-success check (case-insensitive substring match on output) +- MCP config: temp files `codegenesis-mcp-{guid}.json`, always cleaned up in `finally` +- `Console.OutputEncoding = UTF8` set at startup for Unicode symbols on Windows + +## Dependencies + +| Package | Purpose | +|---|---| +| Spectre.Console / Spectre.Console.Cli | Rich terminal UI + CLI framework | +| Microsoft.Extensions.* (10.0 preview) | DI, configuration, logging | +| Serilog + File + Console sinks | Structured logging to `logs/` | +| YamlDotNet | YAML pipeline config parsing | +| xUnit + NSubstitute + FluentAssertions | Testing | + +## CI + +GitHub Actions on push/PR to `main`: restore -> build Release -> test Release (ubuntu-latest, .NET 10 preview). + +## Adding a New Step Type + +1. Implement `IPipelineStep` (sealed class with primary constructor) +2. Add discriminator field on `StepEntry` in `Config/PipelineConfig.cs` +3. Add branch in `StepBuilder.Build()` +4. Handle rendering in `PipelineRenderer` if needed +5. Add tests in `CodeGenesis.Engine.Tests/Steps/` diff --git a/CodeGenesis.Engine.Tests/Config/McpContextBuilderTests.cs b/CodeGenesis.Engine.Tests/Config/McpContextBuilderTests.cs new file mode 100644 index 0000000..6747564 --- /dev/null +++ b/CodeGenesis.Engine.Tests/Config/McpContextBuilderTests.cs @@ -0,0 +1,170 @@ +using CodeGenesis.Engine.Config; +using FluentAssertions; + +namespace CodeGenesis.Engine.Tests.Config; + +public class McpContextBuilderTests +{ + [Fact] + public void Build_NoDocumentedServers_ReturnsNull() + { + var servers = new Dictionary + { + ["server1"] = new() { Command = "npx" } + }; + + var result = McpContextBuilder.Build(servers); + + result.Should().BeNull(); + } + + [Fact] + public void Build_EmptyDictionary_ReturnsNull() + { + var result = McpContextBuilder.Build(new Dictionary()); + + result.Should().BeNull(); + } + + [Fact] + public void Build_WithDescription_IncludesServerSection() + { + var servers = new Dictionary + { + ["jira"] = new() + { + Command = "npx", + Description = "Search and manage Jira tickets" + } + }; + + var result = McpContextBuilder.Build(servers); + + result.Should().NotBeNull(); + result.Should().Contain("## Available MCP Tools"); + result.Should().Contain("### jira"); + result.Should().Contain("Search and manage Jira tickets"); + } + + [Fact] + public void Build_WithParameters_IncludesParameterList() + { + var servers = new Dictionary + { + ["jira"] = new() + { + Command = "npx", + Description = "Manage tickets", + Parameters = new Dictionary + { + ["project_key"] = new() + { + Description = "The Jira project key", + Example = "PROJ-123" + } + } + } + }; + + var result = McpContextBuilder.Build(servers); + + result.Should().Contain("**Parameters:**"); + result.Should().Contain("`project_key`"); + result.Should().Contain("The Jira project key"); + result.Should().Contain("`PROJ-123`"); + } + + [Fact] + public void Build_ParameterWithoutExample_OmitsExampleClause() + { + var servers = new Dictionary + { + ["svc"] = new() + { + Description = "A service", + Parameters = new Dictionary + { + ["id"] = new() { Description = "The ID", Example = null } + } + } + }; + + var result = McpContextBuilder.Build(servers); + + result.Should().Contain("`id`: The ID"); + result.Should().NotContain("example:"); + } + + [Fact] + public void Build_ParameterWithoutDescription_OmitsDescriptionClause() + { + var servers = new Dictionary + { + ["svc"] = new() + { + Description = "A service", + Parameters = new Dictionary + { + ["id"] = new() { Description = null, Example = "abc-123" } + } + } + }; + + var result = McpContextBuilder.Build(servers); + + result.Should().Contain("`id`"); + result.Should().Contain("`abc-123`"); + } + + [Fact] + public void Build_ServerWithParametersButNoDescription_IsIncluded() + { + var servers = new Dictionary + { + ["svc"] = new() + { + Command = "npx", + Parameters = new Dictionary + { + ["x"] = new() { Description = "A param" } + } + } + }; + + var result = McpContextBuilder.Build(servers); + + result.Should().NotBeNull(); + result.Should().Contain("### svc"); + result.Should().Contain("`x`"); + } + + [Fact] + public void Build_MultipleServers_EmitsAllSections() + { + var servers = new Dictionary + { + ["jira"] = new() { Description = "Jira access" }, + ["github"] = new() { Description = "GitHub access" } + }; + + var result = McpContextBuilder.Build(servers); + + result.Should().Contain("### jira"); + result.Should().Contain("### github"); + } + + [Fact] + public void Build_UndocumentedServersAreSkipped() + { + var servers = new Dictionary + { + ["documented"] = new() { Description = "Has docs" }, + ["bare"] = new() { Command = "npx" } + }; + + var result = McpContextBuilder.Build(servers); + + result.Should().Contain("### documented"); + result.Should().NotContain("### bare"); + } +} diff --git a/CodeGenesis.Engine.Tests/Config/McpServerConfigTests.cs b/CodeGenesis.Engine.Tests/Config/McpServerConfigTests.cs index cc9b710..7c497df 100644 --- a/CodeGenesis.Engine.Tests/Config/McpServerConfigTests.cs +++ b/CodeGenesis.Engine.Tests/Config/McpServerConfigTests.cs @@ -92,4 +92,82 @@ public void ResolveTemplates_NoPlaceholders_ReturnsIdentical() resolved.Args.Should().Equal("-y", "@modelcontextprotocol/server"); resolved.Env["NODE_ENV"].Should().Be("production"); } + + [Fact] + public void ResolveTemplates_ResolvesDescription() + { + var config = new McpServerConfig + { + Command = "npx", + Description = "Access {{system_name}} tickets" + }; + + var resolved = config.ResolveTemplates(s => s.Replace("{{system_name}}", "Jira")); + + resolved.Description.Should().Be("Access Jira tickets"); + } + + [Fact] + public void ResolveTemplates_ResolvesParameterDescriptionAndExample() + { + var config = new McpServerConfig + { + Command = "npx", + Parameters = new Dictionary + { + ["project_key"] = new() + { + Description = "The {{board}} project key", + Example = "{{default_project}}-123" + } + } + }; + + var resolved = config.ResolveTemplates(s => s + .Replace("{{board}}", "Jira") + .Replace("{{default_project}}", "PROJ")); + + resolved.Parameters!["project_key"].Description.Should().Be("The Jira project key"); + resolved.Parameters["project_key"].Example.Should().Be("PROJ-123"); + } + + [Fact] + public void ResolveTemplates_NullDescription_RemainsNull() + { + var config = new McpServerConfig { Command = "npx", Description = null }; + + var resolved = config.ResolveTemplates(s => s); + + resolved.Description.Should().BeNull(); + } + + [Fact] + public void ResolveTemplates_NullParameters_RemainsNull() + { + var config = new McpServerConfig { Command = "npx", Parameters = null }; + + var resolved = config.ResolveTemplates(s => s); + + resolved.Parameters.Should().BeNull(); + } + + [Fact] + public void ResolveTemplates_DoesNotMutateOriginalDescriptionOrParameters() + { + var config = new McpServerConfig + { + Command = "npx", + Description = "{{service}}", + Parameters = new Dictionary + { + ["key"] = new() { Description = "{{desc}}", Example = "{{ex}}" } + } + }; + + config.ResolveTemplates(s => "resolved"); + + config.Description.Should().Be("{{service}}"); + config.Parameters["key"].Description.Should().Be("{{desc}}"); + config.Parameters["key"].Example.Should().Be("{{ex}}"); + } } diff --git a/CodeGenesis.Engine.Tests/Config/PipelineConfigLoaderTests.cs b/CodeGenesis.Engine.Tests/Config/PipelineConfigLoaderTests.cs index cd901d7..fe4fb16 100644 --- a/CodeGenesis.Engine.Tests/Config/PipelineConfigLoaderTests.cs +++ b/CodeGenesis.Engine.Tests/Config/PipelineConfigLoaderTests.cs @@ -295,5 +295,47 @@ public void LoadFromFile_WithInputsAndOutputs_ParsesCorrectly() } } + [Fact] + public void LoadFromFile_McpServerWithDescriptionAndParameters_ParsesCorrectly() + { + var yaml = """ + pipeline: + name: MCP Test + settings: + mcp_servers: + jira: + command: npx + args: ["-y", "@anthropic/mcp-jira"] + description: "Search and manage Jira tickets" + parameters: + project_key: + description: "The Jira project key" + example: "PROJ-123" + env: + JIRA_TOKEN: "secret" + steps: + - name: step1 + prompt: Do something + """; + + var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.yaml"); + File.WriteAllText(path, yaml); + try + { + var config = PipelineConfigLoader.LoadFromFile(path); + + var jira = config.Settings.McpServers!["jira"]; + jira.Command.Should().Be("npx"); + jira.Description.Should().Be("Search and manage Jira tickets"); + jira.Parameters.Should().ContainKey("project_key"); + jira.Parameters!["project_key"].Description.Should().Be("The Jira project key"); + jira.Parameters["project_key"].Example.Should().Be("PROJ-123"); + } + finally + { + File.Delete(path); + } + } + #endregion } diff --git a/CodeGenesis.Engine/Config/McpContextBuilder.cs b/CodeGenesis.Engine/Config/McpContextBuilder.cs new file mode 100644 index 0000000..f97ec9d --- /dev/null +++ b/CodeGenesis.Engine/Config/McpContextBuilder.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace CodeGenesis.Engine.Config; + +/// +/// Builds a Markdown "Available MCP Tools" section from MCP server metadata, +/// for injection into the system prompt. +/// +public static class McpContextBuilder +{ + /// + /// Generates a Markdown section describing available MCP servers. + /// Returns null when no server has a description or parameters (nothing to emit). + /// + public static string? Build(Dictionary servers) + { + var documented = servers + .Where(kv => !string.IsNullOrWhiteSpace(kv.Value.Description) + || kv.Value.Parameters is { Count: > 0 }) + .ToList(); + + if (documented.Count == 0) + return null; + + var sb = new StringBuilder(); + sb.AppendLine("## Available MCP Tools"); + sb.AppendLine(); + + foreach (var (key, config) in documented) + { + sb.Append("### ").AppendLine(key); + sb.AppendLine(); + + if (!string.IsNullOrWhiteSpace(config.Description)) + { + sb.AppendLine(config.Description.Trim()); + sb.AppendLine(); + } + + if (config.Parameters is { Count: > 0 }) + { + sb.AppendLine("**Parameters:**"); + sb.AppendLine(); + + foreach (var (paramName, paramConfig) in config.Parameters) + { + var line = new StringBuilder($"- `{paramName}`"); + + if (!string.IsNullOrWhiteSpace(paramConfig.Description)) + line.Append($": {paramConfig.Description.Trim()}"); + + if (!string.IsNullOrWhiteSpace(paramConfig.Example)) + line.Append($" (example: `{paramConfig.Example.Trim()}`)"); + + sb.AppendLine(line.ToString()); + } + + sb.AppendLine(); + } + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/CodeGenesis.Engine/Config/McpServerConfig.cs b/CodeGenesis.Engine/Config/McpServerConfig.cs index 7491413..964a1a2 100644 --- a/CodeGenesis.Engine/Config/McpServerConfig.cs +++ b/CodeGenesis.Engine/Config/McpServerConfig.cs @@ -2,9 +2,26 @@ namespace CodeGenesis.Engine.Config; +/// +/// Documents a parameter that an MCP server tool accepts. +/// This is YAML-only metadata injected into the system prompt; +/// it is not written to the --mcp-config JSON. +/// +public sealed class McpParameterConfig +{ + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + [YamlMember(Alias = "example")] + public string? Example { get; set; } +} + /// /// Configuration for a single MCP stdio server. /// Maps to the Claude CLI --mcp-config JSON format. +/// The and fields are +/// pipeline metadata only — they are injected into the system prompt so the +/// LLM knows when and how to use each tool. /// public sealed class McpServerConfig { @@ -18,12 +35,35 @@ public sealed class McpServerConfig public Dictionary Env { get; set; } = new(); /// - /// Creates a deep clone with all template placeholders resolved. + /// Human-readable description of what this MCP server does. + /// Injected into the system prompt so the LLM knows when to use this tool. + /// + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + /// + /// Documents the parameters this server's tools accept. + /// Key is parameter name; value carries description and optional example. + /// + [YamlMember(Alias = "parameters")] + public Dictionary? Parameters { get; set; } + + /// + /// Creates a deep clone with all template placeholders resolved, + /// including description and parameter texts. /// public McpServerConfig ResolveTemplates(Func resolve) => new() { Command = resolve(Command), Args = Args.Select(resolve).ToList(), - Env = Env.ToDictionary(kv => kv.Key, kv => resolve(kv.Value)) + Env = Env.ToDictionary(kv => kv.Key, kv => resolve(kv.Value)), + Description = Description is not null ? resolve(Description) : null, + Parameters = Parameters?.ToDictionary( + kv => kv.Key, + kv => new McpParameterConfig + { + Description = kv.Value.Description is not null ? resolve(kv.Value.Description) : null, + Example = kv.Value.Example is not null ? resolve(kv.Value.Example) : null + }) }; } diff --git a/CodeGenesis.Engine/Steps/StepBuilder.cs b/CodeGenesis.Engine/Steps/StepBuilder.cs index 1607dc8..cc33ee7 100644 --- a/CodeGenesis.Engine/Steps/StepBuilder.cs +++ b/CodeGenesis.Engine/Steps/StepBuilder.cs @@ -73,6 +73,18 @@ private IPipelineStep BuildSimple(StepEntry entry) ? PipelineConfigLoader.ResolveTemplate(stepConfig.SystemPrompt, variables) : null; + // Append MCP tool documentation to system prompt when available + if (mergedMcpServers is { Count: > 0 }) + { + var mcpSection = McpContextBuilder.Build(mergedMcpServers); + if (mcpSection is not null) + { + resolvedSystemPrompt = resolvedSystemPrompt is not null + ? resolvedSystemPrompt + "\n\n" + mcpSection + : mcpSection; + } + } + var retryPolicy = RetryPolicy.Resolve(stepConfig, globalSettings); return new DynamicStep(claude, stepConfig, resolvedPrompt, resolvedSystemPrompt, stepModel, mergedMcpServers, retryPolicy); } diff --git a/README.md b/README.md index 9e76954..f807911 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,27 @@ steps: > Use `{{variable}}` for inputs and `{{steps.}}` for outputs from previous steps. +### MCP Server Tools + +Add `description` and `parameters` to MCP servers so the LLM knows when and how to use each tool: + +```yaml +settings: + mcp_servers: + jira: + command: "npx" + args: ["-y", "@anthropic/mcp-jira"] + description: "Search and manage Jira tickets" + parameters: + project_key: + description: "The Jira project key" + example: "PROJ-123" + env: + JIRA_TOKEN: "{{jira_token}}" +``` + +> Descriptions and parameters are injected into the system prompt — they are not sent to the Claude CLI config. + ## Documentation Full documentation is available in the **[Wiki](https://github.com/viamus/code-genesis/wiki)**: