Skip to content

Commit 4255c1f

Browse files
ADO Lens 0.7.1
ADO Lens 0.7.1
2 parents 0beb933 + ebbbd51 commit 4255c1f

7 files changed

Lines changed: 133 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## 0.7.1 — 2026-10-15
4+
5+
Fixes from use in a process with many required fields.
6+
7+
- Creating a work item no longer asks for fields Azure DevOps fills itself (Iteration ID, Area ID and other system
8+
fields some processes mark as required); the message names each missing field with its reference name and allowed
9+
values.
10+
- `fields` of `ado_create`, `ado_update` and recipe parameters take numbers and booleans (`{"Priority": 2}`), an object
11+
sent as a JSON string, or `Name=Value; …` - before, such calls failed with no message.
12+
- An unexpected error inside a tool now says what happened instead of "An error occurred invoking …".
13+
- A value that matches several allowed values (e.g. "2" for "2 - High" and "20 - …") names them instead of failing
14+
with a vague message.
15+
316
## 0.7.0 — 2026-10-15
417

518
Files from Azure DevOps: read what can be read, save the rest.

‎src/AdoLens/AdoLens.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<AssemblyTitle>ADO Lens for Azure DevOps</AssemblyTitle>
2424
<Description>Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP</Description>
2525
<Copyright>Copyright © 2026 WillowDev</Copyright>
26-
<Version>0.7.0</Version>
26+
<Version>0.7.1</Version>
2727
</PropertyGroup>
2828

2929
<ItemGroup>

‎src/AdoLens/McpTools.cs‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,35 @@ public static string Instructions(Settings settings)
5454
return Intro + string.Concat(Lines.Where(l => active.Contains(l.Key)).Select(l => l.Value));
5555
}
5656

