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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## 0.8.1 — 2026-10-18

Fixes from testing on a real project.

- `adolens` works in any new terminal after an install or upgrade with the script (`get.ps1`, `install.ps1`,
`adolens upgrade --install` on such an installation) too: the program folder is added to the user's PATH, as the
setup EXE already did; `uninstall.ps1` takes it off.

- Tool arguments in another JSON shape are read instead of failing with a bare "An error occurred invoking":
`ids=714800` (a number for text), `workItems=[1,2]`, `file=1`, `top="20"`, `draft="true"`. This was behind the failing
`ado_update` (even with only a comment), `ado_pr_update action=create` with work items and `ado_attachments file=<n>`.
An argument that still cannot be read says which and why.
- `ado_attachments`: two files with the same name are listed by number to pick one.
- `ado_file history=true`: full commit ids (they work in `ado_changes` and `ado_file version=`), changes that came in
with a merge are listed too, and the branch read is named.
- `ado_file` without a version names the branch it read (the default one).
- `ado_sprint`: a sprint over the Azure DevOps limit of 20 000 work items says so instead of a raw server error; with
no current sprint the answer starts with NO CURRENT SPRINT; items in a state this project's process does not know
(e.g. from another project) are put in done / in progress by the state's usual name.

## 0.8.0 — 2026-10-18

Fewer tokens in every Claude Code session: about 1.6k instead of 5.5–7k.
Expand Down
15 changes: 15 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ else {
}
Say "version: $(& $exe version)"

