Skip to content

Commit d7cf578

Browse files
authored
fix: append MCP client info to UserAgent header (#251)
Append MCP client name and version to the UserAgent header sent to ADO APIs ## GitHub issue number None, internally discussed observability gap. ## **Associated Risks** None ## ✅ **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?** E2E. Verified server traces to log new UserAgent value. E.g. `AzureDevOps.MCP/1.1.0 (local) Visual Studio Code/1.101.2`
1 parent 3a96591 commit d7cf578

File tree

11 files changed

+114
-47
lines changed

11 files changed

+114
-47
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure-devops/mcp",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "MCP server for interacting with Azure DevOps",
55
"license": "MIT",
66
"author": "Microsoft Corporation",

src/index.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as azdev from "azure-devops-node-api";
99
import { AccessToken, DefaultAzureCredential } from "@azure/identity";
1010
import { configurePrompts } from "./prompts.js";
1111
import { configureAllTools } from "./tools.js";
12-
import { userAgent } from "./utils.js";
12+
import { UserAgentComposer } from "./useragent.js";
1313
import { packageVersion } from "./version.js";
1414
const args = process.argv.slice(2);
1515
if (args.length === 0) {
@@ -27,15 +27,17 @@ async function getAzureDevOpsToken(): Promise<AccessToken> {
2727
return token;
2828
}
2929

30-
async function getAzureDevOpsClient(): Promise<azdev.WebApi> {
31-
const token = await getAzureDevOpsToken();
32-
const authHandler = azdev.getBearerHandler(token.token);
33-
const connection = new azdev.WebApi(orgUrl, authHandler, undefined, {
34-
productName: "AzureDevOps.MCP",
35-
productVersion: packageVersion,
36-
userAgent: userAgent,
37-
});
38-
return connection;
30+
function getAzureDevOpsClient(userAgentComposer: UserAgentComposer): () => Promise<azdev.WebApi> {
31+
return async () => {
32+
const token = await getAzureDevOpsToken();
33+
const authHandler = azdev.getBearerHandler(token.token);
34+
const connection = new azdev.WebApi(orgUrl, authHandler, undefined, {
35+
productName: "AzureDevOps.MCP",
36+
productVersion: packageVersion,
37+
userAgent: userAgentComposer.userAgent,
38+
});
39+
return connection;
40+
};
3941
}
4042

4143
async function main() {
@@ -44,9 +46,14 @@ async function main() {
4446
version: packageVersion,
4547
});
4648

49+
const userAgentComposer = new UserAgentComposer(packageVersion);
50+
server.server.oninitialized = () => {
51+
userAgentComposer.appendMcpClientInfo(server.server.getClientVersion());
52+
};
53+
4754
configurePrompts(server);
4855

49-
configureAllTools(server, getAzureDevOpsToken, getAzureDevOpsClient);
56+
configureAllTools(server, getAzureDevOpsToken, getAzureDevOpsClient(userAgentComposer), () => userAgentComposer.userAgent);
5057

5158
const transport = new StdioServerTransport();
5259
await server.connect(transport);

src/tools.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ import { configureWikiTools } from "./tools/wiki.js";
1515
import { configureTestPlanTools } from "./tools/testplans.js";
1616
import { configureSearchTools } from "./tools/search.js";
1717

18-
function configureAllTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
18+
function configureAllTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
1919
configureCoreTools(server, tokenProvider, connectionProvider);
2020
configureWorkTools(server, tokenProvider, connectionProvider);
2121
configureBuildTools(server, tokenProvider, connectionProvider);
2222
configureRepoTools(server, tokenProvider, connectionProvider);
23-
configureWorkItemTools(server, tokenProvider, connectionProvider);
23+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
2424
configureReleaseTools(server, tokenProvider, connectionProvider);
2525
configureWikiTools(server, tokenProvider, connectionProvider);
2626
configureTestPlanTools(server, tokenProvider, connectionProvider);
27-
configureSearchTools(server, tokenProvider, connectionProvider);
27+
configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider);
2828
}
2929

3030
export { configureAllTools };

src/tools/search.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
import { WebApi } from "azure-devops-node-api";
77
import { IGitApi } from "azure-devops-node-api/GitApi.js";
88
import { z } from "zod";
9-
import { apiVersion, userAgent } from "../utils.js";
9+
import { apiVersion } from "../utils.js";
1010
import { orgName } from "../index.js";
1111
import { VersionControlRecursionType } from "azure-devops-node-api/interfaces/GitInterfaces.js";
1212
import { GitItem } from "azure-devops-node-api/interfaces/GitInterfaces.js";
@@ -17,7 +17,7 @@ const SEARCH_TOOLS = {
1717
search_workitem: "search_workitem",
1818
};
1919

20-
function configureSearchTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
20+
function configureSearchTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
2121
/*
2222
CODE SEARCH
2323
Get the code search results for a given search text.
@@ -60,7 +60,7 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<Ac
6060
headers: {
6161
"Content-Type": "application/json",
6262
"Authorization": `Bearer ${accessToken.token}`,
63-
"User-Agent": `${userAgent}`,
63+
"User-Agent": userAgentProvider(),
6464
},
6565
body: JSON.stringify(searchRequest),
6666
});
@@ -117,7 +117,7 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<Ac
117117
headers: {
118118
"Content-Type": "application/json",
119119
"Authorization": `Bearer ${accessToken.token}`,
120-
"User-Agent": `${userAgent}`,
120+
"User-Agent": userAgentProvider(),
121121
},
122122
body: JSON.stringify(searchRequest),
123123
});
@@ -169,7 +169,7 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<Ac
169169
headers: {
170170
"Content-Type": "application/json",
171171
"Authorization": `Bearer ${accessToken.token}`,
172-
"User-Agent": `${userAgent}`,
172+
"User-Agent": userAgentProvider(),
173173
},
174174
body: JSON.stringify(searchRequest),
175175
});

src/tools/workitems.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { WebApi } from "azure-devops-node-api";
77
import { WorkItemExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
88
import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js";
99
import { z } from "zod";
10-
import { batchApiVersion, userAgent } from "../utils.js";
10+
import { batchApiVersion } from "../utils.js";
1111

1212
const WORKITEM_TOOLS = {
1313
my_work_items: "wit_my_work_items",
@@ -55,7 +55,7 @@ function getLinkTypeFromName(name: string) {
5555
}
5656
}
5757

58-
function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
58+
function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
5959
server.tool(
6060
WORKITEM_TOOLS.list_backlogs,
6161
"Revieve a list of backlogs for a given project and team.",
@@ -523,7 +523,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
523523
headers: {
524524
"Authorization": `Bearer ${accessToken.token}`,
525525
"Content-Type": "application/json",
526-
"User-Agent": `${userAgent}`,
526+
"User-Agent": userAgentProvider(),
527527
},
528528
body: JSON.stringify(body),
529529
});
@@ -595,7 +595,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
595595
headers: {
596596
"Authorization": `Bearer ${accessToken.token}`,
597597
"Content-Type": "application/json",
598-
"User-Agent": `${userAgent}`,
598+
"User-Agent": userAgentProvider(),
599599
},
600600
body: JSON.stringify(body),
601601
});
@@ -654,7 +654,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
654654
headers: {
655655
"Authorization": `Bearer ${accessToken.token}`,
656656
"Content-Type": "application/json",
657-
"User-Agent": `${userAgent}`,
657+
"User-Agent": userAgentProvider(),
658658
},
659659
body: JSON.stringify(body),
660660
});

src/useragent.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
interface McpClientInfo {
2+
name: string;
3+
version: string;
4+
}
5+
6+
class UserAgentComposer {
7+
private _userAgent: string;
8+
private _mcpClientInfoAppended: boolean;
9+
10+
constructor(packageVersion: string) {
11+
this._userAgent = `AzureDevOps.MCP/${packageVersion} (local)`;
12+
this._mcpClientInfoAppended = false;
13+
}
14+
15+
get userAgent(): string {
16+
return this._userAgent;
17+
}
18+
19+
public appendMcpClientInfo(info: McpClientInfo | undefined): void {
20+
if (!this._mcpClientInfoAppended && info && info.name && info.version) {
21+
this._userAgent += ` ${info.name}/${info.version}`;
22+
this._mcpClientInfoAppended = true;
23+
}
24+
}
25+
}
26+
27+
export { UserAgentComposer, McpClientInfo };

src/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,3 @@ import { packageVersion } from "./version.js";
55

66
export const apiVersion = "7.2-preview.1";
77
export const batchApiVersion = "5.0";
8-
export const userAgent = `AzureDevOps.MCP/${packageVersion} (local)`;

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const packageVersion = "1.0.0";
1+
export const packageVersion = "1.1.0";

test/src/tools/workitems.test.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe("configureWorkItemTools", () => {
4242
let server: McpServer;
4343
let tokenProvider: TokenProviderMock;
4444
let connectionProvider: ConnectionProviderMock;
45+
let userAgentProvider: () => string;
4546
let mockConnection: {
4647
getWorkApi: jest.Mock;
4748
getWorkItemTrackingApi: jest.Mock;
@@ -79,18 +80,20 @@ describe("configureWorkItemTools", () => {
7980
};
8081

8182
connectionProvider = jest.fn().mockResolvedValue(mockConnection);
83+
84+
userAgentProvider = () => "Jest";
8285
});
8386

8487
describe("tool registration", () => {
8588
it("registers core tools on the server", () => {
86-
configureWorkItemTools(server, tokenProvider, connectionProvider);
89+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
8790
expect(server.tool as jest.Mock).toHaveBeenCalled();
8891
});
8992
});
9093

9194
describe("list_backlogs tool", () => {
9295
it("should call getBacklogs API with the correct parameters and return the expected result", async () => {
93-
configureWorkItemTools(server, tokenProvider, connectionProvider);
96+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
9497

9598
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs");
9699
if (!call) throw new Error("wit_list_backlogs tool not registered");
@@ -116,7 +119,7 @@ describe("configureWorkItemTools", () => {
116119

117120
describe("list_backlog_work_items tool", () => {
118121
it("should call getBacklogLevelWorkItems API with the correct parameters and return the expected result", async () => {
119-
configureWorkItemTools(server, tokenProvider, connectionProvider);
122+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
120123

121124
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items");
122125
if (!call) throw new Error("wit_list_backlog_work_items tool not registered");
@@ -184,7 +187,7 @@ describe("configureWorkItemTools", () => {
184187

185188
describe("my_work_items tool", () => {
186189
it("should call getPredefinedQueryResults API with the correct parameters and return the expected result", async () => {
187-
configureWorkItemTools(server, tokenProvider, connectionProvider);
190+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
188191

189192
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_my_work_items");
190193
if (!call) throw new Error("wit_my_work_items tool not registered");
@@ -259,7 +262,7 @@ describe("configureWorkItemTools", () => {
259262

260263
describe("getWorkItemsBatch tool", () => {
261264
it("should call workItemApi.getWorkItemsBatch API with the correct parameters and return the expected result", async () => {
262-
configureWorkItemTools(server, tokenProvider, connectionProvider);
265+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
263266

264267
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids");
265268

@@ -289,7 +292,7 @@ describe("configureWorkItemTools", () => {
289292

290293
describe("get_work_item tool", () => {
291294
it("should call workItemApi.getWorkItem API with the correct parameters and return the expected result", async () => {
292-
configureWorkItemTools(server, tokenProvider, connectionProvider);
295+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
293296

294297
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item");
295298

@@ -316,7 +319,7 @@ describe("configureWorkItemTools", () => {
316319

317320
describe("list_work_item_comments tool", () => {
318321
it("should call workItemApi.getComments API with the correct parameters and return the expected result", async () => {
319-
configureWorkItemTools(server, tokenProvider, connectionProvider);
322+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
320323

321324
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_comments");
322325

@@ -341,7 +344,7 @@ describe("configureWorkItemTools", () => {
341344

342345
describe("add_work_item_comment tool", () => {
343346
it("should call workItemApi.addComment API with the correct parameters and return the expected result", async () => {
344-
configureWorkItemTools(server, tokenProvider, connectionProvider);
347+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
345348

346349
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment");
347350

@@ -366,7 +369,7 @@ describe("configureWorkItemTools", () => {
366369

367370
describe("add_child_work_item tool", () => {
368371
it("should call workItemApi.add_child_work_item API with the correct parameters and return the expected result", async () => {
369-
configureWorkItemTools(server, tokenProvider, connectionProvider);
372+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
370373

371374
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_item");
372375

@@ -426,7 +429,7 @@ describe("configureWorkItemTools", () => {
426429

427430
describe("link_work_item_to_pull_request tool", () => {
428431
it("should call workItemApi.updateWorkItem API with the correct parameters and return the expected result", async () => {
429-
configureWorkItemTools(server, tokenProvider, connectionProvider);
432+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
430433

431434
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request");
432435

@@ -477,7 +480,7 @@ describe("configureWorkItemTools", () => {
477480
});
478481

479482
it("should handle errors from updateWorkItem and return a descriptive error", async () => {
480-
configureWorkItemTools(server, tokenProvider, connectionProvider);
483+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
481484
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request");
482485

483486
if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered");
@@ -498,7 +501,7 @@ describe("configureWorkItemTools", () => {
498501
});
499502

500503
it("should encode special characters in project and repositoryId for vstfsUrl", async () => {
501-
configureWorkItemTools(server, tokenProvider, connectionProvider);
504+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
502505
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request");
503506
if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered");
504507

@@ -533,7 +536,7 @@ describe("configureWorkItemTools", () => {
533536

534537
describe("get_work_items_for_iteration tool", () => {
535538
it("should call workApi.getIterationWorkItems API with the correct parameters and return the expected result", async () => {
536-
configureWorkItemTools(server, tokenProvider, connectionProvider);
539+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
537540

538541
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_for_iteration");
539542

@@ -564,7 +567,7 @@ describe("configureWorkItemTools", () => {
564567

565568
describe("update_work_item tool", () => {
566569
it("should call workItemApi.updateWorkItem API with the correct parameters and return the expected result", async () => {
567-
configureWorkItemTools(server, tokenProvider, connectionProvider);
570+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
568571

569572
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item");
570573

@@ -594,7 +597,7 @@ describe("configureWorkItemTools", () => {
594597

595598
describe("get_work_item_type tool", () => {
596599
it("should call workItemApi.getWorkItemType API with the correct parameters and return the expected result", async () => {
597-
configureWorkItemTools(server, tokenProvider, connectionProvider);
600+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
598601

599602
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_type");
600603

@@ -618,7 +621,7 @@ describe("configureWorkItemTools", () => {
618621

619622
describe("create_work_item tool", () => {
620623
it("should call workItemApi.createWorkItem API with the correct parameters and return the expected result", async () => {
621-
configureWorkItemTools(server, tokenProvider, connectionProvider);
624+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
622625

623626
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item");
624627

@@ -649,7 +652,7 @@ describe("configureWorkItemTools", () => {
649652

650653
describe("get_query tool", () => {
651654
it("should call workItemApi.getQuery API with the correct parameters and return the expected result", async () => {
652-
configureWorkItemTools(server, tokenProvider, connectionProvider);
655+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
653656

654657
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query");
655658

@@ -677,7 +680,7 @@ describe("configureWorkItemTools", () => {
677680

678681
describe("get_query_results_by_id tool", () => {
679682
it("should call workItemApi.getQueryById API with the correct parameters and return the expected result", async () => {
680-
configureWorkItemTools(server, tokenProvider, connectionProvider);
683+
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
681684

682685
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query_results_by_id");
683686

0 commit comments

Comments
 (0)