Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.7.1 — 2026-10-15

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.
Expand Down
2 changes: 1 addition & 1 deletion src/AdoLens/AdoLens.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<AssemblyTitle>ADO Lens for Azure DevOps</AssemblyTitle>
<Description>Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP</Description>
<Copyright>Copyright © 2026 WillowDev</Copyright>
<Version>0.7.0</Version>
<Version>0.7.1</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
47 changes: 41 additions & 6 deletions src/AdoLens/McpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>
/// 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&lt;string,string&gt; would reject {"Priority": 2} before the
/// tool runs, with no message.
/// </summary>
internal static Dictionary<string, string>? 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);
}

/// <summary>Set at server start from the settings: end every answer with "[~N tokens]".</summary>
public static bool ShowTokens { get; set; }

Expand Down Expand Up @@ -89,6 +118,12 @@ internal static async Task<string> Run(string tool, object args, Func<Task<strin
error = true;
throw new McpException("Azure DevOps did not answer in time");
}
catch (Exception ex) when (ex is not OperationCanceledException and not McpException)
{
// Anything unexpected still says what happened: otherwise the client shows only "An error occurred".
error = true;
throw new McpException($"{tool} failed ({ex.GetType().Name}): {ex.Message}");
}
finally
{
Usage.Record(tool, args, result, sw.ElapsedMilliseconds, error);
Expand Down Expand Up @@ -438,7 +473,7 @@ public static Task<string> 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<string, string>? 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,
Expand All @@ -447,7 +482,7 @@ public static Task<string> 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")]
Expand All @@ -464,7 +499,7 @@ public static Task<string> 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<string, string>? 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,
Expand All @@ -473,7 +508,7 @@ public static Task<string> 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")]
Expand Down Expand Up @@ -580,7 +615,7 @@ public static class RecipeTools
public static Task<string> Recipe(Lens lens, string action,
string? name = null,
[Description("For save: the recipe JSON.")] string? recipe = null,
[Description("For run: parameter values.")] Dictionary<string, string>? @params = null,
[Description("For run: parameter values.")] System.Text.Json.JsonElement? @params = null,
string? confirm = null,
string? connection = null,
CancellationToken ct = default)
Expand All @@ -593,7 +628,7 @@ public static Task<string> 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"),
};
});
Expand Down
28 changes: 21 additions & 7 deletions src/AdoLens/WorkItems.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,15 @@ public async Task<List<object>> 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)
Expand All @@ -246,6 +248,14 @@ public async Task<List<object>> BuildOpsAsync(Snapshot s, TypeInfo type, WriteRe
return ops;
}

/// <summary>Fields set by Azure DevOps on every save, never by the caller.</summary>
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));

Expand Down Expand Up @@ -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)
{
Expand Down
54 changes: 54 additions & 0 deletions tests/AdoLens.Tests/CreateFieldTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
namespace AdoLens.Tests;

/// <summary>Creating items in processes that mark system fields required; loose field values; readable errors.</summary>
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<InvalidOperationException>(() =>
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<ModelContextProtocol.McpException>(() =>
McpTools.Run("ado_test", new { }, () => throw new KeyNotFoundException("no key x")));
Assert.Equal("ado_test failed (KeyNotFoundException): no key x", error.Message);
}
}
3 changes: 2 additions & 1 deletion tests/AdoLens.Tests/FakeAdo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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", """
Expand Down Expand Up @@ -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", () => $$$"""
Expand Down
2 changes: 1 addition & 1 deletion tests/AdoLens.Tests/WorkItemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ public async Task Create_checks_required_fields_and_values_before_writing()
demo.CustomerRequired = true;
var missing = await Assert.ThrowsAsync<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
WorkQueries.CreateAsync(lens, null, null, "Product Backlog Item", new WriteRequest { Title = "Export", Fields = new() { ["Customer"] = "Gamma" } }));
Expand Down