# "adolens" in any new terminal: the program folder on the user's PATH, as the setup EXE does. Read and written raw
# (REG_EXPAND_SZ) so entries like %USERPROFILE%\... stay as they are.
$binFull = [IO.Path]::GetFullPath($binDst).TrimEnd('\')
$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$userPath = [string]$envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
$entries = @($userPath -split ';' | Where-Object { $_ })
if (-not ($entries | Where-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\') -ieq $binFull })) {
$envKey.SetValue('Path', (($entries + $binFull) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString)
# Tells Windows (Explorer, new terminals) that the environment changed.
[Environment]::SetEnvironmentVariable('ADOLENS_PATH_REFRESH', '1', 'User')
[Environment]::SetEnvironmentVariable('ADOLENS_PATH_REFRESH', $null, 'User')
Say "added to PATH: $binFull ('adolens' works in terminals opened from now on)"
}
$envKey.Close()

Head '3/6 Azure DevOps connection'
& $exe status --quiet --offline
$state = $LASTEXITCODE
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.8.0</Version>
<Version>0.8.1</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
3 changes: 3 additions & 0 deletions src/AdoLens/Attachments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ void From(string? html, string where)
return ($"{head}: {list.Count} file(s)\n" + string.Join('\n', list.Select((a, i) => $"{i + 1}. {a.Name}{Size(a.Size)}{(a.Comment is { Length: > 0 } c ? $" - {c}" : "")}")) +
"\nfile=<name or number> reads one (documents as text, images and scans shown); save=true saves it to disk (any size).", []);

var same = list.Select((a, i) => (a, i)).Where(x => x.a.Name.Equals(file.Trim(), StringComparison.OrdinalIgnoreCase)).ToList();
if (same.Count > 1)
return ($"{same.Count} files named {same[0].a.Name} on #{id}: pick one by number - {string.Join(", ", same.Select(x => $"file={x.i + 1}{Size(x.a.Size)}"))}", []);
var pick = int.TryParse(file, out var n) && n >= 1 && n <= list.Count ? list[n - 1]
: list.FirstOrDefault(a => a.Name.Equals(file.Trim(), StringComparison.OrdinalIgnoreCase))
?? list.FirstOrDefault(a => a.Name.Contains(file.Trim(), StringComparison.OrdinalIgnoreCase));
Expand Down
26 changes: 24 additions & 2 deletions src/AdoLens/Boards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,13 @@ public static int WorkingDays(DateTime from, DateTime to, HashSet<DayOfWeek> wor
/// <summary>Ids in the sprint of the team and the parent of each child (tasks under their requirement).</summary>
public async Task<(List<int> Ids, Dictionary<int, int> Parent)> SprintItemsAsync(string project, string team, Iteration it, CancellationToken ct)
{
var root = await client.GetAsync($"{Scope(project, team)}_apis/work/teamsettings/iterations/{it.Id}/workitems", ct);
JsonElement root;
try { root = await client.GetAsync($"{Scope(project, team)}_apis/work/teamsettings/iterations/{it.Id}/workitems", ct); }
catch (AdoApiException ex) when (ex.Message.Contains("VS402337", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"{it.Name} holds more than 20 000 work items (the Azure DevOps limit for one query) - it is probably a " +
"collecting iteration, not a sprint; pick another with sprint=<name>, or ask with ado_query");
}
var ids = new List<int>();
var parent = new Dictionary<int, int>();
foreach (var r in Json.Arr(root, "workItemRelations"))
Expand Down Expand Up @@ -188,9 +194,22 @@ static string Category(Snapshot s, JsonElement wi)
{
var type = s.FindType(WorkItems.Field(wi, "System.WorkItemType") ?? "");
var state = WorkItems.Field(wi, "System.State");
return type?.States.FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category ?? "InProgress";
return type?.States.FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category
// A type or state this project does not know (an item of another project in the sprint): any type with that state.
?? s.Types.SelectMany(t => t.States).FirstOrDefault(x => x.Name.Equals(state, StringComparison.OrdinalIgnoreCase))?.Category
?? GuessCategory(state);
}

/// <summary>The category of a state no process here knows, by its usual names.</summary>
internal static string GuessCategory(string? state) => (state ?? "").Trim().ToLowerInvariant() switch
{
"done" or "closed" or "completed" or "finished" or "zamknięty" or "zakończony" or "gotowe" => "Completed",
"removed" or "cut" or "usunięty" => "Removed",
"new" or "proposed" or "to do" or "nowy" => "Proposed",
"resolved" => "Resolved",
_ => "InProgress",
};

static string Group(string category) => category switch
{
"Proposed" => "not started",
Expand Down Expand Up @@ -225,6 +244,9 @@ public static async Task<string> SprintAsync(Lens lens, string? connection, stri
var s = (await session.Meta.GetAsync(name, ct)).Snapshot;

var sb = new StringBuilder();
var asked = (sprint ?? "current").Trim();
if (asked.Equals("current", StringComparison.OrdinalIgnoreCase) && it.Start is { } s0 && it.Finish is { } f0 && (day < s0 || day > f0))
sb.AppendLine("NO CURRENT SPRINT for the team today - this is the " + (day > f0 ? "last one that ended:" : "next one:"));
sb.Append($"{it.Name} ({t})");
if (it.Start is { } start && it.Finish is { } finish)
{
Expand Down
17 changes: 12 additions & 5 deletions src/AdoLens/Knowledge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,19 @@ public static async Task<string> FileAsync(Lens lens, string? connection, string
? $"&versionDescriptor.version={version}&versionDescriptor.versionType=commit"
: $"&versionDescriptor.version={Uri.EscapeDataString(version)}&versionDescriptor.versionType=branch";
text = await Content(session.Client, $"_apis/git/repositories/{file.RepoId}/items?path={Uri.EscapeDataString(file.Path)}&includeContent=true{v}", ct);
where = $"{file.RepoName}: {file.Path}" + (version is null ? "" : $" @ {version}");
where = $"{file.RepoName}: {file.Path} @ " + (version ?? $"{await DefaultBranchAsync(session.Client, file.RepoId!, ct)} (default branch; version=<branch> for another)");
}
if (text is null) return $"no file {where}";
return Show(where, file.Path, text, part, detail == "full");
}

/// <summary>The default branch of a repository without "refs/heads/", or "the default branch" when unknown.</summary>
static async Task<string> DefaultBranchAsync(AdoClient client, string repoId, CancellationToken ct)
{
try { return Json.Str(await client.GetAsync($"_apis/git/repositories/{repoId}", ct), "defaultBranch")?.Replace("refs/heads/", "") ?? "the default branch"; }
catch (AdoApiException) { return "the default branch"; }
}

internal static async Task<string?> Content(AdoClient client, string url, CancellationToken ct)
{
try { return Json.Str(await client.GetAsync(url, ct), "content"); }
Expand Down Expand Up @@ -293,7 +300,7 @@ static async Task<string> HistoryAsync(Session session, FileRef file, string? br
else
{
var b = branch is null ? "" : $"&searchCriteria.itemVersion.version={Uri.EscapeDataString(branch)}&searchCriteria.itemVersion.versionType=branch";
var list = await client.GetListAsync($"_apis/git/repositories/{file.RepoId}/commits?searchCriteria.itemPath={Uri.EscapeDataString(file.Path)}&searchCriteria.$top={top}{b}", ct);
var list = await client.GetListAsync($"_apis/git/repositories/{file.RepoId}/commits?searchCriteria.itemPath={Uri.EscapeDataString(file.Path)}&searchCriteria.$top={top}&searchCriteria.historyMode=fullHistorySimplifyMerges{b}", ct);
// The pull request of each commit: "Merged PR n" in a merge commit, else the PR that brought the commit in.
var prOf = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var c in list)
Expand Down Expand Up @@ -334,13 +341,13 @@ static async Task<string> HistoryAsync(Session session, FileRef file, string? br
var message = Json.Str(c, "comment");
var shaKey = Json.Str(c, "commitId") ?? "";
var pr = MergedPr().IsMatch(message ?? "") && prOf.TryGetValue(shaKey, out var n) ? $"PR {n}"
: prOf.TryGetValue(shaKey, out var via) ? $"commit {(shaKey.Length > 7 ? shaKey[..7] : shaKey)} (PR {via})" : null;
: prOf.TryGetValue(shaKey, out var via) ? $"commit {shaKey} (PR {via})" : null;
var sha = Json.Str(c, "commitId") ?? "";
return new Change(Date(Json.Str(Json.Obj(c, "author"), "date")), Json.Str(Json.Obj(c, "author"), "name"),
pr ?? $"commit {(sha.Length > 7 ? sha[..7] : sha)}", message, items[i]);
pr ?? $"commit {sha}", message, items[i]);
}));
}
var where = file.Tfvc ? file.Path : $"{file.RepoName}: {file.Path}{(branch is null ? "" : $" ({branch})")}";
var where = file.Tfvc ? file.Path : $"{file.RepoName}: {file.Path} ({branch ?? await DefaultBranchAsync(client, file.RepoId!, ct)})";
if (changes.Count == 0) return $"no history for {where}";

var all = changes.SelectMany(c => c.Items).Distinct().ToList();
Expand Down
12 changes: 7 additions & 5 deletions src/AdoLens/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,13 @@ async Task<int> RunServerAsync()
})
.WithStdioServerTransport()
.WithActiveTools(lens.Settings)
.WithRequestFilters(f => f.AddListToolsFilter(next => (context, ct) =>
{
ToolGroups.ForClient(lens.Settings, context.Server);
return next(context, ct);
}))
.WithRequestFilters(f => f
.AddListToolsFilter(next => (context, ct) =>
{
ToolGroups.ForClient(lens.Settings, context.Server);
return next(context, ct);
})
.AddCallToolFilter(ToolArguments.Errors))
.WithPrompts(Prompts.TypesFor(lens.Settings));
await builder.Build().RunAsync();
return 0;
Expand Down
101 changes: 101 additions & 0 deletions src/AdoLens/ToolArguments.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

namespace AdoLens;

/// <summary>
/// How tool arguments are read. Claude does not always send the JSON type of the schema: ids=714800 for a text
/// parameter, workItems=[1,2], file=1, top="20", draft="true". Without this the MCP SDK fails before the tool runs and
/// Claude only gets "An error occurred invoking …". A number or a list given for text becomes that text ("714800",
/// "1;2"), a number given as text is read as a number, "true"/"false" as a yes/no.
/// </summary>
public static class ToolArguments
{
public static readonly JsonSerializerOptions Json = Create();

static JsonSerializerOptions Create()
{
var o = new JsonSerializerOptions(ModelContextProtocol.McpJsonUtilities.DefaultOptions)
{
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
o.Converters.Add(new LenientString());
o.Converters.Add(new LenientBool());
o.MakeReadOnly();
return o;
}

/// <summary>
/// A tool that reads its arguments leniently but shows Claude the plain schema (with a custom converter the schema
/// would lose its types, and number-as-text would add patterns to every number).
/// </summary>
public static McpServerTool Tool(MethodInfo method, IServiceProvider services)
{
var tool = McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = services, SerializerOptions = Json });
tool.ProtocolTool.InputSchema = McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = services }).ProtocolTool.InputSchema;
return tool;
}

/// <summary>Text of a value given in any JSON shape: a list becomes "a;b".</summary>
public static string Text(JsonElement e) => e.ValueKind switch
{
JsonValueKind.String => e.GetString() ?? "",
JsonValueKind.Array => string.Join(";", e.EnumerateArray().Select(Text).Where(s => s.Length > 0)),
JsonValueKind.Null or JsonValueKind.Undefined => "",
_ => e.GetRawText(),
};

sealed class LenientString : JsonConverter<string>
{
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String) return reader.GetString();
if (reader.TokenType == JsonTokenType.Null) return null;
using var doc = JsonDocument.ParseValue(ref reader);
return Text(doc.RootElement);
}

