@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 CHANGED
@@ -1,19 +1,14 @@
1
1
  # Azure DevOps MCP Server
2
2
 
3
- > [!WARNING]
4
- > We recently completed a full tool consolidation that includes renaming of existing tools. Please see the [Toolset documentation](docs/TOOLSET.md) for the complete list of new tool names.
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
- > If this is a breaking change for your agents or skills, you can temporarily pin the version to `@azure-devops/mcp@2.8.1`
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", "${input:ado_org}"]
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", "${input:ado_org}", "-d", "core", "work", "work-items"]
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", "myorg", "--authentication", "azcli"],
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
- const scopes = ["499b84ac-1321-427f-aa17-267ca6975798/.default"];
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(scopes);
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 };
@@ -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.string().optional().describe("Order in which builds are returned (BuildQueryOrder values). Used for: list."),
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.string().optional().describe("Order in which definitions are returned (DefinitionQueryOrder values). Used for: list."),
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
- const fileStream = await buildApi.getArtifactContentZip(project, buildId, resolvedArtifactName);
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
- await new Promise((resolve, reject) => {
348
- fileStream.pipe(writeStream);
349
- fileStream.on("end", () => resolve());
350
- fileStream.on("error", (err) => reject(err));
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
- await new Promise((resolve, reject) => {
356
- fileStream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
357
- fileStream.on("end", () => resolve());
358
- fileStream.on("error", (err) => reject(err));
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 {