From 741f0a7009380f760e326e7f8d73184e24e41a13 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:03:36 +0200 Subject: [PATCH 1/2] Create: no system fields asked for, loose field values, readable errors --- CHANGELOG.md | 13 ++++++ src/AdoLens/McpTools.cs | 47 ++++++++++++++++++--- src/AdoLens/WorkItems.cs | 28 +++++++++---- tests/AdoLens.Tests/CreateFieldTests.cs | 54 +++++++++++++++++++++++++ tests/AdoLens.Tests/FakeAdo.cs | 3 +- tests/AdoLens.Tests/WorkItemTests.cs | 2 +- 6 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 tests/AdoLens.Tests/CreateFieldTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f854238..e04424e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.7.1 — unreleased + +Fixes from use in a process with many required fields. + +- Creating a work item no longer asks for fields Azure DevOps fills itself (Iteration ID, Area ID and other system + fields some processes mark as required); the message names each missing field with its reference name and allowed + values. +- `fields` of `ado_create`, `ado_update` and recipe parameters take numbers and booleans (`{"Priority": 2}`), an object + sent as a JSON string, or `Name=Value; …` - before, such calls failed with no message. +- An unexpected error inside a tool now says what happened instead of "An error occurred invoking …". +- A value that matches several allowed values (e.g. "2" for "2 - High" and "20 - …") names them instead of failing + with a vague message. + ## 0.7.0 — 2026-10-15 Files from Azure DevOps: read what can be read, save the rest. diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs index ae5784f..12b9a90 100644 --- a/src/AdoLens/McpTools.cs +++ b/src/AdoLens/McpTools.cs @@ -54,6 +54,35 @@ public static string Instructions(Settings settings) return Intro + string.Concat(Lines.Where(l => active.Contains(l.Key)).Select(l => l.Value)); } + /// + /// Field values as Claude sends them: an object whose values are text, numbers or booleans, or that object as a JSON + /// string, or "Name=Value; Name2=Value2". A strict Dictionary<string,string> would reject {"Priority": 2} before the + /// tool runs, with no message. + /// + internal static Dictionary? Fields(System.Text.Json.JsonElement? fields) + { + if (fields is not { } f || f.ValueKind is System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined) return null; + if (f.ValueKind == System.Text.Json.JsonValueKind.String) + { + var s = f.GetString() ?? ""; + if (s.TrimStart().StartsWith('{')) + try { return Fields(System.Text.Json.JsonDocument.Parse(s).RootElement.Clone()); } + catch (System.Text.Json.JsonException) { throw new ArgumentException("fields is not valid JSON; give an object like {\"Priority\": 2}"); } + return s.Split([';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => p.Split('=', 2)).Where(p => p.Length == 2) + .ToDictionary(p => p[0].Trim(), p => p[1].Trim(), StringComparer.OrdinalIgnoreCase); + } + if (f.ValueKind != System.Text.Json.JsonValueKind.Object) throw new ArgumentException("fields is an object: {\"Field name\": \"value\"}"); + return f.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ValueKind switch + { + System.Text.Json.JsonValueKind.String => p.Value.GetString() ?? "", + System.Text.Json.JsonValueKind.Null => "", + System.Text.Json.JsonValueKind.True => "true", + System.Text.Json.JsonValueKind.False => "false", + _ => p.Value.GetRawText(), + }, StringComparer.OrdinalIgnoreCase); + } + /// Set at server start from the settings: end every answer with "[~N tokens]". public static bool ShowTokens { get; set; } @@ -89,6 +118,12 @@ internal static async Task Run(string tool, object args, Func Create(Lens lens, string? area = null, [Description("Tags separated by ';'.")] string? tags = null, string? state = null, - [Description("Other fields by name or reference name.")] Dictionary? fields = null, + [Description("Other fields by name or reference name: {\"Priority\": 2}.")] System.Text.Json.JsonElement? fields = null, [Description("A team template (ado_fields type=… lists them) for the values not given.")] string? template = null, string? project = null, string? connection = null, @@ -447,7 +482,7 @@ public static Task Create(Lens lens, () => WorkQueries.CreateAsync(lens, connection, project, type, new WriteRequest { Title = title, Description = description, AcceptanceCriteria = acceptanceCriteria, AssignedTo = assignedTo, - Parent = parent, Iteration = iteration, Area = area, Tags = tags, State = state, Fields = fields, Template = template, + Parent = parent, Iteration = iteration, Area = area, Tags = tags, State = state, Fields = McpTools.Fields(fields), Template = template, }, ct)); [McpServerTool(Name = "ado_update")] @@ -464,7 +499,7 @@ public static Task Update(Lens lens, [Description("Iteration path, 'current' or 'next'.")] string? iteration = null, string? area = null, [Description("'a; b' replaces, '+a; -b' adds and removes.")] string? tags = null, - [Description("Other fields by name or reference name; empty value clears.")] Dictionary? fields = null, + [Description("Other fields by name or reference name; empty value clears.")] System.Text.Json.JsonElement? fields = null, [Description("Markdown comment for each item, alone or with changes; @[name or e-mail] notifies a person.")] string? comment = null, [Description("Token from the preview.")] string? confirm = null, string? connection = null, @@ -473,7 +508,7 @@ public static Task Update(Lens lens, () => WorkQueries.UpdateAsync(lens, connection, ids, wiql, new WriteRequest { Title = title, Description = description, AcceptanceCriteria = acceptanceCriteria, State = state, Reason = reason, - AssignedTo = assignedTo, Iteration = iteration, Area = area, Tags = tags, Fields = fields, + AssignedTo = assignedTo, Iteration = iteration, Area = area, Tags = tags, Fields = McpTools.Fields(fields), }, comment, confirm, lens.Settings.ConfirmBulkAbove, ct)); [McpServerTool(Name = "ado_comment")] @@ -580,7 +615,7 @@ public static class RecipeTools public static Task Recipe(Lens lens, string action, string? name = null, [Description("For save: the recipe JSON.")] string? recipe = null, - [Description("For run: parameter values.")] Dictionary? @params = null, + [Description("For run: parameter values.")] System.Text.Json.JsonElement? @params = null, string? confirm = null, string? connection = null, CancellationToken ct = default) @@ -593,7 +628,7 @@ public static Task Recipe(Lens lens, string action, "show" => Recipes.Show(lens, Need(name, "name")), "save" => Recipes.Save(lens, Need(name, "name"), Need(recipe, "recipe")), "delete" => Recipes.Delete(lens, Need(name, "name")), - "run" => await Recipes.RunAsync(lens, connection, Need(name, "name"), @params, confirm, yes: false, ct), + "run" => await Recipes.RunAsync(lens, connection, Need(name, "name"), McpTools.Fields(@params), confirm, yes: false, ct), _ => throw new ArgumentException("action is list, show, save, run or delete"), }; }); diff --git a/src/AdoLens/WorkItems.cs b/src/AdoLens/WorkItems.cs index e536cea..8acaffe 100644 --- a/src/AdoLens/WorkItems.cs +++ b/src/AdoLens/WorkItems.cs @@ -221,13 +221,15 @@ public async Task> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe if (current is null) { + // Fields Azure DevOps fills itself (ids from the paths, the creator, dates) are "required" in some processes + // but cannot be given: never ask for them. var missing = s.FieldsOf(type.Name) - .Where(f => f.Required && string.IsNullOrEmpty(f.Default) && !set.ContainsKey(f.Ref) - && f.Ref is not ("System.State" or "System.Reason" or "System.AreaPath" or "System.IterationPath")) - .Select(f => f.Allowed.Count > 0 ? $"{f.Name} ({string.Join(" | ", f.Allowed.Take(10))}{(f.Allowed.Count > 10 ? " | …" : "")})" : f.Name) + .Where(f => f.Required && string.IsNullOrEmpty(f.Default) && !set.ContainsKey(f.Ref) && !FilledByAzureDevOps(f.Ref) + && !(s.Fields.TryGetValue(f.Ref, out var def) && def.ReadOnly)) + .Select(f => $"{f.Name} [{f.Ref}]" + (f.Allowed.Count > 0 ? $" ({string.Join(" | ", f.Allowed.Take(10))}{(f.Allowed.Count > 10 ? " | …" : "")})" : "")) .ToList(); if (missing.Count > 0) - throw new InvalidOperationException($"{type.Name} needs: {string.Join("; ", missing)} - pass them in fields"); + throw new InvalidOperationException($"{type.Name} needs: {string.Join("; ", missing)} - pass them in fields (by name or [reference]); area and iteration are fine as given"); } foreach (var (reference, value) in set) @@ -246,6 +248,14 @@ public async Task> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe return ops; } + /// Fields set by Azure DevOps on every save, never by the caller. + internal static bool FilledByAzureDevOps(string reference) => + reference is "System.State" or "System.Reason" or "System.AreaPath" or "System.IterationPath" or "System.AreaId" or "System.IterationId" + or "System.Id" or "System.Rev" or "System.TeamProject" or "System.WorkItemType" or "System.CreatedBy" or "System.CreatedDate" + or "System.ChangedBy" or "System.ChangedDate" or "System.AuthorizedAs" or "System.AuthorizedDate" or "System.RevisedDate" + or "System.Watermark" or "System.NodeName" or "System.PersonId" or "System.CommentCount" + || reference.StartsWith("System.AreaLevel", StringComparison.Ordinal) || reference.StartsWith("System.IterationLevel", StringComparison.Ordinal); + static bool Has(Snapshot s, TypeInfo type, string reference) => s.FieldsOf(type.Name).Any(f => f.Ref.Equals(reference, StringComparison.OrdinalIgnoreCase)); @@ -273,9 +283,13 @@ internal static (TypeField Field, FieldDef? Def) FindField(Snapshot s, TypeInfo if (def?.ReadOnly == true) throw new InvalidOperationException($"{tf.Name} is read-only"); if (tf.Allowed.Count > 0) { - var hit = tf.Allowed.FirstOrDefault(a => a.Equals(v, StringComparison.OrdinalIgnoreCase)) - ?? tf.Allowed.SingleOrDefault(a => a.StartsWith(v, StringComparison.OrdinalIgnoreCase) || a.Contains(" - " + v, StringComparison.OrdinalIgnoreCase)); - return hit ?? throw new InvalidOperationException($"'{value}' is not allowed in {tf.Name} of {type.Name}; values: {string.Join(" | ", tf.Allowed.Take(30))}"); + var exact = tf.Allowed.FirstOrDefault(a => a.Equals(v, StringComparison.OrdinalIgnoreCase)); + if (exact is not null) return exact; + var near = tf.Allowed.Where(a => a.StartsWith(v, StringComparison.OrdinalIgnoreCase) || a.Contains(" - " + v, StringComparison.OrdinalIgnoreCase)).ToList(); + if (near.Count == 1) return near[0]; + throw new InvalidOperationException(near.Count > 1 + ? $"'{value}' matches several values of {tf.Name}: {string.Join(" | ", near.Take(10))} - give the whole value" + : $"'{value}' is not allowed in {tf.Name} of {type.Name}; values: {string.Join(" | ", tf.Allowed.Take(30))}"); } switch (def?.Type) { diff --git a/tests/AdoLens.Tests/CreateFieldTests.cs b/tests/AdoLens.Tests/CreateFieldTests.cs new file mode 100644 index 0000000..3e1102e --- /dev/null +++ b/tests/AdoLens.Tests/CreateFieldTests.cs @@ -0,0 +1,54 @@ +namespace AdoLens.Tests; + +/// Creating items in processes that mark system fields required; loose field values; readable errors. +public class CreateFieldTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-create-{Guid.NewGuid():N}"); + readonly DemoProject demo = new(); + readonly Lens lens; + + public CreateFieldTests() + { + var ado = new DemoItems().Routes(demo.Routes(new FakeAdo())); + var s = new Settings { DefaultConnection = "contoso" }; + s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" }; + lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client()); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public async Task Ids_azure_devops_fills_itself_are_never_asked_for() + { + demo.SystemIdsRequired = true; + demo.CustomerRequired = true; + var error = await Assert.ThrowsAsync(() => + WorkQueries.CreateAsync(lens, null, null, "Product Backlog Item", new WriteRequest { Title = "x", Iteration = "Sprint 6" })); + Assert.Contains("Customer [Custom.Customer] (Alpha | Beta)", error.Message); + Assert.DoesNotContain("Iteration ID", error.Message); + Assert.DoesNotContain("Area ID", error.Message); + Assert.True(WorkItems.FilledByAzureDevOps("System.AreaLevel3")); + } + + [Fact] + public void Field_values_come_as_text_numbers_or_a_json_string() + { + Assert.Equal("2", McpTools.Fields(System.Text.Json.JsonDocument.Parse("""{"Priority": 2}""").RootElement)!["priority"]); + Assert.Equal("true", McpTools.Fields(System.Text.Json.JsonDocument.Parse("""{"Blocked": true}""").RootElement)!["Blocked"]); + Assert.Equal("Beta", McpTools.Fields(System.Text.Json.JsonDocument.Parse("\"{\\\"Customer\\\": \\\"Beta\\\"}\"").RootElement)!["Customer"]); + Assert.Equal("3", McpTools.Fields(System.Text.Json.JsonDocument.Parse("\"Priority=3; Customer=Beta\"").RootElement)!["Priority"]); + Assert.Null(McpTools.Fields(null)); + } + + [Fact] + public async Task Unexpected_errors_still_say_what_happened() + { + var error = await Assert.ThrowsAsync(() => + McpTools.Run("ado_test", new { }, () => throw new KeyNotFoundException("no key x"))); + Assert.Equal("ado_test failed (KeyNotFoundException): no key x", error.Message); + } +} diff --git a/tests/AdoLens.Tests/FakeAdo.cs b/tests/AdoLens.Tests/FakeAdo.cs index 29370cc..b6bd077 100644 --- a/tests/AdoLens.Tests/FakeAdo.cs +++ b/tests/AdoLens.Tests/FakeAdo.cs @@ -54,6 +54,7 @@ sealed class DemoProject public bool CustomerRequired; public bool ExtraState; public bool TestCaseType; + public bool SystemIdsRequired; public FakeAdo Routes(FakeAdo ado) => ado .On("/contoso/_apis/projects/Demo", """ @@ -100,7 +101,7 @@ public FakeAdo Routes(FakeAdo ado) => ado {"referenceName":"System.Description","name":"Description"}, {"referenceName":"Microsoft.VSTS.Common.AcceptanceCriteria","name":"Acceptance Criteria"}, {"referenceName":"Custom.Customer","name":"Customer","alwaysRequired":{{{(CustomerRequired ? "true" : "false")}}},"allowedValues":["Alpha","Beta"]}, - {"referenceName":"WEF_ABC_Kanban.Column","name":"Board Column"} + {"referenceName":"WEF_ABC_Kanban.Column","name":"Board Column"}{{{(SystemIdsRequired ? ",{\"referenceName\":\"System.IterationId\",\"name\":\"Iteration ID\",\"alwaysRequired\":true},{\"referenceName\":\"System.AreaId\",\"name\":\"Area ID\",\"alwaysRequired\":true}" : "")}}} ]} """) .On("/contoso/Demo/_apis/wit/workitemtypes/Bug/fields", () => $$$""" diff --git a/tests/AdoLens.Tests/WorkItemTests.cs b/tests/AdoLens.Tests/WorkItemTests.cs index d09e68a..c5de436 100644 --- a/tests/AdoLens.Tests/WorkItemTests.cs +++ b/tests/AdoLens.Tests/WorkItemTests.cs @@ -157,7 +157,7 @@ public async Task Create_checks_required_fields_and_values_before_writing() demo.CustomerRequired = true; var missing = await Assert.ThrowsAsync(() => WorkQueries.CreateAsync(lens, null, null, "product backlog item", new WriteRequest { Title = "Export" })); - Assert.Contains("needs: Customer (Alpha | Beta)", missing.Message); + Assert.Contains("needs: Customer [Custom.Customer] (Alpha | Beta)", missing.Message); var wrong = await Assert.ThrowsAsync(() => WorkQueries.CreateAsync(lens, null, null, "Product Backlog Item", new WriteRequest { Title = "Export", Fields = new() { ["Customer"] = "Gamma" } })); From ebbbd51f827dd67a99f0a43d8a61e1584f141f26 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:03:54 +0200 Subject: [PATCH 2/2] ADO Lens 0.7.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from use in a process with many required fields. - Creating a work item no longer asks for fields Azure DevOps fills itself (Iteration ID, Area ID and other system fields some processes mark as required); the message names each missing field with its reference name and allowed values. - `fields` of `ado_create`, `ado_update` and recipe parameters take numbers and booleans (`{"Priority": 2}`), an object sent as a JSON string, or `Name=Value; …` - before, such calls failed with no message. - An unexpected error inside a tool now says what happened instead of "An error occurred invoking …". - A value that matches several allowed values (e.g. "2" for "2 - High" and "20 - …") names them instead of failing with a vague message. --- CHANGELOG.md | 2 +- src/AdoLens/AdoLens.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e04424e..1087ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.7.1 — unreleased +## 0.7.1 — 2026-10-15 Fixes from use in a process with many required fields. diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index 90c2fc5..d9db749 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -23,7 +23,7 @@ ADO Lens for Azure DevOps Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP Copyright © 2026 WillowDev - 0.7.0 + 0.7.1