diff --git a/CHANGELOG.md b/CHANGELOG.md
index d23f614..2857ebb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.9.5 — 2026-10-26
+
+- `ado_file` by name works without Code Search (not installed or failing): the file lists of the TFVC Metadata
+ folders of the branches (also in another project of the organization) and of the Git repositories are searched, and
+ the matches come as full paths ($/…/Main/…, $/…/Release/…) to call again with. Nothing found: an example full path.
+ `ADOLENS_NO_CODE_SEARCH=1` tries this way on purpose.
+- `ado_attachments file=all`: every image of the item (pasted into its text or comments, or attached) in one answer;
+ `file="1;3"` several files by number (e.g. three screenshots all called image.png).
+
## 0.9.4 — 2026-10-26
- `ado_trace`: a branch earlier in the environment flow than the one a change was merged into is not reported as
diff --git a/README.md b/README.md
index 39641c3..930911c 100644
--- a/README.md
+++ b/README.md
@@ -225,7 +225,7 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command
| `ado_wiki_write` | `create` a page, `append` / `prepend` to it, or `replace` it with the version from the last full read — a page changed by someone else in the meantime is never overwritten; optional change comment | release notes, keeping docs current | `adolens wiki-write "Team/Release notes" --file notes.md --mode append` |
| `ado_tests` | the tests of work items (ids) or of a sprint's requirements and bugs: their Test Cases with the latest outcome per plan, suite and configuration (who, when), items without tests; `detail=full` adds the steps | "is it tested", sprint review | `adolens tests 123`, `adolens tests` (current sprint) |
| `ado_test_create` | Test Cases for a work item from markdown (`## title`, steps `1. action => expected`), linked as tested-by, in the item's area and iteration; `plan` + `suite` (id or name) add them to a suite | turning acceptance criteria into tests | `adolens test-create 123 --file cases.md --plan R1 --suite Login` |
-| `ado_attachments` | the files of a work item and the screenshots pasted into its text or comments; `file` gives the text of a PDF (`pages=2-5`; scanned pages shown as images), Word, Excel, PowerPoint, e-mail (.msg, .eml), ZIP (list; `part=` one file inside) or text file, or shows an image - the type is read from the bytes, binaries are never sent as text, long documents come as an outline and the start (`detail=full` for all); `save=true` streams the file to disk at any size and returns only its path, size and SHA-256; files over 20 MB or not readable get a link and the hint to save them; `add` attaches a local file | specifications, e-mails and screenshots of items; traces and logs for other tools | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` |
+| `ado_attachments` | the files of a work item and the screenshots pasted into its text or comments; `file=all` shows every image at once, `file="1;3"` several; `file` gives the text of a PDF (`pages=2-5`; scanned pages shown as images), Word, Excel, PowerPoint, e-mail (.msg, .eml), ZIP (list; `part=` one file inside) or text file, or shows an image - the type is read from the bytes, binaries are never sent as text, long documents come as an outline and the start (`detail=full` for all); `save=true` streams the file to disk at any size and returns only its path, size and SHA-256; files over 20 MB or not readable get a link and the hint to save them; `add` attaches a local file | specifications, e-mails and screenshots of items; traces and logs for other tools | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` |
| `ado_create_tree` | a tree of work items from an indented list (`- Feature: …`, children indented, `\| description`, `@person`), under an existing item if given; previewed first, one undo removes it all | breaking a feature into requirements and tasks | — |
| `ado_metrics` | done items and estimate per finished sprint (velocity), cycle and lead time (median, 85%), what is in progress longest | "are we getting faster", planning | `adolens metrics --sprints 6` |
| `ado_label` | a D365 message pasted as seen (values in place of %1 %2 … are fine) or `@File:Id`: the label, its text in every language (missing ones flagged) and the objects, X++ methods, fields and controls using it | "where does this error come from" | `adolens label "Item 1234: received 12, on hand 5"` |
diff --git a/README.pl.md b/README.pl.md
index 9938254..1352bc7 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -226,7 +226,7 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i
| `ado_wiki_write` | `create` — nowa strona, `append` / `prepend` — dopisanie, `replace` — zastąpienie z wersją z ostatniego pełnego odczytu (strony zmienionej w międzyczasie przez kogoś innego nigdy nie nadpisze); opcjonalny komentarz zmiany | notatki wydania, aktualna dokumentacja | `adolens wiki-write "Team/Release notes" --file notes.md --mode append` |
| `ado_tests` | testy work itemów (id) albo wymagań i błędów sprintu: ich Test Case'y z ostatnim wynikiem w każdym planie, zestawie i konfiguracji (kto, kiedy), elementy bez testów; `detail=full` dodaje kroki | „czy to przetestowane”, przegląd sprintu | `adolens tests 123`, `adolens tests` (bieżący sprint) |
| `ado_test_create` | Test Case'y dla work itemu z markdownu (`## tytuł`, kroki `1. akcja => oczekiwany wynik`), podpięte jako tested-by, w obszarze i iteracji elementu; `plan` + `suite` (id albo nazwa) dodają je do zestawu | kryteria akceptacji zamienione w testy | `adolens test-create 123 --file przypadki.md --plan R1 --suite Logowanie` |
-| `ado_attachments` | pliki work itemu i zrzuty ekranu wklejone w jego treść lub komentarze; `file` daje tekst PDF-a (`pages=2-5`; zeskanowane strony pokazane jako obrazy), Worda, Excela, PowerPointa, maila (.msg, .eml), ZIP-a (lista; `part=` jeden plik ze środka) lub pliku tekstowego, albo pokazuje obraz - typ jest rozpoznawany po bajtach, pliki binarne nigdy nie idą jako tekst, długie dokumenty przychodzą jako spis treści i początek (`detail=full` całość); `save=true` zapisuje plik na dysk strumieniowo w dowolnym rozmiarze i zwraca tylko ścieżkę, rozmiar i SHA-256; pliki ponad 20 MB albo nieczytelne dostają link i podpowiedź zapisu; `add` dołącza plik z dysku | specyfikacje, maile i zrzuty przy elementach; trace'y i logi dla innych narzędzi | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` |
+| `ado_attachments` | pliki work itemu i zrzuty ekranu wklejone w jego treść lub komentarze; `file=all` pokazuje od razu wszystkie obrazy, `file="1;3"` kilka; `file` daje tekst PDF-a (`pages=2-5`; zeskanowane strony pokazane jako obrazy), Worda, Excela, PowerPointa, maila (.msg, .eml), ZIP-a (lista; `part=` jeden plik ze środka) lub pliku tekstowego, albo pokazuje obraz - typ jest rozpoznawany po bajtach, pliki binarne nigdy nie idą jako tekst, długie dokumenty przychodzą jako spis treści i początek (`detail=full` całość); `save=true` zapisuje plik na dysk strumieniowo w dowolnym rozmiarze i zwraca tylko ścieżkę, rozmiar i SHA-256; pliki ponad 20 MB albo nieczytelne dostają link i podpowiedź zapisu; `add` dołącza plik z dysku | specyfikacje, maile i zrzuty przy elementach; trace'y i logi dla innych narzędzi | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` |
| `ado_create_tree` | drzewo work itemów z listy z wcięciami (`- Feature: …`, dzieci wcięte, `\| opis`, `@osoba`), opcjonalnie pod istniejącym elementem; najpierw podgląd, jedno cofnięcie usuwa całość | rozbicie feature'a na wymagania i taski | — |
| `ado_metrics` | zrobione elementy i szacunek w zakończonych sprintach (prędkość), czas realizacji (mediana, 85%), co najdłużej w toku | „czy przyspieszamy”, planowanie | `adolens metrics --sprints 6` |
| `ado_label` | komunikat D365 wklejony tak, jak widać na ekranie (z wartościami zamiast %1 %2 …) albo `@Plik:Id`: etykieta, jej tekst w każdym języku (brakujące oznaczone) i obiekty, metody X++, pola i kontrolki, które jej używają | „skąd ten błąd” | `adolens label "Towar 1234: przyjęto 12, stan 5"` |
diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj
index fecfc88..3eb1e49 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.9.4
+ 0.9.5
diff --git a/src/AdoLens/Attachments.cs b/src/AdoLens/Attachments.cs
index 04ed2cb..4a3cb45 100644
--- a/src/AdoLens/Attachments.cs
+++ b/src/AdoLens/Attachments.cs
@@ -52,6 +52,13 @@ void From(string? html, string where)
return found;
}
+ /// At most this many files in one answer (file=all or a list of numbers).
+ const int MaxMany = 8;
+
+ static bool IsImage(Attached a) =>
+ a.Comment?.StartsWith("pasted", StringComparison.Ordinal) == true
+ || Path.GetExtension(a.Name).ToLowerInvariant() is ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".webp";
+
static string Size(long? bytes) => bytes is not { } b ? "" : b < 1024 ? $" ({b} B)" : b < 1024 * 1024 ? $" ({b / 1024} KB)" : $" ({b / 1024.0 / 1024:0.#} MB)";
///
@@ -71,7 +78,35 @@ void From(string? html, string where)
if (list.Count == 0) return ($"{head}: no attachments and no pasted images", []);
if (string.IsNullOrWhiteSpace(file))
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= reads one (documents as text, images and scans shown); save=true saves it to disk (any size).", []);
+ "\nfile= reads one (documents as text, images and scans shown), file=all shows every image, file=\"1;3\" several; save=true saves it to disk (any size).", []);
+
+ // Several at once: file=all (every image, pasted or attached) or numbers "1;3;4".
+ var many = file.Trim().ToLowerInvariant() is "all" or "images" ? list.Where(IsImage).ToList()
+ : file.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is { Length: > 1 } numbers
+ ? numbers.Select(x => int.TryParse(x, out var k) && k >= 1 && k <= list.Count ? list[k - 1] : null).OfType().ToList()
+ : null;
+ if (many is not null)
+ {
+ if (many.Count == 0) return ($"{head}: no images among its {list.Count} file(s)", []);
+ var shown = new StringBuilder($"{head}: {Math.Min(many.Count, MaxMany)} of {many.Count} file(s)");
+ var images = new List<(byte[] Bytes, string Mime)>();
+ foreach (var a in many.Take(MaxMany))
+ {
+ var url = a.Url + (a.Url.Contains('?') ? "&" : "?") + "download=true";
+ try
+ {
+ var data = await session.Client.GetBytesAsync(url, MaxDownload, ct);
+ var read = Documents.Read(a.Name, data, pages, part);
+ images.AddRange(read.Images);
+ shown.Append($"\n- {a.Name} ({Documents.Size(data.Length)}{(a.Comment is { Length: > 0 } c ? $", {c}" : "")})")
+ .Append(read.Images.Count > 0 ? $": {read.Images.Count} image(s)" : "")
+ .Append(read.Text is { Length: > 0 } t ? "\n" + Documents.Fit(t, MaxText / MaxMany, false) : "");
+ }
+ catch (AdoApiException ex) { shown.Append($"\n- {a.Name}: not read ({Formatting.Collapse(ex.Message, 80)})"); }
+ }
+ if (many.Count > MaxMany) shown.Append($"\n… {many.Count - MaxMany} more: file= for the rest");
+ return (shown.ToString(), images);
+ }
var same = list.Select((a, i) => (a, i)).Where(x => x.a.Name.Equals(file.Trim(), StringComparison.OrdinalIgnoreCase)).ToList();
if (same.Count > 1)
diff --git a/src/AdoLens/Knowledge.cs b/src/AdoLens/Knowledge.cs
index baa2338..4710478 100644
--- a/src/AdoLens/Knowledge.cs
+++ b/src/AdoLens/Knowledge.cs
@@ -143,6 +143,78 @@ static async Task CodeAsync(AdoClient client, string project, string tex
sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName, string Project);
+ /// File lists already read in this process: TFVC folder or Git repository → its file paths.
+ static readonly System.Collections.Concurrent.ConcurrentDictionary> Listings = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Files whose path ends with (a name with or without ".xml"), from the file lists of the
+ /// TFVC Metadata folders (found under $/project, a few levels deep) and of up to five Git repositories; and an
+ /// example full path to suggest when nothing matches.
+ ///
+ static async Task<(List Hits, string? Hint)> ListingAsync(Session session, string project, string path, string? repo, CancellationToken ct)
+ {
+ var client = session.Client;
+ var tail = "/" + path.TrimStart('/');
+ bool Match(string p) => p.EndsWith(tail, StringComparison.OrdinalIgnoreCase) || p.EndsWith(tail + ".xml", StringComparison.OrdinalIgnoreCase);
+ var hits = new List();
+ string? hint = null;
+
+ // TFVC: the Metadata folders of the branches ($/Project/Trunk/Main/Metadata, $/Project/Release/Metadata, ...).
+ var metadata = new List();
+ // The project's own TFVC first; code kept in another project of the organization is found from $/.
+ foreach (var start in new[] { $"$/{project}", "$/" })
+ {
+ if (metadata.Count > 0) break;
+ try
+ {
+ var level = new List { start };
+ for (var depth = 0; depth < (start == "$/" ? 5 : 4) && level.Count > 0 && metadata.Count < 6; depth++)
+ {
+ var next = new List();
+ foreach (var folder in level.Take(15))
+ {
+ var children = (await client.GetListAsync($"_apis/tfvc/items?scopePath={Uri.EscapeDataString(folder)}&recursionLevel=OneLevel", ct))
+ .Where(i => Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").Where(p => p.Length > 0 && !p.Equals(folder, StringComparison.OrdinalIgnoreCase)).ToList();
+ foreach (var c in children)
+ if (c.EndsWith("/Metadata", StringComparison.OrdinalIgnoreCase)) metadata.Add(c);
+ else next.Add(c);
+ }
+ level = next;
+ }
+ }
+ catch (AdoApiException) { }
+ }
+ foreach (var root in metadata.Take(6))
+ {
+ try
+ {
+ if (!Listings.TryGetValue(root, out var files))
+ Listings[root] = files = (await client.GetListAsync($"_apis/tfvc/items?scopePath={Uri.EscapeDataString(root)}&recursionLevel=Full", ct))
+ .Where(i => !Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").ToList();
+ hits.AddRange(files.Where(Match).Select(p => new CodeHit("", p, null, 1, true)));
+ }
+ catch (AdoApiException) { }
+ }
+ if (metadata.Count > 0) hint = $"{metadata[0]}///AxClass/{path.Trim('/')}{(path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "" : ".xml")}";
+
+ // Git: the repositories of the project (or the one given).
+ try
+ {
+ var repos = repo is not null ? [Json.Str(await client.GetAsync($"{AdoClient.Scope(project)}_apis/git/repositories/{Uri.EscapeDataString(repo)}", ct), "name") ?? repo]
+ : (await client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories", ct)).Select(r => Json.Str(r, "name") ?? "").Where(n => n.Length > 0).Take(5).ToList();
+ foreach (var r in repos)
+ {
+ if (!Listings.TryGetValue($"git:{project}/{r}", out var files))
+ Listings[$"git:{project}/{r}"] = files = (await client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories/{Uri.EscapeDataString(r)}/items?scopePath=/&recursionLevel=Full", ct))
+ .Where(i => !Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").ToList();
+ hits.AddRange(files.Where(Match).Select(p => new CodeHit(r, p, null, 1, false)));
+ hint ??= $"repo={r} path=//{path.Trim('/')}";
+ }
+ }
+ catch (AdoApiException) { }
+ return (hits.Take(20).ToList(), hint);
+ }
+
///
/// A TFVC path ($/…), a Git path with its repository, or only a file name: then Code Search finds it (several
/// matches are listed for the caller to choose).
@@ -176,12 +248,25 @@ sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName,
}
var name = path[(path.LastIndexOf('/') + 1)..];
List hits;
- try { (hits, _) = await CodeHitsAsync(session.Client, project, $"file:{name}", 20, repo, ct); }
+ try
+ {
+ // ADOLENS_NO_CODE_SEARCH=1 tries the way without Code Search (diagnostics).
+ if (Environment.GetEnvironmentVariable("ADOLENS_NO_CODE_SEARCH") == "1") throw new AdoApiException("Code Search turned off by ADOLENS_NO_CODE_SEARCH");
+ (hits, _) = await CodeHitsAsync(session.Client, project, $"file:{name}", 20, repo, ct);
+ }
catch (AdoApiException ex)
{
- throw new InvalidOperationException($"give the full path ($/… for TFVC, or the path with repo= for Git): finding files by name needs Code Search ({ex.Message})");
+ // Without Code Search (not installed, or failing): the file lists of the TFVC Metadata folders and of the
+ // Git repositories, read once per session.
+ var (listed, hint) = await ListingAsync(session, project, path, repo, ct);
+ if (listed.Count == 0)
+ throw new InvalidOperationException($"Code Search failed ({Formatting.Collapse(ex.Message, 120)}) and no file '{path}' is in the file lists either; " +
+ $"give the full path{(hint is null ? " ($/… for TFVC, or the path with repo= for Git)" : $", e.g. {hint}")}");
+ hits = listed;
}
- var matching = hits.Where(h => h.Path.EndsWith(path.StartsWith('/') ? path : "/" + path, StringComparison.OrdinalIgnoreCase)).ToList();
+ var wanted = path.StartsWith('/') ? path : "/" + path;
+ // An X++ object may be named without ".xml".
+ var matching = hits.Where(h => h.Path.EndsWith(wanted, StringComparison.OrdinalIgnoreCase) || h.Path.EndsWith(wanted + ".xml", StringComparison.OrdinalIgnoreCase)).ToList();
if (matching.Count == 1)
{
var h = matching[0];
diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs
index 775303d..f94d7b4 100644
--- a/src/AdoLens/McpTools.cs
+++ b/src/AdoLens/McpTools.cs
@@ -583,7 +583,7 @@ public static Task CreateTree(Lens lens, string items,
() => WorkTree.CreateAsync(lens, connection, project, parent, items, iteration, area, confirm, ct));
[McpServerTool(Name = "ado_attachments"), Toolset(Toolsets.Code)]
- [Description("Files of a work item and screenshots pasted into it: the list; file= gives a document's text (PDF, Word, Excel, PowerPoint, e-mail, ZIP list, text) or shows images and scanned pages; save=true streams it to disk (any size) and returns the path; add= attaches a file.")]
+ [Description("Files of a work item and screenshots pasted into it: the list; file= gives a document's text (PDF, Word, Excel, PowerPoint, e-mail, ZIP list, text) or shows images and scanned pages, file=all every image at once, file=\"1;3\" several; save=true streams it to disk (any size) and returns the path; add= attaches a file.")]
public static async Task> AttachmentsTool(Lens lens, int id,
string? file = null,
[Description("PDF pages: 3 or 2-5.")] string? pages = null,
diff --git a/tests/AdoLens.Tests/AttachmentTests.cs b/tests/AdoLens.Tests/AttachmentTests.cs
index 3b6091c..c66025c 100644
--- a/tests/AdoLens.Tests/AttachmentTests.cs
+++ b/tests/AdoLens.Tests/AttachmentTests.cs
@@ -17,6 +17,46 @@ static byte[] Zip(params (string Path, string Content)[] parts)
return ms.ToArray();
}
+ [Fact]
+ public async Task All_images_of_an_item_come_in_one_answer()
+ {
+ var items = new DemoItems();
+ var rel = (string id) => new System.Text.Json.Nodes.JsonObject
+ {
+ ["rel"] = "AttachedFile",
+ ["url"] = $"https://dev.azure.com/contoso/_apis/wit/attachments/{id}?fileName=image.png",
+ ["attributes"] = new System.Text.Json.Nodes.JsonObject { ["name"] = "image.png", ["resourceSize"] = 9 },
+ };
+ items.Items[1]["relations"] = new System.Text.Json.Nodes.JsonArray(rel("a1"), rel("a2"), new System.Text.Json.Nodes.JsonObject
+ {
+ ["rel"] = "AttachedFile", ["url"] = "https://dev.azure.com/contoso/_apis/wit/attachments/a3?fileName=spec.txt",
+ ["attributes"] = new System.Text.Json.Nodes.JsonObject { ["name"] = "spec.txt", ["resourceSize"] = 4 },
+ });
+ byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0];
+ var ado = items.Routes(new DemoProject().Routes(new FakeAdo()))
+ .On("/contoso/_apis/wit/attachments/", _ => new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new ByteArrayContent(png) });
+ var dir = Path.Combine(Path.GetTempPath(), $"adolens-att-{Guid.NewGuid():N}");
+ try
+ {
+ var s = new Settings { DefaultConnection = "contoso" };
+ s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" };
+ var lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client());
+
+ var (text, images) = await Attachments.ReadAsync(lens, null, 1, "all", null);
+ Assert.Contains("2 of 2 file(s)", text);
+ Assert.Equal(2, images.Count);
+
+ // Two files with one name: a list of numbers takes both.
+ var (_, picked) = await Attachments.ReadAsync(lens, null, 1, "1;2", null);
+ Assert.Equal(2, picked.Count);
+ }
+ finally
+ {
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
+ }
+ }
+
[Fact]
public void Word_documents_become_markdown_like_text()
{