@azure-devops/mcp 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -25
- package/dist/auth.js +2 -103
- package/dist/oauth.js +130 -0
- package/dist/shared/tool-annotations.js +85 -0
- package/dist/tools/pipelines.js +51 -12
- package/dist/tools/repositories.js +208 -26
- package/dist/tools/search.js +26 -3
- package/dist/tools/test-plans.js +55 -18
- package/dist/tools/wiki.js +9 -9
- package/dist/tools/work-items.js +149 -31
- package/dist/tools.js +20 -17
- package/dist/version.js +1 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
# Azure DevOps MCP Server
|
|
2
2
|
|
|
3
|
-
> [!
|
|
4
|
-
> We
|
|
3
|
+
> [!IMPORTANT]
|
|
4
|
+
> We recommend using the [Remote MCP Server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server) instead of this local server. It requires no installation and gets new features first.
|
|
5
5
|
>
|
|
6
|
-
>
|
|
6
|
+
> [Learn more](#remote-mcp-server-recommended)
|
|
7
7
|
|
|
8
8
|
This project gives AI agents access to Azure DevOps through the Model Context Protocol (MCP). Use the hosted remote server for the simplest setup, or run the local server when you need a `stdio` connection.
|
|
9
9
|
|
|
10
10
|
## Table of Contents
|
|
11
11
|
|
|
12
|
-
> [!IMPORTANT]
|
|
13
|
-
> We recommend using the [Remote MCP Server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server) instead of this local server. It requires no installation and gets new features first.
|
|
14
|
-
>
|
|
15
|
-
> [Learn more](#remote-mcp-server-recommended)
|
|
16
|
-
|
|
17
12
|
1. [Overview](#overview)
|
|
18
13
|
2. [Design](#design)
|
|
19
14
|
3. [Remote MCP Server (Recommended)](#remote-mcp-server-recommended)
|
|
@@ -103,18 +98,11 @@ These steps use Visual Studio Code and GitHub Copilot. For other supported clien
|
|
|
103
98
|
|
|
104
99
|
```json
|
|
105
100
|
{
|
|
106
|
-
"inputs": [
|
|
107
|
-
{
|
|
108
|
-
"id": "ado_org",
|
|
109
|
-
"type": "promptString",
|
|
110
|
-
"description": "Azure DevOps organization name (e.g. 'contoso')"
|
|
111
|
-
}
|
|
112
|
-
],
|
|
113
101
|
"servers": {
|
|
114
102
|
"ado": {
|
|
115
103
|
"type": "stdio",
|
|
116
104
|
"command": "npx",
|
|
117
|
-
"args": ["-y", "@azure-devops/mcp", "
|
|
105
|
+
"args": ["-y", "@azure-devops/mcp", "{organization}"]
|
|
118
106
|
}
|
|
119
107
|
}
|
|
120
108
|
}
|
|
@@ -141,18 +129,11 @@ Add `-d` followed by the domains to the server arguments. For example, this conf
|
|
|
141
129
|
|
|
142
130
|
```json
|
|
143
131
|
{
|
|
144
|
-
"inputs": [
|
|
145
|
-
{
|
|
146
|
-
"id": "ado_org",
|
|
147
|
-
"type": "promptString",
|
|
148
|
-
"description": "Azure DevOps organization name (e.g. 'contoso')"
|
|
149
|
-
}
|
|
150
|
-
],
|
|
151
132
|
"servers": {
|
|
152
133
|
"ado_with_filtered_domains": {
|
|
153
134
|
"type": "stdio",
|
|
154
135
|
"command": "npx",
|
|
155
|
-
"args": ["-y", "@azure-devops/mcp", "
|
|
136
|
+
"args": ["-y", "@azure-devops/mcp", "{organization}", "-d", "core", "work", "work-items"]
|
|
156
137
|
}
|
|
157
138
|
}
|
|
158
139
|
}
|
|
@@ -174,7 +155,7 @@ Set default Azure DevOps project and team values in `.vscode/mcp.json` so tools
|
|
|
174
155
|
"ado": {
|
|
175
156
|
"type": "stdio",
|
|
176
157
|
"command": "npx",
|
|
177
|
-
"args": ["-y", "@azure-devops/mcp", "
|
|
158
|
+
"args": ["-y", "@azure-devops/mcp", "{organization}", "--authentication", "azcli"],
|
|
178
159
|
"env": {
|
|
179
160
|
"ado_mcp_project": "Contoso",
|
|
180
161
|
"ado_mcp_team": "Fabrikam Team"
|
package/dist/auth.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
// Copyright (c) Microsoft Corporation.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
3
|
import { AzureCliCredential, ChainedTokenCredential, DefaultAzureCredential } from "@azure/identity";
|
|
4
|
-
import { PublicClientApplication } from "@azure/msal-node";
|
|
5
|
-
import { NativeBrokerPlugin } from "@azure/msal-node-extensions";
|
|
6
|
-
import open from "open";
|
|
7
4
|
import { logger } from "./logger.js";
|
|
8
|
-
|
|
5
|
+
import { azureDevOpsScopes, OAuthAuthenticator } from "./oauth.js";
|
|
9
6
|
const patAllowedHosts = new Set(["dev.azure.com", "vssps.dev.azure.com", "almsearch.dev.azure.com"]);
|
|
10
7
|
function isPatAllowedHost(hostname) {
|
|
11
8
|
const normalizedHostname = hostname.toLowerCase();
|
|
@@ -30,104 +27,6 @@ function installPatFetchInterceptor(basicValue) {
|
|
|
30
27
|
return originalFetch(input, { ...init, headers });
|
|
31
28
|
};
|
|
32
29
|
}
|
|
33
|
-
class OAuthAuthenticator {
|
|
34
|
-
static clientId = "0d50963b-7bb9-4fe7-94c7-a99af00b5136";
|
|
35
|
-
static defaultAuthority = "https://login.microsoftonline.com/common";
|
|
36
|
-
static zeroTenantId = "00000000-0000-0000-0000-000000000000";
|
|
37
|
-
accountId;
|
|
38
|
-
publicClientApp;
|
|
39
|
-
publicClientAppFallback;
|
|
40
|
-
constructor(tenantId) {
|
|
41
|
-
this.accountId = null;
|
|
42
|
-
let authority = OAuthAuthenticator.defaultAuthority;
|
|
43
|
-
if (tenantId && tenantId !== OAuthAuthenticator.zeroTenantId) {
|
|
44
|
-
authority = `https://login.microsoftonline.com/${tenantId}`;
|
|
45
|
-
logger.debug(`OAuthAuthenticator: Using tenant-specific authority for tenantId='${tenantId}'`);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
logger.debug(`OAuthAuthenticator: Using default common authority`);
|
|
49
|
-
}
|
|
50
|
-
this.publicClientApp = new PublicClientApplication({
|
|
51
|
-
auth: {
|
|
52
|
-
clientId: OAuthAuthenticator.clientId,
|
|
53
|
-
authority,
|
|
54
|
-
},
|
|
55
|
-
broker: {
|
|
56
|
-
nativeBrokerPlugin: new NativeBrokerPlugin(),
|
|
57
|
-
},
|
|
58
|
-
system: {
|
|
59
|
-
loggerOptions: {
|
|
60
|
-
loggerCallback: (level, message) => {
|
|
61
|
-
logger.debug(`MSALClient[${level}]: ${message}`);
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
this.publicClientAppFallback = new PublicClientApplication({
|
|
67
|
-
auth: {
|
|
68
|
-
clientId: OAuthAuthenticator.clientId,
|
|
69
|
-
authority,
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
logger.debug(`OAuthAuthenticator: Initialized with clientId='${OAuthAuthenticator.clientId}'`);
|
|
73
|
-
}
|
|
74
|
-
async getToken() {
|
|
75
|
-
let authResult = null;
|
|
76
|
-
if (this.accountId) {
|
|
77
|
-
logger.debug(`OAuthAuthenticator: Attempting silent token acquisition for cached account`);
|
|
78
|
-
try {
|
|
79
|
-
authResult = await this.publicClientApp.acquireTokenSilent({
|
|
80
|
-
scopes,
|
|
81
|
-
account: this.accountId,
|
|
82
|
-
});
|
|
83
|
-
logger.debug(`OAuthAuthenticator: Successfully acquired token silently`);
|
|
84
|
-
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
logger.debug(`OAuthAuthenticator: Silent token acquisition failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
-
authResult = null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
logger.debug(`OAuthAuthenticator: No cached account available, interactive auth required`);
|
|
92
|
-
}
|
|
93
|
-
if (!authResult) {
|
|
94
|
-
logger.debug(`OAuthAuthenticator: Starting interactive token acquisition`);
|
|
95
|
-
try {
|
|
96
|
-
authResult = await this.publicClientApp.acquireTokenInteractive({
|
|
97
|
-
scopes,
|
|
98
|
-
openBrowser: async (url) => {
|
|
99
|
-
logger.debug(`OAuthAuthenticator: Opening browser for authentication with target URL: ${url}`);
|
|
100
|
-
open(url);
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
this.accountId = authResult.account;
|
|
104
|
-
logger.debug(`OAuthAuthenticator: Successfully acquired token interactively, account cached`);
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
const msalErrorMessage = error.platformBrokerError ? JSON.stringify(error.platformBrokerError) : "";
|
|
108
|
-
logger.debug(`OAuthAuthenticator: Interactive token acquisition failed: ${error instanceof Error ? error.message + msalErrorMessage : String(error)}`);
|
|
109
|
-
authResult = null;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (!authResult) {
|
|
113
|
-
logger.debug(`OAuthAuthenticator: Starting interactive token acquisition without broker`);
|
|
114
|
-
authResult = await this.publicClientAppFallback.acquireTokenInteractive({
|
|
115
|
-
scopes,
|
|
116
|
-
openBrowser: async (url) => {
|
|
117
|
-
logger.debug(`OAuthAuthenticator: Opening browser for authentication with target URL: ${url}`);
|
|
118
|
-
open(url);
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
logger.debug(`OAuthAuthenticator: Successfully acquired token interactively without broker`);
|
|
122
|
-
}
|
|
123
|
-
if (!authResult?.accessToken) {
|
|
124
|
-
logger.error(`OAuthAuthenticator: Authentication result contains no access token`);
|
|
125
|
-
throw new Error("Failed to obtain Azure DevOps OAuth token.");
|
|
126
|
-
}
|
|
127
|
-
logger.debug(`OAuthAuthenticator: Token obtained successfully`);
|
|
128
|
-
return authResult.accessToken;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
30
|
function createAuthenticator(type, tenantId) {
|
|
132
31
|
logger.debug(`Creating authenticator of type '${type}' with tenantId='${tenantId ?? "undefined"}'`);
|
|
133
32
|
switch (type) {
|
|
@@ -170,7 +69,7 @@ function createAuthenticator(type, tenantId) {
|
|
|
170
69
|
credential = new ChainedTokenCredential(azureCliCredential, credential);
|
|
171
70
|
}
|
|
172
71
|
return async () => {
|
|
173
|
-
const result = await credential.getToken(
|
|
72
|
+
const result = await credential.getToken(azureDevOpsScopes);
|
|
174
73
|
if (!result) {
|
|
175
74
|
logger.error(`${type}: Failed to obtain token - credential.getToken returned null/undefined`);
|
|
176
75
|
throw new Error("Failed to obtain Azure DevOps token. Ensure you have Azure CLI logged or use interactive type of authentication.");
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Copyright (c) Microsoft Corporation.
|
|
2
|
+
// Licensed under the MIT License.
|
|
3
|
+
import { PublicClientApplication } from "@azure/msal-node";
|
|
4
|
+
import open from "open";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
export const azureDevOpsScopes = ["499b84ac-1321-427f-aa17-267ca6975798/.default"];
|
|
7
|
+
const clientId = "0d50963b-7bb9-4fe7-94c7-a99af00b5136";
|
|
8
|
+
const defaultAuthority = "https://login.microsoftonline.com/common";
|
|
9
|
+
const zeroTenantId = "00000000-0000-0000-0000-000000000000";
|
|
10
|
+
class ClientAppWrapper {
|
|
11
|
+
accountId;
|
|
12
|
+
publicClientApp;
|
|
13
|
+
withBroker;
|
|
14
|
+
constructor(authority) {
|
|
15
|
+
this.accountId = null;
|
|
16
|
+
this.publicClientApp = new PublicClientApplication({
|
|
17
|
+
auth: {
|
|
18
|
+
clientId,
|
|
19
|
+
authority,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
this.withBroker = false;
|
|
23
|
+
}
|
|
24
|
+
static async initialize(authority, tryWithBroker) {
|
|
25
|
+
const clientApp = new ClientAppWrapper(authority);
|
|
26
|
+
if (tryWithBroker) {
|
|
27
|
+
try {
|
|
28
|
+
const { NativeBrokerPlugin } = await import("@azure/msal-node-extensions");
|
|
29
|
+
clientApp.publicClientApp = new PublicClientApplication({
|
|
30
|
+
auth: {
|
|
31
|
+
clientId,
|
|
32
|
+
authority,
|
|
33
|
+
},
|
|
34
|
+
broker: {
|
|
35
|
+
nativeBrokerPlugin: new NativeBrokerPlugin(),
|
|
36
|
+
},
|
|
37
|
+
system: {
|
|
38
|
+
loggerOptions: {
|
|
39
|
+
loggerCallback: (level, message) => {
|
|
40
|
+
logger.debug(`MSALClient[${level}]: ${message}`);
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
clientApp.withBroker = true;
|
|
46
|
+
logger.debug(`PublicClientApplication(authority='${authority}') initialized with broker successfully`);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
logger.debug(`PublicClientApplication(authority='${authority}') initialized without broker`);
|
|
50
|
+
logger.error(`Failed to initialize PublicClientApplication with broker: ${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return clientApp;
|
|
54
|
+
}
|
|
55
|
+
isWithBroker() {
|
|
56
|
+
return this.withBroker;
|
|
57
|
+
}
|
|
58
|
+
async getToken() {
|
|
59
|
+
const logPrefix = `ClientAppWrapper(${this.withBroker ? "withBroker" : "noBroker"})`;
|
|
60
|
+
let authResult = null;
|
|
61
|
+
if (this.accountId) {
|
|
62
|
+
logger.debug(`${logPrefix}: Attempting silent token acquisition for cached account`);
|
|
63
|
+
try {
|
|
64
|
+
authResult = await this.publicClientApp.acquireTokenSilent({
|
|
65
|
+
scopes: azureDevOpsScopes,
|
|
66
|
+
account: this.accountId,
|
|
67
|
+
});
|
|
68
|
+
logger.debug(`${logPrefix}: Successfully acquired token silently`);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
logger.debug(`${logPrefix}: Silent token acquisition failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
72
|
+
authResult = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
logger.debug(`${logPrefix}: No cached account available, interactive auth required`);
|
|
77
|
+
}
|
|
78
|
+
if (!authResult) {
|
|
79
|
+
logger.debug(`${logPrefix}: Starting interactive token acquisition`);
|
|
80
|
+
try {
|
|
81
|
+
authResult = await this.publicClientApp.acquireTokenInteractive({
|
|
82
|
+
scopes: azureDevOpsScopes,
|
|
83
|
+
openBrowser: async (url) => {
|
|
84
|
+
logger.debug(`${logPrefix}: Opening browser for authentication with target URL: ${url}`);
|
|
85
|
+
open(url);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
this.accountId = authResult.account;
|
|
89
|
+
logger.debug(`${logPrefix}: Successfully acquired token interactively, account cached`);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const msalErrorMessage = error.platformBrokerError ? JSON.stringify(error.platformBrokerError) : "";
|
|
93
|
+
logger.debug(`${logPrefix}: Interactive token acquisition failed: ${error instanceof Error ? error.message + msalErrorMessage : String(error)}`);
|
|
94
|
+
authResult = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!authResult?.accessToken) {
|
|
98
|
+
throw new Error("Failed to obtain Azure DevOps OAuth token.");
|
|
99
|
+
}
|
|
100
|
+
logger.debug(`${logPrefix}: Token obtained successfully`);
|
|
101
|
+
return authResult.accessToken;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export class OAuthAuthenticator {
|
|
105
|
+
authority;
|
|
106
|
+
clientApp;
|
|
107
|
+
constructor(tenantId) {
|
|
108
|
+
this.authority = defaultAuthority;
|
|
109
|
+
if (tenantId && tenantId !== zeroTenantId) {
|
|
110
|
+
this.authority = `https://login.microsoftonline.com/${tenantId}`;
|
|
111
|
+
}
|
|
112
|
+
this.clientApp = null;
|
|
113
|
+
}
|
|
114
|
+
async getToken() {
|
|
115
|
+
if (!this.clientApp) {
|
|
116
|
+
this.clientApp = await ClientAppWrapper.initialize(this.authority, true);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const token = await this.clientApp.getToken();
|
|
120
|
+
return token;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
if (this.clientApp.isWithBroker()) {
|
|
124
|
+
logger.debug(`OAuthAuthenticator: Token acquisition failed with broker, retrying without broker`);
|
|
125
|
+
this.clientApp = await ClientAppWrapper.initialize(this.authority, false);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return await this.clientApp.getToken();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright (c) Microsoft Corporation.
|
|
2
|
+
// Licensed under the MIT License.
|
|
3
|
+
const READ_ONLY_ANNOTATIONS = {
|
|
4
|
+
readOnlyHint: true,
|
|
5
|
+
destructiveHint: false,
|
|
6
|
+
idempotentHint: true,
|
|
7
|
+
openWorldHint: true,
|
|
8
|
+
};
|
|
9
|
+
const MUTATING_ANNOTATIONS = {
|
|
10
|
+
readOnlyHint: false,
|
|
11
|
+
destructiveHint: true,
|
|
12
|
+
idempotentHint: false,
|
|
13
|
+
openWorldHint: true,
|
|
14
|
+
};
|
|
15
|
+
const ADDITIVE_ANNOTATIONS = {
|
|
16
|
+
...MUTATING_ANNOTATIONS,
|
|
17
|
+
destructiveHint: false,
|
|
18
|
+
};
|
|
19
|
+
const TOOL_ANNOTATIONS = {
|
|
20
|
+
advsec_get_alert_details: READ_ONLY_ANNOTATIONS,
|
|
21
|
+
advsec_get_alerts: READ_ONLY_ANNOTATIONS,
|
|
22
|
+
core_get_identity_ids: READ_ONLY_ANNOTATIONS,
|
|
23
|
+
core_list_projects: READ_ONLY_ANNOTATIONS,
|
|
24
|
+
core_list_project_teams: READ_ONLY_ANNOTATIONS,
|
|
25
|
+
mcp_apps_ping: { ...READ_ONLY_ANNOTATIONS, openWorldHint: false },
|
|
26
|
+
pipelines_artifact: MUTATING_ANNOTATIONS,
|
|
27
|
+
pipelines_build: READ_ONLY_ANNOTATIONS,
|
|
28
|
+
pipelines_build_log: READ_ONLY_ANNOTATIONS,
|
|
29
|
+
pipelines_definition: READ_ONLY_ANNOTATIONS,
|
|
30
|
+
pipelines_run: READ_ONLY_ANNOTATIONS,
|
|
31
|
+
pipelines_write: MUTATING_ANNOTATIONS,
|
|
32
|
+
repo_branch: READ_ONLY_ANNOTATIONS,
|
|
33
|
+
repo_create_branch: ADDITIVE_ANNOTATIONS,
|
|
34
|
+
repo_file: READ_ONLY_ANNOTATIONS,
|
|
35
|
+
repo_file_write: MUTATING_ANNOTATIONS,
|
|
36
|
+
repo_pull_request: READ_ONLY_ANNOTATIONS,
|
|
37
|
+
repo_pull_request_org: READ_ONLY_ANNOTATIONS,
|
|
38
|
+
repo_pull_request_thread: READ_ONLY_ANNOTATIONS,
|
|
39
|
+
repo_pull_request_thread_write: MUTATING_ANNOTATIONS,
|
|
40
|
+
repo_pull_request_write: MUTATING_ANNOTATIONS,
|
|
41
|
+
repo_repository: READ_ONLY_ANNOTATIONS,
|
|
42
|
+
repo_search_commits: READ_ONLY_ANNOTATIONS,
|
|
43
|
+
search_code: READ_ONLY_ANNOTATIONS,
|
|
44
|
+
search_wiki: READ_ONLY_ANNOTATIONS,
|
|
45
|
+
search_workitem: READ_ONLY_ANNOTATIONS,
|
|
46
|
+
testplan: READ_ONLY_ANNOTATIONS,
|
|
47
|
+
testplan_show_test_results_from_build_id: READ_ONLY_ANNOTATIONS,
|
|
48
|
+
testplan_test_case_write: MUTATING_ANNOTATIONS,
|
|
49
|
+
testplan_test_plan_write: MUTATING_ANNOTATIONS,
|
|
50
|
+
testplan_test_suite_write: MUTATING_ANNOTATIONS,
|
|
51
|
+
wiki: READ_ONLY_ANNOTATIONS,
|
|
52
|
+
wiki_upsert_page: MUTATING_ANNOTATIONS,
|
|
53
|
+
wit_backlog: MUTATING_ANNOTATIONS,
|
|
54
|
+
wit_query: READ_ONLY_ANNOTATIONS,
|
|
55
|
+
wit_work_item: READ_ONLY_ANNOTATIONS,
|
|
56
|
+
wit_work_item_attachment: READ_ONLY_ANNOTATIONS,
|
|
57
|
+
wit_work_item_attachment_link: ADDITIVE_ANNOTATIONS,
|
|
58
|
+
wit_work_item_attachment_upload: ADDITIVE_ANNOTATIONS,
|
|
59
|
+
wit_work_item_comment_write: MUTATING_ANNOTATIONS,
|
|
60
|
+
wit_work_item_link_write: MUTATING_ANNOTATIONS,
|
|
61
|
+
wit_work_item_write: MUTATING_ANNOTATIONS,
|
|
62
|
+
work: READ_ONLY_ANNOTATIONS,
|
|
63
|
+
work_capacity_write: MUTATING_ANNOTATIONS,
|
|
64
|
+
work_iteration_write: ADDITIVE_ANNOTATIONS,
|
|
65
|
+
};
|
|
66
|
+
function configureToolsWithAnnotations(server, configureFn) {
|
|
67
|
+
const originalTool = server.tool;
|
|
68
|
+
server.tool = new Proxy(originalTool, {
|
|
69
|
+
apply(target, thisArg, argumentsList) {
|
|
70
|
+
const toolName = argumentsList[0];
|
|
71
|
+
if (typeof toolName !== "string" || !Object.hasOwn(TOOL_ANNOTATIONS, toolName)) {
|
|
72
|
+
throw new Error(`Missing MCP annotations for tool: ${String(toolName)}`);
|
|
73
|
+
}
|
|
74
|
+
argumentsList.splice(argumentsList.length - 1, 0, TOOL_ANNOTATIONS[toolName]);
|
|
75
|
+
return Reflect.apply(target, thisArg, argumentsList);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
configureFn();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
server.tool = originalTool;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export { configureToolsWithAnnotations, TOOL_ANNOTATIONS };
|
package/dist/tools/pipelines.js
CHANGED
|
@@ -3,13 +3,30 @@
|
|
|
3
3
|
import { apiVersion, getEnumKeys, safeEnumConvert } from "../utils.js";
|
|
4
4
|
import { BuildQueryOrder, DefinitionQueryOrder } from "azure-devops-node-api/interfaces/BuildInterfaces.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
const buildQueryOrderKeys = getEnumKeys(BuildQueryOrder);
|
|
7
|
+
const definitionQueryOrderKeys = getEnumKeys(DefinitionQueryOrder);
|
|
6
8
|
import { StageUpdateType } from "azure-devops-node-api/interfaces/BuildInterfaces.js";
|
|
7
9
|
import { ConfigurationType, RepositoryType } from "azure-devops-node-api/interfaces/PipelinesInterfaces.js";
|
|
8
10
|
import { mkdirSync, createWriteStream } from "fs";
|
|
9
11
|
import { createExternalContentResponse } from "../shared/content-safety.js";
|
|
10
12
|
import { join, posix, resolve, win32 } from "path";
|
|
13
|
+
import { Readable, Transform } from "stream";
|
|
14
|
+
import { pipeline } from "stream/promises";
|
|
11
15
|
import { pipelinesWriteShape } from "./pipelines.dto.js";
|
|
12
16
|
const errorResult = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
17
|
+
const MAX_ARTIFACT_FILE_BYTES = 1024 * 1024 * 1024;
|
|
18
|
+
const MAX_ARTIFACT_BASE64_BYTES = 10 * 1024 * 1024;
|
|
19
|
+
function getArtifactResponseStream(response) {
|
|
20
|
+
if (!response.body)
|
|
21
|
+
throw new Error("Artifact download returned an empty response body.");
|
|
22
|
+
return Readable.fromWeb(response.body);
|
|
23
|
+
}
|
|
24
|
+
function validateArtifactContentLength(response, maxBytes, limitDescription) {
|
|
25
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
26
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
27
|
+
throw new Error(`Artifact download exceeds the ${limitDescription} limit.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
13
30
|
async function runPipeline(args, connectionProvider) {
|
|
14
31
|
if (!args.pipelineId)
|
|
15
32
|
return errorResult("pipelineId is required for run_pipeline");
|
|
@@ -138,7 +155,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
|
|
|
138
155
|
continuationToken: z.string().optional().describe("Token for continuing paged results. Used for: list, get_changes."),
|
|
139
156
|
maxBuildsPerDefinition: z.number().optional().describe("Maximum number of builds per definition. Used for: list."),
|
|
140
157
|
deletedFilter: z.number().optional().describe("Filter for deleted builds (see QueryDeletedOption enum). Used for: list."),
|
|
141
|
-
queryOrder: z.
|
|
158
|
+
queryOrder: z.enum(buildQueryOrderKeys).optional().describe("Order in which builds are returned. Used for: list."),
|
|
142
159
|
branchName: z.string().optional().describe("Branch name to filter builds. Used for: list."),
|
|
143
160
|
buildIds: z.array(z.coerce.number().min(1)).optional().describe("Array of specific build IDs to retrieve. Used for: list."),
|
|
144
161
|
repositoryId: z.string().optional().describe("Repository ID to filter builds. Used for: list."),
|
|
@@ -222,7 +239,7 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
|
|
|
222
239
|
repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Repository type to filter definitions. Used for: list."),
|
|
223
240
|
name: z.string().optional().describe("Name filter for build definitions. Used for: list."),
|
|
224
241
|
path: z.string().optional().describe("Path filter for build definitions. Used for: list."),
|
|
225
|
-
queryOrder: z.
|
|
242
|
+
queryOrder: z.enum(definitionQueryOrderKeys).optional().describe("Order in which definitions are returned. Used for: list."),
|
|
226
243
|
top: z.number().optional().describe("Maximum number of definitions to return. Used for: list."),
|
|
227
244
|
continuationToken: z.string().optional().describe("Token for continuing paged results. Used for: list."),
|
|
228
245
|
minMetricsTime: z.coerce.date().optional().describe("Minimum metrics time to filter definitions. Used for: list."),
|
|
@@ -338,25 +355,47 @@ function configurePipelineTools(server, tokenProvider, connectionProvider, userA
|
|
|
338
355
|
if (!artifact) {
|
|
339
356
|
return { content: [{ type: "text", text: `Artifact ${resolvedArtifactName} not found in build ${buildId}.` }], isError: true };
|
|
340
357
|
}
|
|
341
|
-
|
|
358
|
+
if (!artifact.resource?.downloadUrl) {
|
|
359
|
+
throw new Error(`Artifact ${resolvedArtifactName} does not have a download URL.`);
|
|
360
|
+
}
|
|
361
|
+
const accessToken = await tokenProvider();
|
|
362
|
+
const response = await fetch(artifact.resource.downloadUrl, {
|
|
363
|
+
headers: { "Authorization": `Bearer ${accessToken}`, "User-Agent": userAgentProvider() },
|
|
364
|
+
});
|
|
365
|
+
if (!response.ok) {
|
|
366
|
+
throw new Error(`Artifact download failed: ${response.status} ${response.statusText}`);
|
|
367
|
+
}
|
|
342
368
|
if (destinationPath) {
|
|
369
|
+
validateArtifactContentLength(response, MAX_ARTIFACT_FILE_BYTES, "1 GB file size");
|
|
343
370
|
const fullDestinationPath = resolve(destinationPath);
|
|
344
371
|
mkdirSync(fullDestinationPath, { recursive: true });
|
|
345
372
|
const fileDestinationPath = join(fullDestinationPath, `${resolvedArtifactName}.zip`);
|
|
346
373
|
const writeStream = createWriteStream(fileDestinationPath);
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
374
|
+
let downloadedBytes = 0;
|
|
375
|
+
const sizeLimitStream = new Transform({
|
|
376
|
+
transform(chunk, _encoding, callback) {
|
|
377
|
+
downloadedBytes += chunk.length;
|
|
378
|
+
if (downloadedBytes > MAX_ARTIFACT_FILE_BYTES) {
|
|
379
|
+
callback(new Error("Artifact download exceeds the 1 GB file size limit."));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
callback(null, chunk);
|
|
383
|
+
},
|
|
351
384
|
});
|
|
385
|
+
await pipeline(getArtifactResponseStream(response), sizeLimitStream, writeStream);
|
|
352
386
|
return { content: [{ type: "text", text: `Artifact ${resolvedArtifactName} downloaded to ${destinationPath}.` }] };
|
|
353
387
|
}
|
|
388
|
+
validateArtifactContentLength(response, MAX_ARTIFACT_BASE64_BYTES, "10 MB base64 response size");
|
|
354
389
|
const chunks = [];
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
390
|
+
let downloadedBytes = 0;
|
|
391
|
+
for await (const chunk of getArtifactResponseStream(response)) {
|
|
392
|
+
const bufferChunk = Buffer.from(chunk);
|
|
393
|
+
downloadedBytes += bufferChunk.length;
|
|
394
|
+
if (downloadedBytes > MAX_ARTIFACT_BASE64_BYTES) {
|
|
395
|
+
throw new Error("Artifact download exceeds the 10 MB base64 response size limit.");
|
|
396
|
+
}
|
|
397
|
+
chunks.push(bufferChunk);
|
|
398
|
+
}
|
|
360
399
|
const buffer = Buffer.concat(chunks);
|
|
361
400
|
const base64Data = buffer.toString("base64");
|
|
362
401
|
return {
|