public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => writer.WriteStringValue(value);
}

sealed class LenientBool : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.Number => reader.GetDouble() != 0,
JsonTokenType.String => (reader.GetString() ?? "").Trim().ToLowerInvariant() switch
{
"true" or "yes" or "1" or "tak" => true,
"false" or "no" or "0" or "nie" or "" => false,
var s => throw new JsonException($"'{s}' is not true or false"),
},
_ => throw new JsonException("expected true or false"),
};

public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) => writer.WriteBooleanValue(value);
}

/// <summary>
/// A failure outside the tool's own code (an argument that cannot be read at all) comes back as text Claude can act
/// on instead of the SDK's bare "An error occurred".
/// </summary>
public static McpRequestFilter<CallToolRequestParams, CallToolResult> Errors => next => async (context, ct) =>
{
try { return await next(context, ct); }
catch (Exception ex) when (ex is ArgumentException or JsonException or FormatException or InvalidOperationException or NotSupportedException)
{
var tool = context.Params?.Name ?? "tool";
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = $"{tool}: the arguments could not be read ({ex.GetType().Name}): {ex.Message}" }],
};
}
};
}
4 changes: 2 additions & 2 deletions src/AdoLens/ToolGroups.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public static void ForClient(Settings settings, McpServer server)
if (server.ServerOptions.ToolCollection is not { } tools) return;
using (tools.DeferChangedEvents())
foreach (var method in Toolsets.Methods(Toolsets.Active(settings)))
tools.TryAdd(McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = server.Services }));
tools.TryAdd(ToolArguments.Tool(method, server.Services!));
}

public static string Load(Settings settings, McpServer server, string? enable)
Expand Down Expand Up @@ -68,7 +68,7 @@ public static string Load(Settings settings, McpServer server, string? enable)
var added = new List<string>();
foreach (var method in Toolsets.Methods([group]))
{
var tool = McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = server.Services });
var tool = ToolArguments.Tool(method, server.Services!);
if (tools.TryAdd(tool)) added.Add(Toolsets.ToolName(method));
}
result.AppendLine(added.Count == 0
Expand Down
2 changes: 1 addition & 1 deletion src/AdoLens/Toolsets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public static Microsoft.Extensions.DependencyInjection.IMcpServerBuilder WithAct
{
foreach (var method in Methods(Loaded(settings)))
Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton(builder.Services,
sp => McpServerTool.Create(method, options: new McpServerToolCreateOptions { Services = sp }));
sp => ToolArguments.Tool(method, sp));
return builder;
}

Expand Down
Loading