From 34ced6ca9e2857272abe9343b0ab538de98bcef2 Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Wed, 29 Apr 2026 06:20:13 -0600 Subject: [PATCH 01/21] Document isError behavior for 404 in tool descriptions (#1206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > This PR description was drafted with GitHub Copilot assistance. Fixes #1190 ## Summary Several tool descriptions don't document their not-found behavior (`isError: true`), making it harder for LLM agents to anticipate and handle missing resources gracefully. This PR adds `Returns isError: true if the [resource] is not found.` to the descriptions of five tools: | Tool | Added text | |------|-----------| | `repo_get_branch_by_name` | Returns isError: true if the branch is not found. | | `repo_list_directory` | Returns isError: true if the path is not found. | | `repo_get_file_content` | Returns isError: true if the file is not found. | | `wiki_get_page` | Returns isError: true if the page is not found. | | `wiki_get_page_content` | Returns isError: true if the wiki page is not found. | The sixth tool mentioned in #1190 (`repo_list_branches_by_repo`) is addressed separately in PR #1204. ## Associated Risks None — description-only changes, no logic affected. ## PR Checklist - [x] Description text changes only - [x] All 924 existing tests pass - [x] No conflicts with other open PRs (#1203, #1204, #1205) ## How did you test it Ran the full test suite (`npx jest`) — 924 tests pass with no changes needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/tools/repositories.ts | 7 ++++--- src/tools/wiki.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index f0a53e4c..52d66048 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -962,7 +962,7 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise Promise Promise Promise Date: Wed, 29 Apr 2026 06:21:53 -0600 Subject: [PATCH 02/21] Clarify branch list tool descriptions to indicate string return (#1204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > This PR description was drafted with GitHub Copilot assistance. Fixes #1189 Agents seeing "Retrieve a list of branches" reasonably expect objects with metadata (commit SHA, ahead/behind counts, etc.) and attempt to access properties that don't exist. For example in Copilot CLI actual session, before this change: ``` The branch listing returned string names rather than objects with metadata — there's no commit SHA, ahead/behind count, or other properties available from this tool. ``` Clarify the descriptions of `repo_list_branches_by_repo` and `repo_list_my_branches_by_repo` to indicate they return branch name strings, not full branch objects, and point to `repo_get_branch_by_name` for full details. This PR updates the descriptions to say "branch name strings, not full branch objects" and directs agents to `repo_get_branch_by_name` when they need full branch details. ## GitHub issue number #1189 ## **Associated Risks** Description-only change. No behavioral change. ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** - `npm test -- --runTestsByPath test/src/tools/repositories.test.ts --runInBand` - `npm run validate-tools` Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dan Hellem --- src/tools/repositories.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index 52d66048..36972339 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -860,7 +860,7 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise Date: Wed, 29 Apr 2026 09:16:18 -0400 Subject: [PATCH 03/21] [dependencies]: Bump typescript-eslint from 8.59.0 to 8.59.1 (#1202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.59.0 to 8.59.1.
Release notes

Sourced from typescript-eslint's releases.

v8.59.1

8.59.1 (2026-04-27)

🩹 Fixes

  • eslint-plugin: [no-unnecessary-type-assertion] fix crash "TypeError: checker.getTypeArguments is not a function" (#12246)
  • eslint-plugin: [no-unnecessary-type-assertion] preserve index signatures in undefined unions (#12257)
  • eslint-plugin: [no-unnecessary-type-assertion] preserve phantom type arguments in generic inference (#12269)
  • eslint-plugin: [no-unnecessary-type-assertion] avoid false positive in logical assignment assertions (#12278)
  • eslint-plugin: [no-unnecessary-type-arguments] handle instantiation expressions (#12220)
  • eslint-plugin: [no-unnecessary-condition] treat void as nullish in no-unnecessary-condition (#12241)

❤️ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Changelog

Sourced from typescript-eslint's changelog.

8.59.1 (2026-04-27)

This was a version bump only for typescript-eslint to align it with other projects, there were no code changes.

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=typescript-eslint&package-manager=npm_and_yarn&previous-version=8.59.0&new-version=8.59.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dan Hellem --- package-lock.json | 122 +++++++++++++++++++++++----------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index cbc62b2e..e2cd8b21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1909,17 +1909,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", - "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/type-utils": "8.59.0", - "@typescript-eslint/utils": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1932,7 +1932,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.0", + "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1948,16 +1948,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", - "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "engines": { @@ -1973,14 +1973,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", - "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.0", - "@typescript-eslint/types": "^8.59.0", + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "engines": { @@ -1995,14 +1995,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", - "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0" + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2013,9 +2013,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", - "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", "dev": true, "license": "MIT", "engines": { @@ -2030,15 +2030,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", - "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2055,9 +2055,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", - "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", "dev": true, "license": "MIT", "engines": { @@ -2069,16 +2069,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", - "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.0", - "@typescript-eslint/tsconfig-utils": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2149,16 +2149,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", - "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0" + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2173,13 +2173,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", - "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8546,16 +8546,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", - "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0" + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" From ff02c2e725657731594bca0119bd0526e4d01576 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:26:11 -0400 Subject: [PATCH 04/21] [dependencies]: Bump @azure/msal-node from 5.1.4 to 5.1.5 (#1208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@azure/msal-node](https://github.com/AzureAD/microsoft-authentication-library-for-js) from 5.1.4 to 5.1.5.
Release notes

Sourced from @​azure/msal-node's releases.

@​azure/msal-node-extensions v5.1.5

5.1.5

Tue, 28 Apr 2026 21:30:33 GMT

Patches

  • Bump @​azure/msal-common to v16.5.2 (beachball)

@​azure/msal-node v5.1.5

5.1.5

Tue, 28 Apr 2026 21:30:32 GMT

Patches

Commits
  • 7ca7202 fix(sample): acquire token after B2C edit profile policy (#8568)
  • 29a826b Address dependabot Github Actions alerts (#8550)
  • 34a4e06 fix(msal-browser): CookieStorage tolerates malformed percent-encoded cookies ...
  • a800fd2 fix(msal-common): use proper 2-arg comparator in getAccountInfoFilter… (#8559)
  • 1737897 fix(msal-browser): freeze Date.now() in beforeEach to eliminate timestamp fla...
  • b9c8b81 fix(msal-node): replace uuid with node:crypto.randomUUID() (GHSA-w5hq… (#8566)
  • 8033a18 Remove beachball change file for msal-browser native broker revert (#8567)
  • e6f44e9 Revert "Bugfix - include extra query parameters in ExtraParameters in Platfor...
  • 109a351 Bugfix - include extra query parameters in ExtraParameters in PlatformAuthReq...
  • 2747951 Native Auth:fix: use client_info="1" string value in native auth token reques...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@azure/msal-node&package-manager=npm_and_yarn&previous-version=5.1.4&new-version=5.1.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dan Hellem --- package-lock.json | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index e2cd8b21..ac1dff94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -187,23 +187,22 @@ } }, "node_modules/@azure/msal-node": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.4.tgz", - "integrity": "sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.5.tgz", + "integrity": "sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==", "license": "MIT", "dependencies": { - "@azure/msal-common": "16.5.1", - "jsonwebtoken": "^9.0.0", - "uuid": "^8.3.0" + "@azure/msal-common": "16.5.2", + "jsonwebtoken": "^9.0.0" }, "engines": { "node": ">=20" } }, "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { - "version": "16.5.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.1.tgz", - "integrity": "sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==", + "version": "16.5.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.2.tgz", + "integrity": "sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==", "license": "MIT", "engines": { "node": ">=0.8.0" @@ -8688,15 +8687,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", From e2e150a4d67977a2be58477388782bd09b515490 Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Wed, 29 Apr 2026 08:06:48 -0600 Subject: [PATCH 05/21] Auto-resolve repository name to GUID in get_build_definitions (#1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > This PR description was drafted with GitHub Copilot assistance. Fixes #1194 Auto-resolve repository names to GUIDs in `pipelines_get_build_definitions` for Azure Repos (TfsGit). The Azure DevOps `getDefinitions()` API requires a GUID for `repositoryId` when `repositoryType` is TfsGit. Agents naturally pass repository names (which they get from other tools), causing a cryptic failure: ``` Tool: ado-dnceng-public-pipelines_get_build_definitions Args: { "project": "public", "repositoryId": "aspnet-AspLabs", "repositoryType": "TfsGit" } Result: Repository ID for a tfsgit repository should be a GUID. IsError: true ``` This PR detects when `repositoryId` is not a GUID (and the repository type is TfsGit or unspecified), resolves it via `getRepositories(project)`, and passes the GUID to the API. If the name isn't found, it returns a clear error. Confirmed with live ADO API against `aspnet-AspLabs` in dnceng — name fails, resolved GUID succeeds. ## GitHub issue number #1194 ## **Associated Risks** Adds an extra API call (`getRepositories`) only when `repositoryId` is not a GUID and the repository type is TfsGit or unspecified. GUID values and non-TfsGit types are passed through unchanged. ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** - `npm test -- --runTestsByPath test/src/tools/pipelines.test.ts --runInBand` - Confirmed with live ADO API: `getDefinitions(project, "aspnet-AspLabs", "TfsGit")` returns "Repository ID should be a GUID"; after resolving to GUID `4b7f10b6-89f4-46cf-8858-4c21032ec66a` returns 1 definition Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dan Hellem --- src/tools/pipelines.ts | 28 ++++- test/src/tools/pipelines.test.ts | 184 ++++++++++++++++++++++++++++++- 2 files changed, 207 insertions(+), 5 deletions(-) diff --git a/src/tools/pipelines.ts b/src/tools/pipelines.ts index 3000db59..55f8afb8 100644 --- a/src/tools/pipelines.ts +++ b/src/tools/pipelines.ts @@ -35,7 +35,12 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< "Retrieves a list of build definitions for a given project.", { project: z.string().describe("Project ID or name to get build definitions for"), - repositoryId: z.string().optional().describe("Repository ID to filter build definitions"), + repositoryId: z + .string() + .optional() + .describe( + "Repository ID to filter build definitions. Can be a GUID or a repository name; when a name is provided, it is auto-resolved to the repository GUID using the project parameter (Azure Repos / TfsGit only)." + ), repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Type of repository to filter build definitions"), name: z.string().optional().describe("Name of the build definition to filter"), path: z.string().optional().describe("Path of the build definition to filter"), @@ -76,10 +81,29 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< }) => { const connection = await connectionProvider(); const buildApi = await connection.getBuildApi(); + + // Auto-resolve repositoryId from name to GUID for Azure Repos + let resolvedRepositoryId = repositoryId; + if (repositoryId) { + const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId); + if (!isGuid && (!repositoryType || repositoryType === "TfsGit")) { + const gitApi = await connection.getGitApi(); + const repositories = await gitApi.getRepositories(project); + const repo = repositories?.find((r) => r.name === repositoryId); + if (!repo?.id) { + return { + content: [{ type: "text", text: `Error: Repository '${repositoryId}' not found in project '${project}'.` }], + isError: true, + }; + } + resolvedRepositoryId = repo.id; + } + } + const buildDefinitions = await buildApi.getDefinitions( project, name, - repositoryId, + resolvedRepositoryId, repositoryType, safeEnumConvert(DefinitionQueryOrder, queryOrder), top, diff --git a/test/src/tools/pipelines.test.ts b/test/src/tools/pipelines.test.ts index 5d697a6d..f68ca118 100644 --- a/test/src/tools/pipelines.test.ts +++ b/test/src/tools/pipelines.test.ts @@ -25,7 +25,7 @@ describe("configurePipelineTools", () => { let tokenProvider: TokenProviderMock; let connectionProvider: ConnectionProviderMock; let userAgentProvider: () => string; - let mockConnection: { getBuildApi: jest.Mock; getPipelinesApi: jest.Mock; serverUrl: string }; + let mockConnection: { getBuildApi: jest.Mock; getPipelinesApi: jest.Mock; getGitApi: jest.Mock; serverUrl: string }; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -34,6 +34,7 @@ describe("configurePipelineTools", () => { mockConnection = { getBuildApi: jest.fn(), getPipelinesApi: jest.fn(), + getGitApi: jest.fn(), serverUrl: "https://dev.azure.com/test-org", }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); @@ -380,7 +381,7 @@ describe("configurePipelineTools", () => { const params = { project: "test-project", - repositoryId: "repo-123", + repositoryId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", repositoryType: "TfsGit" as const, name: "test-build", top: 10, @@ -391,7 +392,7 @@ describe("configurePipelineTools", () => { expect(mockBuildApi.getDefinitions).toHaveBeenCalledWith( "test-project", "test-build", - "repo-123", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "TfsGit", undefined, // queryOrder 10, // top @@ -435,6 +436,183 @@ describe("configurePipelineTools", () => { await expect(handler(params)).rejects.toThrow("API Error"); }); + + it("should auto-resolve repository name to GUID for TfsGit", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockGitApi = { + getRepositories: jest.fn().mockResolvedValue([ + { id: "resolved-guid-1234", name: "my-repo" }, + { id: "other-guid-5678", name: "other-repo" }, + ]), + }; + const mockBuildApi = { + getDefinitions: jest.fn().mockResolvedValue([{ id: 1, name: "Build" }]), + }; + mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); + + const params = { + project: "test-project", + repositoryId: "my-repo", + }; + + const result = await handler(params); + + expect(mockGitApi.getRepositories).toHaveBeenCalledWith("test-project"); + expect(mockBuildApi.getDefinitions).toHaveBeenCalledWith( + "test-project", + undefined, // name + "resolved-guid-1234", // resolved repositoryId + undefined, // repositoryType + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + expect(result.isError).toBeFalsy(); + }); + + it("should return error when repository name is not found", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockGitApi = { + getRepositories: jest.fn().mockResolvedValue([{ id: "some-guid", name: "other-repo" }]), + }; + const mockBuildApi = { + getDefinitions: jest.fn(), + }; + mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); + + const params = { + project: "test-project", + repositoryId: "nonexistent-repo", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("nonexistent-repo"); + expect(result.content[0].text).toContain("not found"); + expect(mockBuildApi.getDefinitions).not.toHaveBeenCalled(); + }); + + it("should pass GUID repositoryId through without resolution", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockBuildApi = { + getDefinitions: jest.fn().mockResolvedValue([]), + }; + mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + + const params = { + project: "test-project", + repositoryId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }; + + const result = await handler(params); + + expect(mockBuildApi.getDefinitions).toHaveBeenCalledWith( + "test-project", + undefined, + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + expect(result.isError).toBeFalsy(); + }); + + it("should not resolve repository name for GitHub repositoryType", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockBuildApi = { + getDefinitions: jest.fn().mockResolvedValue([]), + }; + mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + + const params = { + project: "test-project", + repositoryId: "owner/repo", + repositoryType: "GitHub" as const, + }; + + const result = await handler(params); + + // Should pass through without attempting resolution + expect(mockBuildApi.getDefinitions).toHaveBeenCalledWith( + "test-project", + undefined, + "owner/repo", + "GitHub", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + expect(result.isError).toBeFalsy(); + }); + + it("should propagate error when getRepositories fails during name resolution", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockGitApi = { + getRepositories: jest.fn().mockRejectedValue(new Error("Project access denied")), + }; + mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); + + const params = { + project: "test-project", + repositoryId: "my-repo", + }; + + await expect(handler(params)).rejects.toThrow("Project access denied"); + }); }); describe("get_definition_revisions tool", () => { From 2e1f8e3be11703ea894df4bfb5a1a856156fbd5f Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Wed, 29 Apr 2026 10:24:31 -0600 Subject: [PATCH 06/21] Return isError for missing paths in repo_list_directory (#1203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1188 Return `isError: true` when `repo_list_directory` finds no items at the requested path. The Azure DevOps `getItems()` API returns `null` for nonexistent paths rather than throwing. The tool currently returns a success response with "No items found at path: ..." which the agent treats as a valid empty result. This makes it difficult for agents to detect a bad path and recover. This PR adds `isError: true` to the response and appends "The path may not exist in the repository." to the message, consistent with the `isError` pattern already used elsewhere in this codebase (e.g., `core.ts` identities lookup, `work.ts` iterations, `repositories.ts` PR iterations). Confirmed with live ADO API: `getItems("/nonexistent")` on a real repository returns `null`, not an exception. Note -- the case where `[]` is returned is also treated as an error, consistent with other tools in this MCP, although in practice this hasn't been seen. An empty directory always returns at least itself in the results. ## Example ``` Tool: ado-dnceng-public-repo_list_directory Args: { "project": "public", "repositoryId": "2459d599-fdb2-4d28-9810-daeec061cf90", "path": "/nonexistent-path-xyz-12345" } ``` currently ``` Result: No items found at path: /nonexistent-path-xyz-12345 IsError: false ``` then with this fix ``` Result: No items found at path: /nonexistent-path-xyz-12345. The path may not exist in the repository IsError: true ``` ## GitHub issue number #1188 ## **Associated Risks** Agents that currently ignore the "No items found" message and treat it as success will now see `isError: true`. This is the intended behavior change. ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** - `npm test -- --runTestsByPath test/src/tools/repositories.test.ts --runInBand` - Confirmed with live ADO API that `getItems()` returns `null` for nonexistent paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dan Hellem --- src/tools/repositories.ts | 3 ++- test/src/tools/repositories.test.ts | 41 +++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index 36972339..2f09387e 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -2004,7 +2004,8 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise { ); }); - it("should return no items found message", async () => { + it("should return isError when no items found (empty array)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); const [, , , handler] = call; @@ -7889,7 +7889,44 @@ describe("repos tools", () => { const result = await handler({ repositoryId: "repo123", path: "/missing" }); expect(result).toEqual({ - content: [{ type: "text", text: "No items found at path: /missing" }], + content: [{ type: "text", text: "No items found at path: /missing. The path may not exist in the repository." }], + isError: true, + }); + }); + + it("should succeed for empty directory (folder entry only)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const [, , , handler] = call; + + const items = [ + { + path: "/empty-dir", + isFolder: true, + gitObjectType: 2, + commitId: "abc123", + }, + ]; + mockGitApi.getItems.mockResolvedValue(items); + + const result = await handler({ repositoryId: "repo123", path: "/empty-dir" }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('"count": 1'); + }); + + it("should return isError when getItems returns null", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const [, , , handler] = call; + + mockGitApi.getItems.mockResolvedValue(null); + + const result = await handler({ repositoryId: "repo123", path: "/nonexistent" }); + + expect(result).toEqual({ + content: [{ type: "text", text: "No items found at path: /nonexistent. The path may not exist in the repository." }], + isError: true, }); }); }); From ddd430d75f09b3e402bfe93e9f542a211f165a5e Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Wed, 29 Apr 2026 12:02:37 -0600 Subject: [PATCH 07/21] Avoid stack overflow flattening test results (#1183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > This PR description was drafted with GitHub Copilot assistance. Fixes #1179 Avoid stack overflow when flattening large result groups in `testplan_show_test_results_from_build_id`. The previous implementation used: ```ts allResults.push(...group.results) ``` For very large result groups, that spreads every result as a function argument and can exceed JavaScript engine argument limits, throwing `Maximum call stack size exceeded`: ``` Tool: ado-dnceng-public-testplan_show_test_results_from_build_id Args: { "project": "public", "buildid": 1395596 } Result: MCP server 'ado-dnceng-public': Error fetching test results: Maximum call stack size exceeded ``` This PR changes the flattening to iterate results and push one item at a time. The structure of the JSON is not changed -- it's the same structure the code would produce if it didn't stack overflow the engine by using spread. ## GitHub issue number #1179 ## **Associated Risks** This is intended to be behavior-preserving except for avoiding the spread/argument-limit failure. It does not otherwise change result fetching or output shape. Returning very large output successfully may now expose a limitation elsewhere. ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** - `npm test -- --runTestsByPath test/src/tools/test-plan.test.ts --runInBand` - `npm run validate-tools` Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dan Hellem --- src/tools/test-plans.ts | 4 +++- test/src/tools/test-plan.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/tools/test-plans.ts b/src/tools/test-plans.ts index 3a47f56e..bf8f5eaa 100644 --- a/src/tools/test-plans.ts +++ b/src/tools/test-plans.ts @@ -449,7 +449,9 @@ function configureTestPlanTools(server: McpServer, tokenProvider: () => Promise< if (testResultDetails.resultsForGroup) { for (const group of testResultDetails.resultsForGroup) { if (group.results) { - allResults.push(...group.results); + for (const result of group.results) { + allResults.push(result); + } } } } diff --git a/test/src/tools/test-plan.test.ts b/test/src/tools/test-plan.test.ts index dad46ce2..c5a4285b 100644 --- a/test/src/tools/test-plan.test.ts +++ b/test/src/tools/test-plan.test.ts @@ -848,6 +848,30 @@ describe("configureTestPlanTools", () => { }); }); + it("should handle large result groups without spreading them onto the stack", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_show_test_results_from_build_id"); + if (!call) throw new Error("testplan_show_test_results_from_build_id tool not registered"); + const [, , , handler] = call; + + const largeResults = Array.from({ length: 150_000 }, (_, id) => ({ + id, + testCaseTitle: `Test ${id}`, + outcome: "Passed", + })); + + (mockTestResultsApi.getTestResultDetailsForBuild as jest.Mock).mockResolvedValue({ + resultsForGroup: [{ results: largeResults }], + }); + + const result = await handler({ project: "proj1", buildid: 456 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveLength(largeResults.length); + expect(parsed[0].testCaseTitle).toBe("Test 0"); + expect(parsed[largeResults.length - 1].testCaseTitle).toBe("Test 149999"); + }); + it("should handle empty results groups without errors", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_show_test_results_from_build_id"); From 2a73b4b0059e41540e8305e4b709081b4894777b Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Wed, 29 Apr 2026 14:28:03 -0600 Subject: [PATCH 08/21] Harden artifact download path validation (#1185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > This PR description was drafted with GitHub Copilot assistance. Fixes #1181 Clarify and harden path validation for `pipelines_download_artifact`. The tool rejects absolute `destinationPath` values, but agents primarily see runtime MCP tool descriptions rather than repository docs. In Copilot CLI I got ```text Tool: ado-dnceng-public-pipelines_download_artifact Args: { "project": "public", "buildId": 1395596, "artifactName": "Windows_NT_Libraries Test Run release coreclr windows x86 Release_Attempt1", "destinationPath": "C:\\temp\\runtime-127406-testresults" } Result: Invalid destinationPath: absolute paths and path traversals are not allowed. ``` This PR makes the relative-only requirement visible in both the top-level tool description and the `destinationPath` parameter description. It also strengthens validation by treating `artifactName` as a name rather than a path and rejecting path separators, absolute paths, drive-letter syntax, and `.`/`..` path segments. ## GitHub issue number #1181 ## **Associated Risks** This intentionally keeps absolute `destinationPath` values rejected to avoid arbitrary local write locations. Agents that currently pass absolute paths will receive a clearer pre-call schema description and a clearer validation error. ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** - `npm test -- --runTestsByPath test/src/tools/pipelines.test.ts --runInBand` - `npm run validate-tools` --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dan Hellem --- docs/TOOLSET.md | 2 +- src/tools/pipelines.ts | 16 ++++---- test/src/tools/pipelines.test.ts | 69 ++++++++++++-------------------- 3 files changed, 36 insertions(+), 51 deletions(-) diff --git a/docs/TOOLSET.md b/docs/TOOLSET.md index eb7ddfc4..ff21f4ce 100644 --- a/docs/TOOLSET.md +++ b/docs/TOOLSET.md @@ -229,7 +229,7 @@ Lists artifacts for a given build. Downloads a pipeline artifact. - **Required**: `project`, `buildId`, `artifactName` -- **Optional**: `destinationPath` +- **Optional**: `destinationPath` (relative local path; absolute paths and path traversal are not allowed) ## Repositories diff --git a/src/tools/pipelines.ts b/src/tools/pipelines.ts index 55f8afb8..c11679d9 100644 --- a/src/tools/pipelines.ts +++ b/src/tools/pipelines.ts @@ -556,23 +556,25 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< server.tool( PIPELINE_TOOLS.pipelines_download_artifact, - "Downloads a pipeline artifact.", + "Downloads a pipeline artifact. When destinationPath is provided, it must be a relative local path; absolute paths and path traversal are not allowed.", { project: z.string().describe("The name or ID of the project."), buildId: z.coerce.number().min(1).describe("The ID of the build."), artifactName: z.string().describe("The name of the artifact to download."), - destinationPath: z.string().optional().describe("The local path to download the artifact to. If not provided, returns binary content as base64."), + destinationPath: z.string().optional().describe("The relative local path to download the artifact to. If not provided, returns binary content as base64."), }, async ({ project, buildId, artifactName, destinationPath }) => { - const isAbsolutePath = (value: string) => posix.isAbsolute(value) || win32.isAbsolute(value); + const hasUnsafePathSegment = (value: string) => value.split(/[\\/]+/).some((segment) => segment === "." || segment === ".."); + const hasPathSeparators = (value: string) => /[\\/]/.test(value); const hasDriveLetter = (value: string) => /^[a-zA-Z]:/.test(value); + const isAbsolutePath = (value: string) => posix.isAbsolute(value) || win32.isAbsolute(value); - if (artifactName.includes("..")) { - throw new Error("Invalid artifactName: path traversal is not allowed."); + if (hasUnsafePathSegment(artifactName) || hasPathSeparators(artifactName) || hasDriveLetter(artifactName) || isAbsolutePath(artifactName)) { + throw new Error("Invalid artifactName: artifactName must be a file name, not a path."); } - if (destinationPath && (destinationPath.includes("..") || isAbsolutePath(destinationPath) || hasDriveLetter(destinationPath))) { - throw new Error("Invalid destinationPath: absolute paths and path traversals are not allowed."); + if (destinationPath && (hasUnsafePathSegment(destinationPath) || isAbsolutePath(destinationPath) || hasDriveLetter(destinationPath))) { + throw new Error("Invalid destinationPath: use a relative path without path traversal."); } const connection = await connectionProvider(); diff --git a/test/src/tools/pipelines.test.ts b/test/src/tools/pipelines.test.ts index f68ca118..803311a7 100644 --- a/test/src/tools/pipelines.test.ts +++ b/test/src/tools/pipelines.test.ts @@ -1685,7 +1685,7 @@ describe("configurePipelineTools", () => { destinationPath: "C:\\temp\\artifacts", }; - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid destinationPath: use a relative path without path traversal."); expect(connectionProvider).not.toHaveBeenCalled(); }); @@ -1702,7 +1702,7 @@ describe("configurePipelineTools", () => { destinationPath: "/tmp/artifacts", }; - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid destinationPath: use a relative path without path traversal."); expect(connectionProvider).not.toHaveBeenCalled(); }); @@ -1719,11 +1719,18 @@ describe("configurePipelineTools", () => { destinationPath: "..\\..\\temp\\artifacts", }; - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid destinationPath: use a relative path without path traversal."); expect(connectionProvider).not.toHaveBeenCalled(); }); - it("should reject artifactName with path traversal segments", async () => { + it.each([ + ["path traversal segments", "..\\..\\drop"], + ["Windows path separators", "folder\\drop"], + ["Unix path separators", "folder/drop"], + ["Windows absolute path", "C:\\temp\\drop"], + ["Unix absolute path", "/tmp/drop"], + ["current directory segment", "."], + ])("should reject artifactName with %s", async (_description, artifactName) => { configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact"); if (!call) throw new Error("pipelines_download_artifact tool not registered"); @@ -1732,15 +1739,25 @@ describe("configurePipelineTools", () => { const params = { project: "test-project", buildId: 12345, - artifactName: "..\\..\\drop", + artifactName, destinationPath: "temp\\artifacts", }; - await expect(handler(params)).rejects.toThrow("Invalid artifactName: path traversal is not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid artifactName: artifactName must be a file name, not a path."); expect(connectionProvider).not.toHaveBeenCalled(); }); - it("should reject destinationPath with a Windows drive-relative path", async () => { + it.each([ + ["Windows drive-relative path", "D:artifacts"], + ["Windows drive-relative path with subdirectory", "E:sub\\deep"], + ["only a drive letter and colon", "D:"], + ["Windows root-relative path", "\\temp\\artifacts"], + ["Windows UNC path", "\\\\server\\share\\artifacts"], + ["Windows extended-length path", "\\\\?\\C:\\temp\\artifacts"], + ["segment-level traversal", "temp\\..\\artifacts"], + ["current directory segment", "."], + ["segment-level current directory", "temp\\.\\artifacts"], + ])("should reject destinationPath with %s", async (_description, destinationPath) => { configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact"); if (!call) throw new Error("pipelines_download_artifact tool not registered"); @@ -1750,44 +1767,10 @@ describe("configurePipelineTools", () => { project: "test-project", buildId: 12345, artifactName: "drop", - destinationPath: "D:artifacts", + destinationPath, }; - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); - expect(connectionProvider).not.toHaveBeenCalled(); - }); - - it("should reject destinationPath with a drive-relative path with subdirectory", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact"); - if (!call) throw new Error("pipelines_download_artifact tool not registered"); - const [, , , handler] = call; - - const params = { - project: "test-project", - buildId: 12345, - artifactName: "drop", - destinationPath: "E:sub\\deep", - }; - - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); - expect(connectionProvider).not.toHaveBeenCalled(); - }); - - it("should reject destinationPath with only a drive letter and colon", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact"); - if (!call) throw new Error("pipelines_download_artifact tool not registered"); - const [, , , handler] = call; - - const params = { - project: "test-project", - buildId: 12345, - artifactName: "drop", - destinationPath: "D:", - }; - - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid destinationPath: use a relative path without path traversal."); expect(connectionProvider).not.toHaveBeenCalled(); }); From c95c9b337229ff4e64870f557942d37b820b3269 Mon Sep 17 00:00:00 2001 From: Dan Moseley Date: Fri, 1 May 2026 06:53:16 -0600 Subject: [PATCH 09/21] Fix test result outcome filtering (#1182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > This PR description was drafted with GitHub Copilot assistance. Fixes #1129 Avoid sending the invalid Azure DevOps result-details `$filter` expression for `outcomes` in `testplan_show_test_results_from_build_id`. The current implementation builds an expression like: ```text Outcome eq 'Failed' or Outcome eq 'Aborted' ``` Azure DevOps rejects this for the result-details endpoint, this is what I got in Copilot: ```text Tool: ado-dnceng-testplan_show_test_results_from_build_id Args: { "project": "internal", "buildid": 2952924, "outcomes": ["Failed"] } Result: Error fetching test results: Argument filter with value Outcome eq 'Failed' is not correct. ``` ## Cause This is because of a bug in test-plans.ts, where it assumed values should be quoted and joined with "or" when what actually is expected by the API is unquoted and joined with comma: https://github.com/microsoft/azure-devops-mcp/blob/main/src/tools/test-plans.ts#L431-L432 Proof -- this [example working URL](https://vstmr.dev.azure.com/dnceng-public/public/_apis/testresults/resultdetailsbybuild?buildId=1396794&publishContext=CI&groupBy=TestRun&%24filter=Outcome%20eq%20Failed%2CPassed&%24orderby=&shouldIncludeResults=true&queryRunSummaryForInProgress=false) filters for "failed or passed" and contains `Outcome%20eq%20Failed%2CPassed` ie unquoted comma separated. ## GitHub issue number #1129 ## **Associated Risks** Relies on the filter syntax, which is not well (or at all?) documented, however the existing code already relies on this. Plus, this repo is owned by the Azure Devops team, who can presumably be confident of what is OK to rely on. ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** - `npm test -- --runTestsByPath test/src/tools/test-plan.test.ts --runInBand` - `npm run validate-tools` --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dan Hellem --- src/tools/test-plans.ts | 5 +++-- test/src/tools/test-plan.test.ts | 8 ++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/tools/test-plans.ts b/src/tools/test-plans.ts index bf8f5eaa..88894736 100644 --- a/src/tools/test-plans.ts +++ b/src/tools/test-plans.ts @@ -428,8 +428,9 @@ function configureTestPlanTools(server: McpServer, tokenProvider: () => Promise< const connection = await connectionProvider(); const testResultsApi = await connection.getTestResultsApi(); - // Build filter expression for outcomes if specified - const outcomeFilter = outcomes?.map((o) => `Outcome eq '${o}'`).join(" or "); + // Build filter expression for outcomes if specified. + // The API accepts: Outcome eq Failed,Passed (unquoted, comma-separated) + const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined; // Fetch test result details for the build in a single API call // This is more efficient than getTestRuns + getTestResults per run, diff --git a/test/src/tools/test-plan.test.ts b/test/src/tools/test-plan.test.ts index c5a4285b..139fac93 100644 --- a/test/src/tools/test-plan.test.ts +++ b/test/src/tools/test-plan.test.ts @@ -752,11 +752,7 @@ describe("configureTestPlanTools", () => { const [, , , handler] = call; (mockTestResultsApi.getTestResultDetailsForBuild as jest.Mock).mockResolvedValue({ - resultsForGroup: [ - { - results: [{ id: 1, testCaseTitle: "FailingTest", outcome: "Failed", errorMessage: "error" }], - }, - ], + resultsForGroup: [], }); await handler({ project: "proj1", buildid: 123, outcomes: ["Failed", "Aborted"] }); @@ -766,7 +762,7 @@ describe("configureTestPlanTools", () => { 123, undefined, // publishContext undefined, // groupBy - "Outcome eq 'Failed' or Outcome eq 'Aborted'", // filter expression + "Outcome eq Failed,Aborted", // filter expression undefined, // orderby true // shouldIncludeResults ); From ba0ddca47d0ca139a17216eab95fb5b3bbff2c88 Mon Sep 17 00:00:00 2001 From: Dan Hellem Date: Mon, 4 May 2026 13:37:02 -0400 Subject: [PATCH 10/21] Add support for default project and team configuration (#1225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request adds support for setting default Azure DevOps project and team values via environment variables and updates the documentation and tests accordingly. Now, if the environment variables are set, users will not be prompted to select a project or team, streamlining the workflow. The changes also document how to set these defaults in `.vscode/mcp.json` and ensure robust test coverage for the new behavior. ## GitHub issue number #1195 ## **Associated Risks** None ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** manual testing and updated auto tests --- README.md | 31 ++++++++++++-- src/shared/elicitations.ts | 14 +++++++ test/src/elicitations.test.ts | 79 ++++++++++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8d8203da..e78a1dcb 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ This TypeScript project provides a **local** MCP server for Azure DevOps, enabli 4. [⚙️ Supported Tools](#️-supported-tools) 5. [🔌 Installation & Getting Started](#-installation--getting-started) 6. [🌏 Using Domains](#-using-domains) -7. [📝 Troubleshooting](#-troubleshooting) -8. [🎩 Examples & Best Practices](#-examples--best-practices) -9. [🙋‍♀️ Frequently Asked Questions](#️-frequently-asked-questions) -10. [📌 Contributing](#-contributing) +7. [🐥 Project and Team Defaults](#-project-and-team-defaults) +8. [📝 Troubleshooting](#-troubleshooting) +9. [🎩 Examples & Best Practices](#-examples--best-practices) +10. [🙋‍♀️ Frequently Asked Questions](#️-frequently-asked-questions) +11. [📌 Contributing](#-contributing) ## 📺 Overview @@ -170,6 +171,28 @@ We recommend that you always enable `core` tools so that you can fetch project l > By default all domains are loaded +## 🐥 Project and Team Defaults + +You can also configure default Azure DevOps project and team values from `.vscode/mcp.json` using `project` and `team`, so tools can skip selection prompts. + +### Example `.vscode/mcp.json` + +```json +{ + "servers": { + "ado": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@azure-devops/mcp", "myorg", "--authentication", "azcli"], + "env": { + "ado_mcp_project": "Contoso", + "ado_mcp_team": "Fabrikam Team" + } + } + } +} +``` + ## 📝 Troubleshooting See the [Troubleshooting guide](./docs/TROUBLESHOOTING.md) for help with common issues and logging. diff --git a/src/shared/elicitations.ts b/src/shared/elicitations.ts index 558dd487..0a009b4c 100644 --- a/src/shared/elicitations.ts +++ b/src/shared/elicitations.ts @@ -15,6 +15,13 @@ interface ElicitResponse { export type ElicitResult = ElicitResolved | ElicitResponse; export async function elicitProject(server: McpServer, connection: WebApi, message?: string): Promise { + // Check for default project from environment variable + const defaultProject = process.env.ado_mcp_project; + + if (defaultProject) { + return { resolved: defaultProject }; + } + const coreApi = await connection.getCoreApi(); const projects = await coreApi.getProjects("wellFormed", 100, 0, undefined, false); @@ -50,6 +57,13 @@ export async function elicitProject(server: McpServer, connection: WebApi, messa } export async function elicitTeam(server: McpServer, connection: WebApi, project: string, message?: string): Promise { + // Check for default team from environment variable + const defaultTeam = process.env.ado_mcp_team; + + if (defaultTeam) { + return { resolved: defaultTeam }; + } + const coreApi = await connection.getCoreApi(); const teams = await coreApi.getTeams(project, undefined, undefined, undefined, false); diff --git a/test/src/elicitations.test.ts b/test/src/elicitations.test.ts index a3538d27..34ae4e97 100644 --- a/test/src/elicitations.test.ts +++ b/test/src/elicitations.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, expect, it, beforeEach } from "@jest/globals"; +import { describe, expect, it, beforeEach, afterEach } from "@jest/globals"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { elicitProject, elicitTeam } from "../../src/shared/elicitations"; @@ -25,7 +25,18 @@ describe("elicitations", () => { }); describe("elicitProject", () => { + const originalProjectEnv = process.env.ado_mcp_project; + + afterEach(() => { + if (originalProjectEnv === undefined) { + delete process.env.ado_mcp_project; + } else { + process.env.ado_mcp_project = originalProjectEnv; + } + }); + it("should use default message when no message is provided", async () => { + delete process.env.ado_mcp_project; (mockCoreApi.getProjects as jest.Mock).mockResolvedValue([{ id: "proj-1", name: "ProjectAlpha" }]); const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; @@ -37,10 +48,48 @@ describe("elicitations", () => { expect(callArgs.message).toBe("Select the Azure DevOps project."); expect(result).toEqual({ resolved: "ProjectAlpha" }); }); + + it("should resolve to default project from ado_mcp_project env var without elicitation", async () => { + process.env.ado_mcp_project = "DefaultProject"; + + const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; + + const result = await elicitProject(server, mockConnection as unknown as WebApi); + + expect(result).toEqual({ resolved: "DefaultProject" }); + expect(mockConnection.getCoreApi).not.toHaveBeenCalled(); + expect(mockCoreApi.getProjects).not.toHaveBeenCalled(); + expect(elicitMock).not.toHaveBeenCalled(); + }); + + it("should not use empty ado_mcp_project env var as default", async () => { + process.env.ado_mcp_project = ""; + (mockCoreApi.getProjects as jest.Mock).mockResolvedValue([{ id: "proj-1", name: "ProjectAlpha" }]); + + const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; + elicitMock.mockResolvedValue({ action: "accept", content: { project: "ProjectAlpha" } }); + + const result = await elicitProject(server, mockConnection as unknown as WebApi); + + expect(mockCoreApi.getProjects).toHaveBeenCalled(); + expect(elicitMock).toHaveBeenCalled(); + expect(result).toEqual({ resolved: "ProjectAlpha" }); + }); }); describe("elicitTeam", () => { + const originalTeamEnv = process.env.ado_mcp_team; + + afterEach(() => { + if (originalTeamEnv === undefined) { + delete process.env.ado_mcp_team; + } else { + process.env.ado_mcp_team = originalTeamEnv; + } + }); + it("should use default message when no message is provided", async () => { + delete process.env.ado_mcp_team; (mockCoreApi.getTeams as jest.Mock).mockResolvedValue([{ id: "team-1", name: "Team One" }]); const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; @@ -54,6 +103,7 @@ describe("elicitations", () => { }); it("should fall back to team id when name is missing", async () => { + delete process.env.ado_mcp_team; (mockCoreApi.getTeams as jest.Mock).mockResolvedValue([ { id: "team-1", name: undefined }, { id: undefined, name: undefined }, @@ -71,5 +121,32 @@ describe("elicitations", () => { expect(oneOf[1]).toEqual({ const: "", title: "Unknown team" }); expect(result).toEqual({ resolved: "team-1" }); }); + + it("should resolve to default team from ado_mcp_team env var without elicitation", async () => { + process.env.ado_mcp_team = "DefaultTeam"; + + const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; + + const result = await elicitTeam(server, mockConnection as unknown as WebApi, "ProjectAlpha"); + + expect(result).toEqual({ resolved: "DefaultTeam" }); + expect(mockConnection.getCoreApi).not.toHaveBeenCalled(); + expect(mockCoreApi.getTeams).not.toHaveBeenCalled(); + expect(elicitMock).not.toHaveBeenCalled(); + }); + + it("should not use empty ado_mcp_team env var as default", async () => { + process.env.ado_mcp_team = ""; + (mockCoreApi.getTeams as jest.Mock).mockResolvedValue([{ id: "team-1", name: "Team One" }]); + + const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; + elicitMock.mockResolvedValue({ action: "accept", content: { team: "Team One" } }); + + const result = await elicitTeam(server, mockConnection as unknown as WebApi, "ProjectAlpha"); + + expect(mockCoreApi.getTeams).toHaveBeenCalled(); + expect(elicitMock).toHaveBeenCalled(); + expect(result).toEqual({ resolved: "Team One" }); + }); }); }); From 9da6b7063b929d9b46c276fed620906fbbcb46c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 12:49:17 -0400 Subject: [PATCH 11/21] [dependencies]: Bump typescript-eslint from 8.59.1 to 8.59.2 (#1229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.59.1 to 8.59.2.
Release notes

Sourced from typescript-eslint's releases.

v8.59.2

8.59.2 (2026-05-04)

🩹 Fixes

  • eslint-plugin: [no-unsafe-type-assertion] handle crash on recursive template literal types (#12150)
  • eslint-plugin: [no-deprecated] object destructuring values should be treated as declarations (#12292)
  • rule-tester: add TypeScript as a peer dependency (#12288)

❤️ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Changelog

Sourced from typescript-eslint's changelog.

8.59.2 (2026-05-04)

This was a version bump only for typescript-eslint to align it with other projects, there were no code changes.

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=typescript-eslint&package-manager=npm_and_yarn&previous-version=8.59.1&new-version=8.59.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 122 +++++++++++++++++++++++----------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac1dff94..b1136e39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1908,17 +1908,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1931,7 +1931,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1947,16 +1947,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1972,14 +1972,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1994,14 +1994,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2012,9 +2012,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", "dev": true, "license": "MIT", "engines": { @@ -2029,15 +2029,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2054,9 +2054,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -2068,16 +2068,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2148,16 +2148,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2172,13 +2172,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8545,16 +8545,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", - "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1" + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" From feac5c9320ec465e6001e638d4b749c7e59c7047 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 10:59:26 -0400 Subject: [PATCH 12/21] [dependencies]: Bump ip-address and express-rate-limit (#1235) Bumps [ip-address](https://github.com/beaugunderson/ip-address) and [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit). These dependencies needed to be updated together. Updates `ip-address` from 10.1.0 to 10.2.0
Commits

Updates `express-rate-limit` from 8.3.0 to 8.5.1
Release notes

Sourced from express-rate-limit's releases.

v8.5.1

You can view the changelog here.

v8.5.0

You can view the changelog here.

v8.4.1

You can view the changelog here.

v8.4.0

You can view the changelog here.

v8.3.2

You can view the changelog here.

v8.3.1

You can view the changelog here.

Commits
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for express-rate-limit since your current version.


Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/azure-devops-mcp/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1136e39..bd2264d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4031,12 +4031,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", - "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -4741,9 +4741,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" From 1f896415d8ca0425baa662299fc1fb6dbfe3d13a Mon Sep 17 00:00:00 2001 From: simontherry Date: Thu, 7 May 2026 17:11:27 +0200 Subject: [PATCH 13/21] Add merge commit message option to repo_update_pull_request (#1217) ## Summary - Add an optional `mergeCommitMessage` parameter to `repo_update_pull_request`. - Pass the value through to `completionOptions.mergeCommitMessage` when autocomplete completion options are built. - Add Jest coverage for the autocomplete path. ## Why Azure DevOps supports customizing the final merge or squash commit message through pull request completion options, but the MCP tool did not expose that field. Fixes #1209 ## Validation - `npm test -- --runTestsByPath test/src/tools/repositories.test.ts` - `npm run validate-tools` Co-authored-by: Simon Therry Co-authored-by: Dan Hellem --- src/tools/repositories.ts | 6 +++ test/src/tools/repositories.test.ts | 57 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index 2f09387e..f0da7f5e 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -366,6 +366,7 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise Promise { expect(parsedResult.pullRequestId).toBe(123); }); + it("should set merge commit message when autocomplete is enabled", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + if (!call) throw new Error("repo_update_pull_request tool not registered"); + const [, , , handler] = call; + + const mockUpdatedPR = { + pullRequestId: 123, + title: "Updated PR", + autoCompleteSetBy: { id: "user-id" }, + completionOptions: { + mergeStrategy: 2, // Squash + deleteSourceBranch: true, + transitionWorkItems: false, + bypassPolicy: false, + mergeCommitMessage: "Merged PR 123: Update dependencies", + }, + }; + + mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); + mockGetCurrentUserDetails.mockResolvedValue({ + authenticatedUser: { id: "current-user-id" }, + authorizedUser: { id: "current-user-id" }, + }); + + const params = { + repositoryId: "test-repo-id", + pullRequestId: 123, + project: "test-project", + autoComplete: true, + mergeStrategy: "Squash", + mergeCommitMessage: "Merged PR 123: Update dependencies", + deleteSourceBranch: true, + transitionWorkItems: false, + }; + + const result = await handler(params); + + expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + autoCompleteSetBy: { id: "current-user-id" }, + completionOptions: expect.objectContaining({ + mergeStrategy: 2, // GitPullRequestMergeStrategy.Squash + mergeCommitMessage: "Merged PR 123: Update dependencies", + deleteSourceBranch: true, + transitionWorkItems: false, + bypassPolicy: false, + }), + }), + "test-repo-id", + 123, + "test-project" + ); + expect(result.isError).toBeFalsy(); + }); + it("should disable autocomplete when autoComplete is false", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); From b23a2b4088217940d7ccd7fabf8ffdc686132192 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 11:28:00 -0400 Subject: [PATCH 14/21] [dependencies]: Bump hono from 4.12.14 to 4.12.18 (#1236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [hono](https://github.com/honojs/hono) from 4.12.14 to 4.12.18.
Release notes

Sourced from hono's releases.

v4.12.18

Security fixes

This release includes fixes for the following security issues:

Cache Middleware ignores Vary: Authorization / Vary: Cookie leading to cross-user cache leakage

Affects: Cache Middleware. Fixes missing cache-skip handling for Vary: Authorization and Vary: Cookie, where a response cached for one authenticated user could be served to other users. GHSA-p77w-8qqv-26rm

CSS Declaration Injection via Style Object Values in JSX SSR

Affects: hono/jsx. Fixes a missing CSS-context escape for style object values and property names, where untrusted input could inject additional CSS declarations. The impact is limited to CSS and does not allow JavaScript execution. GHSA-qp7p-654g-cw7p

Improper validation of NumericDate claims (exp, nbf, iat) in JWT verify()

Affects: hono/utils/jwt. Fixes improper validation of exp, nbf, and iat claims, where falsy, non-finite, or non-numeric values could silently bypass time-based checks instead of being rejected per RFC 7519. GHSA-hm8q-7f3q-5f36


Users who use the JWT helper, hono/jsx, or the Cache middleware are strongly encouraged to upgrade to this version.

v4.12.17

What's Changed

New Contributors

Full Changelog: https://github.com/honojs/hono/compare/v4.12.16...v4.12.17

v4.12.16

Security fixes

This release includes fixes for the following security issues:

Unvalidated JSX Tag Names in hono/jsx May Allow HTML Injection

Affects: hono/jsx. Fixes missing validation of JSX tag names when using jsx() or createElement(), which could allow HTML injection if untrusted input is used as the tag name. GHSA-69xw-7hcm-h432

bodyLimit() can be bypassed for chunked / unknown-length requests

Affects: Body Limit Middleware. Fixes late enforcement for request bodies without a reliable Content-Length (e.g. chunked requests), where oversized requests could reach handlers and return successful responses before being rejected. GHSA-9vqf-7f2p-gf9v

v4.12.15

What's Changed

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=hono&package-manager=npm_and_yarn&previous-version=4.12.14&new-version=4.12.18)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/azure-devops-mcp/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dan Hellem --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bd2264d4..ec9f76d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4550,9 +4550,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" From d4895f4a17ca78095b280f19795d5313c4092b33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 08:29:18 -0400 Subject: [PATCH 15/21] [dependencies]: Bump fast-uri from 3.1.0 to 3.1.2 (#1240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
Release notes

Sourced from fast-uri's releases.

v3.1.2

⚠️ Security Release

What's Changed

Full Changelog: https://github.com/fastify/fast-uri/compare/v3.1.1...v3.1.2

v3.1.1

⚠️ Security Release

What's Changed

New Contributors

Full Changelog: https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.1

Commits
  • 919dd8e Bumped v3.1.2
  • c65ba57 fixup: linting
  • 6c86c17 Merge commit from fork
  • a95158a Handle malformed fragment decoding without throwing (#171)
  • cea547c Bumped v3.1.1
  • 876ce79 Merge commit from fork
  • dcdf690 ci: add lock-threads workflow (#169)
  • c860e65 build(deps-dev): bump neostandard from 0.12.2 to 0.13.0 (#167)
  • 9b4c6dc build(deps): bump fastify/workflows/.github/workflows/plugins-ci.yml (#166)
  • 85d09a9 build(deps): bump fastify/workflows/.github/workflows/plugins-ci-package-mana...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fast-uri&package-manager=npm_and_yarn&previous-version=3.1.0&new-version=3.1.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/microsoft/azure-devops-mcp/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec9f76d1..dc21db81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4100,9 +4100,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", From 72d0b9b2065b4a1b705456c61aa4962caed8c17f Mon Sep 17 00:00:00 2001 From: Dan Hellem Date: Mon, 11 May 2026 15:17:15 -0400 Subject: [PATCH 16/21] Update README.md for clarity and improved onboarding instructions (#1247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request updates the `README.md` to emphasize the Remote MCP Server as the recommended onboarding path and clarifies the distinction between remote and local server usage. It improves onboarding instructions, adds a quick-start example for remote configuration, and updates documentation links for clarity. ## GitHub issue number N/A ## **Associated Risks** None ## 🧪 **How did you test it?** N/A --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e78a1dcb..bf118067 100644 --- a/README.md +++ b/README.md @@ -3,19 +3,19 @@ > [!IMPORTANT] > The Azure DevOps Remote MCP Server is now available in public preview for all organizations. We recommend migrating to the [Remote MCP Server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server) going forward. > -> [Learn more](#-remote-mcp-server) +> [Learn more](#-remote-mcp-server-recommended) -This TypeScript project provides a **local** MCP server for Azure DevOps, enabling you to perform a wide range of Azure DevOps tasks directly from your code editor. +This project provides Azure DevOps MCP tooling for AI agents, with a **remote-first** onboarding experience and a local server option when you need it. ## 📄 Table of Contents 1. [📺 Overview](#-overview) 2. [🏆 Expectations](#-expectations) -3. [🚀 Remote MCP Server](#-remote-mcp-server) +3. [🚀 Remote MCP Server (Recommended)](#-remote-mcp-server-recommended) 4. [⚙️ Supported Tools](#️-supported-tools) -5. [🔌 Installation & Getting Started](#-installation--getting-started) -6. [🌏 Using Domains](#-using-domains) -7. [🐥 Project and Team Defaults](#-project-and-team-defaults) +5. [🔌 Local MCP Server Installation (Optional)](#-local-mcp-server-installation-optional) +6. [🌏 Using Domains (local)](#-using-domains-local) +7. [🐥 Project and Team Defaults (local)](#-project-and-team-defaults-local) 8. [📝 Troubleshooting](#-troubleshooting) 9. [🎩 Examples & Best Practices](#-examples--best-practices) 10. [🙋‍♀️ Frequently Asked Questions](#️-frequently-asked-questions) @@ -40,9 +40,9 @@ The Azure DevOps MCP Server brings Azure DevOps context to your agents. Try prom ## 🏆 Expectations -The Azure DevOps MCP Server is built from tools that are concise, simple, focused, and easy to use—each designed for a specific scenario. We intentionally avoid complex tools that try to do too much. The goal is to provide a thin abstraction layer over the REST APIs, making data access straightforward and letting the language model handle complex reasoning. +The Azure DevOps MCP Server is built around tools that are concise, simple, focused, and easy to use, with each one designed for a specific scenario. We intentionally avoid creating complex tools that try to do too much. The goal is to provide a thin abstraction layer over the REST APIs that makes data access straightforward while allowing the language model to handle the more complex reasoning. -## 🚀 Remote MCP Server +## 🚀 Remote MCP Server (Recommended) The Azure DevOps **Remote MCP Server** is now available in [public preview](https://devblogs.microsoft.com/devops/azure-devops-remote-mcp-server-public-preview). @@ -55,13 +55,40 @@ If you encounter issues with tools, need support, or have a feature request, you > [!WARNING] > Internal Microsoft users of the Remote MCP Server should **not** create issues in this repository. Please use the dedicated Teams channel instead. -For instructions on how to get started with the Remote MCP Server, see the [onboarding documentation](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server). +For complete instructions, see the [Remote MCP Server onboarding documentation](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops). + +### Quick start with `.vscode/mcp.json` + +Use this configuration to connect directly to the Azure DevOps-hosted endpoint using streamable HTTP transport: + +```json +{ + "servers": { + "ado-remote-mcp": { + "url": "https://mcp.dev.azure.com/{organization}", + "type": "http" + } + }, + "inputs": [] +} +``` + +See [documentation](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#mcpjson-configuration) for additional configuration options. + +After saving `.vscode/mcp.json`, start the server from the MCP view in VS Code, then run a prompt like `List ADO projects`. ## ⚙️ Supported Tools -See [TOOLSET.md](./docs/TOOLSET.md) for a comprehensive list. +See the [Available Tools](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#available-tools) documentation for the complete list of available remote tools. + +For a comprehensive list of local tools, see [TOOLSET.md](./docs/TOOLSET.md). + +## 🔌 Local MCP Server Installation (Optional) + +> [!IMPORTANT] +> Start with the Remote MCP Server first. Use the local MCP Server only if your scenario specifically requires a local `stdio` setup. -## 🔌 Installation & Getting Started +Use this section if you specifically need the local `stdio` server experience. For most users, start with the [Remote MCP Server](#-remote-mcp-server-recommended) section above. For the best experience, use Visual Studio Code and GitHub Copilot. See the [getting started documentation](./docs/GETTINGSTARTED.md) to use our MCP Server with other tools such as Visual Studio 2022, Claude Code, Cursor, Opencode, and Kilocode. @@ -73,7 +100,7 @@ For the best experience, use Visual Studio Code and GitHub Copilot. See the [get ### Installation -#### 🧨 Install from Public Feed (Recommended) +#### 🧨 Install from Public Feed This installation method is the easiest for all users of Visual Studio Code. @@ -140,7 +167,7 @@ Open GitHub Copilot Chat and try a prompt like `List ADO projects`. The first ti See the [getting started documentation](./docs/GETTINGSTARTED.md) to use our MCP Server with other tools such as Visual Studio 2022, Claude Code, and Cursor. -## 🌏 Using Domains +## 🌏 Using Domains (local) Azure DevOps exposes a large surface area. As a result, our Azure DevOps MCP Server includes many tools. To keep the toolset manageable, avoid confusing the model, and respect client limits on loaded tools, use Domains to load only the areas you need. Domains are named groups of related tools (for example: core, work, work-items, repositories, wiki). Add the `-d` argument and the domain names to the server args in your `mcp.json` to list the domains to enable. @@ -171,7 +198,7 @@ We recommend that you always enable `core` tools so that you can fetch project l > By default all domains are loaded -## 🐥 Project and Team Defaults +## 🐥 Project and Team Defaults (local) You can also configure default Azure DevOps project and team values from `.vscode/mcp.json` using `project` and `team`, so tools can skip selection prompts. From f85662c6cd68ee451de5d9d805817190c7c5830e Mon Sep 17 00:00:00 2001 From: Dan Hellem Date: Mon, 11 May 2026 16:33:47 -0400 Subject: [PATCH 17/21] =?UTF-8?q?Enhance=20error=20handling=20in=20configu?= =?UTF-8?q?reWikiTools=20for=20wiki=20page=20content=20re=E2=80=A6=20(#123?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request improves the robustness and test coverage of the wiki tools, especially around error handling and edge cases in wiki URL parsing and content fetching. The most important changes include clarifying assumptions in the main code, expanding test coverage for various edge cases, and improving error messaging. ## GitHub issue number N/A ## **Associated Risks** None ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Improvements to test coverage for wiki --- src/tools/wiki.ts | 9 ++- test/src/tools/wiki.test.ts | 144 +++++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/src/tools/wiki.ts b/src/tools/wiki.ts index 5c7b9214..852bd053 100644 --- a/src/tools/wiki.ts +++ b/src/tools/wiki.ts @@ -258,10 +258,11 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise { expect(result.content[0].text).toContain("Error fetching wiki page metadata: Network error"); }); + it("should handle non-Error throwables in metadata catch block", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); + if (!call) throw new Error("wiki_get_page tool not registered"); + const [, , , handler] = call; + + mockFetch.mockImplementation(() => { + throw "string thrown, not an Error"; + }); + + const params = { + wikiIdentifier: "wiki1", + project: "proj1", + path: "/Home", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error fetching wiki page metadata: Unknown error occurred"); + }); + it("should encode project and wikiIdentifier in URL to prevent path injection", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); @@ -854,6 +876,81 @@ describe("configureWikiTools", () => { expect(result.content[0].text).toContain("UNTRUSTED"); }); + it("should fall back to getPageText when pageId fetch returns non-404 error status", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + (tokenProvider as jest.Mock).mockResolvedValueOnce("abc"); + + const mockFetch = jest.fn(); + global.fetch = mockFetch as typeof fetch; + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + + const mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("fallback after server error")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/123/Page-Title"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(result.content[0].text).toContain("fallback after server error"); + }); + + it("should normalize pagePath query parameter when it lacks a leading slash", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + const mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("normalized path content")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki?pagePath=Home"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/Home", undefined, undefined, true); + expect(result.content[0].text).toContain("normalized path content"); + }); + + it("should default to root path when URL has no segments after wikiIdentifier and no pagePath", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + const mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("bare-wiki root content")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(result.content[0].text).toContain("bare-wiki root content"); + }); + it("should use default root path when resolvedPath is undefined", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); @@ -878,7 +975,7 @@ describe("configureWikiTools", () => { expect(result.isError).toBeUndefined(); }); - it("should handle scenario where resolvedProject/Wiki become null after URL processing", async () => { + it("should return URL parse error for malformed wiki URL with empty project and wiki segments", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); @@ -900,6 +997,51 @@ describe("configureWikiTools", () => { expect(result.content[0].text).toContain("URL does not match expected wiki pattern"); }); + it("should return parse error when wiki URL is missing the wikiIdentifier segment", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + const url = "https://dev.azure.com/proj/_wiki/wikis/"; + const result = await handler({ url }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Could not extract project or wikiIdentifier from URL"); + }); + + it("should fall back to getPageText when REST page-by-id returns null JSON body", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + (tokenProvider as jest.Mock).mockResolvedValueOnce("abc"); + + const mockFetch = jest.fn(); + global.fetch = mockFetch as typeof fetch; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(null), + }); + + const mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("fallback root content")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/123/Page-Title"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(result.content[0].text).toContain("fallback root content"); + }); + it("should encode project and wikiIdentifier in REST URL to prevent path injection", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); From 1d8dda897de571d4174270fe159fb223abadd97f Mon Sep 17 00:00:00 2001 From: Dan Hellem Date: Mon, 11 May 2026 16:34:45 -0400 Subject: [PATCH 18/21] Enhance error handling for pull request creation and update tests (#1246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request improves error handling and messaging for pull request creation failures in the repository tools. When the API does not return data and no matching pull request is found, the user is now given a more informative error message, and the response is explicitly marked as an error. Corresponding tests have been updated to validate these changes. ## GitHub issue number #1245 ## **Associated Risks** None ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Manual tested and updated and ran automated tests --- src/tools/repositories.ts | 9 +++++++-- test/src/tools/repositories.test.ts | 7 ++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index f0da7f5e..bed5f2af 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -184,6 +184,9 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise ({ id: id.trim() })) : []; + const noDataErrorMessage = + `Pull request creation returned no data and no matching PR was found. This often means repositoryId=\"${repositoryId}\" was not resolvable. ` + + "Try the repository GUID from repo_list_repos_by_project instead of the Project/RepoName slash format."; const forkSource: GitForkRef | undefined = forkSourceRepositoryId ? { @@ -217,7 +220,8 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise { expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); - it("should return no-data message when createPullRequest returns null and fallback finds no PRs", async () => { + it("should return error when createPullRequest returns null and fallback finds no PRs", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); @@ -1203,8 +1203,9 @@ describe("repos tools", () => { const result = await handler(params); - expect(result.content[0].text).toBe("Pull request created but API returned no data."); - expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('repositoryId="repo123"'); + expect(result.content[0].text).toContain("repo_list_repos_by_project"); + expect(result.isError).toBe(true); }); }); From fc4576274ee46988ff4f6a7ce64fbf1612d5d35d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 21:47:03 -0400 Subject: [PATCH 19/21] [dependencies]: Bump lint-staged from 16.4.0 to 17.0.0 (#1230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [lint-staged](https://github.com/lint-staged/lint-staged) from 16.4.0 to 17.0.0.
Release notes

Sourced from lint-staged's releases.

v17.0.0

Major Changes

  • #1745 e244adf Thanks @​iiroj! - Node.js v20 is no longer supported, and the oldest supported version is now 22.22.1, which is an active LTS version at the time of this release. Node.js 20 will be EOL after April 2026. Please upgrade your Node.js version!

  • #1676 0584e0b Thanks @​outslept! - Lint-staged now tries to verify the installed Git version is at least 2.32.0, released in 2021. If you're using an even older Git version, you need to upgrade it before running lint-staged!

  • #1745 2dcc40a Thanks @​iiroj! - The dependency yaml is now marked as optional and probably won't be installed by default. If you're using a YAML configuration file you should install the package separately:

    npm install --development yaml
    

    If you're using .lintstagedrc as the config file name (without a file extension), it will be treated as a YAML file. If the content is JSON, consider renaming it to .lintstagedrc.json to avoid needing to install yaml.

Minor Changes

  • #1748 809d5ef Thanks @​iiroj! - Add new option --hide-all for hiding all unstaged changes and untracked files, before running tasks. This makes it easier to run tools like Knip which check for unused code. Untracked files are included in the backup stash and restored automatically after running.

  • #1759 f13045a Thanks @​iiroj! - Update dependencies, including tinyexec@1.1.1 to fix the following issues:

    • When using a Node.js version manager with multiple versions installed (nvm, n, for example), scripts with the #!/usr/bin/env node shebang (Prettier, ESLint, for example) were previously spawned using the default Node.js version configured by the version manager (the one which node points to) on POSIX systems. Now, they will be spawned with the same version that lint-staged itself was started with.
      • For example, if your default Node.js version is 24.14.1 but lint-staged is run with the latest version 25.9.0, the tasks spawned by lint-staged will now also use version 25.9.0. Previously they were spawned using 24.14.1.
    • When installing Node.js from the Ubuntu App Center (Snap store), the node executable available in PATH is a symlink pointing to Snap itself. The sandboxing features of Snap prevented lint-staged from spawning scripts with the #!/usr/bin/env node shebang, because it meant lint-staged tried to spawn Snap via the symlink. This resulted in an ENOENT error when trying to run prettier, for example. Now, since the real node executable's directory is available in the PATH, lint-staged will instead spawn the script with the real node binary succesfully.
  • #1761 d3251b1 Thanks @​iiroj! - Lint-staged now runs git update-index --again after running tasks, instead of git add <originally staged files>. This should improve compatibility when using non-default indexes, for example when committing with a pathspec git commit -m "message" . instead of adding files to the index.

  • #1745 a9585ac Thanks @​iiroj! - Remove commander as a dependency and use the built-in parseArgs from node:util to parse CLI flags.

Patch Changes

  • #1755 c82d30b Thanks @​iiroj! - All tests now pass on the Bun runtime (latest).

  • #1750 a401818 Thanks @​iiroj! - Remove manual handling for git stash --keep-index resurrecting deleted files, because the issue was fixed in Git 2.23.0 and lint-staged requires at least Git 2.32.0.

  • #1771 c4b8936 Thanks @​iiroj! - Fix documentation about multiple config files and the --cwd option. When using it, all tasks will be run in the specified directory. For example, to run everything in the actual process.cwd(), use lint-staged --cwd=".".

Changelog

Sourced from lint-staged's changelog.

17.0.0

Major Changes

  • #1745 e244adf Thanks @​iiroj! - Node.js v20 is no longer supported, and the oldest supported version is now 22.22.1, which is an active LTS version at the time of this release. Node.js 20 will be EOL after April 2026. Please upgrade your Node.js version!

  • #1676 0584e0b Thanks @​outslept! - Lint-staged now tries to verify the installed Git version is at least 2.32.0, released in 2021. If you're using an even older Git version, you need to upgrade it before running lint-staged!

  • #1745 2dcc40a Thanks @​iiroj! - The dependency yaml is now marked as optional and probably won't be installed by default. If you're using a YAML configuration file you should install the package separately:

    npm install --development yaml
    

    If you're using .lintstagedrc as the config file name (without a file extension), it will be treated as a YAML file. If the content is JSON, consider renaming it to .lintstagedrc.json to avoid needing to install yaml.

Minor Changes

  • #1748 809d5ef Thanks @​iiroj! - Add new option --hide-all for hiding all unstaged changes and untracked files, before running tasks. This makes it easier to run tools like Knip which check for unused code. Untracked files are included in the backup stash and restored automatically after running.

  • #1759 f13045a Thanks @​iiroj! - Update dependencies, including tinyexec@1.1.1 to fix the following issues:

    • When using a Node.js version manager with multiple versions installed (nvm, n, for example), scripts with the #!/usr/bin/env node shebang (Prettier, ESLint, for example) were previously spawned using the default Node.js version configured by the version manager (the one which node points to) on POSIX systems. Now, they will be spawned with the same version that lint-staged itself was started with.
      • For example, if your default Node.js version is 24.14.1 but lint-staged is run with the latest version 25.9.0, the tasks spawned by lint-staged will now also use version 25.9.0. Previously they were spawned using 24.14.1.
    • When installing Node.js from the Ubuntu App Center (Snap store), the node executable available in PATH is a symlink pointing to Snap itself. The sandboxing features of Snap prevented lint-staged from spawning scripts with the #!/usr/bin/env node shebang, because it meant lint-staged tried to spawn Snap via the symlink. This resulted in an ENOENT error when trying to run prettier, for example. Now, since the real node executable's directory is available in the PATH, lint-staged will instead spawn the script with the real node binary succesfully.
  • #1761 d3251b1 Thanks @​iiroj! - Lint-staged now runs git update-index --again after running tasks, instead of git add <originally staged files>. This should improve compatibility when using non-default indexes, for example when committing with a pathspec git commit -m "message" . instead of adding files to the index.

  • #1745 a9585ac Thanks @​iiroj! - Remove commander as a dependency and use the built-in parseArgs from node:util to parse CLI flags.

Patch Changes

  • #1755 c82d30b Thanks @​iiroj! - All tests now pass on the Bun runtime (latest).

  • #1750 a401818 Thanks @​iiroj! - Remove manual handling for git stash --keep-index resurrecting deleted files, because the issue was fixed in Git 2.23.0 and lint-staged requires at least Git 2.32.0.

  • #1771 c4b8936 Thanks @​iiroj! - Fix documentation about multiple config files and the --cwd option. When using it, all tasks will be run in the specified directory. For example, to run everything in the actual process.cwd(), use lint-staged --cwd=".".

Commits
  • 5e06d60 Merge pull request #1747 from lint-staged/changeset-release/main
  • adb794d chore(changeset): release
  • e72151d Merge pull request #1775 from lint-staged/updates
  • ac054f0 test: run tests on Node.js 26, drop 25
  • 6da0fcd build(deps): update dependencies
  • 1b46346 Merge pull request #1774 from lint-staged/changesets-commit-message
  • 6be5aa6 ci: fix Changeset commit message
  • 28f7f4f Merge pull request #1773 from lint-staged/signed-changesets-user
  • a2e6856 ci: configure GitHub Bot user for changesets
  • 0db4e08 Merge pull request #1772 from lint-staged/signed-changesets
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=lint-staged&package-manager=npm_and_yarn&previous-version=16.4.0&new-version=17.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dan Hellem --- package-lock.json | 167 ++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 81 insertions(+), 88 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc21db81..f9af048c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "husky": "^9.1.7", "jest": "^30.0.2", "jest-extended": "^7.0.0", - "lint-staged": "^16.2.7", + "lint-staged": "^17.0.0", "prettier": "3.8.3", "shx": "^0.4.0", "ts-jest": "^29.4.6", @@ -3110,14 +3110,14 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -3127,14 +3127,14 @@ } }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -3302,23 +3302,6 @@ "node": ">=12.20" } }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3901,9 +3884,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true, "license": "MIT" }, @@ -4318,9 +4301,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -6120,27 +6103,28 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", - "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.0.tgz", + "integrity": "sha512-286BsrsEp/7taRKY839AAU8E78uModiwmU7/cbuHbGfNA4mQVtLXA2Au9pLgWI2v4/M+PHq+s/rH+LJlVKucwA==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.3", - "listr2": "^9.0.5", - "picomatch": "^4.0.3", + "listr2": "^10.2.1", + "picomatch": "^4.0.4", "string-argv": "^0.3.2", - "tinyexec": "^1.0.4", - "yaml": "^2.8.2" + "tinyexec": "^1.1.2" }, "bin": { "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": ">=20.17" + "node": ">=22.22.1" }, "funding": { "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.8.4" } }, "node_modules/lint-staged/node_modules/picomatch": { @@ -6157,21 +6141,20 @@ } }, "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "wrap-ansi": "^10.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.13.0" } }, "node_modules/listr2/node_modules/ansi-styles": { @@ -6187,44 +6170,36 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -6325,9 +6300,9 @@ } }, "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { @@ -6360,6 +6335,23 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -7837,17 +7829,17 @@ } }, "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" @@ -8236,9 +8228,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -8953,11 +8945,12 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "dev": true, "license": "ISC", + "optional": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 66cc3377..a8e9404a 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "husky": "^9.1.7", "jest": "^30.0.2", "jest-extended": "^7.0.0", - "lint-staged": "^16.2.7", + "lint-staged": "^17.0.0", "prettier": "3.8.3", "shx": "^0.4.0", "ts-jest": "^29.4.6", From e472c84ca700d9052e0e79c93dffcf3cb251d972 Mon Sep 17 00:00:00 2001 From: Nikola Pejic Date: Mon, 18 May 2026 14:29:12 +0200 Subject: [PATCH 20/21] Delete .github/workflows/ai-issue-processing.yml (#1258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not really used, and also could be problematic. ## GitHub issue number ## **Associated Risks** _Replace_ by possible risks this pull request can bring you might have thought of ## ✅ **PR Checklist** - [ ] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [ ] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [ ] Title of the pull request is clear and informative. - [ ] 👌 Code hygiene - [ ] 🔭 Telemetry added, updated, or N/A - [ ] 📄 Documentation added, updated, or N/A - [ ] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** _Replace_ with use cases tested and models used --- .github/workflows/ai-issue-processing.yml | 54 ----------------------- 1 file changed, 54 deletions(-) delete mode 100644 .github/workflows/ai-issue-processing.yml diff --git a/.github/workflows/ai-issue-processing.yml b/.github/workflows/ai-issue-processing.yml deleted file mode 100644 index e4785ca9..00000000 --- a/.github/workflows/ai-issue-processing.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: AI Issue Processing - -on: - issues: - types: [labeled] - -jobs: - issue-processing: - if: github.event.label.name == 'ai-issue-processing' - runs-on: ubuntu-latest - permissions: - contents: read - models: read - issues: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Add Needs Review Label - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - gh issue edit $ISSUE_NUMBER --add-label "Needs Review 👓" --repo ${{ github.repository }} - - - name: Call GitHub Model API - id: ai-inference - uses: actions/ai-inference@v1 - with: - model: gpt-4o-mini - system-prompt-file: .github/ai-automation/ai-issue-processing-system-prompt.md - prompt: | - Issue Title: ${{ github.event.issue.title }} - - Issue Description: - ${{ github.event.issue.body }} - - - name: Parse and Apply Labels - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - AI_RESPONSE: ${{ steps.ai-inference.outputs.response }} - run: | - echo "AI Response: $AI_RESPONSE" - - # Parse the JSON response to extract labels - LABELS=$(echo "$AI_RESPONSE" | python3 -c "import sys, json; print(' '.join([f'--add-label \"{label}\"' for label in json.load(sys.stdin)['labels']]))") - - # Add the recommended labels and remove the ai-issue-processing label - if [ -n "$LABELS" ]; then - eval gh issue edit $ISSUE_NUMBER $LABELS --remove-label "ai-issue-processing" --repo ${{ github.repository }} - else - gh issue edit $ISSUE_NUMBER --remove-label "ai-issue-processing" --repo ${{ github.repository }} - fi From bb008b1cb20548cad746a9088624823f3880f881 Mon Sep 17 00:00:00 2001 From: krid-583 Date: Tue, 19 May 2026 17:07:44 +0530 Subject: [PATCH 21/21] Fixing repo_get_pull_request_changes to be able to get the file changes for PR's with only added and only deleted files (#1259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `repo_get_pull_request_changes` doesn't get the file changes when having only added files or only deleted files in the PR even when the includeDiffs and includeLineContent is set to true. ## GitHub issue number 1237 ## **Associated Risks** None ## ✅ **PR Checklist** - [X] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [X] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [X] Title of the pull request is clear and informative. - [X] 👌 Code hygiene - [ ] 🔭 Telemetry added, updated, or N/A - [ ] 📄 Documentation added, updated, or N/A - [X] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** - Added 2 unit tests concerning both the scenarios where files are only added and only deleted - Added 6 other unit tests to ensure line coverage is satisfied. - Performed manual testing on VS Code Copilot Only deleted scenario mentioned in the issue: image Only added scenario mentioned in the issue: image --------- Co-authored-by: Krishna Prasath D Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/tools/repositories.ts | 415 ++++++++++++++-------------- test/src/tools/repositories.test.ts | 384 +++++++++++++++++++++++++ 2 files changed, 597 insertions(+), 202 deletions(-) diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index bed5f2af..05501ed9 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -1206,11 +1206,14 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise 0) { - try { + try { + // Fetch diffs for modified files. Add/Delete files are excluded from getFileDiffs + // because they don't have two versions to compare; their content is fetched + // separately below via getItemText when includeLineContent is true. + let fileDiffs: any[] = []; + if (fileDiffParams.length > 0) { // Azure DevOps getFileDiffs API accepts max 10 files per request const FILE_DIFF_BATCH_SIZE = 10; - let fileDiffs: any[] = []; for (let i = 0; i < fileDiffParams.length; i += FILE_DIFF_BATCH_SIZE) { const batch = fileDiffParams.slice(i, i + FILE_DIFF_BATCH_SIZE); const batchDiffs = await gitApi.getFileDiffs( @@ -1224,226 +1227,234 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise { - // Normalize path for comparison (remove leading slash) - const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path; - const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath); - return { - ...entry, - diff: matchingDiff || null, - }; - }), - }; - - // If includeLineContent is true, fetch actual file content with concurrency limit - if (includeLineContent && enrichedChanges.changeEntries) { - const CONCURRENCY_LIMIT = 10; - const entriesWithContent = [...enrichedChanges.changeEntries]; - for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) { - const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT); - const batchResults = await Promise.all( - batch.map(async (entry) => { - const ct = entry.changeType ?? 0; - const isAdd = !!(ct & VersionControlChangeType.Add); - const isDelete = !!(ct & VersionControlChangeType.Delete); - - const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path; - - if (!entryPath) { - return entry; - } + // Merge diff content with change metadata. + // Added/deleted entries get diff: null here and are enriched below. + const enrichedChanges = { + ...changes, + changeEntries: changes.changeEntries.map((entry) => { + // Normalize path for comparison (remove leading slash) + const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path; + const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath); + return { + ...entry, + diff: matchingDiff || null, + }; + }), + }; - // Handle added files: fetch full content at target commit and create synthetic diff - if (isAdd && !entry.diff) { - try { - const targetStream = await gitApi - .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) - .catch(() => null); - if (targetStream) { - const targetText = await streamToString(targetStream); - const targetLines = targetText.split(/\r?\n/); - return { - ...entry, - diff: { - path: entryPath, - originalPath: entryPath, - lineDiffBlocks: [ - { - changeType: 1, // Add - originalLineNumberStart: 0, - originalLinesCount: 0, - modifiedLineNumberStart: 1, - modifiedLinesCount: targetLines.length, - modifiedLines: targetLines, - }, - ], - }, - }; - } - } catch (addError) { + // If includeLineContent is true, fetch actual file content with concurrency limit + if (includeLineContent && enrichedChanges.changeEntries) { + const CONCURRENCY_LIMIT = 10; + const entriesWithContent = [...enrichedChanges.changeEntries]; + for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) { + const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT); + const batchResults = await Promise.all( + batch.map(async (entry) => { + const ct = entry.changeType ?? 0; + const isAdd = !!(ct & VersionControlChangeType.Add); + const isDelete = !!(ct & VersionControlChangeType.Delete); + + const entryPath = entry.item?.path ? (entry.item.path.startsWith("/") ? entry.item.path.substring(1) : entry.item.path) : undefined; + // For deleted files ADO sets item.path to null and puts the path in originalPath only. + // Normalise originalPath once and use it as the fallback throughout. + const normalizedOriginalPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : undefined; + // effectivePath is what we use as the "current" path for API calls / early-exit guard. + // For additions/modifications it's item.path; for deletions it's originalPath. + const effectivePath = entryPath ?? normalizedOriginalPath; + + if (!effectivePath) { + return entry; + } + + // Handle added files: fetch full content at target commit and create synthetic diff + if (isAdd && !entry.diff) { + try { + const targetStream = await gitApi + .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) + .catch(() => null); + if (targetStream) { + const targetText = await streamToString(targetStream); + const targetLines = targetText.split(/\r?\n/); return { ...entry, - _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`, + diff: { + path: effectivePath, + originalPath: null, + lineDiffBlocks: [ + { + changeType: 1, // Add + originalLineNumberStart: 0, + originalLinesCount: 0, + modifiedLineNumberStart: 1, + modifiedLinesCount: targetLines.length, + modifiedLines: targetLines, + }, + ], + }, }; } - return entry; + } catch (addError) { + return { + ...entry, + _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`, + }; } + return entry; + } - // Handle deleted files: fetch full content at base commit and create synthetic diff - if (isDelete && !entry.diff) { - try { - const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath; - const baseStream = await gitApi - .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) - .catch(() => null); - if (baseStream) { - const baseText = await streamToString(baseStream); - const baseLines = baseText.split(/\r?\n/); - return { - ...entry, - diff: { - path: entryPath, - originalPath: basePath, - lineDiffBlocks: [ - { - changeType: 2, // Delete - originalLineNumberStart: 1, - originalLinesCount: baseLines.length, - modifiedLineNumberStart: 0, - modifiedLinesCount: 0, - originalLines: baseLines, - }, - ], - }, - }; - } - } catch (delError) { + // Handle deleted files: fetch full content at base commit and create synthetic diff. + // basePath prefers originalPath (the pre-deletion path); falls back to effectivePath. + if (isDelete && !entry.diff) { + try { + const basePath = normalizedOriginalPath ?? effectivePath; + const baseStream = await gitApi + .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) + .catch(() => null); + if (baseStream) { + const baseText = await streamToString(baseStream); + const baseLines = baseText.split(/\r?\n/); return { ...entry, - _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`, + diff: { + path: null, + originalPath: basePath, + lineDiffBlocks: [ + { + changeType: 2, // Delete + originalLineNumberStart: 1, + originalLinesCount: baseLines.length, + modifiedLineNumberStart: 0, + modifiedLinesCount: 0, + originalLines: baseLines, + }, + ], + }, }; } - return entry; - } - - // For modified/renamed files, skip if no diff blocks - if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) { - return entry; + } catch (delError) { + return { + ...entry, + _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`, + }; } - - // For renamed/moved files, the base version is at the original path - const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath; - - try { - // Fetch file content at both commits - const [baseContent, targetContent] = await Promise.all([ - // Base version (original) - use basePath for renamed files - gitApi - .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) - .catch(() => null), - // Target version (modified) - gitApi - .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) - .catch(() => null), - ]); - - // Convert streams to text - const baseText = baseContent ? await streamToString(baseContent) : ""; - const targetText = targetContent ? await streamToString(targetContent) : ""; - - // Check if response is an Azure DevOps error (returned as JSON in the stream) - const checkForApiError = (text: string, label: string) => { - if (text.startsWith("{")) { - try { - const parsed = JSON.parse(text); - if (parsed.$id && parsed.innerException !== undefined) { - throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Failed to fetch")) throw e; - // Not valid JSON or not an error response — treat as legitimate content + return entry; + } + + // For modified/renamed files, skip if no diff blocks + if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) { + return entry; + } + + // For renamed/moved files, the base version is at the original path + const basePath = normalizedOriginalPath ?? effectivePath; + + try { + // Fetch file content at both commits + const [baseContent, targetContent] = await Promise.all([ + // Base version (original) - use basePath for renamed files + gitApi + .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) + .catch(() => null), + // Target version (modified) + gitApi + .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) + .catch(() => null), + ]); + + // Convert streams to text + const baseText = baseContent ? await streamToString(baseContent) : ""; + const targetText = targetContent ? await streamToString(targetContent) : ""; + + // Check if response is an Azure DevOps error (returned as JSON in the stream) + const checkForApiError = (text: string, label: string) => { + if (text.startsWith("{")) { + try { + const parsed = JSON.parse(text); + if (parsed.$id && parsed.innerException !== undefined) { + throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`); } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Failed to fetch")) throw e; + // Not valid JSON or not an error response — treat as legitimate content + } + } + }; + checkForApiError(baseText, "base"); + checkForApiError(targetText, "target"); + + // Split into lines + const baseLines = baseText.split(/\r?\n/); + const targetLines = targetText.split(/\r?\n/); + + // Enrich each lineDiffBlock with actual line content + const enrichedDiff = { + ...entry.diff, + lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block: any) => { + const enrichedBlock: any = { ...block }; + + // Add original (base) lines if they exist + if (block.originalLineNumberStart && block.originalLinesCount) { + const startIdx = block.originalLineNumberStart - 1; + const endIdx = startIdx + block.originalLinesCount; + enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx); } - }; - checkForApiError(baseText, "base"); - checkForApiError(targetText, "target"); - - // Split into lines - const baseLines = baseText.split(/\r?\n/); - const targetLines = targetText.split(/\r?\n/); - - // Enrich each lineDiffBlock with actual line content - const enrichedDiff = { - ...entry.diff, - lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block: any) => { - const enrichedBlock: any = { ...block }; - - // Add original (base) lines if they exist - if (block.originalLineNumberStart && block.originalLinesCount) { - const startIdx = block.originalLineNumberStart - 1; - const endIdx = startIdx + block.originalLinesCount; - enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx); - } - - // Add modified (target) lines if they exist - if (block.modifiedLineNumberStart && block.modifiedLinesCount) { - const startIdx = block.modifiedLineNumberStart - 1; - const endIdx = startIdx + block.modifiedLinesCount; - enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx); - } - return enrichedBlock; - }), - }; + // Add modified (target) lines if they exist + if (block.modifiedLineNumberStart && block.modifiedLinesCount) { + const startIdx = block.modifiedLineNumberStart - 1; + const endIdx = startIdx + block.modifiedLinesCount; + enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx); + } - return { - ...entry, - diff: enrichedDiff, - }; - } catch (contentError) { - // If content fetch fails, return entry with error - return { - ...entry, - _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`, - }; - } - }) - ); - // Write batch results back into the array - for (let j = 0; j < batchResults.length; j++) { - entriesWithContent[i + j] = batchResults[j]; - } + return enrichedBlock; + }), + }; + + return { + ...entry, + diff: enrichedDiff, + }; + } catch (contentError) { + // If content fetch fails, return entry with error + return { + ...entry, + _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`, + }; + } + }) + ); + // Write batch results back into the array + for (let j = 0; j < batchResults.length; j++) { + entriesWithContent[i + j] = batchResults[j]; } - - enrichedChanges.changeEntries = entriesWithContent; } - return { - content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }], - }; - } catch (diffError) { - // If diff fetching fails, return metadata with error info - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - ...changes, - _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`, - _note: "Returned metadata only", - }, - null, - 2 - ), - }, - ], - }; + enrichedChanges.changeEntries = entriesWithContent; } + + return { + content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }], + }; + } catch (diffError) { + // If diff fetching fails, return metadata with error info + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + ...changes, + _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`, + _note: "Returned metadata only", + }, + null, + 2 + ), + }, + ], + }; } } } diff --git a/test/src/tools/repositories.test.ts b/test/src/tools/repositories.test.ts index 23ce7aee..9a7e67e5 100644 --- a/test/src/tools/repositories.test.ts +++ b/test/src/tools/repositories.test.ts @@ -4860,6 +4860,390 @@ describe("repos tools", () => { expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch target file content"); expect(parsedResult.changeEntries[0]._contentFetchError).toContain("TF401175"); }); + + it("should return file content for PRs with only added files (no modified files)", async () => { + // Regression test: when all changes are Add, fileDiffParams is empty, so getFileDiffs + // was never called and the code fell through to the metadata-only fallback, losing the + // includeLineContent enrichment for added files. + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [ + { item: { path: "/Testfolder.md" }, originalPath: null, changeType: 1 }, // Add + { item: { path: "/New Folder/Addition 1" }, originalPath: null, changeType: 1 }, // Add + { item: { path: "/New Folder/Addition 2" }, originalPath: null, changeType: 1 }, // Add + ], + nextSkip: 0, + nextTop: 0, + }; + + const { Readable } = await import("stream"); + const makeStream = (content: string) => { + const s = new Readable(); + s.push(content); + s.push(null); + return s; + }; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getItemText.mockResolvedValueOnce(makeStream("# Testfolder\nHello")).mockResolvedValueOnce(makeStream("Addition 1 content")).mockResolvedValueOnce(makeStream("Addition 2 content")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + // getFileDiffs must NOT have been called (no modified files) + expect(mockGitApi.getFileDiffs).not.toHaveBeenCalled(); + // getItemText must have been called once per added file + expect(mockGitApi.getItemText).toHaveBeenCalledTimes(3); + + const parsedResult = JSON.parse(result.content[0].text); + // Each added entry should have a synthetic diff with the full file content, + // path set to the new file path, and originalPath null (file didn't exist before). + expect(parsedResult.changeEntries[0].diff.path).toBe("Testfolder.md"); + expect(parsedResult.changeEntries[0].diff.originalPath).toBeNull(); + expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].modifiedLines).toEqual(["# Testfolder", "Hello"]); + expect(parsedResult.changeEntries[1].diff.lineDiffBlocks[0].modifiedLines).toEqual(["Addition 1 content"]); + expect(parsedResult.changeEntries[2].diff.lineDiffBlocks[0].modifiedLines).toEqual(["Addition 2 content"]); + }); + + it("should return file content for PRs with only deleted files (no modified files)", async () => { + // Regression test: mirror of the addition case for deletions — when all changes are Delete, + // fileDiffParams is empty so the enrichment block was previously skipped entirely. + // Also covers the ADO behaviour where item.path is null for deletions (path lives in originalPath). + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + // ADO real shape: item.path is null for deletions; path is in originalPath only + const mockChanges = { + changeEntries: [ + { originalPath: "/src/removed.ts", item: { path: null }, changeType: 16 }, // Delete + { originalPath: "/src/gone.ts", item: { path: null }, changeType: 16 }, // Delete + ], + nextSkip: 0, + nextTop: 0, + }; + + const { Readable } = await import("stream"); + const makeStream = (content: string) => { + const s = new Readable(); + s.push(content); + s.push(null); + return s; + }; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getItemText.mockResolvedValueOnce(makeStream("export const removed = true;")).mockResolvedValueOnce(makeStream("export const gone = true;")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + // getFileDiffs must NOT have been called (no modified files) + expect(mockGitApi.getFileDiffs).not.toHaveBeenCalled(); + // getItemText must have been called once per deleted file, using the normalised originalPath + expect(mockGitApi.getItemText).toHaveBeenCalledTimes(2); + expect(mockGitApi.getItemText).toHaveBeenCalledWith( + "12345678-1234-1234-1234-123456789012", + "src/removed.ts", // leading slash stripped + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { version: "def456", versionType: expect.anything() } + ); + + const parsedResult = JSON.parse(result.content[0].text); + // Each deleted entry should have a synthetic diff with the removed file content, + // path null (file no longer exists) and originalPath set to the pre-deletion path. + expect(parsedResult.changeEntries[0].diff.path).toBeNull(); + expect(parsedResult.changeEntries[0].diff.originalPath).toBe("src/removed.ts"); + expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].originalLines).toEqual(["export const removed = true;"]); + expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].changeType).toBe(2); // Delete + expect(parsedResult.changeEntries[1].diff.path).toBeNull(); + expect(parsedResult.changeEntries[1].diff.originalPath).toBe("src/gone.ts"); + expect(parsedResult.changeEntries[1].diff.lineDiffBlocks[0].originalLines).toEqual(["export const gone = true;"]); + }); + + it("should return error when non-GUID repositoryId is used without a project", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const params = { + repositoryId: "my-repository-name", // not a GUID + pullRequestId: 456, + iterationId: 1, + // project intentionally omitted + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("When using a repository name instead of a GUID"); + expect(mockGitApi.getPullRequestIterationChanges).not.toHaveBeenCalled(); + }); + + it("should return entry unchanged when getItemText rejects for added file (catch null path)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ item: { path: "/new-file.ts" }, originalPath: null, changeType: 1 }], // Add + nextSkip: 0, + nextTop: 0, + }; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + // getItemText rejects → .catch(() => null) fires → targetStream = null → return entry + mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.changeEntries[0].diff).toBeNull(); + expect(parsedResult.changeEntries[0]._contentFetchError).toBeUndefined(); + }); + + it("should return _contentFetchError when streamToString throws for added file", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ item: { path: "/new-file.ts" }, originalPath: null, changeType: 1 }], // Add + nextSkip: 0, + nextTop: 0, + }; + + const { Readable } = await import("stream"); + const errorStream = new Readable({ + read() { + this.emit("error", new Error("Stream read error")); + }, + }); + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getItemText.mockResolvedValueOnce(errorStream); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch added file content"); + expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Stream read error"); + }); + + it("should return entry unchanged when getItemText rejects for deleted file (catch null path)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ originalPath: "/deleted-file.ts", item: { path: null }, changeType: 16 }], // Delete + nextSkip: 0, + nextTop: 0, + }; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + // getItemText rejects → .catch(() => null) fires → baseStream = null → return entry + mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.changeEntries[0].diff).toBeNull(); + expect(parsedResult.changeEntries[0]._contentFetchError).toBeUndefined(); + }); + + it("should return _contentFetchError when streamToString throws for deleted file", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ originalPath: "/deleted-file.ts", item: { path: null }, changeType: 16 }], // Delete + nextSkip: 0, + nextTop: 0, + }; + + const { Readable } = await import("stream"); + const errorStream = new Readable({ + read() { + this.emit("error", new Error("Stream read error")); + }, + }); + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getItemText.mockResolvedValueOnce(errorStream); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch deleted file content"); + expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Stream read error"); + }); + + it("should handle getItemText rejection for modified file via catch null (empty lines result)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ item: { path: "/src/file.ts" }, changeType: 2 }], // Edit + nextSkip: 0, + nextTop: 0, + }; + + const mockFileDiffs = [ + { + path: "src/file.ts", + lineDiffBlocks: [ + { + changeType: 3, + modifiedLineNumberStart: 10, + modifiedLinesCount: 1, + originalLineNumberStart: 10, + originalLinesCount: 1, + }, + ], + }, + ]; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); + // Both getItemText calls reject → .catch(() => null) fires → null content → empty lines + mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")).mockRejectedValueOnce(new Error("Network error")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + const diffBlock = parsedResult.changeEntries[0].diff.lineDiffBlocks[0]; + expect(diffBlock.originalLines).toEqual([]); + expect(diffBlock.modifiedLines).toEqual([]); + }); }); describe("repo_reply_to_comment", () => {