57+
/// <summary>
58+
/// Field values as Claude sends them: an object whose values are text, numbers or booleans, or that object as a JSON
59+
/// string, or "Name=Value; Name2=Value2". A strict Dictionary&lt;string,string&gt; would reject {"Priority": 2} before the
60+
/// tool runs, with no message.
61+
/// </summary>
62+
internal static Dictionary<string, string>? Fields(System.Text.Json.JsonElement? fields)
63+
{
64+
if (fields is not { } f || f.ValueKind is System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined) return null;
65+
if (f.ValueKind == System.Text.Json.JsonValueKind.String)
66+
{
67+
var s = f.GetString() ?? "";
68+
if (s.TrimStart().StartsWith('{'))
69+
try { return Fields(System.Text.Json.JsonDocument.Parse(s).RootElement.Clone()); }
70+
catch (System.Text.Json.JsonException) { throw new ArgumentException("fields is not valid JSON; give an object like {\"Priority\": 2}"); }
71+
return s.Split([';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
72+
.Select(p => p.Split('=', 2)).Where(p => p.Length == 2)
73+
.ToDictionary(p => p[0].Trim(), p => p[1].Trim(), StringComparer.OrdinalIgnoreCase);
74+
}
75+
if (f.ValueKind != System.Text.Json.JsonValueKind.Object) throw new ArgumentException("fields is an object: {\"Field name\": \"value\"}");
76+
return f.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ValueKind switch
77+
{
78+
System.Text.Json.JsonValueKind.String => p.Value.GetString() ?? "",
79+
System.Text.Json.JsonValueKind.Null => "",
80+
System.Text.Json.JsonValueKind.True => "true",
81+
System.Text.Json.JsonValueKind.False => "false",
82+
_ => p.Value.GetRawText(),
83+
}, StringComparer.OrdinalIgnoreCase);
84+
}
85+
5786
/// <summary>Set at server start from the settings: end every answer with "[~N tokens]".</summary>
5887
public static bool ShowTokens { get; set; }
5988

@@ -89,6 +118,12 @@ internal static async Task<string> Run(string tool, object args, Func<Task<strin
89118
error = true;
90119
throw new McpException("Azure DevOps did not answer in time");
91120
}
121+
catch (Exception ex) when (ex is not OperationCanceledException and not McpException)
122+
{
123+
// Anything unexpected still says what happened: otherwise the client shows only "An error occurred".
124+
error = true;
125+
throw new McpException($"{tool} failed ({ex.GetType().Name}): {ex.Message}");
126+
}
92127
finally
93128
{
94129
Usage.Record(tool, args, result, sw.ElapsedMilliseconds, error);
@@ -438,7 +473,7 @@ public static Task<string> Create(Lens lens,
438473
string? area = null,
439474
[Description("Tags separated by ';'.")] string? tags = null,
440475
string? state = null,
441-
[Description("Other fields by name or reference name.")] Dictionary<string, string>? fields = null,
476+
[Description("Other fields by name or reference name: {\"Priority\": 2}.")] System.Text.Json.JsonElement? fields = null,
442477
[Description("A team template (ado_fields type=… lists them) for the values not given.")] string? template = null,
443478
string? project = null,
444479
string? connection = null,
@@ -447,7 +482,7 @@ public static Task<string> Create(Lens lens,
447482
() => WorkQueries.CreateAsync(lens, connection, project, type, new WriteRequest
448483
{
449484
Title = title, Description = description, AcceptanceCriteria = acceptanceCriteria, AssignedTo = assignedTo,
450-
Parent = parent, Iteration = iteration, Area = area, Tags = tags, State = state, Fields = fields, Template = template,
485+
Parent = parent, Iteration = iteration, Area = area, Tags = tags, State = state, Fields = McpTools.Fields(fields), Template = template,
451486
}, ct));
452487

453488
[McpServerTool(Name = "ado_update")]
@@ -464,7 +499,7 @@ public static Task<string> Update(Lens lens,
464499
[Description("Iteration path, 'current' or 'next'.")] string? iteration = null,
465500
string? area = null,
466501
[Description("'a; b' replaces, '+a; -b' adds and removes.")] string? tags = null,
467-
[Description("Other fields by name or reference name; empty value clears.")] Dictionary<string, string>? fields = null,
502+
[Description("Other fields by name or reference name; empty value clears.")] System.Text.Json.JsonElement? fields = null,
468503
[Description("Markdown comment for each item, alone or with changes; @[name or e-mail] notifies a person.")] string? comment = null,
469504
[Description("Token from the preview.")] string? confirm = null,
470505
string? connection = null,
@@ -473,7 +508,7 @@ public static Task<string> Update(Lens lens,
473508
() => WorkQueries.UpdateAsync(lens, connection, ids, wiql, new WriteRequest
474509
{
475510
Title = title, Description = description, AcceptanceCriteria = acceptanceCriteria, State = state, Reason = reason,
476-
AssignedTo = assignedTo, Iteration = iteration, Area = area, Tags = tags, Fields = fields,
511+
AssignedTo = assignedTo, Iteration = iteration, Area = area, Tags = tags, Fields = McpTools.Fields(fields),
477512
}, comment, confirm, lens.Settings.ConfirmBulkAbove, ct));
478513

479514
[McpServerTool(Name = "ado_comment")]
@@ -580,7 +615,7 @@ public static class RecipeTools
580615
public static Task<string> Recipe(Lens lens, string action,
581616
string? name = null,
582617
[Description("For save: the recipe JSON.")] string? recipe = null,
583-
[Description("For run: parameter values.")] Dictionary<string, string>? @params = null,
618+
[Description("For run: parameter values.")] System.Text.Json.JsonElement? @params = null,
584619
string? confirm = null,
585620
string? connection = null,
586621
CancellationToken ct = default)
@@ -593,7 +628,7 @@ public static Task<string> Recipe(Lens lens, string action,
593628
"show" => Recipes.Show(lens, Need(name, "name")),
594629
"save" => Recipes.Save(lens, Need(name, "name"), Need(recipe, "recipe")),
595630
"delete" => Recipes.Delete(lens, Need(name, "name")),
596-
"run" => await Recipes.RunAsync(lens, connection, Need(name, "name"), @params, confirm, yes: false, ct),
631+
"run" => await Recipes.RunAsync(lens, connection, Need(name, "name"), McpTools.Fields(@params), confirm, yes: false, ct),
597632
_ => throw new ArgumentException("action is list, show, save, run or delete"),
598633
};
599634
});

‎src/AdoLens/WorkItems.cs‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,15 @@ public async Task<List<object>> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe
221221

222222
if (current is null)
223223
{
224+
// Fields Azure DevOps fills itself (ids from the paths, the creator, dates) are "required" in some processes
225+
// but cannot be given: never ask for them.
224226
var missing = s.FieldsOf(type.Name)
225-
.Where(f => f.Required && string.IsNullOrEmpty(f.Default) && !set.ContainsKey(f.Ref)
226-
&& f.Ref is not ("System.State" or "System.Reason" or "System.AreaPath" or "System.IterationPath"))
227-
.Select(f => f.Allowed.Count > 0 ? $"{f.Name} ({string.Join(" | ", f.Allowed.Take(10))}{(f.Allowed.Count > 10 ? " | …" : "")})" : f.Name)
227+
.Where(f => f.Required && string.IsNullOrEmpty(f.Default) && !set.ContainsKey(f.Ref) && !FilledByAzureDevOps(f.Ref)
228+
&& !(s.Fields.TryGetValue(f.Ref, out var def) && def.ReadOnly))
229+
.Select(f => $"{f.Name} [{f.Ref}]" + (f.Allowed.Count > 0 ? $" ({string.Join(" | ", f.Allowed.Take(10))}{(f.Allowed.Count > 10 ? " | …" : "")})" : ""))
228230
.ToList();
229231
if (missing.Count > 0)
230-
throw new InvalidOperationException($"{type.Name} needs: {string.Join("; ", missing)} - pass them in fields");
232+
throw new InvalidOperationException($"{type.Name} needs: {string.Join("; ", missing)} - pass them in fields (by name or [reference]); area and iteration are fine as given");
231233
}
232234

233235
foreach (var (reference, value) in set)
@@ -246,6 +248,14 @@ public async Task<List<object>> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe
246248
return ops;
247249
}
248250

251+
/// <summary>Fields set by Azure DevOps on every save, never by the caller.</summary>
252+
internal static bool FilledByAzureDevOps(string reference) =>
253+
reference is "System.State" or "System.Reason" or "System.AreaPath" or "System.IterationPath" or "System.AreaId" or "System.IterationId"
254+
or "System.Id" or "System.Rev" or "System.TeamProject" or "System.WorkItemType" or "System.CreatedBy" or "System.CreatedDate"
255+
or "System.ChangedBy" or "System.ChangedDate" or "System.AuthorizedAs" or "System.AuthorizedDate" or "System.RevisedDate"
256+
or "System.Watermark" or "System.NodeName" or "System.PersonId" or "System.CommentCount"
257+
|| reference.StartsWith("System.AreaLevel", StringComparison.Ordinal) || reference.StartsWith("System.IterationLevel", StringComparison.Ordinal);
258+
249259
static bool Has(Snapshot s, TypeInfo type, string reference) =>
250260
s.FieldsOf(type.Name).Any(f => f.Ref.Equals(reference, StringComparison.OrdinalIgnoreCase));
251261

@@ -273,9 +283,13 @@ internal static (TypeField Field, FieldDef? Def) FindField(Snapshot s, TypeInfo
273283
if (def?.ReadOnly == true) throw new InvalidOperationException($"{tf.Name} is read-only");
274284
if (tf.Allowed.Count > 0)
275285
{
276-
var hit = tf.Allowed.FirstOrDefault(a => a.Equals(v, StringComparison.OrdinalIgnoreCase))
277-
?? tf.Allowed.SingleOrDefault(a => a.StartsWith(v, StringComparison.OrdinalIgnoreCase) || a.Contains(" - " + v, StringComparison.OrdinalIgnoreCase));
278-
return hit ?? throw new InvalidOperationException($"'{value}' is not allowed in {tf.Name} of {type.Name}; values: {string.Join(" | ", tf.Allowed.Take(30))}");
286+
var exact = tf.Allowed.FirstOrDefault(a => a.Equals(v, StringComparison.OrdinalIgnoreCase));
287+
if (exact is not null) return exact;
288+
var near = tf.Allowed.Where(a => a.StartsWith(v, StringComparison.OrdinalIgnoreCase) || a.Contains(" - " + v, StringComparison.OrdinalIgnoreCase)).ToList();
289+
if (near.Count == 1) return near[0];
290+
throw new InvalidOperationException(near.Count > 1
291+
? $"'{value}' matches several values of {tf.Name}: {string.Join(" | ", near.Take(10))} - give the whole value"
292+
: $"'{value}' is not allowed in {tf.Name} of {type.Name}; values: {string.Join(" | ", tf.Allowed.Take(30))}");
279293
}
280294
switch (def?.Type)
281295
{
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace AdoLens.Tests;
2+
3+
/// <summary>Creating items in processes that mark system fields required; loose field values; readable errors.</summary>
4+
public class CreateFieldTests : IDisposable
5+
{
6+
readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-create-{Guid.NewGuid():N}");
7+
readonly DemoProject demo = new();
8+
readonly Lens lens;
9+
10+
public CreateFieldTests()
11+
{
12+
var ado = new DemoItems().Routes(demo.Routes(new FakeAdo()));
13+
var s = new Settings { DefaultConnection = "contoso" };
14+
s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" };
15+
lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client());
16+
}
17+
18+
public void Dispose()
19+
{
20+
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
21+
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
22+
}
23+
24+
[Fact]
25+
public async Task Ids_azure_devops_fills_itself_are_never_asked_for()
26+
{
27+
demo.SystemIdsRequired = true;
28+
demo.CustomerRequired = true;
29+
var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
30+
WorkQueries.CreateAsync(lens, null, null, "Product Backlog Item", new WriteRequest { Title = "x", Iteration = "Sprint 6" }));
31+
Assert.Contains("Customer [Custom.Customer] (Alpha | Beta)", error.Message);
32+
Assert.DoesNotContain("Iteration ID", error.Message);
33+
Assert.DoesNotContain("Area ID", error.Message);
34+
Assert.True(WorkItems.FilledByAzureDevOps("System.AreaLevel3"));
35+
}
36+
37+
[Fact]
38+
public void Field_values_come_as_text_numbers_or_a_json_string()
39+
{
40+
Assert.Equal("2", McpTools.Fields(System.Text.Json.JsonDocument.Parse("""{"Priority": 2}""").RootElement)!["priority"]);
41+
Assert.Equal("true", McpTools.Fields(System.Text.Json.JsonDocument.Parse("""{"Blocked": true}""").RootElement)!["Blocked"]);
42+
Assert.Equal("Beta", McpTools.Fields(System.Text.Json.JsonDocument.Parse("\"{\\\"Customer\\\": \\\"Beta\\\"}\"").RootElement)!["Customer"]);
43+
Assert.Equal("3", McpTools.Fields(System.Text.Json.JsonDocument.Parse("\"Priority=3; Customer=Beta\"").RootElement)!["Priority"]);
44+
Assert.Null(McpTools.Fields(null));
45+
}
46+
47+
[Fact]
48+
public async Task Unexpected_errors_still_say_what_happened()
49+
{
50+
var error = await Assert.ThrowsAsync<ModelContextProtocol.McpException>(() =>
51+
McpTools.Run("ado_test", new { }, () => throw new KeyNotFoundException("no key x")));
52+
Assert.Equal("ado_test failed (KeyNotFoundException): no key x", error.Message);
53+
}
54+
}

‎tests/AdoLens.Tests/FakeAdo.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ sealed class DemoProject
5454
public bool CustomerRequired;
5555
public bool ExtraState;
5656
public bool TestCaseType;
57+
public bool SystemIdsRequired;
5758

5859
public FakeAdo Routes(FakeAdo ado) => ado
5960
.On("/contoso/_apis/projects/Demo", """
@@ -100,7 +101,7 @@ public FakeAdo Routes(FakeAdo ado) => ado
100101
{"referenceName":"System.Description","name":"Description"},
101102
{"referenceName":"Microsoft.VSTS.Common.AcceptanceCriteria","name":"Acceptance Criteria"},
102103
{"referenceName":"Custom.Customer","name":"Customer","alwaysRequired":{{{(CustomerRequired ? "true" : "false")}}},"allowedValues":["Alpha","Beta"]},
103-
{"referenceName":"WEF_ABC_Kanban.Column","name":"Board Column"}
104+
{"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}" : "")}}}
104105
]}
105106
""")
106107
.On("/contoso/Demo/_apis/wit/workitemtypes/Bug/fields", () => $$$"""

‎tests/AdoLens.Tests/WorkItemTests.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ public async Task Create_checks_required_fields_and_values_before_writing()
157157
demo.CustomerRequired = true;
158158
var missing = await Assert.ThrowsAsync<InvalidOperationException>(() =>
159159
WorkQueries.CreateAsync(lens, null, null, "product backlog item", new WriteRequest { Title = "Export" }));
160-
Assert.Contains("needs: Customer (Alpha | Beta)", missing.Message);
160+
Assert.Contains("needs: Customer [Custom.Customer] (Alpha | Beta)", missing.Message);
161161

162162
var wrong = await Assert.ThrowsAsync<InvalidOperationException>(() =>
163163
WorkQueries.CreateAsync(lens, null, null, "Product Backlog Item", new WriteRequest { Title = "Export", Fields = new() { ["Customer"] = "Gamma" } }));

0 commit comments

Comments
 (0)