@daghis/teamcity-mcp 1.4.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0](https://github.com/Daghis/teamcity-mcp/compare/v1.4.0...v1.5.0) (2025-09-20)
4
+
5
+
6
+ ### Features
7
+
8
+ * **teamcity:** add streaming artifact downloads ([#161](https://github.com/Daghis/teamcity-mcp/issues/161)) ([b50b773](https://github.com/Daghis/teamcity-mcp/commit/b50b773ebee6f5cc85f4c53c98fef525e7098fe9)), closes [#151](https://github.com/Daghis/teamcity-mcp/issues/151)
9
+
3
10
  ## [1.4.0](https://github.com/Daghis/teamcity-mcp/compare/v1.3.5...v1.4.0) (2025-09-20)
4
11
 
5
12
 
package/dist/index.js CHANGED
@@ -758,9 +758,9 @@ var TeamCityLogger = class _TeamCityLogger {
758
758
  */
759
759
  ensureLogDirectory(directory) {
760
760
  try {
761
- const fs = require("fs");
762
- if (fs.existsSync(directory) === false) {
763
- fs.mkdirSync(directory, { recursive: true });
761
+ const fs2 = require("fs");
762
+ if (fs2.existsSync(directory) === false) {
763
+ fs2.mkdirSync(directory, { recursive: true });
764
764
  }
765
765
  } catch (error2) {
766
766
  this.winston?.warn?.("Failed to create log directory, using current directory", { error: error2 });
@@ -921,6 +921,11 @@ function debug(message, meta) {
921
921
  }
922
922
 
923
923
  // src/tools.ts
924
+ var import_node_crypto = require("node:crypto");
925
+ var import_node_fs = require("node:fs");
926
+ var import_node_os = require("node:os");
927
+ var import_node_path = require("node:path");
928
+ var import_promises = require("node:stream/promises");
924
929
  var import_axios35 = require("axios");
925
930
  var import_zod4 = require("zod");
926
931
 
@@ -931,6 +936,318 @@ var ResolutionTypeEnum = {
931
936
  AtTime: "atTime"
932
937
  };
933
938
 
939
+ // src/teamcity/utils/build-locator.ts
940
+ var toBuildLocator = (buildId) => buildId.includes(":") ? buildId : `id:${buildId}`;
941
+
942
+ // src/teamcity/artifact-manager.ts
943
+ var ArtifactManager = class _ArtifactManager {
944
+ client;
945
+ cache = /* @__PURE__ */ new Map();
946
+ static cacheTtlMs = 6e4;
947
+ // 1 minute
948
+ static defaultLimit = 100;
949
+ static maxLimit = 1e3;
950
+ constructor(client) {
951
+ this.client = client;
952
+ }
953
+ getBaseUrl() {
954
+ const baseUrl = this.client.getApiConfig().baseUrl;
955
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
956
+ }
957
+ /**
958
+ * List artifacts for a build
959
+ */
960
+ async listArtifacts(buildId, options = {}) {
961
+ const cacheKey = this.getCacheKey(buildId, options);
962
+ if (!options.forceRefresh) {
963
+ const cached = this.getFromCache(cacheKey);
964
+ if (cached) {
965
+ return cached;
966
+ }
967
+ }
968
+ try {
969
+ const buildLocator = toBuildLocator(buildId);
970
+ const response = await this.client.modules.builds.getFilesListOfBuild(
971
+ buildLocator,
972
+ void 0,
973
+ void 0,
974
+ "file(name,fullName,size,modificationTime,href,children(file(name,fullName,size,modificationTime,href)))"
975
+ );
976
+ const baseUrl = this.getBaseUrl();
977
+ let artifacts = this.parseArtifacts(
978
+ response.data ?? {},
979
+ buildId,
980
+ options.includeNested,
981
+ baseUrl
982
+ );
983
+ artifacts = this.applyFilters(artifacts, options);
984
+ if (options.limit ?? options.offset) {
985
+ artifacts = this.paginate(
986
+ artifacts,
987
+ options.offset ?? 0,
988
+ options.limit ?? _ArtifactManager.defaultLimit
989
+ );
990
+ }
991
+ this.cacheResult(cacheKey, artifacts);
992
+ return artifacts;
993
+ } catch (error2) {
994
+ const err = error2;
995
+ if (err.response?.status === 401) {
996
+ throw new Error("Authentication failed: Invalid TeamCity token");
997
+ }
998
+ if (err.response?.status === 404) {
999
+ throw new Error(`Build not found: ${buildId}`);
1000
+ }
1001
+ const errMsg = err.message ?? String(error2);
1002
+ throw new Error(`Failed to fetch artifacts: ${errMsg}`);
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Download a specific artifact
1007
+ */
1008
+ async downloadArtifact(buildId, artifactPath, options = {}) {
1009
+ const artifacts = await this.listArtifacts(buildId);
1010
+ const artifact = artifacts.find((a) => a.path === artifactPath || a.name === artifactPath);
1011
+ if (!artifact) {
1012
+ throw new Error(`Artifact not found: ${artifactPath}`);
1013
+ }
1014
+ if (options.maxSize && artifact.size > options.maxSize) {
1015
+ throw new Error(
1016
+ `Artifact size exceeds maximum allowed size: ${artifact.size} > ${options.maxSize}`
1017
+ );
1018
+ }
1019
+ try {
1020
+ const encoding = options.encoding ?? "buffer";
1021
+ const normalizedPath = artifact.path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
1022
+ const buildLocator = toBuildLocator(buildId);
1023
+ const artifactRequestPath = `content/${normalizedPath}`;
1024
+ if (encoding === "text") {
1025
+ const response2 = await this.client.modules.builds.downloadFileOfBuild(
1026
+ artifactRequestPath,
1027
+ buildLocator,
1028
+ void 0,
1029
+ void 0,
1030
+ { responseType: "text" }
1031
+ );
1032
+ const axiosResponse2 = response2;
1033
+ const { data, headers } = axiosResponse2;
1034
+ if (typeof data !== "string") {
1035
+ throw new Error("Artifact download returned a non-text payload when text was expected");
1036
+ }
1037
+ const mimeType = typeof headers?.["content-type"] === "string" ? headers["content-type"] : void 0;
1038
+ return {
1039
+ name: artifact.name,
1040
+ path: artifact.path,
1041
+ size: artifact.size,
1042
+ content: data,
1043
+ mimeType
1044
+ };
1045
+ }
1046
+ if (encoding === "stream") {
1047
+ const response2 = await this.client.modules.builds.downloadFileOfBuild(
1048
+ artifactRequestPath,
1049
+ buildLocator,
1050
+ void 0,
1051
+ void 0,
1052
+ { responseType: "stream" }
1053
+ );
1054
+ const axiosResponse2 = response2;
1055
+ const stream = axiosResponse2.data;
1056
+ if (!this.isReadableStream(stream)) {
1057
+ throw new Error(
1058
+ "Artifact download returned a non-stream payload when stream was requested"
1059
+ );
1060
+ }
1061
+ const mimeType = typeof axiosResponse2.headers?.["content-type"] === "string" ? axiosResponse2.headers["content-type"] : void 0;
1062
+ return {
1063
+ name: artifact.name,
1064
+ path: artifact.path,
1065
+ size: artifact.size,
1066
+ content: stream,
1067
+ mimeType
1068
+ };
1069
+ }
1070
+ const response = await this.client.modules.builds.downloadFileOfBuild(
1071
+ artifactRequestPath,
1072
+ buildLocator,
1073
+ void 0,
1074
+ void 0,
1075
+ { responseType: "arraybuffer" }
1076
+ );
1077
+ const axiosResponse = response;
1078
+ const buffer = this.ensureBinaryBuffer(axiosResponse.data);
1079
+ let content;
1080
+ if (encoding === "base64") {
1081
+ content = buffer.toString("base64");
1082
+ } else {
1083
+ content = buffer;
1084
+ }
1085
+ return {
1086
+ name: artifact.name,
1087
+ path: artifact.path,
1088
+ size: artifact.size,
1089
+ content,
1090
+ mimeType: typeof axiosResponse.headers?.["content-type"] === "string" ? axiosResponse.headers["content-type"] : void 0
1091
+ };
1092
+ } catch (error2) {
1093
+ const errMsg = error2 instanceof Error ? error2.message : "Unknown error";
1094
+ throw new Error(`Failed to download artifact: ${errMsg}`);
1095
+ }
1096
+ }
1097
+ /**
1098
+ * Download multiple artifacts
1099
+ */
1100
+ async downloadMultipleArtifacts(buildId, artifactPaths, options = {}) {
1101
+ if (options.encoding === "stream") {
1102
+ throw new Error("Streaming downloads are only supported when requesting a single artifact");
1103
+ }
1104
+ const downloadOptions = { encoding: "base64", ...options };
1105
+ const results = await Promise.allSettled(
1106
+ artifactPaths.map((path) => this.downloadArtifact(buildId, path, downloadOptions))
1107
+ );
1108
+ return results.map((result, index) => {
1109
+ if (result.status === "fulfilled") {
1110
+ return result.value;
1111
+ } else {
1112
+ const fallbackName = artifactPaths[index] ?? "unknown";
1113
+ return {
1114
+ name: fallbackName,
1115
+ path: fallbackName,
1116
+ size: 0,
1117
+ error: result.reason.message
1118
+ };
1119
+ }
1120
+ });
1121
+ }
1122
+ /**
1123
+ * Parse artifacts from API response
1124
+ */
1125
+ parseArtifacts(data, buildId, includeNested, baseUrl) {
1126
+ const artifacts = [];
1127
+ const files = data.file ?? [];
1128
+ for (const file of files) {
1129
+ if (file.children && includeNested) {
1130
+ const nested = this.parseArtifacts(file.children, buildId, includeNested, baseUrl);
1131
+ artifacts.push(...nested);
1132
+ } else if (!file.children) {
1133
+ artifacts.push({
1134
+ name: file.name ?? "",
1135
+ path: file.fullName ?? file.name ?? "",
1136
+ size: file.size ?? 0,
1137
+ modificationTime: file.modificationTime ?? "",
1138
+ downloadUrl: `${baseUrl}/app/rest/builds/id:${buildId}/artifacts/content/${file.fullName ?? file.name ?? ""}`,
1139
+ isDirectory: false
1140
+ });
1141
+ }
1142
+ }
1143
+ return artifacts;
1144
+ }
1145
+ ensureBinaryBuffer(payload) {
1146
+ if (Buffer.isBuffer(payload)) {
1147
+ return payload;
1148
+ }
1149
+ if (payload instanceof ArrayBuffer) {
1150
+ return Buffer.from(payload);
1151
+ }
1152
+ throw new Error("Artifact download returned unexpected binary payload type");
1153
+ }
1154
+ isReadableStream(value) {
1155
+ if (value == null || typeof value !== "object") {
1156
+ return false;
1157
+ }
1158
+ const candidate = value;
1159
+ return typeof candidate.pipe === "function";
1160
+ }
1161
+ /**
1162
+ * Apply filters to artifacts
1163
+ */
1164
+ applyFilters(artifacts, options) {
1165
+ let filtered = artifacts;
1166
+ if (options.nameFilter) {
1167
+ const regex = this.globToRegex(options.nameFilter);
1168
+ filtered = filtered.filter((a) => regex.test(a.name));
1169
+ }
1170
+ if (options.pathFilter) {
1171
+ const regex = this.globToRegex(options.pathFilter);
1172
+ filtered = filtered.filter((a) => regex.test(a.path));
1173
+ }
1174
+ if (options.extension) {
1175
+ const ext = options.extension.startsWith(".") ? options.extension : `.${options.extension}`;
1176
+ filtered = filtered.filter((a) => a.name.endsWith(ext));
1177
+ }
1178
+ if (options.minSize !== void 0) {
1179
+ const minSize = options.minSize;
1180
+ filtered = filtered.filter((a) => a.size >= minSize);
1181
+ }
1182
+ if (options.maxSize !== void 0) {
1183
+ const maxSize = options.maxSize;
1184
+ filtered = filtered.filter((a) => a.size <= maxSize);
1185
+ }
1186
+ return filtered;
1187
+ }
1188
+ /**
1189
+ * Convert glob pattern to regex
1190
+ */
1191
+ globToRegex(pattern) {
1192
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1193
+ return new RegExp(`^${escaped}$`);
1194
+ }
1195
+ /**
1196
+ * Paginate results
1197
+ */
1198
+ paginate(artifacts, offset, limit) {
1199
+ const effectiveLimit = Math.min(limit, _ArtifactManager.maxLimit);
1200
+ return artifacts.slice(offset, offset + effectiveLimit);
1201
+ }
1202
+ /**
1203
+ * Generate cache key
1204
+ */
1205
+ getCacheKey(buildId, options) {
1206
+ const { forceRefresh: _forceRefresh, ...cacheOptions } = options;
1207
+ return `${buildId}:${JSON.stringify(cacheOptions)}`;
1208
+ }
1209
+ /**
1210
+ * Get from cache if valid
1211
+ */
1212
+ getFromCache(key) {
1213
+ const entry = this.cache.get(key);
1214
+ if (!entry) {
1215
+ return null;
1216
+ }
1217
+ const age = Date.now() - entry.timestamp;
1218
+ if (age > _ArtifactManager.cacheTtlMs) {
1219
+ this.cache.delete(key);
1220
+ return null;
1221
+ }
1222
+ return entry.artifacts;
1223
+ }
1224
+ /**
1225
+ * Cache artifacts
1226
+ */
1227
+ cacheResult(key, artifacts) {
1228
+ this.cache.set(key, {
1229
+ artifacts,
1230
+ timestamp: Date.now()
1231
+ });
1232
+ this.cleanCache();
1233
+ }
1234
+ /**
1235
+ * Remove expired cache entries
1236
+ */
1237
+ cleanCache() {
1238
+ const now = Date.now();
1239
+ const expired = [];
1240
+ for (const [key, entry] of this.cache.entries()) {
1241
+ if (now - entry.timestamp > _ArtifactManager.cacheTtlMs) {
1242
+ expired.push(key);
1243
+ }
1244
+ }
1245
+ for (const key of expired) {
1246
+ this.cache.delete(key);
1247
+ }
1248
+ }
1249
+ };
1250
+
934
1251
  // src/teamcity/build-configuration-update-manager.ts
935
1252
  var BuildConfigurationUpdateManager = class {
936
1253
  client;
@@ -1317,9 +1634,6 @@ var BuildConfigurationUpdateManager = class {
1317
1634
  }
1318
1635
  };
1319
1636
 
1320
- // src/teamcity/utils/build-locator.ts
1321
- var toBuildLocator = (buildId) => buildId.includes(":") ? buildId : `id:${buildId}`;
1322
-
1323
1637
  // src/teamcity/build-results-manager.ts
1324
1638
  var BuildResultsManager = class _BuildResultsManager {
1325
1639
  client;
@@ -1802,7 +2116,7 @@ function createAdapterFromTeamCityAPI(api, options = {}) {
1802
2116
  listTestFailures: (buildId) => api.listTestFailures(buildId),
1803
2117
  builds: buildApi,
1804
2118
  listBuildArtifacts: (buildId, options2) => api.listBuildArtifacts(buildId, options2),
1805
- downloadArtifactContent: (buildId, artifactPath) => api.downloadBuildArtifact(buildId, artifactPath),
2119
+ downloadArtifactContent: (buildId, artifactPath, requestOptions) => api.downloadBuildArtifact(buildId, artifactPath, requestOptions),
1806
2120
  getBuildStatistics: (buildId, fields) => api.getBuildStatistics(buildId, fields),
1807
2121
  listChangesForBuild: (buildId, fields) => api.listChangesForBuild(buildId, fields),
1808
2122
  listSnapshotDependencies: (buildId) => api.listSnapshotDependencies(buildId),
@@ -36371,13 +36685,15 @@ var TeamCityAPI = class _TeamCityAPI {
36371
36685
  options?.logBuildUsage
36372
36686
  );
36373
36687
  }
36374
- async downloadBuildArtifact(buildId, artifactPath) {
36688
+ async downloadBuildArtifact(buildId, artifactPath, options) {
36375
36689
  const normalizedPath = artifactPath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
36690
+ const requestOptions = {
36691
+ ...options ?? {},
36692
+ responseType: options?.responseType ?? "arraybuffer"
36693
+ };
36376
36694
  return this.axiosInstance.get(
36377
36695
  `/app/rest/builds/id:${buildId}/artifacts/content/${normalizedPath}`,
36378
- {
36379
- responseType: "arraybuffer"
36380
- }
36696
+ requestOptions
36381
36697
  );
36382
36698
  }
36383
36699
  async getBuildStatistics(buildId, fields) {
@@ -37972,6 +38288,95 @@ var DEV_TOOLS = [
37972
38288
  );
37973
38289
  }
37974
38290
  },
38291
+ {
38292
+ name: "download_build_artifact",
38293
+ description: "Download a single artifact with optional streaming output",
38294
+ inputSchema: {
38295
+ type: "object",
38296
+ properties: {
38297
+ buildId: { type: "string", description: "Build ID" },
38298
+ artifactPath: { type: "string", description: "Artifact path or name" },
38299
+ encoding: {
38300
+ type: "string",
38301
+ description: "Response encoding: 'base64' (default), 'text', or 'stream'",
38302
+ enum: ["base64", "text", "stream"],
38303
+ default: "base64"
38304
+ },
38305
+ maxSize: {
38306
+ type: "number",
38307
+ description: "Maximum artifact size (bytes) allowed before aborting"
38308
+ },
38309
+ outputPath: {
38310
+ type: "string",
38311
+ description: "Optional absolute path to write streamed content; defaults to a temp file when streaming"
38312
+ }
38313
+ },
38314
+ required: ["buildId", "artifactPath"]
38315
+ },
38316
+ handler: async (args) => {
38317
+ const schema = import_zod4.z.object({
38318
+ buildId: import_zod4.z.string().min(1),
38319
+ artifactPath: import_zod4.z.string().min(1),
38320
+ encoding: import_zod4.z.enum(["base64", "text", "stream"]).default("base64"),
38321
+ maxSize: import_zod4.z.number().int().positive().optional(),
38322
+ outputPath: import_zod4.z.string().min(1).optional()
38323
+ });
38324
+ const isReadableStream = (value) => typeof value === "object" && value !== null && typeof value.pipe === "function";
38325
+ const toTempFilePath = (artifactName) => {
38326
+ const base = (0, import_node_path.basename)(artifactName || "artifact");
38327
+ const safeStem = base.replace(/[^a-zA-Z0-9._-]/g, "_") || "artifact";
38328
+ const ext = (0, import_node_path.extname)(safeStem);
38329
+ const stemWithoutExt = ext ? safeStem.slice(0, -ext.length) : safeStem;
38330
+ const finalStem = stemWithoutExt || "artifact";
38331
+ const fileName = `${finalStem}-${(0, import_node_crypto.randomUUID)()}${ext}`;
38332
+ return (0, import_node_path.join)((0, import_node_os.tmpdir)(), fileName);
38333
+ };
38334
+ return runTool(
38335
+ "download_build_artifact",
38336
+ schema,
38337
+ async (typed) => {
38338
+ const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance());
38339
+ const manager = new ArtifactManager(adapter);
38340
+ const artifact = await manager.downloadArtifact(typed.buildId, typed.artifactPath, {
38341
+ encoding: typed.encoding,
38342
+ maxSize: typed.maxSize
38343
+ });
38344
+ if (typed.encoding === "stream") {
38345
+ const stream = artifact.content;
38346
+ if (!isReadableStream(stream)) {
38347
+ throw new Error("Streaming download did not return a readable stream");
38348
+ }
38349
+ const targetPath = typed.outputPath ?? toTempFilePath(artifact.name);
38350
+ await import_node_fs.promises.mkdir((0, import_node_path.dirname)(targetPath), { recursive: true });
38351
+ await (0, import_promises.pipeline)(stream, (0, import_node_fs.createWriteStream)(targetPath));
38352
+ const stats = await import_node_fs.promises.stat(targetPath);
38353
+ return json({
38354
+ name: artifact.name,
38355
+ path: artifact.path,
38356
+ size: artifact.size,
38357
+ mimeType: artifact.mimeType,
38358
+ encoding: "stream",
38359
+ outputPath: targetPath,
38360
+ bytesWritten: stats.size
38361
+ });
38362
+ }
38363
+ const payloadContent = artifact.content;
38364
+ if (typeof payloadContent !== "string") {
38365
+ throw new Error(`Expected ${typed.encoding} artifact content as string`);
38366
+ }
38367
+ return json({
38368
+ name: artifact.name,
38369
+ path: artifact.path,
38370
+ size: artifact.size,
38371
+ mimeType: artifact.mimeType,
38372
+ encoding: typed.encoding,
38373
+ content: payloadContent
38374
+ });
38375
+ },
38376
+ args
38377
+ );
38378
+ }
38379
+ },
37975
38380
  {
37976
38381
  name: "get_test_details",
37977
38382
  description: "Get detailed information about test failures",