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