Skip to main content
Glama
tools.ts196 kB
/** * Static Tool Definitions for TeamCity MCP Server * Simple, direct tool implementations without complex abstractions */ import { randomUUID } from 'node:crypto'; import { createWriteStream, promises as fs } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { isAxiosError } from 'axios'; import { z } from 'zod'; import { getMCPMode as getMCPModeFromConfig } from '@/config'; import { type Build, type Mutes, ResolutionTypeEnum } from '@/teamcity-client/models'; import type { Step } from '@/teamcity-client/models/step'; import { AgentRequirementsManager } from '@/teamcity/agent-requirements-manager'; import { type ArtifactContent, ArtifactManager } from '@/teamcity/artifact-manager'; import { BuildConfigurationCloneManager } from '@/teamcity/build-configuration-clone-manager'; import { BuildConfigurationUpdateManager, setArtifactRulesWithFallback, } from '@/teamcity/build-configuration-update-manager'; import { BuildDependencyManager } from '@/teamcity/build-dependency-manager'; import { BuildFeatureManager } from '@/teamcity/build-feature-manager'; import { BuildResultsManager } from '@/teamcity/build-results-manager'; import { type TeamCityClientAdapter, createAdapterFromTeamCityAPI, } from '@/teamcity/client-adapter'; import { TeamCityAPIError, TeamCityNotFoundError, isRetryableError } from '@/teamcity/errors'; import { createPaginatedFetcher, fetchAllPages } from '@/teamcity/pagination'; import { buildBranchSegmentInput, hasBranchSegment, normalizeLocatorSegments, } from '@/utils/list-builds-locator'; import { debug } from '@/utils/logger'; import { json, runTool } from '@/utils/mcp'; import { TeamCityAPI } from './api-client'; const isReadableStream = (value: unknown): value is Readable => typeof value === 'object' && value !== null && typeof (value as Readable).pipe === 'function'; interface ArtifactPayloadBase { name: string; path: string; size: number; mimeType?: string; } interface StreamOptions { explicitOutputPath?: string; outputDir?: string; } interface ArtifactStreamPayload extends ArtifactPayloadBase { encoding: 'stream'; outputPath: string; bytesWritten: number; } interface ArtifactContentPayload extends ArtifactPayloadBase { encoding: 'base64' | 'text'; content: string; } type ArtifactToolPayload = ArtifactStreamPayload | ArtifactContentPayload; type ArtifactPathInput = | string | { path: string; buildId?: string; downloadUrl?: string; }; interface NormalizedArtifactRequest { path: string; buildId: string; downloadUrl?: string; } const sanitizeFileName = ( artifactName: string ): { sanitizedBase: string; stem: string; ext: string; } => { const base = basename(artifactName || 'artifact'); const safeBase = base.replace(/[^a-zA-Z0-9._-]/g, '_') || 'artifact'; const ext = extname(safeBase); const stemCandidate = ext ? safeBase.slice(0, -ext.length) : safeBase; const stem = stemCandidate || 'artifact'; const sanitizedBase = ext ? `${stem}${ext}` : stem; return { sanitizedBase, stem, ext }; }; const buildRandomFileName = (artifactName: string): string => { const { stem, ext } = sanitizeFileName(artifactName); return `${stem}-${randomUUID()}${ext}`; }; const sanitizePathSegments = (artifactPath: string | undefined, fallbackName: string): string[] => { const rawSegments = artifactPath?.split('/') ?? []; const sanitizedSegments = rawSegments .map((segment) => segment.trim()) .filter((segment) => segment.length > 0 && segment !== '.' && segment !== '..') .map((segment) => segment.replace(/[^a-zA-Z0-9._-]/g, '_')); if (sanitizedSegments.length === 0) { const { sanitizedBase } = sanitizeFileName(fallbackName); sanitizedSegments.push(sanitizedBase); } return sanitizedSegments; }; const ensureUniquePath = async (candidate: string): Promise<string> => { const ext = extname(candidate); const stem = ext ? candidate.slice(0, -ext.length) : candidate; const probe = async (attempt: number): Promise<string> => { const next = attempt === 0 ? candidate : `${stem}-${attempt}${ext}`; try { // Reserve the path atomically so subsequent writers do not win the race const handle = await fs.open(next, 'wx'); await handle.close(); return next; } catch (error) { const err = error as NodeJS.ErrnoException | undefined; if (err?.code === 'EEXIST') { return probe(attempt + 1); } throw error; } }; return probe(0); }; const resolveStreamOutputPath = async ( artifact: ArtifactContent, options: StreamOptions ): Promise<string> => { const { explicitOutputPath } = options; if (typeof explicitOutputPath === 'string' && explicitOutputPath.length > 0) { const target = explicitOutputPath; await fs.mkdir(dirname(target), { recursive: true }); return target; } if (options.outputDir) { const segments = sanitizePathSegments(artifact.path, artifact.name); const parts = segments.slice(0, -1); const fileName = segments[segments.length - 1] ?? sanitizeFileName(artifact.name).sanitizedBase; const baseDir = resolve(options.outputDir); const candidate = resolve(baseDir, ...parts, fileName); const relativePath = relative(baseDir, candidate); if (relativePath.startsWith('..') || isAbsolute(relativePath)) { throw new Error('Resolved artifact path escapes the configured output directory'); } await fs.mkdir(dirname(candidate), { recursive: true }); return ensureUniquePath(candidate); } const tempFilePath = join(tmpdir(), buildRandomFileName(artifact.name)); await fs.mkdir(dirname(tempFilePath), { recursive: true }); return tempFilePath; }; const writeArtifactStreamToDisk = async ( artifact: ArtifactContent, stream: Readable, options: StreamOptions ): Promise<{ outputPath: string; bytesWritten: number }> => { const targetPath = await resolveStreamOutputPath(artifact, options); await pipeline(stream, createWriteStream(targetPath)); const stats = await fs.stat(targetPath); return { outputPath: targetPath, bytesWritten: stats.size }; }; const buildArtifactPayload = async ( artifact: ArtifactContent, encoding: 'base64' | 'text' | 'stream', options: StreamOptions ): Promise<ArtifactToolPayload> => { if (encoding === 'stream') { const contentStream = artifact.content; if (!isReadableStream(contentStream)) { throw new Error('Streaming download did not return a readable stream'); } const { outputPath, bytesWritten } = await writeArtifactStreamToDisk( artifact, contentStream, options ); return { name: artifact.name, path: artifact.path, size: artifact.size, mimeType: artifact.mimeType, encoding: 'stream', outputPath, bytesWritten, }; } const payloadContent = artifact.content; if (typeof payloadContent !== 'string') { throw new Error(`Expected ${encoding} artifact content as string`); } return { name: artifact.name, path: artifact.path, size: artifact.size, mimeType: artifact.mimeType, encoding, content: payloadContent, }; }; const toNormalizedArtifactRequests = ( inputs: ArtifactPathInput[], defaultBuildId: string ): NormalizedArtifactRequest[] => inputs.map((entry) => { if (typeof entry === 'string') { return { path: entry, buildId: defaultBuildId }; } const path = entry.path.trim(); const buildId = (entry.buildId ?? defaultBuildId).trim(); if (!buildId) { throw new Error(`Artifact request for path "${path}" is missing a buildId`); } return { path, buildId, downloadUrl: entry.downloadUrl?.trim(), }; }); const getErrorMessage = (error: unknown): string => { if (isAxiosError(error)) { const status = error.response?.status; const data = error.response?.data; let detail: string | undefined; if (typeof data === 'string') { detail = data; } else if (data !== undefined && data !== null && typeof data === 'object') { try { detail = JSON.stringify(data); } catch { detail = '[unserializable response body]'; } } return `HTTP ${status ?? 'unknown'}${detail ? `: ${detail}` : ''}`; } if (error instanceof Error) { return error.message; } if (typeof error === 'object' && error !== null && 'message' in error) { return String((error as { message?: unknown }).message); } return String(error ?? 'Unknown error'); }; const downloadArtifactByUrl = async ( adapter: TeamCityClientAdapter, request: NormalizedArtifactRequest & { downloadUrl: string }, encoding: 'base64' | 'text' | 'stream', options: StreamOptions & { maxSize?: number } ): Promise<ArtifactToolPayload> => { const axios = adapter.getAxios(); const responseType = encoding === 'stream' ? 'stream' : encoding === 'text' ? 'text' : 'arraybuffer'; const response = await axios.get(request.downloadUrl, { responseType }); const mimeType = typeof response.headers?.['content-type'] === 'string' ? response.headers['content-type'] : undefined; const contentLengthHeader = response.headers?.['content-length']; const contentLength = typeof contentLengthHeader === 'string' ? Number.parseInt(contentLengthHeader, 10) : undefined; if (options.maxSize && typeof contentLength === 'number' && contentLength > options.maxSize) { throw new Error( `Artifact size exceeds maximum allowed size: ${contentLength} > ${options.maxSize}` ); } if (encoding === 'stream') { const stream = response.data; if (!isReadableStream(stream)) { throw new Error('Streaming download did not return a readable stream'); } const artifact: ArtifactContent = { name: request.path.split('/').pop() ?? request.path, path: request.path, size: contentLength ?? 0, content: stream, mimeType, }; return buildArtifactPayload(artifact, 'stream', options); } const rawPayload = response.data; if (encoding === 'text') { if (typeof rawPayload !== 'string') { throw new Error('Artifact download returned a non-text payload when text was expected'); } const textSize = Buffer.byteLength(rawPayload, 'utf8'); if (options.maxSize && textSize > options.maxSize) { throw new Error( `Artifact size exceeds maximum allowed size: ${textSize} > ${options.maxSize}` ); } const artifact: ArtifactContent = { name: request.path.split('/').pop() ?? request.path, path: request.path, size: textSize, content: rawPayload, mimeType, }; return buildArtifactPayload(artifact, 'text', options); } let buffer: Buffer; if (Buffer.isBuffer(rawPayload)) { buffer = rawPayload; } else if (rawPayload instanceof ArrayBuffer) { buffer = Buffer.from(rawPayload); } else if (ArrayBuffer.isView(rawPayload)) { buffer = Buffer.from(rawPayload.buffer); } else { throw new Error('Artifact download returned unexpected binary payload type'); } if (options.maxSize && buffer.byteLength > options.maxSize) { throw new Error( `Artifact size exceeds maximum allowed size: ${buffer.byteLength} > ${options.maxSize}` ); } const artifact: ArtifactContent = { name: request.path.split('/').pop() ?? request.path, path: request.path, size: buffer.byteLength, content: buffer.toString('base64'), mimeType, }; return buildArtifactPayload(artifact, 'base64', options); }; // Tool response type export interface ToolResponse { content?: Array<{ type: string; text: string }>; error?: string; success?: boolean; data?: unknown; } // Tool definition - handlers use unknown but are cast internally export interface ToolDefinition { name: string; description: string; inputSchema: unknown; handler: (args: unknown) => Promise<ToolResponse>; mode?: 'dev' | 'full'; // If not specified, available in both modes } // Specific argument types are intentionally scoped to the handlers that use them. // Zod validates at runtime; these interfaces keep compile-time safety and clean linting. interface DeleteProjectArgs { projectId: string; } interface CreateBuildConfigArgs { projectId: string; name: string; id: string; description?: string; } interface CloneBuildConfigArgs { sourceBuildTypeId: string; name: string; id: string; projectId?: string; description?: string; parameters?: Record<string, string>; copyBuildCounter?: boolean; } interface UpdateBuildConfigArgs { buildTypeId: string; name?: string; description?: string; paused?: boolean; artifactRules?: string; } interface AddParameterArgs { buildTypeId: string; name: string; value: string; } interface UpdateParameterArgs { buildTypeId: string; name: string; value: string; } interface DeleteParameterArgs { buildTypeId: string; name: string; } interface CreateVCSRootArgs { projectId: string; name: string; id: string; vcsName: string; url: string; branch?: string; } interface AuthorizeAgentArgs { agentId: string; authorize: boolean; } interface AssignAgentToPoolArgs { agentId: string; poolId: string; } interface ManageBuildTriggersArgs { buildTypeId: string; action: 'add' | 'delete'; triggerId?: string; type?: string; properties?: Record<string, unknown>; } /** * Get the current MCP mode from environment */ export function getMCPMode(): 'dev' | 'full' { return getMCPModeFromConfig(); } /** * Developer tools (dev mode) - Read-only operations for developers */ const DEV_TOOLS: ToolDefinition[] = [ // === Basic Tools === { name: 'ping', description: 'Test MCP server connectivity', inputSchema: { type: 'object', properties: { message: { type: 'string', description: 'Optional message to echo back' }, }, }, handler: async (args: unknown) => { const typedArgs = args as { message?: string }; return { content: [ { type: 'text', text: `pong${typedArgs.message ? `: ${typedArgs.message}` : ''}`, }, ], }; }, }, // === Project Tools === { name: 'list_projects', description: 'List TeamCity projects (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional locator to filter projects' }, parentProjectId: { type: 'string', description: 'Filter by parent project ID' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), parentProjectId: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_projects', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); if (typed.parentProjectId) baseParts.push(`parent:(id:${typed.parentProjectId})`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.projects.getAllProjects( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { project?: unknown[]; count?: number }; return Array.isArray(data.project) ? (data.project as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'get_project', description: 'Get details of a specific project', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'Project ID' }, }, required: ['projectId'], }, handler: async (args: unknown) => { const schema = z.object({ projectId: z.string().min(1) }); return runTool( 'get_project', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const project = await adapter.getProject(typed.projectId); return json(project); }, args ); }, }, // === Build Tools === { name: 'list_builds', description: 'List TeamCity builds (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional build locator to filter builds' }, projectId: { type: 'string', description: 'Filter by project ID' }, buildTypeId: { type: 'string', description: 'Filter by build type ID' }, branch: { type: 'string', description: 'Filter by branch (logical or VCS name)' }, status: { type: 'string', enum: ['SUCCESS', 'FAILURE', 'ERROR'], description: 'Filter by status', }, count: { type: 'number', description: 'Deprecated: use pageSize', default: 10 }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), projectId: z.string().min(1).optional(), buildTypeId: z.string().min(1).optional(), branch: z.string().min(1).optional(), status: z.enum(['SUCCESS', 'FAILURE', 'ERROR']).optional(), count: z.number().int().min(1).max(1000).default(10).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_builds', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const locatorSegments = normalizeLocatorSegments(typed.locator); const hasBranchInLocator = hasBranchSegment(locatorSegments); // Build shared filter parts const baseParts: string[] = [...locatorSegments]; if (typed.projectId) baseParts.push(`project:(id:${typed.projectId})`); if (typed.buildTypeId) baseParts.push(`buildType:(id:${typed.buildTypeId})`); if (typed.branch) { const branchSegment = buildBranchSegmentInput(typed.branch); if (!hasBranchInLocator) { baseParts.push(branchSegment); } } if (typed.status) baseParts.push(`status:${typed.status}`); const pageSize = typed.pageSize ?? typed.count ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; // Use the generated client directly to retain nextHref/prevHref in response.data return adapter.modules.builds.getAllBuilds(locator as string | undefined, typed.fields); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { build?: unknown[]; count?: number }; return Array.isArray(data.build) ? (data.build as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages, }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } // Single page const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'get_build', description: 'Get details of a specific build', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID' }, }, required: ['buildId'], }, handler: async (args: unknown) => { const schema = z.object({ buildId: z.string().min(1) }); return runTool( 'get_build', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const build = (await adapter.getBuild(typed.buildId)) as { status?: string; statusText?: string; }; return json(build); }, args ); }, }, { name: 'trigger_build', description: 'Trigger a new build', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID to trigger' }, branchName: { type: 'string', description: 'Branch to build (optional)' }, comment: { type: 'string', description: 'Build comment (optional)' }, properties: { type: 'object', description: 'Optional build parameters to set when triggering the build', additionalProperties: { type: 'string' }, }, }, required: ['buildTypeId'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeId: z.string().min(1), branchName: z.string().min(1).max(255).optional(), comment: z.string().max(500).optional(), properties: z.record(z.string(), z.string()).optional(), }); return runTool( 'trigger_build', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const directBranch = typed.branchName?.trim(); const normalizedDirectBranch = directBranch && directBranch.length > 0 ? directBranch : undefined; const rawPropertyBranch = typed.properties?.['teamcity.build.branch']; const trimmedPropertyBranch = rawPropertyBranch?.trim(); const normalizedPropertyBranch = trimmedPropertyBranch && trimmedPropertyBranch.length > 0 ? trimmedPropertyBranch : undefined; const branchName = normalizedDirectBranch ?? normalizedPropertyBranch; if ( normalizedDirectBranch && normalizedPropertyBranch && normalizedDirectBranch !== normalizedPropertyBranch ) { const errorPayload = { success: false, action: 'trigger_build', error: `Conflicting branch overrides: branchName='${normalizedDirectBranch}' vs properties.teamcity.build.branch='${normalizedPropertyBranch}'.`, } as const; return { success: false, error: errorPayload.error, content: [{ type: 'text', text: JSON.stringify(errorPayload, null, 2) }], }; } const propertyEntries = typed.properties ? Object.entries(typed.properties).map(([name, value]) => ({ name, value: name === 'teamcity.build.branch' && normalizedPropertyBranch ? normalizedPropertyBranch : value, })) : []; const propertiesPayload = propertyEntries.length > 0 ? { property: propertyEntries } : undefined; const buildRequest: Partial<Build> & { buildType: { id: string }; properties?: { property: Array<{ name: string; value: string }> }; } = { buildType: { id: typed.buildTypeId }, }; if (branchName) { buildRequest.branchName = branchName; } const commentText = typed.comment?.trim(); if (commentText && commentText.length > 0) { buildRequest.comment = { text: commentText }; } if (propertiesPayload) { buildRequest.properties = propertiesPayload; } const sendXmlFallback = async (error: unknown) => { const escapeXml = (value: string): string => value .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;'); const branchPart = branchName ? `<branchName>${escapeXml(branchName)}</branchName>` : ''; const commentPart = commentText ? `<comment><text>${escapeXml(commentText)}</text></comment>` : ''; const propertiesPart = propertiesPayload ? `<properties>${propertiesPayload.property .map( (prop) => `<property name="${escapeXml(prop.name)}" value="${escapeXml(prop.value)}"/>` ) .join('')}</properties>` : ''; const xml = `<?xml version="1.0" encoding="UTF-8"?><build><buildType id="${escapeXml( typed.buildTypeId )}"/>${branchPart}${commentPart}${propertiesPart}</build>`; const response = await adapter.http.post('/app/rest/buildQueue', xml, { headers: { 'Content-Type': 'application/xml', Accept: 'application/json' }, params: { moveToTop: false }, }); const build = response.data as { id?: number | string; state?: string; status?: string; branchName?: string; }; return json({ success: true, action: 'trigger_build', buildId: String(build.id ?? ''), state: (build.state as string) ?? undefined, status: (build.status as string) ?? undefined, branchName: (build.branchName as string) ?? branchName, fallback: { mode: 'xml', reason: (error as Error)?.message }, }); }; try { const response = await adapter.modules.buildQueue.addBuildToQueue( false, buildRequest as Build, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, } ); const build = response.data as { id?: number | string; state?: string; status?: string; branchName?: string; }; return json({ success: true, action: 'trigger_build', buildId: String(build.id ?? ''), state: (build.state as string) ?? undefined, status: (build.status as string) ?? undefined, branchName: (build.branchName as string) ?? branchName, }); } catch (error) { return sendXmlFallback(error); } }, args ); }, }, { name: 'cancel_queued_build', description: 'Cancel a queued build by ID', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Queued build ID' }, }, required: ['buildId'], }, handler: async (args: unknown) => { const schema = z.object({ buildId: z.string().min(1) }); return runTool( 'cancel_queued_build', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.buildQueue.deleteQueuedBuild(typed.buildId); return json({ success: true, action: 'cancel_queued_build', buildId: typed.buildId }); }, args ); }, // Available in dev and full modes (developer convenience) }, { name: 'get_build_status', description: 'Get build status with optional test/problem and queue context details', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID' }, buildNumber: { type: 'string', description: 'Human build number (requires buildTypeId when provided)', }, buildTypeId: { type: 'string', description: 'Build configuration identifier (required when using buildNumber)', }, includeTests: { type: 'boolean', description: 'Include test summary' }, includeProblems: { type: 'boolean', description: 'Include build problems' }, includeQueueTotals: { type: 'boolean', description: 'Include total queued count (extra API call when queued)', }, includeQueueReason: { type: 'boolean', description: 'Include waitReason for the queued item (extra API call when queued)', }, }, }, handler: async (args: unknown) => { const schema = z .object({ buildId: z.string().min(1).optional(), buildNumber: z.string().min(1).optional(), buildTypeId: z.string().min(1).optional(), includeTests: z.boolean().optional(), includeProblems: z.boolean().optional(), includeQueueTotals: z.boolean().optional(), includeQueueReason: z.boolean().optional(), }) .superRefine((value, ctx) => { if (!value.buildId && !value.buildNumber) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['buildId'], message: 'Either buildId or buildNumber must be provided', }); } if (value.buildNumber && !value.buildTypeId) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['buildTypeId'], message: 'buildTypeId is required when querying by buildNumber', }); } }); return runTool( 'get_build_status', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const statusManager = new ( await import('@/teamcity/build-status-manager') ).BuildStatusManager(adapter); const result = await statusManager.getBuildStatus({ buildId: typed.buildId, buildNumber: typed.buildNumber, buildTypeId: typed.buildTypeId, includeTests: typed.includeTests, includeProblems: typed.includeProblems, }); if (result.state === 'queued') { const enrich: { totalQueued?: number; waitReason?: string; canMoveToTop?: boolean } = {}; // Derive canMoveToTop without extra call if (typeof result.queuePosition === 'number') { enrich.canMoveToTop = result.queuePosition > 1; } if (typed.includeQueueTotals) { try { const countResp = await adapter.modules.buildQueue.getAllQueuedBuilds( undefined, 'count' ); enrich.totalQueued = (countResp.data as { count?: number }).count; } catch { /* ignore */ } } if (typed.includeQueueReason) { try { const targetBuildId = typed.buildId ?? result.buildId; if (targetBuildId) { const qb = await adapter.modules.buildQueue.getQueuedBuild(targetBuildId); enrich.waitReason = (qb.data as { waitReason?: string }).waitReason; } } catch { /* ignore */ } } return json({ ...result, ...enrich }); } return json(result); }, args ); }, }, { name: 'fetch_build_log', description: 'Fetch build log with pagination (by lines)', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID (TeamCity internal id)' }, buildNumber: { type: 'string', description: 'Human build number (e.g., 54). If provided, optionally include buildTypeId to disambiguate.', }, buildTypeId: { type: 'string', description: 'Optional build type ID to disambiguate buildNumber', }, page: { type: 'number', description: '1-based page number' }, pageSize: { type: 'number', description: 'Lines per page (default 500)' }, startLine: { type: 'number', description: '0-based start line (overrides page)' }, lineCount: { type: 'number', description: 'Max lines to return (overrides pageSize)' }, tail: { type: 'boolean', description: 'Tail mode: return last N lines' }, encoding: { type: 'string', description: "Response encoding: 'text' (default) or 'stream'", enum: ['text', 'stream'], default: 'text', }, outputPath: { type: 'string', description: 'Optional absolute path to write streamed logs; defaults to a temp file when streaming', }, }, required: [], }, handler: async (args: unknown) => { const schema = z .object({ buildId: z.string().min(1).optional(), buildNumber: z.union([z.string().min(1), z.number().int().min(0)]).optional(), buildTypeId: z.string().min(1).optional(), page: z.number().int().min(1).optional(), pageSize: z.number().int().min(1).max(5000).optional(), startLine: z.number().int().min(0).optional(), lineCount: z.number().int().min(1).max(5000).optional(), tail: z.boolean().optional(), encoding: z.enum(['text', 'stream']).default('text'), outputPath: z.string().min(1).optional(), }) .superRefine((value, ctx) => { if (!value.buildId && typeof value.buildNumber === 'undefined') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Provide either buildId or buildNumber', path: ['buildId'], }); } if (value.encoding === 'stream' && value.tail) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Streaming mode does not support tail queries', path: ['tail'], }); } }); return runTool( 'fetch_build_log', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); // Resolve effective buildId from buildId or buildNumber (+ optional buildTypeId) let effectiveBuildId: string | undefined; if (typed.buildId) { effectiveBuildId = typed.buildId; } else { const numberStr = String(typed.buildNumber); const baseLocatorParts: string[] = []; if (typed.buildTypeId) baseLocatorParts.push(`buildType:(id:${typed.buildTypeId})`); // Include non-default branches so build numbers on PR branches resolve baseLocatorParts.push('branch:default:any'); baseLocatorParts.push(`number:${numberStr}`); // Limit result set to avoid huge payloads baseLocatorParts.push('count:10'); const locator = baseLocatorParts.join(','); const resp = (await adapter.listBuilds(locator)) as { build?: Array<{ id?: number; buildTypeId?: string }>; }; const builds = Array.isArray(resp.build) ? resp.build : []; if (builds.length === 0) { // Fallback: if buildTypeId is provided, fetch recent builds for that configuration and match by number if (typed.buildTypeId) { const recent = (await adapter.listBuilds( `buildType:(id:${typed.buildTypeId}),branch:default:any,count:100` )) as { build?: Array<{ id?: number; number?: string }> }; const items = Array.isArray(recent.build) ? recent.build : []; const match = items.find((b) => String(b.number) === numberStr); if (match?.id != null) { effectiveBuildId = String(match.id); } else { throw new Error( `No build found with number ${numberStr} for buildTypeId ${typed.buildTypeId}` ); } } else { throw new Error( `No build found with number ${numberStr}${typed.buildTypeId ? ` for buildTypeId ${typed.buildTypeId}` : ''}` ); } } if (!effectiveBuildId && !typed.buildTypeId && builds.length > 1) { throw new Error( `Multiple builds match number ${numberStr}. Provide buildTypeId to disambiguate.` ); } if (!effectiveBuildId) { const found = builds[0]; if (!found?.id) { throw new Error('Resolved build has no id'); } effectiveBuildId = String(found.id); } } if (!effectiveBuildId) { throw new Error('Failed to resolve buildId from inputs'); } const shouldRetry = (error: unknown): boolean => { if (error instanceof TeamCityAPIError) { if (error.code === 'HTTP_404') { return true; } return isRetryableError(error); } if (isAxiosError(error)) { const status = error.response?.status; if (status === 404) { return true; } if (status != null && status >= 500 && status < 600) { return true; } if (!status) { // Network-level failure (timeout, connection reset, etc.) return true; } } return false; }; const normalizeError = (error: unknown): Error => { if (isAxiosError(error)) { const status = error.response?.status; const statusText = (error.response?.statusText ?? '').trim(); const base = status ? `${status}${statusText ? ` ${statusText}` : ''}` : error.message; return new Error(base || 'Request failed'); } if (error instanceof Error) { return error; } return new Error(String(error)); }; const wait = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); const attemptBuffered = async () => { if (typed.tail) { const count = typed.lineCount ?? typed.pageSize ?? 500; const full = await adapter.getBuildLog(effectiveBuildId); const allLines = full.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); if (allLines.length > 0 && allLines[allLines.length - 1] === '') allLines.pop(); const total = allLines.length; const start = Math.max(0, total - count); const lines = allLines.slice(start); return json({ lines, meta: { buildId: effectiveBuildId, buildNumber: typeof typed.buildNumber !== 'undefined' ? String(typed.buildNumber) : undefined, buildTypeId: typed.buildTypeId, mode: 'tail', pageSize: count, startLine: start, hasMore: start > 0, totalLines: total, }, }); } const effectivePageSize = typed.lineCount ?? typed.pageSize ?? 500; const startLine = typeof typed.startLine === 'number' ? typed.startLine : ((typed.page ?? 1) - 1) * effectivePageSize; const chunk = await adapter.getBuildLogChunk(effectiveBuildId, { startLine, lineCount: effectivePageSize, }); const page = Math.floor(startLine / effectivePageSize) + 1; const hasMore = chunk.nextStartLine !== undefined; return json({ lines: chunk.lines, meta: { buildId: effectiveBuildId, buildNumber: typeof typed.buildNumber !== 'undefined' ? String(typed.buildNumber) : undefined, buildTypeId: typed.buildTypeId, page, pageSize: effectivePageSize, startLine: chunk.startLine, nextPage: hasMore ? page + 1 : undefined, prevPage: page > 1 ? page - 1 : undefined, hasMore, totalLines: chunk.totalLines, nextStartLine: chunk.nextStartLine, }, }); }; const attemptStream = async () => { const effectivePageSize = typed.lineCount ?? typed.pageSize ?? 500; const startLine = typeof typed.startLine === 'number' ? typed.startLine : ((typed.page ?? 1) - 1) * effectivePageSize; const response = await adapter.downloadBuildLogContent<Readable>(effectiveBuildId, { params: { start: startLine, count: effectivePageSize, }, responseType: 'stream', }); const stream = response.data; if (!isReadableStream(stream)) { throw new Error('Streaming log download did not return a readable stream'); } const safeBuildId = effectiveBuildId.replace(/[^a-zA-Z0-9._-]/g, '_') || 'build'; const defaultFileName = `build-log-${safeBuildId}-${startLine}-${randomUUID()}.log`; const targetPath = typed.outputPath ?? join(tmpdir(), defaultFileName); await fs.mkdir(dirname(targetPath), { recursive: true }); await pipeline(stream, createWriteStream(targetPath)); const stats = await fs.stat(targetPath); const page = Math.floor(startLine / effectivePageSize) + 1; return json({ encoding: 'stream', outputPath: targetPath, bytesWritten: stats.size, meta: { buildId: effectiveBuildId, buildNumber: typeof typed.buildNumber !== 'undefined' ? String(typed.buildNumber) : undefined, buildTypeId: typed.buildTypeId, page, pageSize: effectivePageSize, startLine, }, }); }; const isStream = typed.encoding === 'stream'; const maxAttempts = isStream ? 3 : typed.tail ? 5 : 3; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { // eslint-disable-next-line no-await-in-loop -- sequential retry attempts require awaiting inside loop return await (isStream ? attemptStream() : attemptBuffered()); } catch (error) { if (shouldRetry(error) && attempt < maxAttempts - 1) { // eslint-disable-next-line no-await-in-loop -- intentional backoff between sequential retries await wait(500 * (attempt + 1)); continue; } throw normalizeError(error); } } throw new Error('Unable to fetch build log after retries'); }, args ); }, }, // === Build Configuration Tools === { name: 'list_build_configs', description: 'List build configurations (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional build type locator to filter' }, projectId: { type: 'string', description: 'Filter by project ID' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), projectId: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_build_configs', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); if (typed.projectId) baseParts.push(`affectedProject:(id:${typed.projectId})`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.buildTypes.getAllBuildTypes( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { buildType?: unknown[]; count?: number }; return Array.isArray(data.buildType) ? (data.buildType as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'get_build_config', description: 'Get details of a build configuration', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, }, required: ['buildTypeId'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeId: z.string().min(1) }); return runTool( 'get_build_config', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const buildType = (await adapter.getBuildType(typed.buildTypeId)) as { parameters?: { property?: Array<{ name?: string; value?: string }> }; }; return json(buildType); }, args ); }, }, // === Test Tools === { name: 'list_test_failures', description: 'List test failures for a build (supports pagination)', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, required: ['buildId'], }, handler: async (args: unknown) => { const schema = z.object({ buildId: z.string().min(1), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_test_failures', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts: string[] = [`build:(id:${typed.buildId})`, 'status:FAILURE']; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.join(','); return adapter.modules.tests.getAllTestOccurrences(locator as string, typed.fields); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { testOccurrence?: unknown[]; count?: number }; return Array.isArray(data.testOccurrence) ? (data.testOccurrence as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, // === VCS Tools === { name: 'list_vcs_roots', description: 'List VCS roots (supports pagination)', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'Filter by project ID' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ projectId: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_vcs_roots', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.projectId) baseParts.push(`affectedProject:(id:${typed.projectId})`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.vcsRoots.getAllVcsRoots( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { ['vcs-root']?: unknown[]; count?: number }; return Array.isArray(data['vcs-root']) ? (data['vcs-root'] as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'get_vcs_root', description: 'Get details of a VCS root (including properties)', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'VCS root ID' }, }, required: ['id'], }, handler: async (args: unknown) => { const schema = z.object({ id: z.string().min(1) }); return runTool( 'get_vcs_root', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const listing = await adapter.modules.vcsRoots.getAllVcsRoots(`id:${typed.id}`); const rootEntry = (listing.data as { vcsRoot?: unknown[] }).vcsRoot?.[0] as | { id?: string; name?: string; href?: string } | undefined; const props = await adapter.modules.vcsRoots.getAllVcsRootProperties(typed.id); return json({ id: rootEntry?.id ?? typed.id, name: rootEntry?.name, href: rootEntry?.href, properties: props.data, }); }, args ); }, }, { name: 'set_vcs_root_property', description: 'Set a single VCS root property (e.g., branch, branchSpec, url)', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'VCS root ID' }, name: { type: 'string', description: 'Property name (e.g., branch, branchSpec, url)' }, value: { type: 'string', description: 'Property value' }, }, required: ['id', 'name', 'value'], }, handler: async (args: unknown) => { const schema = z.object({ id: z.string().min(1), name: z.string().min(1), value: z.string(), }); return runTool( 'set_vcs_root_property', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.vcsRoots.setVcsRootProperty(typed.id, typed.name, typed.value, { headers: { 'Content-Type': 'text/plain', Accept: 'text/plain' }, }); return json({ success: true, action: 'set_vcs_root_property', id: typed.id, name: typed.name, }); }, args ); }, mode: 'full', }, { name: 'delete_vcs_root_property', description: 'Delete a single VCS root property', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'VCS root ID' }, name: { type: 'string', description: 'Property name' }, }, required: ['id', 'name'], }, handler: async (args: unknown) => { const schema = z.object({ id: z.string().min(1), name: z.string().min(1) }); return runTool( 'delete_vcs_root_property', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.vcsRoots.deleteVcsRootProperty(typed.id, typed.name); return json({ success: true, action: 'delete_vcs_root_property', id: typed.id, name: typed.name, }); }, args ); }, mode: 'full', }, { name: 'update_vcs_root_properties', description: 'Update common VCS root properties in one call', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'VCS root ID' }, url: { type: 'string', description: 'Repository URL' }, branch: { type: 'string', description: 'Default branch (e.g., refs/heads/main)' }, branchSpec: { oneOf: [ { type: 'string', description: 'Branch spec as newline-delimited string' }, { type: 'array', items: { type: 'string' }, description: 'Array of branch spec lines' }, ], }, checkoutRules: { type: 'string', description: 'Checkout rules' }, }, required: ['id'], }, handler: async (args: unknown) => { const schema = z.object({ id: z.string().min(1), url: z.string().min(1).optional(), branch: z.string().min(1).optional(), branchSpec: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(), checkoutRules: z.string().min(1).optional(), }); return runTool( 'update_vcs_root_properties', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const properties: { name: string; value: string }[] = []; if (typeof typed.url === 'string') properties.push({ name: 'url', value: typed.url }); if (typeof typed.branch === 'string') properties.push({ name: 'branch', value: typed.branch }); if (typeof typed.checkoutRules === 'string') properties.push({ name: 'checkout-rules', value: typed.checkoutRules }); if (typed.branchSpec !== undefined) { const value = Array.isArray(typed.branchSpec) ? typed.branchSpec.join('\n') : typed.branchSpec; properties.push({ name: 'branchSpec', value }); } if (properties.length === 0) { return json({ success: true, action: 'update_vcs_root_properties', id: typed.id, updated: 0, }); } await adapter.modules.vcsRoots.setVcsRootProperties( typed.id, undefined, { property: properties }, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } } ); return json({ success: true, action: 'update_vcs_root_properties', id: typed.id, updated: properties.length, }); }, args ); }, mode: 'full', }, // === Queue (read-only) === { name: 'list_queued_builds', description: 'List queued builds (supports TeamCity queue locator + pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Queue locator filter (e.g., project:(id:MyProj))', }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_queued_builds', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.buildQueue.getAllQueuedBuilds( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { build?: unknown[]; count?: number }; return Array.isArray(data.build) ? (data.build as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, // === Server Health & Metrics (read-only) === { name: 'get_server_metrics', description: 'Fetch server metrics (CPU/memory/disk/load) if available', inputSchema: { type: 'object', properties: {} }, handler: async (_args: unknown) => { return runTool( 'get_server_metrics', null, async () => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const metrics = await adapter.modules.server.getAllMetrics(); return json(metrics.data); }, {} ); }, mode: 'full', }, { name: 'get_server_info', description: 'Get TeamCity server info (version, build number, state)', inputSchema: { type: 'object', properties: {} }, handler: async (_args: unknown) => { return runTool( 'get_server_info', null, async () => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const info = await adapter.modules.server.getServerInfo(); return json(info.data); }, {} ); }, }, { name: 'list_server_health_items', description: 'List server health items (warnings/errors) for readiness checks', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional health item locator filter. Omit or empty string fetches all items.', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().optional() }); return runTool( 'list_server_health_items', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); // Normalize locator: treat empty/whitespace-only as undefined (fetch all) // and adjust known-safe patterns (e.g., category:(ERROR) -> category:ERROR) const normalized = (() => { const raw = typeof typed.locator === 'string' ? typed.locator.trim() : undefined; if (!raw || raw.length === 0) return undefined; // Remove parentheses around known severities for category filter return raw.replace(/category:\s*\((ERROR|WARNING|INFO)\)/g, 'category:$1'); })(); try { const response = await adapter.modules.health.getHealthItems(normalized); return json(response.data); } catch (err) { // Some TeamCity versions reject locator filters for /app/rest/health (HTTP 400). // Fall back to fetching all items and apply a best-effort client-side filter // for common patterns to avoid failing the tool call. const isHttp400 = (err as { statusCode?: number })?.statusCode === 400 || (err as { code?: string })?.code === 'VALIDATION_ERROR'; if (!isHttp400) throw err; const all = await adapter.modules.health.getHealthItems(); const rawItems = (all.data?.healthItem ?? []) as Array<Record<string, unknown>>; // Basic filter parser: key:value pairs separated by commas. // Supports keys: severity, category, id. Ignores unknown keys. const filter = (item: Record<string, unknown>): boolean => { if (!normalized) return true; const clauses = normalized .split(',') .map((s) => s.trim()) .filter(Boolean); for (const c of clauses) { const [k, v] = c.split(':'); if (!k || v === undefined) continue; const key = k.trim(); const val = v.trim(); if (key === 'severity') { if (String(item['severity'] ?? '').toUpperCase() !== val.toUpperCase()) return false; } else if (key === 'category') { if (String(item['category'] ?? '') !== val) return false; } else if (key === 'id') { if (String(item['id'] ?? '') !== val) return false; } } return true; }; const items = rawItems.filter(filter); return json({ count: items.length, healthItem: items, href: '/app/rest/health', note: 'Applied client-side filtering due to TeamCity 400 on locator. Unsupported filters ignored.', }); } }, args ); }, mode: 'full', }, { name: 'get_server_health_item', description: 'Get a single server health item by locator', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Health item locator' } }, required: ['locator'], }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1) }); return runTool( 'get_server_health_item', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const response = await adapter.modules.health.getSingleHealthItem(typed.locator); return json(response.data); }, args ); }, mode: 'full', }, // === Availability Policy Guard (read-only) === { name: 'check_availability_guard', description: 'Evaluate server health; returns ok=false if critical health items found (severity ERROR)', inputSchema: { type: 'object', properties: { failOnWarning: { type: 'boolean', description: 'Treat warnings as failures (default false)', }, }, }, handler: async (args: unknown) => { const schema = z.object({ failOnWarning: z.boolean().optional() }); return runTool( 'check_availability_guard', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const resp = await adapter.modules.health.getHealthItems(); const items = (resp.data?.healthItem ?? []) as Array<{ severity?: 'ERROR' | 'WARNING' | 'INFO' | string; id?: string; category?: string; additionalData?: unknown; href?: string; text?: string; }>; const critical = items.filter((i) => i.severity === 'ERROR'); const warnings = items.filter((i) => i.severity === 'WARNING'); const ok = critical.length === 0 && (!typed.failOnWarning || warnings.length === 0); return json({ ok, criticalCount: critical.length, warningCount: warnings.length, items }); }, args ); }, }, // === Agent Compatibility (read-only lookups) === { name: 'get_compatible_build_types_for_agent', description: 'Get build types compatible with the specified agent', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'Agent ID' } }, required: ['agentId'], }, handler: async (args: unknown) => { const schema = z.object({ agentId: z.string().min(1) }); return runTool( 'get_compatible_build_types_for_agent', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const resp = await adapter.modules.agents.getCompatibleBuildTypes(typed.agentId); return json(resp.data); }, args ); }, }, { name: 'get_incompatible_build_types_for_agent', description: 'Get build types incompatible with the specified agent', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'Agent ID' } }, required: ['agentId'], }, handler: async (args: unknown) => { const schema = z.object({ agentId: z.string().min(1) }); return runTool( 'get_incompatible_build_types_for_agent', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const resp = await adapter.modules.agents.getIncompatibleBuildTypes(typed.agentId); return json(resp.data); }, args ); }, }, { name: 'get_agent_enabled_info', description: 'Get the enabled/disabled state for an agent, including comment and switch time', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'Agent ID' } }, required: ['agentId'], }, handler: async (args: unknown) => { const schema = z.object({ agentId: z.string().min(1) }); return runTool( 'get_agent_enabled_info', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const resp = await adapter.modules.agents.getEnabledInfo(typed.agentId); return json(resp.data); }, args ); }, }, { name: 'get_compatible_agents_for_build_type', description: 'List agents compatible with a build type (optionally filter enabled only)', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, includeDisabled: { type: 'boolean', description: 'Include disabled agents (default false)', }, }, required: ['buildTypeId'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeId: z.string().min(1), includeDisabled: z.boolean().optional(), }); return runTool( 'get_compatible_agents_for_build_type', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const filters = [`compatible:(buildType:${typed.buildTypeId})`]; if (!typed.includeDisabled) filters.push('enabled:true'); const locator = filters.join(','); const resp = await adapter.modules.agents.getAllAgents(locator); return json(resp.data); }, args ); }, }, { name: 'count_compatible_agents_for_build_type', description: 'Return only the count of enabled compatible agents for a build type', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, includeDisabled: { type: 'boolean', description: 'Include disabled agents (default false)', }, }, required: ['buildTypeId'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeId: z.string().min(1), includeDisabled: z.boolean().optional(), }); return runTool( 'count_compatible_agents_for_build_type', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const parts = [`compatible:(buildType:${typed.buildTypeId})`]; if (!typed.includeDisabled) parts.push('enabled:true'); const locator = parts.join(','); const resp = await adapter.modules.agents.getAllAgents(locator, 'count'); const count = (resp.data as { count?: number }).count ?? 0; return json({ count }); }, args ); }, }, { name: 'get_compatible_agents_for_queued_build', description: 'List agents compatible with a queued/running build by buildId (optionally filter enabled only)', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID' }, includeDisabled: { type: 'boolean', description: 'Include disabled agents (default false)', }, }, required: ['buildId'], }, handler: async (args: unknown) => { const schema = z.object({ buildId: z.string().min(1), includeDisabled: z.boolean().optional(), }); return runTool( 'get_compatible_agents_for_queued_build', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const build = (await adapter.getBuild(typed.buildId)) as { buildTypeId?: string }; const buildTypeId = build.buildTypeId; if (!buildTypeId) return json({ items: [], count: 0, note: 'Build type ID not found' }); const parts = [`compatible:(buildType:${buildTypeId})`]; if (!typed.includeDisabled) parts.push('enabled:true'); const locator = parts.join(','); const resp = await adapter.modules.agents.getAllAgents(locator); return json(resp.data); }, args ); }, }, { name: 'check_teamcity_connection', description: 'Check connectivity to TeamCity server and basic readiness', inputSchema: { type: 'object', properties: {} }, handler: async (_args: unknown) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const ok = await adapter.testConnection(); return json({ ok }); }, }, // === Agent Tools === { name: 'list_agents', description: 'List build agents (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional agent locator to filter' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_agents', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts: string[] = []; if (typed.locator) parts.push(typed.locator); if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.agents.getAllAgents(locator as string | undefined, typed.fields); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { agent?: unknown[]; count?: number }; return Array.isArray(data.agent) ? (data.agent as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'list_agent_pools', description: 'List agent pools (supports pagination)', inputSchema: { type: 'object', properties: { pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_agent_pools', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts: string[] = []; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.agentPools.getAllAgentPools( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { agentPool?: unknown[]; count?: number }; return Array.isArray(data.agentPool) ? (data.agentPool as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, // === Additional Tools from Complex Implementation === // Build Analysis Tools { name: 'get_build_results', description: 'Get detailed results of a build including tests, artifacts, changes, and statistics', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID' }, buildTypeId: { type: 'string', description: 'Build configuration ID when resolving by number', }, buildNumber: { oneOf: [ { type: 'string', description: 'Build number as TeamCity displays it' }, { type: 'number', description: 'Numeric build number' }, ], description: 'Build number when buildId is not available', }, includeArtifacts: { type: 'boolean', description: 'Include artifacts listing and metadata', }, includeStatistics: { type: 'boolean', description: 'Include build statistics' }, includeChanges: { type: 'boolean', description: 'Include VCS changes' }, includeDependencies: { type: 'boolean', description: 'Include dependency builds' }, artifactFilter: { type: 'string', description: 'Filter artifacts by name/path pattern' }, maxArtifactSize: { type: 'number', description: 'Max artifact content size (bytes) when inlining', }, artifactEncoding: { type: 'string', description: 'Encoding mode for artifacts when includeArtifacts is true', enum: ['base64', 'stream'], default: 'base64', }, }, }, handler: async (args: unknown) => { const schema = z .object({ buildId: z.string().min(1).optional(), buildTypeId: z.string().min(1).optional(), buildNumber: z.union([z.string().min(1), z.number().int()]).optional(), includeArtifacts: z.boolean().optional(), includeStatistics: z.boolean().optional(), includeChanges: z.boolean().optional(), includeDependencies: z.boolean().optional(), artifactFilter: z.string().min(1).optional(), maxArtifactSize: z.number().int().min(1).optional(), artifactEncoding: z.enum(['base64', 'stream']).default('base64'), }) .superRefine((value, ctx) => { const hasBuildId = typeof value.buildId === 'string' && value.buildId.trim().length > 0; const hasBuildType = typeof value.buildTypeId === 'string' && value.buildTypeId.trim().length > 0; const hasBuildNumber = value.buildNumber !== undefined && String(value.buildNumber).trim().length > 0; if (hasBuildType !== hasBuildNumber) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'buildTypeId and buildNumber must be provided together', path: hasBuildType ? ['buildNumber'] : ['buildTypeId'], }); } if (!hasBuildId && !(hasBuildType && hasBuildNumber)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Provide either buildId or buildTypeId with buildNumber', path: ['buildId'], }); } }); return runTool( 'get_build_results', schema, async (typed) => { // Use the manager for rich results via the unified TeamCityAPI adapter. const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const manager = new BuildResultsManager(adapter); const trimmedBuildId = typeof typed.buildId === 'string' ? typed.buildId.trim() : undefined; const hasBuildId = typeof trimmedBuildId === 'string' && trimmedBuildId.length > 0; const buildTypeId = typed.buildTypeId?.trim(); const buildNumberRaw = typed.buildNumber; const buildNumber = typeof buildNumberRaw === 'number' ? buildNumberRaw.toString() : buildNumberRaw?.toString().trim(); let buildLocator: string; let friendlyIdentifier: string; if (hasBuildId && trimmedBuildId) { buildLocator = trimmedBuildId; friendlyIdentifier = `ID '${trimmedBuildId}'`; } else if (buildTypeId && buildNumber) { buildLocator = `buildType:(id:${buildTypeId}),number:${buildNumber}`; friendlyIdentifier = `build type '${buildTypeId}' and number ${buildNumber}`; } else { throw new TeamCityAPIError( 'Unable to resolve build identifier', 'INVALID_BUILD_IDENTIFIER' ); } try { const result = await manager.getBuildResults(buildLocator, { includeArtifacts: typed.includeArtifacts, includeStatistics: typed.includeStatistics, includeChanges: typed.includeChanges, includeDependencies: typed.includeDependencies, artifactFilter: typed.artifactFilter, maxArtifactSize: typed.maxArtifactSize, artifactEncoding: typed.artifactEncoding ?? 'base64', }); return json(result); } catch (error) { if (error instanceof TeamCityNotFoundError) { throw new TeamCityNotFoundError('Build', friendlyIdentifier, error.requestId, error); } throw error; } }, args ); }, }, { name: 'download_build_artifact', description: 'Download a single artifact with optional streaming output', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID' }, artifactPath: { type: 'string', description: 'Artifact path or name' }, encoding: { type: 'string', description: "Response encoding: 'base64' (default), 'text', or 'stream'", enum: ['base64', 'text', 'stream'], default: 'base64', }, maxSize: { type: 'number', description: 'Maximum artifact size (bytes) allowed before aborting', }, outputPath: { type: 'string', description: 'Optional absolute path to write streamed content; defaults to a temp file when streaming', }, }, required: ['buildId', 'artifactPath'], }, handler: async (args: unknown) => { const schema = z.object({ buildId: z.string().min(1), artifactPath: z.string().min(1), encoding: z.enum(['base64', 'text', 'stream']).default('base64'), maxSize: z.number().int().positive().optional(), outputPath: z.string().min(1).optional(), }); return runTool( 'download_build_artifact', schema, async (typed) => { const encoding = typed.encoding ?? 'base64'; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); debug('tools.download_build_artifact.start', { buildId: typed.buildId, encoding, artifactPath: typed.artifactPath, maxSize: typed.maxSize, outputPath: typed.outputPath, }); const manager = new ArtifactManager(adapter); const artifact = await manager.downloadArtifact(typed.buildId, typed.artifactPath, { encoding, maxSize: typed.maxSize, }); const payload = await buildArtifactPayload(artifact, encoding, { explicitOutputPath: typed.outputPath, }); return json(payload); }, args ); }, }, { name: 'download_build_artifacts', description: 'Download multiple artifacts with optional streaming output', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID' }, artifactPaths: { type: 'array', description: 'Artifact paths or names to download', items: { anyOf: [ { type: 'string' }, { type: 'object', properties: { path: { type: 'string' }, buildId: { type: 'string' }, downloadUrl: { type: 'string' }, }, required: ['path'], }, ], }, }, encoding: { type: 'string', description: "Response encoding: 'base64' (default), 'text', or 'stream'", enum: ['base64', 'text', 'stream'], default: 'base64', }, maxSize: { type: 'number', description: 'Maximum artifact size (bytes) allowed before aborting', }, outputDir: { type: 'string', description: 'Optional absolute directory to write streamed artifacts; defaults to temp files when streaming', }, }, required: ['buildId', 'artifactPaths'], }, handler: async (args: unknown) => { const artifactInputSchema = z.union([ z.string().min(1), z.object({ path: z.string().min(1), buildId: z.string().min(1).optional(), downloadUrl: z.string().url().optional(), }), ]); const schema = z .object({ buildId: z.string().min(1), artifactPaths: z.array(artifactInputSchema).min(1), encoding: z.enum(['base64', 'text', 'stream']).default('base64'), maxSize: z.number().int().positive().optional(), outputDir: z .string() .min(1) .optional() .refine((value) => value == null || isAbsolute(value), { message: 'outputDir must be an absolute path', }), }) .superRefine((value, ctx) => { if (value.encoding !== 'stream' && value.outputDir) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'outputDir can only be provided when encoding is set to "stream"', path: ['outputDir'], }); } }); return runTool( 'download_build_artifacts', schema, async (typed) => { const encoding = typed.encoding ?? 'base64'; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const manager = new ArtifactManager(adapter); type ArtifactBatchResult = | (ArtifactToolPayload & { success: true }) | (ArtifactPayloadBase & { success: false; encoding: 'base64' | 'text' | 'stream'; error: string; }); const requests = toNormalizedArtifactRequests(typed.artifactPaths, typed.buildId); const results: ArtifactBatchResult[] = []; for (const request of requests) { try { let payload: ArtifactToolPayload; if (request.downloadUrl) { // eslint-disable-next-line no-await-in-loop payload = await downloadArtifactByUrl( adapter, { ...request, downloadUrl: request.downloadUrl }, encoding, { outputDir: encoding === 'stream' ? typed.outputDir : undefined, maxSize: typed.maxSize, } ); } else { // eslint-disable-next-line no-await-in-loop const artifact = await manager.downloadArtifact(request.buildId, request.path, { encoding, maxSize: typed.maxSize, }); // eslint-disable-next-line no-await-in-loop payload = await buildArtifactPayload(artifact, encoding, { outputDir: encoding === 'stream' ? typed.outputDir : undefined, }); } results.push({ ...payload, success: true }); debug('tools.download_build_artifacts.success', { path: request.path, encoding: payload.encoding, outputPath: payload.encoding === 'stream' ? (payload as ArtifactStreamPayload).outputPath : undefined, }); if (payload.encoding === 'stream') { const streamPayload = payload as ArtifactStreamPayload; debug('tools.download_build_artifacts.stream', { path: request.path, outputPath: streamPayload.outputPath, bytesWritten: streamPayload.bytesWritten, }); } else { debug('tools.download_build_artifacts.buffered', { path: request.path, encoding: payload.encoding, size: payload.size, }); } } catch (error) { results.push({ name: request.path, path: request.path, size: 0, encoding, success: false, error: getErrorMessage(error), }); debug('tools.download_build_artifacts.failure', { path: request.path, encoding, error: getErrorMessage(error), downloadUrl: request.downloadUrl, buildId: request.buildId, }); } } debug('tools.download_build_artifacts.complete', { buildId: typed.buildId, successCount: results.filter((item) => item.success).length, failureCount: results.filter((item) => !item.success).length, }); debug('tools.download_build_artifacts.complete', { buildId: typed.buildId, successCount: results.filter((item) => item.success).length, failureCount: results.filter((item) => !item.success).length, }); const failures = results.filter((item) => !item.success); if (results.length > 0 && failures.length === results.length) { const reason = failures .map((item) => `${item.path}: ${item.error ?? 'unknown error'}`) .join('; '); throw new Error(`All artifact downloads failed: ${reason}`); } return json({ artifacts: results }); }, args ); }, }, { name: 'get_test_details', description: 'Get detailed information about test failures', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID' }, testNameId: { type: 'string', description: 'Test name ID (optional)' }, }, required: ['buildId'], }, handler: async (args: unknown) => { const schema = z.object({ buildId: z.string().min(1), testNameId: z.string().min(1).optional(), }); return runTool( 'get_test_details', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); let locator = `build:(id:${typed.buildId})`; if (typed.testNameId) locator += `,test:(id:${typed.testNameId})`; const response = await adapter.modules.tests.getAllTestOccurrences(locator); return json(response.data); }, args ); }, }, { name: 'analyze_build_problems', description: 'Analyze and report build problems and failures', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Build ID to analyze' }, }, required: ['buildId'], }, handler: async (args: unknown) => { const schema = z.object({ buildId: z.string().min(1) }); return runTool( 'analyze_build_problems', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const build = (await adapter.getBuild(typed.buildId)) as { status?: string; statusText?: string; }; const problems = await adapter.modules.builds.getBuildProblems(`id:${typed.buildId}`); const failures = await adapter.listTestFailures(typed.buildId); return json({ buildStatus: build.status, statusText: build.statusText, problems: problems.data, testFailures: failures, }); }, args ); }, }, // === Changes, Problems & Diagnostics === { name: 'list_changes', description: 'List VCS changes (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional change locator to filter results' }, projectId: { type: 'string', description: 'Filter by project ID via locator helper' }, buildId: { type: 'string', description: 'Filter by build ID via locator helper' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), projectId: z.string().min(1).optional(), buildId: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_changes', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); if (typed.projectId) baseParts.push(`project:(id:${typed.projectId})`); if (typed.buildId) baseParts.push(`build:(id:${typed.buildId})`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.changes.getAllChanges( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { change?: unknown[]; count?: number }; return Array.isArray(data.change) ? (data.change as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'list_problems', description: 'List build problems (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional problem locator to filter results' }, projectId: { type: 'string', description: 'Filter by project ID via locator helper' }, buildId: { type: 'string', description: 'Filter by build ID via locator helper' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), projectId: z.string().min(1).optional(), buildId: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_problems', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); if (typed.projectId) baseParts.push(`project:(id:${typed.projectId})`); if (typed.buildId) baseParts.push(`build:(id:${typed.buildId})`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.problems.getAllBuildProblems( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { problem?: unknown[]; count?: number }; return Array.isArray(data.problem) ? (data.problem as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'list_problem_occurrences', description: 'List problem occurrences (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional problem occurrence locator to filter results', }, buildId: { type: 'string', description: 'Filter by build ID via locator helper' }, problemId: { type: 'string', description: 'Filter by problem ID via locator helper', }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), buildId: z.string().min(1).optional(), problemId: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_problem_occurrences', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); if (typed.buildId) baseParts.push(`build:(id:${typed.buildId})`); if (typed.problemId) baseParts.push(`problem:(id:${typed.problemId})`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.problemOccurrences.getAllBuildProblemOccurrences( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { problemOccurrence?: unknown[]; count?: number }; return Array.isArray(data.problemOccurrence) ? (data.problemOccurrence as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'list_investigations', description: 'List open investigations (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional investigation locator to filter results', }, projectId: { type: 'string', description: 'Filter by project ID via locator helper' }, buildTypeId: { type: 'string', description: 'Filter by build configuration ID via locator helper', }, assigneeUsername: { type: 'string', description: 'Filter by responsible user username via locator helper', }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), projectId: z.string().min(1).optional(), buildTypeId: z.string().min(1).optional(), assigneeUsername: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_investigations', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); if (typed.projectId) baseParts.push(`project:(id:${typed.projectId})`); if (typed.buildTypeId) baseParts.push(`buildType:(id:${typed.buildTypeId})`); if (typed.assigneeUsername) baseParts.push(`responsible:(user:(username:${typed.assigneeUsername}))`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.investigations.getAllInvestigations( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { investigation?: unknown[]; count?: number }; return Array.isArray(data.investigation) ? (data.investigation as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'list_muted_tests', description: 'List muted tests (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional mute locator to filter results' }, projectId: { type: 'string', description: 'Filter by project ID via locator helper' }, buildTypeId: { type: 'string', description: 'Filter by build configuration ID via locator helper', }, testNameId: { type: 'string', description: 'Filter by test name ID via locator helper' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), projectId: z.string().min(1).optional(), buildTypeId: z.string().min(1).optional(), testNameId: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_muted_tests', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); if (typed.projectId) baseParts.push(`project:(id:${typed.projectId})`); if (typed.buildTypeId) baseParts.push(`buildType:(id:${typed.buildTypeId})`); if (typed.testNameId) baseParts.push(`test:(id:${typed.testNameId})`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.mutes.getAllMutedTests( locator as string | undefined, typed.fields ); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { mute?: unknown[]; count?: number }; return Array.isArray(data.mute) ? (data.mute as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'get_versioned_settings_status', description: 'Get Versioned Settings status for a locator', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Locator identifying a project/buildType for Versioned Settings', }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, required: ['locator'], }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1), fields: z.string().min(1).optional(), }); return runTool( 'get_versioned_settings_status', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const response = await adapter.modules.versionedSettings.getVersionedSettingsStatus( typed.locator, typed.fields ); return json(response.data); }, args ); }, }, { name: 'list_users', description: 'List TeamCity users (supports pagination)', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Optional user locator to filter results' }, groupId: { type: 'string', description: 'Filter by group ID via locator helper' }, pageSize: { type: 'number', description: 'Items per page (default 100)' }, maxPages: { type: 'number', description: 'Max pages to fetch (when all=true)' }, all: { type: 'boolean', description: 'Fetch all pages up to maxPages' }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1).optional(), groupId: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(), all: z.boolean().optional(), fields: z.string().min(1).optional(), }); return runTool( 'list_users', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const baseParts: string[] = []; if (typed.locator) baseParts.push(typed.locator); if (typed.groupId) baseParts.push(`group:(id:${typed.groupId})`); const pageSize = typed.pageSize ?? 100; const baseFetch = async ({ count, start }: { count?: number; start?: number }) => { const parts = [...baseParts]; if (typeof count === 'number') parts.push(`count:${count}`); if (typeof start === 'number') parts.push(`start:${start}`); const locator = parts.length > 0 ? parts.join(',') : undefined; return adapter.modules.users.getAllUsers(locator as string | undefined, typed.fields); }; const fetcher = createPaginatedFetcher( baseFetch, (response: unknown) => { const data = response as { user?: unknown[]; count?: number }; return Array.isArray(data.user) ? (data.user as unknown[]) : []; }, (response: unknown) => { const data = response as { count?: number }; return typeof data.count === 'number' ? data.count : undefined; } ); if (typed.all) { const items = await fetchAllPages(fetcher, { pageSize, maxPages: typed.maxPages }); return json({ items, pagination: { mode: 'all', pageSize, fetched: items.length } }); } const firstPage = await fetcher({ count: pageSize, start: 0 }); return json({ items: firstPage.items, pagination: { page: 1, pageSize } }); }, args ); }, }, { name: 'list_roles', description: 'List defined roles and their permissions', inputSchema: { type: 'object', properties: { fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, }, handler: async (args: unknown) => { const schema = z.object({ fields: z.string().min(1).optional() }); return runTool( 'list_roles', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const response = await adapter.modules.roles.getRoles(typed.fields); const roles = (response.data?.role ?? []) as unknown[]; return json({ items: roles, count: roles.length }); }, args ); }, }, { name: 'list_branches', description: 'List branches for a project or build configuration', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'Project ID' }, buildTypeId: { type: 'string', description: 'Build type ID' }, }, }, handler: async (args: unknown) => { const schema = z .object({ projectId: z.string().min(1).optional(), buildTypeId: z.string().min(1).optional(), }) .refine((v) => Boolean(v.projectId ?? v.buildTypeId), { message: 'Either projectId or buildTypeId is required', path: ['projectId'], }); return runTool( 'list_branches', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const locator = typed.buildTypeId ? `buildType:(id:${typed.buildTypeId})` : `project:(id:${typed.projectId})`; const builds = (await adapter.listBuilds(`${locator},count:100}`)) as { build?: Array<{ branchName?: string | null }>; // minimal shape used }; const items = Array.isArray(builds.build) ? builds.build : []; const branchNames = items .map((b) => b.branchName) .filter((n): n is string => typeof n === 'string' && n.length > 0); const branches = new Set(branchNames); return json({ branches: Array.from(branches), count: branches.size }); }, args ); }, }, { name: 'list_parameters', description: 'List parameters for a build configuration', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, }, required: ['buildTypeId'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeId: z.string().min(1) }); return runTool( 'list_parameters', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const buildType = (await adapter.getBuildType(typed.buildTypeId)) as { parameters?: { property?: Array<{ name?: string; value?: string }> }; }; return json({ parameters: buildType.parameters?.property ?? [], count: buildType.parameters?.property?.length ?? 0, }); }, args ); }, }, { name: 'list_project_hierarchy', description: 'List project hierarchy showing parent-child relationships', inputSchema: { type: 'object', properties: { rootProjectId: { type: 'string', description: 'Root project ID (defaults to _Root)' }, }, }, handler: async (args: unknown) => { const schema = z.object({ rootProjectId: z.string().min(1).optional() }); return runTool( 'list_project_hierarchy', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const rootId = typed.rootProjectId ?? '_Root'; type ApiProject = { id?: string; name?: string; parentProjectId?: string; projects?: { project?: unknown[] }; }; async function buildHierarchy( projectId: string, depth = 0 ): Promise<{ id?: string; name?: string; parentId?: string; children: Array<{ id: string; name?: string }>; }> { const response = await adapter.modules.projects.getProject(projectId); const project = response.data as ApiProject; const children: Array<{ id: string; name?: string }> = []; const maybeChildren = project.projects?.project ?? []; if (Array.isArray(maybeChildren)) { for (const childRaw of maybeChildren) { const child = childRaw as { id?: string; name?: string }; if (typeof child.id === 'string' && depth < 3) { // eslint-disable-next-line no-await-in-loop const sub = await buildHierarchy(child.id, depth + 1); children.push({ id: sub.id ?? child.id, name: sub.name }); } else if (typeof child.id === 'string') { children.push({ id: child.id, name: child.name }); } } } return { id: project.id, name: project.name, parentId: project.parentProjectId, children, }; } const hierarchy = await buildHierarchy(rootId); return json(hierarchy); }, args ); }, }, ]; /** * Full mode tools - Write/modify operations (only in full mode) */ const FULL_MODE_TOOLS: ToolDefinition[] = [ // === Project Management Tools === { name: 'create_project', description: 'Create a new TeamCity project', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Project name' }, id: { type: 'string', description: 'Project ID' }, parentProjectId: { type: 'string', description: 'Parent project ID (defaults to _Root)' }, description: { type: 'string', description: 'Project description' }, }, required: ['name', 'id'], }, handler: async (args: unknown) => { const schema = z.object({ name: z.string().min(1), id: z.string().min(1), description: z.string().optional(), parentProjectId: z.string().min(1).optional(), }); return runTool( 'create_project', schema, async (typedArgs) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const project = { name: typedArgs.name, id: typedArgs.id, parentProject: { id: typedArgs.parentProjectId ?? '_Root' }, description: typedArgs.description, }; const response = await adapter.modules.projects.addProject(project, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }); return json({ success: true, action: 'create_project', id: response.data.id }); }, args ); }, mode: 'full', }, { name: 'delete_project', description: 'Delete a TeamCity project', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'Project ID to delete' }, }, required: ['projectId'], }, handler: async (args: unknown) => { const typedArgs = args as DeleteProjectArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.projects.deleteProject(typedArgs.projectId); return json({ success: true, action: 'delete_project', id: typedArgs.projectId }); }, mode: 'full', }, { name: 'update_project_settings', description: 'Update project settings and parameters', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'Project ID' }, name: { type: 'string', description: 'New project name' }, description: { type: 'string', description: 'New project description' }, archived: { type: 'boolean', description: 'Archive/unarchive project' }, }, required: ['projectId'], }, handler: async (args: unknown) => { const schema = z.object({ projectId: z.string().min(1), name: z.string().min(1).optional(), description: z.string().optional(), archived: z.boolean().optional(), }); return runTool( 'update_project_settings', schema, async (typedArgs) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); // Emit debug info about requested changes (avoid logging secrets) debug('update_project_settings invoked', { projectId: typedArgs.projectId, // Only log which fields are present to reduce noise requestedChanges: { name: typeof typedArgs.name !== 'undefined', description: typeof typedArgs.description !== 'undefined', archived: typeof typedArgs.archived !== 'undefined', }, }); if (typedArgs.name) { debug('Setting project field', { projectId: typedArgs.projectId, field: 'name', valuePreview: typedArgs.name, }); await adapter.modules.projects.setProjectField( typedArgs.projectId, 'name', typedArgs.name ); } if (typedArgs.description !== undefined) { debug('Setting project field', { projectId: typedArgs.projectId, field: 'description', valuePreview: typedArgs.description, }); await adapter.modules.projects.setProjectField( typedArgs.projectId, 'description', typedArgs.description ); } if (typedArgs.archived !== undefined) { debug('Setting project field', { projectId: typedArgs.projectId, field: 'archived', valuePreview: String(typedArgs.archived), }); await adapter.modules.projects.setProjectField( typedArgs.projectId, 'archived', String(typedArgs.archived) ); } debug('Project settings updated', { projectId: typedArgs.projectId, appliedChanges: { name: typedArgs.name ?? null, description: typedArgs.description ?? null, archived: typeof typedArgs.archived === 'boolean' ? typedArgs.archived : null, }, }); return json({ success: true, action: 'update_project_settings', id: typedArgs.projectId, }); }, args ); }, mode: 'full', }, // === Build Configuration Management === { name: 'create_build_config', description: 'Create a new build configuration', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'Project ID' }, name: { type: 'string', description: 'Build configuration name' }, id: { type: 'string', description: 'Build configuration ID' }, description: { type: 'string', description: 'Description' }, }, required: ['projectId', 'name', 'id'], }, handler: async (args: unknown) => { const typedArgs = args as CreateBuildConfigArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const buildType = { name: typedArgs.name, id: typedArgs.id, project: { id: typedArgs.projectId }, description: typedArgs.description, }; const response = await adapter.modules.buildTypes.createBuildType(undefined, buildType, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }); return json({ success: true, action: 'create_build_config', id: response.data.id }); }, mode: 'full', }, { name: 'clone_build_config', description: 'Clone an existing build configuration', inputSchema: { type: 'object', properties: { sourceBuildTypeId: { type: 'string', description: 'Source build type ID' }, name: { type: 'string', description: 'New build configuration name' }, id: { type: 'string', description: 'New build configuration ID' }, projectId: { type: 'string', description: 'Target project ID' }, description: { type: 'string', description: 'Description for the cloned configuration' }, parameters: { type: 'object', description: 'Optional parameter overrides to apply to the clone', additionalProperties: { type: 'string' }, }, copyBuildCounter: { type: 'boolean', description: 'Copy the build number counter from the source configuration', }, }, required: ['sourceBuildTypeId', 'name', 'id'], }, handler: async (args: unknown) => { const schema = z .object({ sourceBuildTypeId: z.string().min(1), name: z.string().min(1), id: z.string().min(1), projectId: z.string().min(1).optional(), description: z.string().optional(), parameters: z.record(z.string(), z.string()).optional(), copyBuildCounter: z.boolean().optional(), }) .superRefine((value, ctx) => { if (value.id.trim() === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'id must be a non-empty string.', path: ['id'], }); } }); return runTool( 'clone_build_config', schema, async (typedArgs: CloneBuildConfigArgs) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const manager = new BuildConfigurationCloneManager(adapter); const source = await manager.retrieveConfiguration(typedArgs.sourceBuildTypeId); if (!source) { return json({ success: false, action: 'clone_build_config', error: `Source build configuration not found: ${typedArgs.sourceBuildTypeId}`, }); } const targetProjectId = typedArgs.projectId ?? source.projectId; if (!targetProjectId) { return json({ success: false, action: 'clone_build_config', error: 'projectId is required when the source configuration does not specify a project.', }); } try { const cloned = await manager.cloneConfiguration(source, { id: typedArgs.id, name: typedArgs.name, targetProjectId, description: typedArgs.description ?? source.description, parameters: typedArgs.parameters, copyBuildCounter: typedArgs.copyBuildCounter, }); return json({ success: true, action: 'clone_build_config', id: cloned.id, name: cloned.name, projectId: cloned.projectId, url: cloned.url, description: cloned.description, }); } catch (error) { return json({ success: false, action: 'clone_build_config', error: error instanceof Error ? error.message : 'Failed to clone build configuration.', }); } }, args ); }, mode: 'full', }, { name: 'update_build_config', description: 'Update build configuration settings', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, name: { type: 'string', description: 'New name' }, description: { type: 'string', description: 'New description' }, paused: { type: 'boolean', description: 'Pause/unpause configuration' }, artifactRules: { type: 'string', description: 'Artifact rules' }, }, required: ['buildTypeId'], }, handler: async (args: unknown) => { const typedArgs = args as UpdateBuildConfigArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); // Prefer the richer BuildConfigurationUpdateManager for settings + metadata. // Fallback to direct field updates if retrieval is unavailable (e.g., in tests with shallow mocks). try { const manager = new BuildConfigurationUpdateManager(adapter); const current = await manager.retrieveConfiguration(typedArgs.buildTypeId); if (current) { const updates: { name?: string; description?: string; artifactRules?: string } = {}; if (typedArgs.name != null && typedArgs.name !== '') updates.name = typedArgs.name; if (typedArgs.description !== undefined) updates.description = typedArgs.description; if (typedArgs.artifactRules !== undefined) updates.artifactRules = typedArgs.artifactRules; if (Object.keys(updates).length > 0) { await manager.validateUpdates(current, updates as never); await manager.applyUpdates(current, updates as never); } } else { // No current config available; fall back to direct field updates if (typedArgs.name != null && typedArgs.name !== '') { await adapter.modules.buildTypes.setBuildTypeField( typedArgs.buildTypeId, 'name', typedArgs.name ); } if (typedArgs.description !== undefined) { await adapter.modules.buildTypes.setBuildTypeField( typedArgs.buildTypeId, 'description', typedArgs.description ); } if (typedArgs.artifactRules !== undefined) { await setArtifactRulesWithFallback( adapter.modules.buildTypes, typedArgs.buildTypeId, typedArgs.artifactRules ); } } } catch { // Fallback path if manager cannot be used (e.g., getBuildType not mocked) if (typedArgs.name != null && typedArgs.name !== '') { await adapter.modules.buildTypes.setBuildTypeField( typedArgs.buildTypeId, 'name', typedArgs.name ); } if (typedArgs.description !== undefined) { await adapter.modules.buildTypes.setBuildTypeField( typedArgs.buildTypeId, 'description', typedArgs.description ); } if (typedArgs.artifactRules !== undefined) { await setArtifactRulesWithFallback( adapter.modules.buildTypes, typedArgs.buildTypeId, typedArgs.artifactRules ); } } // Handle paused separately (not part of UpdateManager options) if (typedArgs.paused !== undefined) { await adapter.modules.buildTypes.setBuildTypeField( typedArgs.buildTypeId, 'paused', String(typedArgs.paused) ); } return json({ success: true, action: 'update_build_config', id: typedArgs.buildTypeId }); }, mode: 'full', }, // === Dependency, Feature, and Requirement Management === { name: 'manage_build_dependencies', description: 'Add, update, or delete artifact and snapshot dependencies for a build configuration', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build configuration ID' }, dependencyType: { type: 'string', enum: ['artifact', 'snapshot'], description: 'Dependency type to manage', }, action: { type: 'string', enum: ['add', 'update', 'delete'], description: 'Operation to perform', }, dependencyId: { type: 'string', description: 'Dependency ID (required for update/delete)', }, dependsOn: { type: 'string', description: 'Upstream build configuration ID for the dependency', }, properties: { type: 'object', description: 'Dependency properties (e.g. cleanDestinationDirectory, pathRules)', }, options: { type: 'object', description: 'Snapshot dependency options (e.g. run-build-on-the-same-agent)', }, type: { type: 'string', description: 'Override dependency type value sent to TeamCity', }, disabled: { type: 'boolean', description: 'Disable or enable the dependency' }, }, required: ['buildTypeId', 'dependencyType', 'action'], }, handler: async (args: unknown) => { const propertyValue = z.union([z.string(), z.number(), z.boolean()]); const schema = z .object({ buildTypeId: z.string().min(1), dependencyType: z.enum(['artifact', 'snapshot']), action: z.enum(['add', 'update', 'delete']), dependencyId: z.string().min(1).optional(), dependsOn: z.string().min(1).optional(), properties: z.record(z.string(), propertyValue).optional(), options: z.record(z.string(), propertyValue).optional(), type: z.string().min(1).optional(), disabled: z.boolean().optional(), }) .superRefine((value, ctx) => { if ((value.action === 'update' || value.action === 'delete') && !value.dependencyId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'dependencyId is required for update/delete actions. Provide the TeamCity dependency ID or fall back to the UI.', path: ['dependencyId'], }); } if (value.action === 'add' && !value.dependsOn) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'dependsOn is required when adding a dependency.', path: ['dependsOn'], }); } }); return runTool( 'manage_build_dependencies', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const manager = new BuildDependencyManager(adapter); switch (typed.action) { case 'add': { const result = await manager.addDependency({ buildTypeId: typed.buildTypeId, dependencyType: typed.dependencyType, dependsOn: typed.dependsOn, properties: typed.properties, options: typed.options, type: typed.type, disabled: typed.disabled, }); return json({ success: true, action: 'manage_build_dependencies', operation: 'add', buildTypeId: typed.buildTypeId, dependencyType: typed.dependencyType, dependencyId: result.id, }); } case 'update': { const result = await manager.updateDependency(typed.dependencyId as string, { buildTypeId: typed.buildTypeId, dependencyType: typed.dependencyType, dependsOn: typed.dependsOn, properties: typed.properties, options: typed.options, type: typed.type, disabled: typed.disabled, }); return json({ success: true, action: 'manage_build_dependencies', operation: 'update', buildTypeId: typed.buildTypeId, dependencyType: typed.dependencyType, dependencyId: result.id, }); } case 'delete': { await manager.deleteDependency( typed.dependencyType, typed.buildTypeId, typed.dependencyId as string ); return json({ success: true, action: 'manage_build_dependencies', operation: 'delete', buildTypeId: typed.buildTypeId, dependencyType: typed.dependencyType, dependencyId: typed.dependencyId, }); } default: return json({ success: false, action: 'manage_build_dependencies', error: `Unsupported action: ${typed.action}`, }); } }, args ); }, mode: 'full', }, { name: 'manage_build_features', description: 'Add, update, or delete build features such as ssh-agent or requirements enforcement', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build configuration ID' }, action: { type: 'string', enum: ['add', 'update', 'delete'], description: 'Operation to perform', }, featureId: { type: 'string', description: 'Feature ID (required for update/delete)', }, type: { type: 'string', description: 'Feature type (required for add)' }, properties: { type: 'object', description: 'Feature properties' }, disabled: { type: 'boolean', description: 'Disable or enable the feature' }, }, required: ['buildTypeId', 'action'], }, handler: async (args: unknown) => { const propertyValue = z.union([z.string(), z.number(), z.boolean()]); const schema = z .object({ buildTypeId: z.string().min(1), action: z.enum(['add', 'update', 'delete']), featureId: z.string().min(1).optional(), type: z.string().min(1).optional(), properties: z.record(z.string(), propertyValue).optional(), disabled: z.boolean().optional(), }) .superRefine((value, ctx) => { if ((value.action === 'update' || value.action === 'delete') && !value.featureId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'featureId is required for update/delete actions. Capture the feature ID from TeamCity or use the UI.', path: ['featureId'], }); } if (value.action === 'add' && !value.type) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'type is required when adding a build feature.', path: ['type'], }); } }); return runTool( 'manage_build_features', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const manager = new BuildFeatureManager(adapter); switch (typed.action) { case 'add': { const result = await manager.addFeature({ buildTypeId: typed.buildTypeId, type: typed.type, properties: typed.properties, disabled: typed.disabled, }); return json({ success: true, action: 'manage_build_features', operation: 'add', buildTypeId: typed.buildTypeId, featureId: result.id, }); } case 'update': { const result = await manager.updateFeature(typed.featureId as string, { buildTypeId: typed.buildTypeId, type: typed.type, properties: typed.properties, disabled: typed.disabled, }); return json({ success: true, action: 'manage_build_features', operation: 'update', buildTypeId: typed.buildTypeId, featureId: result.id, }); } case 'delete': { await manager.deleteFeature(typed.buildTypeId, typed.featureId as string); return json({ success: true, action: 'manage_build_features', operation: 'delete', buildTypeId: typed.buildTypeId, featureId: typed.featureId, }); } default: return json({ success: false, action: 'manage_build_features', error: `Unsupported action: ${typed.action}`, }); } }, args ); }, mode: 'full', }, { name: 'manage_agent_requirements', description: 'Add, update, or delete build agent requirements for a configuration', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build configuration ID' }, action: { type: 'string', enum: ['add', 'update', 'delete'], description: 'Operation to perform', }, requirementId: { type: 'string', description: 'Requirement ID (required for update/delete)', }, properties: { type: 'object', description: 'Requirement properties (e.g. property-name, condition, value)', }, disabled: { type: 'boolean', description: 'Disable or enable the requirement' }, }, required: ['buildTypeId', 'action'], }, handler: async (args: unknown) => { const propertyValue = z.union([z.string(), z.number(), z.boolean()]); const schema = z .object({ buildTypeId: z.string().min(1), action: z.enum(['add', 'update', 'delete']), requirementId: z.string().min(1).optional(), properties: z.record(z.string(), propertyValue).optional(), disabled: z.boolean().optional(), }) .superRefine((value, ctx) => { if ((value.action === 'update' || value.action === 'delete') && !value.requirementId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'requirementId is required for update/delete actions. Capture the requirement ID via the API or TeamCity UI.', path: ['requirementId'], }); } }); return runTool( 'manage_agent_requirements', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const manager = new AgentRequirementsManager(adapter); switch (typed.action) { case 'add': { const result = await manager.addRequirement({ buildTypeId: typed.buildTypeId, properties: typed.properties, disabled: typed.disabled, }); return json({ success: true, action: 'manage_agent_requirements', operation: 'add', buildTypeId: typed.buildTypeId, requirementId: result.id, }); } case 'update': { const result = await manager.updateRequirement(typed.requirementId as string, { buildTypeId: typed.buildTypeId, properties: typed.properties, disabled: typed.disabled, }); return json({ success: true, action: 'manage_agent_requirements', operation: 'update', buildTypeId: typed.buildTypeId, requirementId: result.id, }); } case 'delete': { await manager.deleteRequirement(typed.buildTypeId, typed.requirementId as string); return json({ success: true, action: 'manage_agent_requirements', operation: 'delete', buildTypeId: typed.buildTypeId, requirementId: typed.requirementId, }); } default: return json({ success: false, action: 'manage_agent_requirements', error: `Unsupported action: ${typed.action}`, }); } }, args ); }, mode: 'full', }, { name: 'set_build_config_state', description: 'Enable or disable a build configuration by toggling its paused flag', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build configuration ID' }, paused: { type: 'boolean', description: 'True to pause/disable, false to enable' }, }, required: ['buildTypeId', 'paused'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeId: z.string().min(1), paused: z.boolean(), }); return runTool( 'set_build_config_state', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.buildTypes.setBuildTypeField( typed.buildTypeId, 'paused', String(typed.paused), { headers: { 'Content-Type': 'text/plain', Accept: 'application/json', }, } ); return json({ success: true, action: 'set_build_config_state', buildTypeId: typed.buildTypeId, paused: typed.paused, }); }, args ); }, mode: 'full', }, // === VCS attachment === { name: 'add_vcs_root_to_build', description: 'Attach a VCS root to a build configuration', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, vcsRootId: { type: 'string', description: 'VCS root ID' }, checkoutRules: { type: 'string', description: 'Optional checkout rules' }, }, required: ['buildTypeId', 'vcsRootId'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeId: z.string().min(1), vcsRootId: z.string().min(1), checkoutRules: z.string().optional(), }); return runTool( 'add_vcs_root_to_build', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const body = { 'vcs-root': { id: typed.vcsRootId }, 'checkout-rules': typed.checkoutRules, } as Record<string, unknown>; await adapter.modules.buildTypes.addVcsRootToBuildType( typed.buildTypeId, undefined, body, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, } ); return json({ success: true, action: 'add_vcs_root_to_build', buildTypeId: typed.buildTypeId, vcsRootId: typed.vcsRootId, }); }, args ); }, mode: 'full', }, // === Parameter Management === { name: 'add_parameter', description: 'Add a parameter to a build configuration', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, name: { type: 'string', description: 'Parameter name' }, value: { type: 'string', description: 'Parameter value' }, }, required: ['buildTypeId', 'name', 'value'], }, handler: async (args: unknown) => { const typedArgs = args as AddParameterArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const parameter = { name: typedArgs.name, value: typedArgs.value, }; await adapter.modules.buildTypes.createBuildParameterOfBuildType( typedArgs.buildTypeId, undefined, parameter, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } } ); return json({ success: true, action: 'add_parameter', buildTypeId: typedArgs.buildTypeId, name: typedArgs.name, }); }, mode: 'full', }, { name: 'update_parameter', description: 'Update a build configuration parameter', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, name: { type: 'string', description: 'Parameter name' }, value: { type: 'string', description: 'New parameter value' }, }, required: ['buildTypeId', 'name', 'value'], }, handler: async (args: unknown) => { const typedArgs = args as UpdateParameterArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.buildTypes.updateBuildParameterOfBuildType( typedArgs.name, typedArgs.buildTypeId, undefined, { name: typedArgs.name, value: typedArgs.value, }, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } } ); return json({ success: true, action: 'update_parameter', buildTypeId: typedArgs.buildTypeId, name: typedArgs.name, }); }, mode: 'full', }, { name: 'delete_parameter', description: 'Delete a parameter from a build configuration', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, name: { type: 'string', description: 'Parameter name' }, }, required: ['buildTypeId', 'name'], }, handler: async (args: unknown) => { const typedArgs = args as DeleteParameterArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.buildTypes.deleteBuildParameterOfBuildType_2( typedArgs.name, typedArgs.buildTypeId ); return json({ success: true, action: 'delete_parameter', buildTypeId: typedArgs.buildTypeId, name: typedArgs.name, }); }, mode: 'full', }, // === VCS Root Management === { name: 'create_vcs_root', description: 'Create a new VCS root', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'Project ID' }, name: { type: 'string', description: 'VCS root name' }, id: { type: 'string', description: 'VCS root ID' }, vcsName: { type: 'string', description: 'VCS type (e.g., jetbrains.git)' }, url: { type: 'string', description: 'Repository URL' }, branch: { type: 'string', description: 'Default branch' }, }, required: ['projectId', 'name', 'id', 'vcsName', 'url'], }, handler: async (args: unknown) => { const typedArgs = args as CreateVCSRootArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const vcsRoot = { name: typedArgs.name, id: typedArgs.id, vcsName: typedArgs.vcsName, project: { id: typedArgs.projectId }, properties: { property: [ { name: 'url', value: typedArgs.url }, { name: 'branch', value: typedArgs.branch ?? 'refs/heads/master' }, ], }, }; const response = await adapter.modules.vcsRoots.addVcsRoot(undefined, vcsRoot, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }); return json({ success: true, action: 'create_vcs_root', id: response.data.id }); }, mode: 'full', }, // === Agent Management === { name: 'authorize_agent', description: 'Authorize or unauthorize a build agent', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'Agent ID' }, authorize: { type: 'boolean', description: 'true to authorize, false to unauthorize' }, }, required: ['agentId', 'authorize'], }, handler: async (args: unknown) => { const typedArgs = args as AuthorizeAgentArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.agents.setAuthorizedInfo( typedArgs.agentId, undefined, { status: Boolean(typedArgs.authorize) }, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } } ); return json({ success: true, action: 'authorize_agent', agentId: typedArgs.agentId, authorized: typedArgs.authorize, }); }, mode: 'full', }, { name: 'assign_agent_to_pool', description: 'Assign an agent to a different pool', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'Agent ID' }, poolId: { type: 'string', description: 'Agent pool ID' }, }, required: ['agentId', 'poolId'], }, handler: async (args: unknown) => { const typedArgs = args as AssignAgentToPoolArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.agents.setAgentPool(typedArgs.agentId, undefined, { id: parseInt(typedArgs.poolId), }); return json({ success: true, action: 'assign_agent_to_pool', agentId: typedArgs.agentId, poolId: typedArgs.poolId, }); }, mode: 'full', }, // === Build Step Management === { name: 'manage_build_steps', description: 'Add, update, or delete build steps', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, action: { type: 'string', enum: ['add', 'update', 'delete'], description: 'Action to perform', }, stepId: { type: 'string', description: 'Step ID (for update/delete)' }, name: { type: 'string', description: 'Step name' }, type: { type: 'string', description: 'Step type (e.g., simpleRunner)' }, properties: { type: 'object', description: 'Step properties' }, }, required: ['buildTypeId', 'action'], }, handler: async (args: unknown) => { const schema = z .object({ buildTypeId: z.string().min(1, 'buildTypeId is required'), action: z.enum(['add', 'update', 'delete']), stepId: z.string().min(1).optional(), name: z.string().optional(), type: z.string().optional(), properties: z.record(z.string(), z.unknown()).optional(), }) .superRefine((value, ctx) => { if (value.action === 'update' || value.action === 'delete') { if (!value.stepId || value.stepId.trim() === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'stepId is required for update or delete actions', path: ['stepId'], }); } } }); const decodeScriptContent = (key: string, value: string): string => { if (key !== 'script.content' && key !== 'kotlinScript.content') { return value; } const normalised = value.replace(/\r\n/g, '\n'); if (!normalised.includes('\\')) { return normalised; } return normalised .replace(/\\r\\n/g, '\n') .replace(/\\r/g, '\n') .replace(/\\n/g, '\n'); }; return runTool( 'manage_build_steps', schema, async (typedArgs) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); switch (typedArgs.action) { case 'add': { const stepProps: Record<string, string> = Object.fromEntries( Object.entries(typedArgs.properties ?? {}).map(([k, v]) => { const value = String(v); return [k, decodeScriptContent(k, value)]; }) ); // Ensure command runner uses custom script when script.content is provided if (typedArgs.type === 'simpleRunner' && stepProps['script.content']) { stepProps['use.custom.script'] = stepProps['use.custom.script'] ?? 'true'; } const step = { name: typedArgs.name, type: typedArgs.type, properties: { property: Object.entries(stepProps).map(([k, v]) => ({ name: k, value: v })), }, }; await adapter.modules.buildTypes.addBuildStepToBuildType( typedArgs.buildTypeId, undefined, step, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, } ); return json({ success: true, action: 'add_build_step', buildTypeId: typedArgs.buildTypeId, }); } case 'update': { const existingStepResponse = await adapter.modules.buildTypes.getBuildStep( typedArgs.buildTypeId, typedArgs.stepId as string, 'id,name,type,disabled,properties(property(name,value))', { headers: { Accept: 'application/json', }, } ); const existingStep = existingStepResponse.data as Step; const toRecord = (collection?: { property?: Array<{ name?: string | null; value?: unknown }>; }): Record<string, string> => { if (!collection || !Array.isArray(collection.property)) { return {}; } const entries = collection.property .filter((item): item is { name: string; value: unknown } => { return Boolean(item?.name); }) .map((item) => { return [item.name, item.value != null ? String(item.value) : '']; }); return Object.fromEntries(entries); }; const existingProperties = toRecord(existingStep?.properties); const updatePayload: Record<string, unknown> = {}; const mergedName = typedArgs.name ?? existingStep?.name; if (mergedName != null) { updatePayload['name'] = mergedName; } const mergedType = typedArgs.type ?? existingStep?.type; if (mergedType != null) { updatePayload['type'] = mergedType; } if (existingStep?.disabled != null) { updatePayload['disabled'] = existingStep.disabled; } const rawProps = typedArgs.properties ?? {}; const providedProps: Record<string, string> = Object.fromEntries( Object.entries(rawProps).map(([k, v]) => { const value = String(v); return [k, decodeScriptContent(k, value)]; }) ); const mergedProps: Record<string, string> = { ...existingProperties, ...providedProps, }; if (mergedProps['script.content'] && mergedType === 'simpleRunner') { // Ensure simple runners keep custom script flags when updating script content mergedProps['use.custom.script'] = mergedProps['use.custom.script'] ?? 'true'; mergedProps['script.type'] = mergedProps['script.type'] ?? 'customScript'; } if (Object.keys(mergedProps).length > 0) { updatePayload['properties'] = { property: Object.entries(mergedProps).map(([name, value]) => ({ name, value })), }; } if (Object.keys(updatePayload).length === 0) { return json({ success: false, action: 'update_build_step', error: 'No update fields provided', }); } await adapter.modules.buildTypes.replaceBuildStep( typedArgs.buildTypeId, typedArgs.stepId as string, undefined, updatePayload as Step, { headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, } ); return json({ success: true, action: 'update_build_step', buildTypeId: typedArgs.buildTypeId, stepId: typedArgs.stepId, }); } case 'delete': await adapter.modules.buildTypes.deleteBuildStep( typedArgs.buildTypeId, typedArgs.stepId as string ); return json({ success: true, action: 'delete_build_step', buildTypeId: typedArgs.buildTypeId, stepId: typedArgs.stepId, }); default: return json({ success: false, error: 'Invalid action' }); } }, args ); }, mode: 'full', }, // === Build Trigger Management === { name: 'manage_build_triggers', description: 'Add, update, or delete build triggers', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' }, action: { type: 'string', enum: ['add', 'delete'], description: 'Action to perform' }, triggerId: { type: 'string', description: 'Trigger ID (for delete)' }, type: { type: 'string', description: 'Trigger type (e.g., vcsTrigger)' }, properties: { type: 'object', description: 'Trigger properties' }, }, required: ['buildTypeId', 'action'], }, handler: async (args: unknown) => { const typedArgs = args as ManageBuildTriggersArgs; const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); switch (typedArgs.action) { case 'add': { const trigger = { type: typedArgs.type, properties: { property: Object.entries(typedArgs.properties ?? {}).map(([k, v]) => ({ name: k, value: String(v), })), }, }; await adapter.modules.buildTypes.addTriggerToBuildType( typedArgs.buildTypeId, undefined, trigger, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, } ); return json({ success: true, action: 'add_build_trigger', buildTypeId: typedArgs.buildTypeId, }); } case 'delete': if (!typedArgs.triggerId) { return json({ success: false, action: 'delete_build_trigger', error: 'Trigger ID is required for delete action', }); } await adapter.modules.buildTypes.deleteTrigger( typedArgs.buildTypeId, typedArgs.triggerId ); return json({ success: true, action: 'delete_build_trigger', buildTypeId: typedArgs.buildTypeId, triggerId: typedArgs.triggerId, }); default: return json({ success: false, error: 'Invalid action' }); } }, mode: 'full', }, // === Batch pause/unpause specific build configurations === { name: 'set_build_configs_paused', description: 'Set paused/unpaused for a list of build configurations; optionally cancel queued', inputSchema: { type: 'object', properties: { buildTypeIds: { type: 'array', items: { type: 'string' }, description: 'List of buildType IDs', }, paused: { type: 'boolean', description: 'True to pause, false to unpause' }, cancelQueued: { type: 'boolean', description: 'Cancel queued builds for these configs' }, }, required: ['buildTypeIds', 'paused'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeIds: z.array(z.string().min(1)).min(1), paused: z.boolean(), cancelQueued: z.boolean().optional(), }); return runTool( 'set_build_configs_paused', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); let updated = 0; for (const id of typed.buildTypeIds) { // eslint-disable-next-line no-await-in-loop await adapter.modules.buildTypes.setBuildTypeField(id, 'paused', String(typed.paused)); updated += 1; } let canceled = 0; if (typed.cancelQueued) { const queue = await adapter.modules.buildQueue.getAllQueuedBuilds(); const builds = (queue.data?.build ?? []) as Array<{ id?: number; buildTypeId?: string; }>; const ids = new Set(typed.buildTypeIds); const toCancel = builds.filter( (build): build is { id?: number; buildTypeId: string } => typeof build.buildTypeId === 'string' && ids.has(build.buildTypeId) ); for (const b of toCancel) { if (b.id == null) continue; // eslint-disable-next-line no-await-in-loop await adapter.modules.buildQueue.deleteQueuedBuild(String(b.id)); canceled += 1; } } return json({ success: true, action: 'set_build_configs_paused', updated, canceled, paused: typed.paused, ids: typed.buildTypeIds, }); }, args ); }, mode: 'full', }, // === Test Administration === { name: 'mute_tests', description: 'Mute tests within a project or build configuration scope', inputSchema: { type: 'object', properties: { testNameIds: { type: 'array', items: { type: 'string' }, description: 'Test name IDs to mute', }, buildTypeId: { type: 'string', description: 'Scope mute to a specific build configuration ID', }, projectId: { type: 'string', description: 'Scope mute to a project (required if buildTypeId omitted)', }, comment: { type: 'string', description: 'Optional mute comment' }, until: { type: 'string', description: 'Optional ISO timestamp to auto-unmute (yyyyMMddTHHmmss+ZZZZ)', }, fields: { type: 'string', description: 'Optional fields selector for server-side projection', }, }, required: ['testNameIds'], }, handler: async (args: unknown) => { const schema = z .object({ testNameIds: z.array(z.string().min(1)).min(1), buildTypeId: z.string().min(1).optional(), projectId: z.string().min(1).optional(), comment: z.string().optional(), until: z.string().min(1).optional(), fields: z.string().min(1).optional(), }) .refine((value) => Boolean(value.buildTypeId) || Boolean(value.projectId), { message: 'Either buildTypeId or projectId must be provided', path: [], }); return runTool( 'mute_tests', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); let scope: { buildType?: { id: string }; project?: { id: string } }; if (typed.buildTypeId) { scope = { buildType: { id: typed.buildTypeId } }; } else if (typed.projectId) { scope = { project: { id: typed.projectId } }; } else { throw new Error('Scope must include a buildTypeId or projectId'); } const payload: Mutes = { mute: [ { scope, target: { tests: { test: typed.testNameIds.map((id) => ({ id })), }, }, assignment: typed.comment ? { text: typed.comment } : undefined, resolution: typed.until ? { type: ResolutionTypeEnum.AtTime, time: typed.until } : undefined, }, ], }; const response = await adapter.modules.mutes.muteMultipleTests(typed.fields, payload, { headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, }); const muted = Array.isArray(typed.testNameIds) ? typed.testNameIds.length : 0; return json({ success: true, action: 'mute_tests', muted, scope, response: response.data, }); }, args ); }, mode: 'full', }, // === Queue Maintenance === { name: 'move_queued_build_to_top', description: 'Move a queued build to the top of the queue', inputSchema: { type: 'object', properties: { buildId: { type: 'string', description: 'Queued build ID' } }, required: ['buildId'], }, handler: async (args: unknown) => { const schema = z.object({ buildId: z.string().min(1) }); return runTool( 'move_queued_build_to_top', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.buildQueue.setQueuedBuildsOrder(undefined, { build: [{ id: parseInt(typed.buildId) }], }); return json({ success: true, action: 'move_queued_build_to_top', buildId: typed.buildId, }); }, args ); }, mode: 'full', }, { name: 'reorder_queued_builds', description: 'Reorder queued builds by providing the desired sequence of IDs', inputSchema: { type: 'object', properties: { buildIds: { type: 'array', items: { type: 'string' } } }, required: ['buildIds'], }, handler: async (args: unknown) => { const schema = z.object({ buildIds: z.array(z.string().min(1)).min(1) }); return runTool( 'reorder_queued_builds', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); await adapter.modules.buildQueue.setQueuedBuildsOrder(undefined, { build: typed.buildIds.map((id) => ({ id: parseInt(id) })), }); return json({ success: true, action: 'reorder_queued_builds', count: typed.buildIds.length, }); }, args ); }, mode: 'full', }, { name: 'cancel_queued_builds_for_build_type', description: 'Cancel all queued builds for a specific build configuration', inputSchema: { type: 'object', properties: { buildTypeId: { type: 'string', description: 'Build type ID' } }, required: ['buildTypeId'], }, handler: async (args: unknown) => { const schema = z.object({ buildTypeId: z.string().min(1) }); return runTool( 'cancel_queued_builds_for_build_type', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const queue = await adapter.modules.buildQueue.getAllQueuedBuilds(); const builds = (queue.data?.build ?? []) as Array<{ id?: number; buildTypeId?: string }>; const toCancel = builds.filter((b) => b.buildTypeId === typed.buildTypeId); let canceled = 0; for (const b of toCancel) { if (b.id == null) continue; // eslint-disable-next-line no-await-in-loop await adapter.modules.buildQueue.deleteQueuedBuild(String(b.id)); canceled += 1; } return json({ success: true, action: 'cancel_queued_builds_for_build_type', buildTypeId: typed.buildTypeId, canceled, }); }, args ); }, mode: 'full', }, { name: 'cancel_queued_builds_by_locator', description: 'Cancel all queued builds matching a queue locator expression', inputSchema: { type: 'object', properties: { locator: { type: 'string', description: 'Queue locator expression' } }, required: ['locator'], }, handler: async (args: unknown) => { const schema = z.object({ locator: z.string().min(1) }); return runTool( 'cancel_queued_builds_by_locator', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const queue = await adapter.modules.buildQueue.getAllQueuedBuilds(typed.locator); const builds = (queue.data?.build ?? []) as Array<{ id?: number }>; let canceled = 0; for (const b of builds) { if (b.id == null) continue; // eslint-disable-next-line no-await-in-loop await adapter.modules.buildQueue.deleteQueuedBuild(String(b.id)); canceled += 1; } return json({ success: true, action: 'cancel_queued_builds_by_locator', locator: typed.locator, canceled, }); }, args ); }, mode: 'full', }, // === Scoped Pause/Resume (by pool) === { name: 'pause_queue_for_pool', description: 'Disable all agents in a pool to pause queue processing; optionally cancel queued builds for a build type', inputSchema: { type: 'object', properties: { poolId: { type: 'string', description: 'Agent pool ID' }, cancelQueuedForBuildTypeId: { type: 'string', description: 'Optional buildTypeId: cancel queued builds for this configuration', }, comment: { type: 'string', description: 'Optional comment for agent disablement' }, until: { type: 'string', description: 'Optional ISO datetime to auto-reenable' }, }, required: ['poolId'], }, handler: async (args: unknown) => { const schema = z.object({ poolId: z.string().min(1), cancelQueuedForBuildTypeId: z.string().min(1).optional(), comment: z.string().optional(), until: z.string().min(1).optional(), }); return runTool( 'pause_queue_for_pool', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); // Disable all agents in pool const agentsResp = await adapter.modules.agents.getAllAgents( `agentPool:(id:${typed.poolId})` ); const agents = (agentsResp.data?.agent ?? []) as Array<{ id?: string }>; const body: { status: boolean; comment?: { text?: string }; statusSwitchTime?: string } = { status: false, }; if (typed.comment) body.comment = { text: typed.comment }; if (typed.until) body.statusSwitchTime = typed.until; let disabled = 0; for (const a of agents) { const id = a.id; if (!id) continue; // eslint-disable-next-line no-await-in-loop await adapter.modules.agents.setEnabledInfo(id, undefined, body, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }); disabled += 1; } // Optionally cancel queued builds for provided buildTypeId let canceled = 0; if (typed.cancelQueuedForBuildTypeId) { const queue = await adapter.modules.buildQueue.getAllQueuedBuilds(); const builds = (queue.data?.build ?? []) as Array<{ id?: number; buildTypeId?: string; }>; const toCancel = builds.filter( (b) => b.buildTypeId === typed.cancelQueuedForBuildTypeId ); for (const b of toCancel) { if (b.id == null) continue; // eslint-disable-next-line no-await-in-loop await adapter.modules.buildQueue.deleteQueuedBuild(String(b.id)); canceled += 1; } } return json({ success: true, action: 'pause_queue_for_pool', poolId: typed.poolId, disabledAgents: disabled, canceledQueued: canceled, }); }, args ); }, mode: 'full', }, { name: 'resume_queue_for_pool', description: 'Re-enable all agents in a pool to resume queue processing', inputSchema: { type: 'object', properties: { poolId: { type: 'string', description: 'Agent pool ID' } }, required: ['poolId'], }, handler: async (args: unknown) => { const schema = z.object({ poolId: z.string().min(1) }); return runTool( 'resume_queue_for_pool', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const agentsResp = await adapter.modules.agents.getAllAgents( `agentPool:(id:${typed.poolId})` ); const agents = (agentsResp.data?.agent ?? []) as Array<{ id?: string }>; let enabled = 0; for (const a of agents) { const id = a.id; if (!id) continue; // eslint-disable-next-line no-await-in-loop await adapter.modules.agents.setEnabledInfo( id, undefined, { status: true }, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } } ); enabled += 1; } return json({ success: true, action: 'resume_queue_for_pool', poolId: typed.poolId, enabledAgents: enabled, }); }, args ); }, mode: 'full', }, // === Agent Enable/Disable === { name: 'set_agent_enabled', description: 'Enable/disable an agent, with optional comment and schedule', inputSchema: { type: 'object', properties: { agentId: { type: 'string', description: 'Agent ID' }, enabled: { type: 'boolean', description: 'True to enable, false to disable' }, comment: { type: 'string', description: 'Optional comment' }, until: { type: 'string', description: 'Optional ISO datetime to auto-flip state', }, }, required: ['agentId', 'enabled'], }, handler: async (args: unknown) => { const schema = z.object({ agentId: z.string().min(1), enabled: z.boolean(), comment: z.string().optional(), until: z.string().min(1).optional(), }); return runTool( 'set_agent_enabled', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const body: { status: boolean; comment?: { text?: string }; statusSwitchTime?: string; } = { status: typed.enabled }; if (typed.comment) body.comment = { text: typed.comment }; if (typed.until) body.statusSwitchTime = typed.until; const resp = await adapter.modules.agents.setEnabledInfo(typed.agentId, undefined, body, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }); return json({ success: true, action: 'set_agent_enabled', agentId: typed.agentId, enabled: resp.data?.status ?? typed.enabled, }); }, args ); }, mode: 'full', }, { name: 'bulk_set_agents_enabled', description: 'Bulk enable/disable agents selected by pool or locator; supports comment/schedule', inputSchema: { type: 'object', properties: { enabled: { type: 'boolean', description: 'True to enable, false to disable' }, poolId: { type: 'string', description: 'Agent pool ID (optional)' }, locator: { type: 'string', description: 'Agent locator expression (alternative to poolId)', }, comment: { type: 'string', description: 'Optional comment' }, until: { type: 'string', description: 'Optional ISO datetime to auto-flip state', }, includeDisabled: { type: 'boolean', description: 'Include disabled agents in selection (default true when not filtering by enabled)', }, }, required: ['enabled'], }, handler: async (args: unknown) => { const schema = z .object({ enabled: z.boolean(), poolId: z.string().min(1).optional(), locator: z.string().min(1).optional(), comment: z.string().optional(), until: z.string().min(1).optional(), includeDisabled: z.boolean().optional(), }) .refine((v) => Boolean(v.poolId ?? v.locator), { message: 'Either poolId or locator is required', path: ['poolId'], }); return runTool( 'bulk_set_agents_enabled', schema, async (typed) => { const adapter = createAdapterFromTeamCityAPI(TeamCityAPI.getInstance()); const filters: string[] = []; if (typed.poolId) filters.push(`agentPool:(id:${typed.poolId})`); if (typed.locator) filters.push(typed.locator); if (typed.includeDisabled === false) filters.push('enabled:true'); const locator = filters.join(','); const list = await adapter.modules.agents.getAllAgents(locator); const agents = (list.data?.agent ?? []) as Array<{ id?: string; name?: string }>; const body: { status: boolean; comment?: { text?: string }; statusSwitchTime?: string; } = { status: typed.enabled }; if (typed.comment) body.comment = { text: typed.comment }; if (typed.until) body.statusSwitchTime = typed.until; const results: Array<{ id: string; ok: boolean; error?: string }> = []; for (const a of agents) { const id = String(a.id ?? ''); if (!id) continue; try { // eslint-disable-next-line no-await-in-loop await adapter.modules.agents.setEnabledInfo(id, undefined, body, { headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, }); results.push({ id, ok: true }); } catch (e) { const msg = e instanceof Error ? e.message : 'Unknown error'; results.push({ id, ok: false, error: msg }); } } const succeeded = results.filter((r) => r.ok).length; const failed = results.length - succeeded; return json({ success: true, action: 'bulk_set_agents_enabled', total: results.length, succeeded, failed, results, locator, poolId: typed.poolId, }); }, args ); }, mode: 'full', }, ]; /** * Get all available tools based on current mode */ export function getAvailableTools(): ToolDefinition[] { const mode = getMCPMode(); if (mode === 'full') { const combined = [...DEV_TOOLS, ...FULL_MODE_TOOLS]; const map = new Map<string, ToolDefinition>(); for (const t of combined) map.set(t.name, t); return Array.from(map.values()); } // Dev mode: include only tools not explicitly marked as full return DEV_TOOLS.filter((t) => t.mode !== 'full'); } /** * Get tool by name (respects current mode) */ export function getTool(name: string): ToolDefinition | undefined { const tools = getAvailableTools(); return tools.find((tool) => tool.name === name); } /** * Get tool by name or throw a descriptive error if unavailable. * Useful in tests and call sites where the tool is required. */ export function getRequiredTool(name: string): ToolDefinition { const tool = getTool(name); if (!tool) { const mode = getMCPMode(); throw new Error(`Tool not available in ${mode} mode or not registered: ${name}`); } return tool; } /** * Get all tool names (respects current mode) */ export function getToolNames(): string[] { const tools = getAvailableTools(); return tools.map((tool) => tool.name); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Daghis/teamcity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server