diff --git a/README.md b/README.md index a80c4461..8e046d47 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,11 @@ This TypeScript project provides a **local** MCP server for Azure DevOps, enabli 3. [⚙️ Supported Tools](#️-supported-tools) 4. [🔌 Installation & Getting Started](#-installation--getting-started) 5. [🌏 Using Domains](#-using-domains) -6. [📝 Troubleshooting](#-troubleshooting) -7. [🎩 Examples & Best Practices](#-examples--best-practices) -8. [🙋‍♀️ Frequently Asked Questions](#️-frequently-asked-questions) -9. [📌 Contributing](#-contributing) +6. [📖 Read-Only Mode](#-read-only-mode) +7. [📝 Troubleshooting](#-troubleshooting) +8. [🎩 Examples & Best Practices](#-examples--best-practices) +9. [🙋‍♀️ Frequently Asked Questions](#️-frequently-asked-questions) +10. [📌 Contributing](#-contributing) ## 📺 Overview @@ -261,6 +262,31 @@ We recommend that you always enable `core` tools so that you can fetch project l > By default all domains are loaded +## 📖 Read-Only Mode + +For environments where you want to prevent any modifications to your Azure DevOps resources, use the `--read-only` flag. This mode exposes only read-only tools (like listing projects, getting work items, viewing pull requests) while hiding all tools that create, update, or delete data. + +Add the `--read-only` argument to the server args in your `mcp.json`: + +```json +{ + "inputs": [ + { + "id": "ado_org", + "type": "promptString", + "description": "Azure DevOps organization name (e.g. 'contoso')" + } + ], + "servers": { + "ado_readonly": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@azure-devops/mcp", "${input:ado_org}", "--read-only"] + } + } +} +``` + ## 📝 Troubleshooting See the [Troubleshooting guide](./docs/TROUBLESHOOTING.md) for help with common issues and logging. diff --git a/package-lock.json b/package-lock.json index d4c61ada..2e9b2eec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure-devops/mcp", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure-devops/mcp", - "version": "2.2.1", + "version": "2.3.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5d944019..b9ca4694 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure-devops/mcp", - "version": "2.2.1", + "version": "2.3.0", "description": "MCP server for interacting with Azure DevOps", "license": "MIT", "author": "Microsoft Corporation", diff --git a/src/index.ts b/src/index.ts index 6aa4280e..4f618676 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,11 @@ const argv = yargs(hideBin(process.argv)) describe: "Azure tenant ID (optional, applied when using 'interactive' and 'azcli' type of authentication)", type: "string", }) + .option("read-only", { + describe: "Run the server in read-only mode (no write/update tools exposed)", + type: "boolean", + default: false, + }) .help() .parseSync(); @@ -97,7 +102,9 @@ async function main() { // removing prompts untill further notice // configurePrompts(server); - configureAllTools(server, authenticator, getAzureDevOpsClient(authenticator, userAgentComposer), () => userAgentComposer.userAgent, enabledDomains); + const isReadOnlyMode = argv["read-only"]; + + configureAllTools(server, authenticator, getAzureDevOpsClient(authenticator, userAgentComposer), () => userAgentComposer.userAgent, enabledDomains, isReadOnlyMode); const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tools.ts b/src/tools.ts index 765e2b61..ad02cf67 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -15,22 +15,29 @@ import { configureWikiTools } from "./tools/wiki.js"; import { configureWorkTools } from "./tools/work.js"; import { configureWorkItemTools } from "./tools/work-items.js"; -function configureAllTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string, enabledDomains: Set) { +function configureAllTools( + server: McpServer, + tokenProvider: () => Promise, + connectionProvider: () => Promise, + userAgentProvider: () => string, + enabledDomains: Set, + isReadOnlyMode: boolean +) { const configureIfDomainEnabled = (domain: string, configureFn: () => void) => { if (enabledDomains.has(domain)) { configureFn(); } }; - configureIfDomainEnabled(Domain.CORE, () => configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider)); - configureIfDomainEnabled(Domain.WORK, () => configureWorkTools(server, tokenProvider, connectionProvider)); - configureIfDomainEnabled(Domain.PIPELINES, () => configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider)); - configureIfDomainEnabled(Domain.REPOSITORIES, () => configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider)); - configureIfDomainEnabled(Domain.WORK_ITEMS, () => configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider)); - configureIfDomainEnabled(Domain.WIKI, () => configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider)); - configureIfDomainEnabled(Domain.TEST_PLANS, () => configureTestPlanTools(server, tokenProvider, connectionProvider)); - configureIfDomainEnabled(Domain.SEARCH, () => configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider)); - configureIfDomainEnabled(Domain.ADVANCED_SECURITY, () => configureAdvSecTools(server, tokenProvider, connectionProvider)); + configureIfDomainEnabled(Domain.CORE, () => configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode)); + configureIfDomainEnabled(Domain.WORK, () => configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode)); + configureIfDomainEnabled(Domain.PIPELINES, () => configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode)); + configureIfDomainEnabled(Domain.REPOSITORIES, () => configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode)); + configureIfDomainEnabled(Domain.WORK_ITEMS, () => configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode)); + configureIfDomainEnabled(Domain.WIKI, () => configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode)); + configureIfDomainEnabled(Domain.TEST_PLANS, () => configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode)); + configureIfDomainEnabled(Domain.SEARCH, () => configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode)); + configureIfDomainEnabled(Domain.ADVANCED_SECURITY, () => configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode)); } export { configureAllTools }; diff --git a/src/tools/advanced-security.ts b/src/tools/advanced-security.ts index ae335764..7e8c3b0d 100644 --- a/src/tools/advanced-security.ts +++ b/src/tools/advanced-security.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { AlertType, AlertValidityStatus, Confidence, Severity, State } from "azure-devops-node-api/interfaces/AlertInterfaces.js"; import { z } from "zod"; @@ -12,131 +12,147 @@ const ADVSEC_TOOLS = { get_alert_details: "advsec_get_alert_details", }; -function configureAdvSecTools(server: McpServer, _: () => Promise, connectionProvider: () => Promise) { - server.tool( - ADVSEC_TOOLS.get_alerts, - "Retrieve Advanced Security alerts for a repository.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - repository: z.string().describe("The name or ID of the repository to get alerts for."), - alertType: z - .enum(getEnumKeys(AlertType) as [string, ...string[]]) - .optional() - .describe("Filter alerts by type. If not specified, returns all alert types."), - states: z - .array(z.enum(getEnumKeys(State) as [string, ...string[]])) - .optional() - .describe("Filter alerts by state. If not specified, returns alerts in any state."), - severities: z - .array(z.enum(getEnumKeys(Severity) as [string, ...string[]])) - .optional() - .describe("Filter alerts by severity level. If not specified, returns alerts at any severity."), - ruleId: z.string().optional().describe("Filter alerts by rule ID."), - ruleName: z.string().optional().describe("Filter alerts by rule name."), - toolName: z.string().optional().describe("Filter alerts by tool name."), - ref: z.string().optional().describe("Filter alerts by git reference (branch). If not provided and onlyDefaultBranch is true, only includes alerts from default branch."), - onlyDefaultBranch: z.boolean().optional().default(true).describe("If true, only return alerts found on the default branch. Defaults to true."), - confidenceLevels: z - .array(z.enum(getEnumKeys(Confidence) as [string, ...string[]])) - .optional() - .default(["high", "other"]) - .describe("Filter alerts by confidence levels. Only applicable for secret alerts. Defaults to both 'high' and 'other'."), - validity: z - .array(z.enum(getEnumKeys(AlertValidityStatus) as [string, ...string[]])) - .optional() - .describe("Filter alerts by validity status. Only applicable for secret alerts."), - top: z.number().optional().default(100).describe("Maximum number of alerts to return. Defaults to 100."), - orderBy: z.enum(["id", "firstSeen", "lastSeen", "fixedOn", "severity"]).optional().default("severity").describe("Order results by specified field. Defaults to 'severity'."), - continuationToken: z.string().optional().describe("Continuation token for pagination."), - }, - async ({ project, repository, alertType, states, severities, ruleId, ruleName, toolName, ref, onlyDefaultBranch, confidenceLevels, validity, top, orderBy, continuationToken }) => { - try { - const connection = await connectionProvider(); - const alertApi = await connection.getAlertApi(); +function configureAdvSecTools(server: McpServer, _: () => Promise, connectionProvider: () => Promise, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + ADVSEC_TOOLS.get_alerts, + "Retrieve Advanced Security alerts for a repository.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + repository: z.string().describe("The name or ID of the repository to get alerts for."), + alertType: z + .enum(getEnumKeys(AlertType) as [string, ...string[]]) + .optional() + .describe("Filter alerts by type. If not specified, returns all alert types."), + states: z + .array(z.enum(getEnumKeys(State) as [string, ...string[]])) + .optional() + .describe("Filter alerts by state. If not specified, returns alerts in any state."), + severities: z + .array(z.enum(getEnumKeys(Severity) as [string, ...string[]])) + .optional() + .describe("Filter alerts by severity level. If not specified, returns alerts at any severity."), + ruleId: z.string().optional().describe("Filter alerts by rule ID."), + ruleName: z.string().optional().describe("Filter alerts by rule name."), + toolName: z.string().optional().describe("Filter alerts by tool name."), + ref: z.string().optional().describe("Filter alerts by git reference (branch). If not provided and onlyDefaultBranch is true, only includes alerts from default branch."), + onlyDefaultBranch: z.boolean().optional().default(true).describe("If true, only return alerts found on the default branch. Defaults to true."), + confidenceLevels: z + .array(z.enum(getEnumKeys(Confidence) as [string, ...string[]])) + .optional() + .default(["high", "other"]) + .describe("Filter alerts by confidence levels. Only applicable for secret alerts. Defaults to both 'high' and 'other'."), + validity: z + .array(z.enum(getEnumKeys(AlertValidityStatus) as [string, ...string[]])) + .optional() + .describe("Filter alerts by validity status. Only applicable for secret alerts."), + top: z.number().optional().default(100).describe("Maximum number of alerts to return. Defaults to 100."), + orderBy: z.enum(["id", "firstSeen", "lastSeen", "fixedOn", "severity"]).optional().default("severity").describe("Order results by specified field. Defaults to 'severity'."), + continuationToken: z.string().optional().describe("Continuation token for pagination."), + }, + { + readOnlyHint: true, + }, + async ({ project, repository, alertType, states, severities, ruleId, ruleName, toolName, ref, onlyDefaultBranch, confidenceLevels, validity, top, orderBy, continuationToken }) => { + try { + const connection = await connectionProvider(); + const alertApi = await connection.getAlertApi(); - const isSecretAlert = !alertType || alertType.toLowerCase() === "secret"; - const criteria = { - ...(alertType && { alertType: mapStringToEnum(alertType, AlertType) }), - ...(states && { states: mapStringArrayToEnum(states, State) }), - ...(severities && { severities: mapStringArrayToEnum(severities, Severity) }), - ...(ruleId && { ruleId }), - ...(ruleName && { ruleName }), - ...(toolName && { toolName }), - ...(ref && { ref }), - ...(onlyDefaultBranch !== undefined && { onlyDefaultBranch }), - ...(isSecretAlert && confidenceLevels && { confidenceLevels: mapStringArrayToEnum(confidenceLevels, Confidence) }), - ...(isSecretAlert && validity && { validity: mapStringArrayToEnum(validity, AlertValidityStatus) }), - }; + const isSecretAlert = !alertType || alertType.toLowerCase() === "secret"; + const criteria = { + ...(alertType && { alertType: mapStringToEnum(alertType, AlertType) }), + ...(states && { states: mapStringArrayToEnum(states, State) }), + ...(severities && { severities: mapStringArrayToEnum(severities, Severity) }), + ...(ruleId && { ruleId }), + ...(ruleName && { ruleName }), + ...(toolName && { toolName }), + ...(ref && { ref }), + ...(onlyDefaultBranch !== undefined && { onlyDefaultBranch }), + ...(isSecretAlert && confidenceLevels && { confidenceLevels: mapStringArrayToEnum(confidenceLevels, Confidence) }), + ...(isSecretAlert && validity && { validity: mapStringArrayToEnum(validity, AlertValidityStatus) }), + }; - const result = await alertApi.getAlerts( - project, - repository, - top, - orderBy, - criteria, - undefined, // expand parameter - continuationToken - ); + const result = await alertApi.getAlerts( + project, + repository, + top, + orderBy, + criteria, + undefined, // expand parameter + continuationToken + ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [ - { - type: "text", - text: `Error fetching Advanced Security alerts: ${errorMessage}`, - }, - ], - isError: true, - }; + return { + content: [ + { + type: "text", + text: `Error fetching Advanced Security alerts: ${errorMessage}`, + }, + ], + isError: true, + }; + } } - } - ); + ), + + server.tool( + ADVSEC_TOOLS.get_alert_details, + "Get detailed information about a specific Advanced Security alert.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + repository: z.string().describe("The name or ID of the repository containing the alert."), + alertId: z.number().describe("The ID of the alert to retrieve details for."), + ref: z.string().optional().describe("Git reference (branch) to filter the alert."), + }, + { + readOnlyHint: true, + }, + async ({ project, repository, alertId, ref }) => { + try { + const connection = await connectionProvider(); + const alertApi = await connection.getAlertApi(); - server.tool( - ADVSEC_TOOLS.get_alert_details, - "Get detailed information about a specific Advanced Security alert.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - repository: z.string().describe("The name or ID of the repository containing the alert."), - alertId: z.number().describe("The ID of the alert to retrieve details for."), - ref: z.string().optional().describe("Git reference (branch) to filter the alert."), - }, - async ({ project, repository, alertId, ref }) => { - try { - const connection = await connectionProvider(); - const alertApi = await connection.getAlertApi(); + const result = await alertApi.getAlert( + project, + alertId, + repository, + ref, + undefined // expand parameter + ); - const result = await alertApi.getAlert( - project, - alertId, - repository, - ref, - undefined // expand parameter - ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [ + { + type: "text", + text: `Error fetching alert details: ${errorMessage}`, + }, + ], + isError: true, + }; + } + } + ), + ]; - return { - content: [ - { - type: "text", - text: `Error fetching alert details: ${errorMessage}`, - }, - ], - isError: true, - }; + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); } } - ); + } } export { ADVSEC_TOOLS, configureAdvSecTools }; diff --git a/src/tools/core.ts b/src/tools/core.ts index 790724cc..f1d2a8df 100644 --- a/src/tools/core.ts +++ b/src/tools/core.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { z } from "zod"; import { searchIdentities } from "./auth.js"; @@ -20,111 +20,130 @@ function filterProjectsByName(projects: ProjectInfo[], projectNameFilter: string return projects.filter((project) => project.name?.toLowerCase().includes(lowerCaseFilter)); } -function configureCoreTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { - server.tool( - CORE_TOOLS.list_project_teams, - "Retrieve a list of teams for the specified Azure DevOps project.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - mine: z.boolean().optional().describe("If true, only return teams that the authenticated user is a member of."), - top: z.number().optional().describe("The maximum number of teams to return. Defaults to 100."), - skip: z.number().optional().describe("The number of teams to skip for pagination. Defaults to 0."), - }, - async ({ project, mine, top, skip }) => { - try { - const connection = await connectionProvider(); - const coreApi = await connection.getCoreApi(); - const teams = await coreApi.getTeams(project, mine, top, skip, false); - - if (!teams) { - return { content: [{ type: "text", text: "No teams found" }], isError: true }; - } +function configureCoreTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + CORE_TOOLS.list_project_teams, + "Retrieve a list of teams for the specified Azure DevOps project.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + mine: z.boolean().optional().describe("If true, only return teams that the authenticated user is a member of."), + top: z.number().optional().describe("The maximum number of teams to return. Defaults to 100."), + skip: z.number().optional().describe("The number of teams to skip for pagination. Defaults to 0."), + }, + { + readOnlyHint: true, + }, + async ({ project, mine, top, skip }) => { + try { + const connection = await connectionProvider(); + const coreApi = await connection.getCoreApi(); + const teams = await coreApi.getTeams(project, mine, top, skip, false); + + if (!teams) { + return { content: [{ type: "text", text: "No teams found" }], isError: true }; + } - return { - content: [{ type: "text", text: JSON.stringify(teams, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: JSON.stringify(teams, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error fetching project teams: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - CORE_TOOLS.list_projects, - "Retrieve a list of projects in your Azure DevOps organization.", - { - stateFilter: z.enum(["all", "wellFormed", "createPending", "deleted"]).default("wellFormed").describe("Filter projects by their state. Defaults to 'wellFormed'."), - top: z.number().optional().describe("The maximum number of projects to return. Defaults to 100."), - skip: z.number().optional().describe("The number of projects to skip for pagination. Defaults to 0."), - continuationToken: z.number().optional().describe("Continuation token for pagination. Used to fetch the next set of results if available."), - projectNameFilter: z.string().optional().describe("Filter projects by name. Supports partial matches."), - }, - async ({ stateFilter, top, skip, continuationToken, projectNameFilter }) => { - try { - const connection = await connectionProvider(); - const coreApi = await connection.getCoreApi(); - const projects = await coreApi.getProjects(stateFilter, top, skip, continuationToken, false); - - if (!projects) { - return { content: [{ type: "text", text: "No projects found" }], isError: true }; + return { + content: [{ type: "text", text: `Error fetching project teams: ${errorMessage}` }], + isError: true, + }; } + } + ), + + server.tool( + CORE_TOOLS.list_projects, + "Retrieve a list of projects in your Azure DevOps organization.", + { + stateFilter: z.enum(["all", "wellFormed", "createPending", "deleted"]).default("wellFormed").describe("Filter projects by their state. Defaults to 'wellFormed'."), + top: z.number().optional().describe("The maximum number of projects to return. Defaults to 100."), + skip: z.number().optional().describe("The number of projects to skip for pagination. Defaults to 0."), + continuationToken: z.number().optional().describe("Continuation token for pagination. Used to fetch the next set of results if available."), + projectNameFilter: z.string().optional().describe("Filter projects by name. Supports partial matches."), + }, + { + readOnlyHint: true, + }, + async ({ stateFilter, top, skip, continuationToken, projectNameFilter }) => { + try { + const connection = await connectionProvider(); + const coreApi = await connection.getCoreApi(); + const projects = await coreApi.getProjects(stateFilter, top, skip, continuationToken, false); + + if (!projects) { + return { content: [{ type: "text", text: "No projects found" }], isError: true }; + } + + const filteredProject = projectNameFilter ? filterProjectsByName(projects, projectNameFilter) : projects; - const filteredProject = projectNameFilter ? filterProjectsByName(projects, projectNameFilter) : projects; - - return { - content: [{ type: "text", text: JSON.stringify(filteredProject, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: JSON.stringify(filteredProject, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error fetching projects: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - CORE_TOOLS.get_identity_ids, - "Retrieve Azure DevOps identity IDs for a provided search filter.", - { - searchFilter: z.string().describe("Search filter (unique name, display name, email) to retrieve identity IDs for."), - }, - async ({ searchFilter }) => { - try { - const identities = await searchIdentities(searchFilter, tokenProvider, connectionProvider, userAgentProvider); - - if (!identities || identities.value?.length === 0) { - return { content: [{ type: "text", text: "No identities found" }], isError: true }; + return { + content: [{ type: "text", text: `Error fetching projects: ${errorMessage}` }], + isError: true, + }; } + } + ), + + server.tool( + CORE_TOOLS.get_identity_ids, + "Retrieve Azure DevOps identity IDs for a provided search filter.", + { + searchFilter: z.string().describe("Search filter (unique name, display name, email) to retrieve identity IDs for."), + }, + { + readOnlyHint: true, + }, + async ({ searchFilter }) => { + try { + const identities = await searchIdentities(searchFilter, tokenProvider, connectionProvider, userAgentProvider); + + if (!identities || identities.value?.length === 0) { + return { content: [{ type: "text", text: "No identities found" }], isError: true }; + } + + const identitiesTrimmed = identities.value?.map((identity: IdentityBase) => { + return { + id: identity.id, + displayName: identity.providerDisplayName, + descriptor: identity.descriptor, + }; + }); - const identitiesTrimmed = identities.value?.map((identity: IdentityBase) => { return { - id: identity.id, - displayName: identity.providerDisplayName, - descriptor: identity.descriptor, + content: [{ type: "text", text: JSON.stringify(identitiesTrimmed, null, 2) }], }; - }); - - return { - content: [{ type: "text", text: JSON.stringify(identitiesTrimmed, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error fetching identities: ${errorMessage}` }], - isError: true, - }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + return { + content: [{ type: "text", text: `Error fetching identities: ${errorMessage}` }], + isError: true, + }; + } + } + ), + ]; + + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); } } - ); + } } export { CORE_TOOLS, configureCoreTools }; diff --git a/src/tools/pipelines.ts b/src/tools/pipelines.ts index 3ac984b0..ffa00c2b 100644 --- a/src/tools/pipelines.ts +++ b/src/tools/pipelines.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { apiVersion, getEnumKeys, safeEnumConvert } from "../utils.js"; import { WebApi } from "azure-devops-node-api"; import { BuildQueryOrder, DefinitionQueryOrder } from "azure-devops-node-api/interfaces/BuildInterfaces.js"; @@ -22,153 +22,138 @@ const PIPELINE_TOOLS = { pipelines_run_pipeline: "pipelines_run_pipeline", }; -function configurePipelineTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { - server.tool( - PIPELINE_TOOLS.pipelines_get_build_definitions, - "Retrieves a list of build definitions for a given project.", - { - project: z.string().describe("Project ID or name to get build definitions for"), - repositoryId: z.string().optional().describe("Repository ID to filter build definitions"), - repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Type of repository to filter build definitions"), - name: z.string().optional().describe("Name of the build definition to filter"), - path: z.string().optional().describe("Path of the build definition to filter"), - queryOrder: z - .enum(getEnumKeys(DefinitionQueryOrder) as [string, ...string[]]) - .optional() - .describe("Order in which build definitions are returned"), - top: z.number().optional().describe("Maximum number of build definitions to return"), - continuationToken: z.string().optional().describe("Token for continuing paged results"), - minMetricsTime: z.coerce.date().optional().describe("Minimum metrics time to filter build definitions"), - definitionIds: z.array(z.number()).optional().describe("Array of build definition IDs to filter"), - builtAfter: z.coerce.date().optional().describe("Return definitions that have builds after this date"), - notBuiltAfter: z.coerce.date().optional().describe("Return definitions that do not have builds after this date"), - includeAllProperties: z.boolean().optional().describe("Whether to include all properties in the results"), - includeLatestBuilds: z.boolean().optional().describe("Whether to include the latest builds for each definition"), - taskIdFilter: z.string().optional().describe("Task ID to filter build definitions"), - processType: z.number().optional().describe("Process type to filter build definitions"), - yamlFilename: z.string().optional().describe("YAML filename to filter build definitions"), - }, - async ({ - project, - repositoryId, - repositoryType, - name, - path, - queryOrder, - top, - continuationToken, - minMetricsTime, - definitionIds, - builtAfter, - notBuiltAfter, - includeAllProperties, - includeLatestBuilds, - taskIdFilter, - processType, - yamlFilename, - }) => { - const connection = await connectionProvider(); - const buildApi = await connection.getBuildApi(); - const buildDefinitions = await buildApi.getDefinitions( +function configurePipelineTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + PIPELINE_TOOLS.pipelines_get_build_definitions, + "Retrieves a list of build definitions for a given project.", + { + project: z.string().describe("Project ID or name to get build definitions for"), + repositoryId: z.string().optional().describe("Repository ID to filter build definitions"), + repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Type of repository to filter build definitions"), + name: z.string().optional().describe("Name of the build definition to filter"), + path: z.string().optional().describe("Path of the build definition to filter"), + queryOrder: z + .enum(getEnumKeys(DefinitionQueryOrder) as [string, ...string[]]) + .optional() + .describe("Order in which build definitions are returned"), + top: z.number().optional().describe("Maximum number of build definitions to return"), + continuationToken: z.string().optional().describe("Token for continuing paged results"), + minMetricsTime: z.coerce.date().optional().describe("Minimum metrics time to filter build definitions"), + definitionIds: z.array(z.number()).optional().describe("Array of build definition IDs to filter"), + builtAfter: z.coerce.date().optional().describe("Return definitions that have builds after this date"), + notBuiltAfter: z.coerce.date().optional().describe("Return definitions that do not have builds after this date"), + includeAllProperties: z.boolean().optional().describe("Whether to include all properties in the results"), + includeLatestBuilds: z.boolean().optional().describe("Whether to include the latest builds for each definition"), + taskIdFilter: z.string().optional().describe("Task ID to filter build definitions"), + processType: z.number().optional().describe("Process type to filter build definitions"), + yamlFilename: z.string().optional().describe("YAML filename to filter build definitions"), + }, + { + readOnlyHint: true, + }, + async ({ project, - name, repositoryId, repositoryType, - safeEnumConvert(DefinitionQueryOrder, queryOrder), + name, + path, + queryOrder, top, continuationToken, minMetricsTime, definitionIds, - path, builtAfter, notBuiltAfter, includeAllProperties, includeLatestBuilds, taskIdFilter, processType, - yamlFilename - ); + yamlFilename, + }) => { + const connection = await connectionProvider(); + const buildApi = await connection.getBuildApi(); + const buildDefinitions = await buildApi.getDefinitions( + project, + name, + repositoryId, + repositoryType, + safeEnumConvert(DefinitionQueryOrder, queryOrder), + top, + continuationToken, + minMetricsTime, + definitionIds, + path, + builtAfter, + notBuiltAfter, + includeAllProperties, + includeLatestBuilds, + taskIdFilter, + processType, + yamlFilename + ); + + return { + content: [{ type: "text", text: JSON.stringify(buildDefinitions, null, 2) }], + }; + } + ), - return { - content: [{ type: "text", text: JSON.stringify(buildDefinitions, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_get_build_definition_revisions, - "Retrieves a list of revisions for a specific build definition.", - { - project: z.string().describe("Project ID or name to get the build definition revisions for"), - definitionId: z.number().describe("ID of the build definition to get revisions for"), - }, - async ({ project, definitionId }) => { - const connection = await connectionProvider(); - const buildApi = await connection.getBuildApi(); - const revisions = await buildApi.getDefinitionRevisions(project, definitionId); - - return { - content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_get_builds, - "Retrieves a list of builds for a given project.", - { - project: z.string().describe("Project ID or name to get builds for"), - definitions: z.array(z.number()).optional().describe("Array of build definition IDs to filter builds"), - queues: z.array(z.number()).optional().describe("Array of queue IDs to filter builds"), - buildNumber: z.string().optional().describe("Build number to filter builds"), - minTime: z.coerce.date().optional().describe("Minimum finish time to filter builds"), - maxTime: z.coerce.date().optional().describe("Maximum finish time to filter builds"), - requestedFor: z.string().optional().describe("User ID or name who requested the build"), - reasonFilter: z.number().optional().describe("Reason filter for the build (see BuildReason enum)"), - statusFilter: z.number().optional().describe("Status filter for the build (see BuildStatus enum)"), - resultFilter: z.number().optional().describe("Result filter for the build (see BuildResult enum)"), - tagFilters: z.array(z.string()).optional().describe("Array of tags to filter builds"), - properties: z.array(z.string()).optional().describe("Array of property names to include in the results"), - top: z.number().optional().describe("Maximum number of builds to return"), - continuationToken: z.string().optional().describe("Token for continuing paged results"), - maxBuildsPerDefinition: z.number().optional().describe("Maximum number of builds per definition"), - deletedFilter: z.number().optional().describe("Filter for deleted builds (see QueryDeletedOption enum)"), - queryOrder: z - .enum(getEnumKeys(BuildQueryOrder) as [string, ...string[]]) - .default("QueueTimeDescending") - .optional() - .describe("Order in which builds are returned"), - branchName: z.string().optional().describe("Branch name to filter builds"), - buildIds: z.array(z.number()).optional().describe("Array of build IDs to retrieve"), - repositoryId: z.string().optional().describe("Repository ID to filter builds"), - repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Type of repository to filter builds"), - }, - async ({ - project, - definitions, - queues, - buildNumber, - minTime, - maxTime, - requestedFor, - reasonFilter, - statusFilter, - resultFilter, - tagFilters, - properties, - top, - continuationToken, - maxBuildsPerDefinition, - deletedFilter, - queryOrder, - branchName, - buildIds, - repositoryId, - repositoryType, - }) => { - const connection = await connectionProvider(); - const buildApi = await connection.getBuildApi(); - const builds = await buildApi.getBuilds( + server.tool( + PIPELINE_TOOLS.pipelines_get_build_definition_revisions, + "Retrieves a list of revisions for a specific build definition.", + { + project: z.string().describe("Project ID or name to get the build definition revisions for"), + definitionId: z.number().describe("ID of the build definition to get revisions for"), + }, + { + readOnlyHint: true, + }, + async ({ project, definitionId }) => { + const connection = await connectionProvider(); + const buildApi = await connection.getBuildApi(); + const revisions = await buildApi.getDefinitionRevisions(project, definitionId); + + return { + content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }], + }; + } + ), + + server.tool( + PIPELINE_TOOLS.pipelines_get_builds, + "Retrieves a list of builds for a given project.", + { + project: z.string().describe("Project ID or name to get builds for"), + definitions: z.array(z.number()).optional().describe("Array of build definition IDs to filter builds"), + queues: z.array(z.number()).optional().describe("Array of queue IDs to filter builds"), + buildNumber: z.string().optional().describe("Build number to filter builds"), + minTime: z.coerce.date().optional().describe("Minimum finish time to filter builds"), + maxTime: z.coerce.date().optional().describe("Maximum finish time to filter builds"), + requestedFor: z.string().optional().describe("User ID or name who requested the build"), + reasonFilter: z.number().optional().describe("Reason filter for the build (see BuildReason enum)"), + statusFilter: z.number().optional().describe("Status filter for the build (see BuildStatus enum)"), + resultFilter: z.number().optional().describe("Result filter for the build (see BuildResult enum)"), + tagFilters: z.array(z.string()).optional().describe("Array of tags to filter builds"), + properties: z.array(z.string()).optional().describe("Array of property names to include in the results"), + top: z.number().optional().describe("Maximum number of builds to return"), + continuationToken: z.string().optional().describe("Token for continuing paged results"), + maxBuildsPerDefinition: z.number().optional().describe("Maximum number of builds per definition"), + deletedFilter: z.number().optional().describe("Filter for deleted builds (see QueryDeletedOption enum)"), + queryOrder: z + .enum(getEnumKeys(BuildQueryOrder) as [string, ...string[]]) + .default("QueueTimeDescending") + .optional() + .describe("Order in which builds are returned"), + branchName: z.string().optional().describe("Branch name to filter builds"), + buildIds: z.array(z.number()).optional().describe("Array of build IDs to retrieve"), + repositoryId: z.string().optional().describe("Repository ID to filter builds"), + repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Type of repository to filter builds"), + }, + { + readOnlyHint: true, + }, + async ({ project, definitions, queues, @@ -185,272 +170,332 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< continuationToken, maxBuildsPerDefinition, deletedFilter, - safeEnumConvert(BuildQueryOrder, queryOrder), + queryOrder, branchName, buildIds, repositoryId, - repositoryType - ); + repositoryType, + }) => { + const connection = await connectionProvider(); + const buildApi = await connection.getBuildApi(); + const builds = await buildApi.getBuilds( + project, + definitions, + queues, + buildNumber, + minTime, + maxTime, + requestedFor, + reasonFilter, + statusFilter, + resultFilter, + tagFilters, + properties, + top, + continuationToken, + maxBuildsPerDefinition, + deletedFilter, + safeEnumConvert(BuildQueryOrder, queryOrder), + branchName, + buildIds, + repositoryId, + repositoryType + ); + + return { + content: [{ type: "text", text: JSON.stringify(builds, null, 2) }], + }; + } + ), - return { - content: [{ type: "text", text: JSON.stringify(builds, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_get_build_log, - "Retrieves the logs for a specific build.", - { - project: z.string().describe("Project ID or name to get the build log for"), - buildId: z.number().describe("ID of the build to get the log for"), - }, - async ({ project, buildId }) => { - const connection = await connectionProvider(); - const buildApi = await connection.getBuildApi(); - const logs = await buildApi.getBuildLogs(project, buildId); - - return { - content: [{ type: "text", text: JSON.stringify(logs, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_get_build_log_by_id, - "Get a specific build log by log ID.", - { - project: z.string().describe("Project ID or name to get the build log for"), - buildId: z.number().describe("ID of the build to get the log for"), - logId: z.number().describe("ID of the log to retrieve"), - startLine: z.number().optional().describe("Starting line number for the log content, defaults to 0"), - endLine: z.number().optional().describe("Ending line number for the log content, defaults to the end of the log"), - }, - async ({ project, buildId, logId, startLine, endLine }) => { - const connection = await connectionProvider(); - const buildApi = await connection.getBuildApi(); - const logLines = await buildApi.getBuildLogLines(project, buildId, logId, startLine, endLine); - - return { - content: [{ type: "text", text: JSON.stringify(logLines, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_get_build_changes, - "Get the changes associated with a specific build.", - { - project: z.string().describe("Project ID or name to get the build changes for"), - buildId: z.number().describe("ID of the build to get changes for"), - continuationToken: z.string().optional().describe("Continuation token for pagination"), - top: z.number().default(100).describe("Number of changes to retrieve, defaults to 100"), - includeSourceChange: z.boolean().optional().describe("Whether to include source changes in the results, defaults to false"), - }, - async ({ project, buildId, continuationToken, top, includeSourceChange }) => { - const connection = await connectionProvider(); - const buildApi = await connection.getBuildApi(); - const changes = await buildApi.getBuildChanges(project, buildId, continuationToken, top, includeSourceChange); - - return { - content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_get_run, - "Gets a run for a particular pipeline.", - { - project: z.string().describe("Project ID or name to run the build in"), - pipelineId: z.number().describe("ID of the pipeline to run"), - runId: z.number().describe("ID of the run to get"), - }, - async ({ project, pipelineId, runId }) => { - const connection = await connectionProvider(); - const pipelinesApi = await connection.getPipelinesApi(); - const pipelineRun = await pipelinesApi.getRun(project, pipelineId, runId); - - return { - content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_list_runs, - "Gets top 10000 runs for a particular pipeline.", - { - project: z.string().describe("Project ID or name to run the build in"), - pipelineId: z.number().describe("ID of the pipeline to run"), - }, - async ({ project, pipelineId }) => { - const connection = await connectionProvider(); - const pipelinesApi = await connection.getPipelinesApi(); - const pipelineRuns = await pipelinesApi.listRuns(project, pipelineId); - - return { - content: [{ type: "text", text: JSON.stringify(pipelineRuns, null, 2) }], - }; - } - ); - - const variableSchema = z.object({ - value: z.string().optional(), - isSecret: z.boolean().optional(), - }); - - const resourcesSchema = z.object({ - builds: z - .record( - z.string().describe("Name of the build resource."), - z.object({ - version: z.string().optional().describe("Version of the build resource."), - }) - ) - .optional(), - containers: z - .record( - z.string().describe("Name of the container resource."), - z.object({ - version: z.string().optional().describe("Version of the container resource."), - }) - ) - .optional(), - packages: z - .record( - z.string().describe("Name of the package resource."), - z.object({ - version: z.string().optional().describe("Version of the package resource."), - }) - ) - .optional(), - pipelines: z.record( - z.string().describe("Name of the pipeline resource."), - z.object({ - runId: z.number().describe("Id of the source pipeline run that triggered or is referenced by this pipeline run."), - version: z.string().optional().describe("Version of the source pipeline run."), - }) + server.tool( + PIPELINE_TOOLS.pipelines_get_build_log, + "Retrieves the logs for a specific build.", + { + project: z.string().describe("Project ID or name to get the build log for"), + buildId: z.number().describe("ID of the build to get the log for"), + }, + { + readOnlyHint: true, + }, + async ({ project, buildId }) => { + const connection = await connectionProvider(); + const buildApi = await connection.getBuildApi(); + const logs = await buildApi.getBuildLogs(project, buildId); + + return { + content: [{ type: "text", text: JSON.stringify(logs, null, 2) }], + }; + } ), - repositories: z - .record( - z.string().describe("Name of the repository resource."), - z.object({ - refName: z.string().describe("Reference name, e.g., refs/heads/main."), - token: z.string().optional(), - tokenType: z.string().optional(), - version: z.string().optional().describe("Version of the repository resource, git commit sha."), - }) - ) - .optional(), - }); - - server.tool( - PIPELINE_TOOLS.pipelines_run_pipeline, - "Starts a new run of a pipeline.", - { - project: z.string().describe("Project ID or name to run the build in"), - pipelineId: z.number().describe("ID of the pipeline to run"), - pipelineVersion: z.number().optional().describe("Version of the pipeline to run. If not provided, the latest version will be used."), - previewRun: z.boolean().optional().describe("If true, returns the final YAML document after parsing templates without creating a new run."), - resources: resourcesSchema.optional().describe("A dictionary of resources to pass to the pipeline."), - stagesToSkip: z.array(z.string()).optional().describe("A list of stages to skip."), - templateParameters: z.record(z.string(), z.string()).optional().describe("Custom build parameters as key-value pairs"), - variables: z.record(z.string(), variableSchema).optional().describe("A dictionary of variables to pass to the pipeline."), - yamlOverride: z.string().optional().describe("YAML override for the pipeline run."), - }, - async ({ project, pipelineId, pipelineVersion, previewRun, resources, stagesToSkip, templateParameters, variables, yamlOverride }) => { - if (!previewRun && yamlOverride) { - throw new Error("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'."); + + server.tool( + PIPELINE_TOOLS.pipelines_get_build_log_by_id, + "Get a specific build log by log ID.", + { + project: z.string().describe("Project ID or name to get the build log for"), + buildId: z.number().describe("ID of the build to get the log for"), + logId: z.number().describe("ID of the log to retrieve"), + startLine: z.number().optional().describe("Starting line number for the log content, defaults to 0"), + endLine: z.number().optional().describe("Ending line number for the log content, defaults to the end of the log"), + }, + { + readOnlyHint: true, + }, + async ({ project, buildId, logId, startLine, endLine }) => { + const connection = await connectionProvider(); + const buildApi = await connection.getBuildApi(); + const logLines = await buildApi.getBuildLogLines(project, buildId, logId, startLine, endLine); + + return { + content: [{ type: "text", text: JSON.stringify(logLines, null, 2) }], + }; } + ), - const connection = await connectionProvider(); - const pipelinesApi = await connection.getPipelinesApi(); - const runRequest = { - previewRun: previewRun, - resources: { - ...resources, - }, - stagesToSkip: stagesToSkip, - templateParameters: templateParameters, - variables: variables, - yamlOverride: yamlOverride, - }; - - const pipelineRun = await pipelinesApi.runPipeline(runRequest, project, pipelineId, pipelineVersion); - const queuedBuild = { id: pipelineRun.id }; - const buildId = queuedBuild.id; - if (buildId === undefined) { - throw new Error("Failed to get build ID from pipeline run"); + server.tool( + PIPELINE_TOOLS.pipelines_get_build_changes, + "Get the changes associated with a specific build.", + { + project: z.string().describe("Project ID or name to get the build changes for"), + buildId: z.number().describe("ID of the build to get changes for"), + continuationToken: z.string().optional().describe("Continuation token for pagination"), + top: z.number().default(100).describe("Number of changes to retrieve, defaults to 100"), + includeSourceChange: z.boolean().optional().describe("Whether to include source changes in the results, defaults to false"), + }, + { + readOnlyHint: true, + }, + async ({ project, buildId, continuationToken, top, includeSourceChange }) => { + const connection = await connectionProvider(); + const buildApi = await connection.getBuildApi(); + const changes = await buildApi.getBuildChanges(project, buildId, continuationToken, top, includeSourceChange); + + return { + content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], + }; } + ), - return { - content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_get_build_status, - "Fetches the status of a specific build.", - { - project: z.string().describe("Project ID or name to get the build status for"), - buildId: z.number().describe("ID of the build to get the status for"), - }, - async ({ project, buildId }) => { - const connection = await connectionProvider(); - const buildApi = await connection.getBuildApi(); - const build = await buildApi.getBuildReport(project, buildId); - - return { - content: [{ type: "text", text: JSON.stringify(build, null, 2) }], - }; - } - ); - - server.tool( - PIPELINE_TOOLS.pipelines_update_build_stage, - "Updates the stage of a specific build.", - { - project: z.string().describe("Project ID or name to update the build stage for"), - buildId: z.number().describe("ID of the build to update"), - stageName: z.string().describe("Name of the stage to update"), - status: z.enum(getEnumKeys(StageUpdateType) as [string, ...string[]]).describe("New status for the stage"), - forceRetryAllJobs: z.boolean().default(false).describe("Whether to force retry all jobs in the stage."), - }, - async ({ project, buildId, stageName, status, forceRetryAllJobs }) => { - const connection = await connectionProvider(); - const orgUrl = connection.serverUrl; - const endpoint = `${orgUrl}/${project}/_apis/build/builds/${buildId}/stages/${stageName}?api-version=${apiVersion}`; - const token = await tokenProvider(); - - const body = { - forceRetryAllJobs: forceRetryAllJobs, - state: safeEnumConvert(StageUpdateType, status), - }; - - const response = await fetch(endpoint, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}`, - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), + server.tool( + PIPELINE_TOOLS.pipelines_get_run, + "Gets a run for a particular pipeline.", + { + project: z.string().describe("Project ID or name to run the build in"), + pipelineId: z.number().describe("ID of the pipeline to run"), + runId: z.number().describe("ID of the run to get"), + }, + { + readOnlyHint: true, + }, + async ({ project, pipelineId, runId }) => { + const connection = await connectionProvider(); + const pipelinesApi = await connection.getPipelinesApi(); + const pipelineRun = await pipelinesApi.getRun(project, pipelineId, runId); + + return { + content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }], + }; + } + ), + + server.tool( + PIPELINE_TOOLS.pipelines_list_runs, + "Gets top 10000 runs for a particular pipeline.", + { + project: z.string().describe("Project ID or name to run the build in"), + pipelineId: z.number().describe("ID of the pipeline to run"), + }, + { + readOnlyHint: true, + }, + async ({ project, pipelineId }) => { + const connection = await connectionProvider(); + const pipelinesApi = await connection.getPipelinesApi(); + const pipelineRuns = await pipelinesApi.listRuns(project, pipelineId); + + return { + content: [{ type: "text", text: JSON.stringify(pipelineRuns, null, 2) }], + }; + } + ), + + (() => { + const variableSchema = z.object({ + value: z.string().optional(), + isSecret: z.boolean().optional(), + }); + + const resourcesSchema = z.object({ + builds: z + .record( + z.string().describe("Name of the build resource."), + z.object({ + version: z.string().optional().describe("Version of the build resource."), + }) + ) + .optional(), + containers: z + .record( + z.string().describe("Name of the container resource."), + z.object({ + version: z.string().optional().describe("Version of the container resource."), + }) + ) + .optional(), + packages: z + .record( + z.string().describe("Name of the package resource."), + z.object({ + version: z.string().optional().describe("Version of the package resource."), + }) + ) + .optional(), + pipelines: z.record( + z.string().describe("Name of the pipeline resource."), + z.object({ + runId: z.number().describe("Id of the source pipeline run that triggered or is referenced by this pipeline run."), + version: z.string().optional().describe("Version of the source pipeline run."), + }) + ), + repositories: z + .record( + z.string().describe("Name of the repository resource."), + z.object({ + refName: z.string().describe("Reference name, e.g., refs/heads/main."), + token: z.string().optional(), + tokenType: z.string().optional(), + version: z.string().optional().describe("Version of the repository resource, git commit sha."), + }) + ) + .optional(), }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to update build stage: ${response.status} ${errorText}`); + return server.tool( + PIPELINE_TOOLS.pipelines_run_pipeline, + "Starts a new run of a pipeline.", + { + project: z.string().describe("Project ID or name to run the build in"), + pipelineId: z.number().describe("ID of the pipeline to run"), + pipelineVersion: z.number().optional().describe("Version of the pipeline to run. If not provided, the latest version will be used."), + previewRun: z.boolean().optional().describe("If true, returns the final YAML document after parsing templates without creating a new run."), + resources: resourcesSchema.optional().describe("A dictionary of resources to pass to the pipeline."), + stagesToSkip: z.array(z.string()).optional().describe("A list of stages to skip."), + templateParameters: z.record(z.string(), z.string()).optional().describe("Custom build parameters as key-value pairs"), + variables: z.record(z.string(), variableSchema).optional().describe("A dictionary of variables to pass to the pipeline."), + yamlOverride: z.string().optional().describe("YAML override for the pipeline run."), + }, + { + readOnlyHint: false, + }, + async ({ project, pipelineId, pipelineVersion, previewRun, resources, stagesToSkip, templateParameters, variables, yamlOverride }) => { + if (!previewRun && yamlOverride) { + throw new Error("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'."); + } + + const connection = await connectionProvider(); + const pipelinesApi = await connection.getPipelinesApi(); + const runRequest = { + previewRun: previewRun, + resources: { + ...resources, + }, + stagesToSkip: stagesToSkip, + templateParameters: templateParameters, + variables: variables, + yamlOverride: yamlOverride, + }; + + const pipelineRun = await pipelinesApi.runPipeline(runRequest, project, pipelineId, pipelineVersion); + const queuedBuild = { id: pipelineRun.id }; + const buildId = queuedBuild.id; + if (buildId === undefined) { + throw new Error("Failed to get build ID from pipeline run"); + } + + return { + content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }], + }; + } + ); + })(), + + server.tool( + PIPELINE_TOOLS.pipelines_get_build_status, + "Fetches the status of a specific build.", + { + project: z.string().describe("Project ID or name to get the build status for"), + buildId: z.number().describe("ID of the build to get the status for"), + }, + { + readOnlyHint: true, + }, + async ({ project, buildId }) => { + const connection = await connectionProvider(); + const buildApi = await connection.getBuildApi(); + const build = await buildApi.getBuildReport(project, buildId); + + return { + content: [{ type: "text", text: JSON.stringify(build, null, 2) }], + }; } + ), - const updatedBuild = await response.text(); + server.tool( + PIPELINE_TOOLS.pipelines_update_build_stage, + "Updates the stage of a specific build.", + { + project: z.string().describe("Project ID or name to update the build stage for"), + buildId: z.number().describe("ID of the build to update"), + stageName: z.string().describe("Name of the stage to update"), + status: z.enum(getEnumKeys(StageUpdateType) as [string, ...string[]]).describe("New status for the stage"), + forceRetryAllJobs: z.boolean().default(false).describe("Whether to force retry all jobs in the stage."), + }, + { + readOnlyHint: false, + }, + async ({ project, buildId, stageName, status, forceRetryAllJobs }) => { + const connection = await connectionProvider(); + const orgUrl = connection.serverUrl; + const endpoint = `${orgUrl}/${project}/_apis/build/builds/${buildId}/stages/${stageName}?api-version=${apiVersion}`; + const token = await tokenProvider(); + + const body = { + forceRetryAllJobs: forceRetryAllJobs, + state: safeEnumConvert(StageUpdateType, status), + }; + + const response = await fetch(endpoint, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to update build stage: ${response.status} ${errorText}`); + } + + const updatedBuild = await response.text(); + + return { + content: [{ type: "text", text: JSON.stringify(updatedBuild, null, 2) }], + }; + } + ), + ]; - return { - content: [{ type: "text", text: JSON.stringify(updatedBuild, null, 2) }], - }; + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); + } } - ); + } } export { PIPELINE_TOOLS, configurePipelineTools }; diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index 9f10e235..9af3796f 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { GitRef, @@ -130,915 +130,982 @@ function trimPullRequest(pr: GitPullRequest, includeDescription = false) { }; } -function configureRepoTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { - server.tool( - REPO_TOOLS.create_pull_request, - "Create a new pull request.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request will be created."), - sourceRefName: z.string().describe("The source branch name for the pull request, e.g., 'refs/heads/feature-branch'."), - targetRefName: z.string().describe("The target branch name for the pull request, e.g., 'refs/heads/main'."), - title: z.string().describe("The title of the pull request."), - description: z.string().optional().describe("The description of the pull request. Optional."), - isDraft: z.boolean().optional().default(false).describe("Indicates whether the pull request is a draft. Defaults to false."), - workItems: z.string().optional().describe("Work item IDs to associate with the pull request, space-separated."), - forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository that the pull request originates from. Optional, used when creating a pull request from a fork."), - }, - async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : []; - - const forkSource: GitForkRef | undefined = forkSourceRepositoryId - ? { - repository: { - id: forkSourceRepositoryId, - }, - } - : undefined; +function configureRepoTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + REPO_TOOLS.create_pull_request, + "Create a new pull request.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request will be created."), + sourceRefName: z.string().describe("The source branch name for the pull request, e.g., 'refs/heads/feature-branch'."), + targetRefName: z.string().describe("The target branch name for the pull request, e.g., 'refs/heads/main'."), + title: z.string().describe("The title of the pull request."), + description: z.string().optional().describe("The description of the pull request. Optional."), + isDraft: z.boolean().optional().default(false).describe("Indicates whether the pull request is a draft. Defaults to false."), + workItems: z.string().optional().describe("Work item IDs to associate with the pull request, space-separated."), + forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository that the pull request originates from. Optional, used when creating a pull request from a fork."), + }, + { + readOnlyHint: false, + }, + async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, workItems, forkSourceRepositoryId }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : []; - const pullRequest = await gitApi.createPullRequest( - { - sourceRefName, - targetRefName, - title, - description, - isDraft, - workItemRefs: workItemRefs, - forkSource, - }, - repositoryId - ); + const forkSource: GitForkRef | undefined = forkSourceRepositoryId + ? { + repository: { + id: forkSourceRepositoryId, + }, + } + : undefined; + + const pullRequest = await gitApi.createPullRequest( + { + sourceRefName, + targetRefName, + title, + description, + isDraft, + workItemRefs: workItemRefs, + forkSource, + }, + repositoryId + ); - const trimmedPullRequest = trimPullRequest(pullRequest, true); + const trimmedPullRequest = trimPullRequest(pullRequest, true); - return { - content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.create_branch, - "Create a new branch in the repository.", - { - repositoryId: z.string().describe("The ID of the repository where the branch will be created."), - branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."), - sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."), - sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."), - }, - async ({ repositoryId, branchName, sourceBranchName, sourceCommitId }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); + return { + content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }], + }; + } + ), + + server.tool( + REPO_TOOLS.create_branch, + "Create a new branch in the repository.", + { + repositoryId: z.string().describe("The ID of the repository where the branch will be created."), + branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."), + sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."), + sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."), + }, + { + readOnlyHint: false, + }, + async ({ repositoryId, branchName, sourceBranchName, sourceCommitId }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); - let commitId = sourceCommitId; + let commitId = sourceCommitId; + + // If no commit ID is provided, get the latest commit from the source branch + if (!commitId) { + const sourceRefName = `refs/heads/${sourceBranchName}`; + try { + const sourceBranch = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, sourceBranchName); + const branch = sourceBranch.find((b) => b.name === sourceRefName); + if (!branch || !branch.objectId) { + return { + content: [ + { + type: "text", + text: `Error: Source branch '${sourceBranchName}' not found in repository ${repositoryId}`, + }, + ], + isError: true, + }; + } + commitId = branch.objectId; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error retrieving source branch '${sourceBranchName}': ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + } + + // Create the new branch using updateRefs + const newRefName = `refs/heads/${branchName}`; + const refUpdate = { + name: newRefName, + newObjectId: commitId, + oldObjectId: "0000000000000000000000000000000000000000", // All zeros indicates creating a new ref + }; - // If no commit ID is provided, get the latest commit from the source branch - if (!commitId) { - const sourceRefName = `refs/heads/${sourceBranchName}`; try { - const sourceBranch = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, sourceBranchName); - const branch = sourceBranch.find((b) => b.name === sourceRefName); - if (!branch || !branch.objectId) { + const result = await gitApi.updateRefs([refUpdate], repositoryId); + + // Check if the branch creation was successful + if (result && result.length > 0 && result[0].success) { return { content: [ { type: "text", - text: `Error: Source branch '${sourceBranchName}' not found in repository ${repositoryId}`, + text: `Branch '${branchName}' created successfully from '${sourceBranchName}' (${commitId})`, + }, + ], + }; + } else { + const errorMessage = result && result.length > 0 && result[0].customMessage ? result[0].customMessage : "Unknown error occurred during branch creation"; + return { + content: [ + { + type: "text", + text: `Error creating branch '${branchName}': ${errorMessage}`, }, ], isError: true, }; } - commitId = branch.objectId; } catch (error) { return { content: [ { type: "text", - text: `Error retrieving source branch '${sourceBranchName}': ${error instanceof Error ? error.message : String(error)}`, + text: `Error creating branch '${branchName}': ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } + ), + + server.tool( + REPO_TOOLS.update_pull_request, + "Update a Pull Request by ID with specified fields, including setting autocomplete with various completion options.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request exists."), + pullRequestId: z.number().describe("The ID of the pull request to update."), + title: z.string().optional().describe("The new title for the pull request."), + description: z.string().optional().describe("The new description for the pull request."), + isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."), + targetRefName: z.string().optional().describe("The new target branch name (e.g., 'refs/heads/main')."), + status: z.enum(["Active", "Abandoned"]).optional().describe("The new status of the pull request. Can be 'Active' or 'Abandoned'."), + autoComplete: z.boolean().optional().describe("Set the pull request to autocomplete when all requirements are met."), + mergeStrategy: z + .enum(getEnumKeys(GitPullRequestMergeStrategy) as [string, ...string[]]) + .optional() + .describe("The merge strategy to use when the pull request autocompletes. Defaults to 'NoFastForward'."), + deleteSourceBranch: z.boolean().optional().default(false).describe("Whether to delete the source branch when the pull request autocompletes. Defaults to false."), + transitionWorkItems: z.boolean().optional().default(true).describe("Whether to transition associated work items to the next state when the pull request autocompletes. Defaults to true."), + bypassReason: z.string().optional().describe("Reason for bypassing branch policies. When provided, branch policies will be automatically bypassed during autocompletion."), + }, + { + readOnlyHint: false, + }, + async ({ repositoryId, pullRequestId, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, deleteSourceBranch, transitionWorkItems, bypassReason }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); - // Create the new branch using updateRefs - const newRefName = `refs/heads/${branchName}`; - const refUpdate = { - name: newRefName, - newObjectId: commitId, - oldObjectId: "0000000000000000000000000000000000000000", // All zeros indicates creating a new ref - }; + // Build update object with only provided fields + const updateRequest: Record = {}; - try { - const result = await gitApi.updateRefs([refUpdate], repositoryId); + if (title !== undefined) updateRequest.title = title; + if (description !== undefined) updateRequest.description = description; + if (isDraft !== undefined) updateRequest.isDraft = isDraft; + if (targetRefName !== undefined) updateRequest.targetRefName = targetRefName; + if (status !== undefined) { + updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf(); + } - // Check if the branch creation was successful - if (result && result.length > 0 && result[0].success) { - return { - content: [ - { - type: "text", - text: `Branch '${branchName}' created successfully from '${sourceBranchName}' (${commitId})`, - }, - ], - }; - } else { - const errorMessage = result && result.length > 0 && result[0].customMessage ? result[0].customMessage : "Unknown error occurred during branch creation"; + if (autoComplete !== undefined) { + if (autoComplete) { + const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); + const autoCompleteUserId = data.authenticatedUser.id; + updateRequest.autoCompleteSetBy = { id: autoCompleteUserId }; + + const completionOptions: GitPullRequestCompletionOptions = { + deleteSourceBranch: deleteSourceBranch || false, + transitionWorkItems: transitionWorkItems !== false, // Default to true unless explicitly set to false + bypassPolicy: !!bypassReason, // Automatically set to true if bypassReason is provided + }; + + if (mergeStrategy) { + completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy as keyof typeof GitPullRequestMergeStrategy]; + } + + if (bypassReason) { + completionOptions.bypassReason = bypassReason; + } + + updateRequest.completionOptions = completionOptions; + } else { + updateRequest.autoCompleteSetBy = null; + updateRequest.completionOptions = null; + } + } + + // Validate that at least one field is provided for update + if (Object.keys(updateRequest).length === 0) { return { - content: [ - { - type: "text", - text: `Error creating branch '${branchName}': ${errorMessage}`, - }, - ], + content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, status, or autoComplete options) must be provided for update." }], isError: true, }; } - } catch (error) { + + const updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId); + const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true); + return { - content: [ - { - type: "text", - text: `Error creating branch '${branchName}': ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, + content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }], }; } - } - ); - - server.tool( - REPO_TOOLS.update_pull_request, - "Update a Pull Request by ID with specified fields, including setting autocomplete with various completion options.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request exists."), - pullRequestId: z.number().describe("The ID of the pull request to update."), - title: z.string().optional().describe("The new title for the pull request."), - description: z.string().optional().describe("The new description for the pull request."), - isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."), - targetRefName: z.string().optional().describe("The new target branch name (e.g., 'refs/heads/main')."), - status: z.enum(["Active", "Abandoned"]).optional().describe("The new status of the pull request. Can be 'Active' or 'Abandoned'."), - autoComplete: z.boolean().optional().describe("Set the pull request to autocomplete when all requirements are met."), - mergeStrategy: z - .enum(getEnumKeys(GitPullRequestMergeStrategy) as [string, ...string[]]) - .optional() - .describe("The merge strategy to use when the pull request autocompletes. Defaults to 'NoFastForward'."), - deleteSourceBranch: z.boolean().optional().default(false).describe("Whether to delete the source branch when the pull request autocompletes. Defaults to false."), - transitionWorkItems: z.boolean().optional().default(true).describe("Whether to transition associated work items to the next state when the pull request autocompletes. Defaults to true."), - bypassReason: z.string().optional().describe("Reason for bypassing branch policies. When provided, branch policies will be automatically bypassed during autocompletion."), - }, - async ({ repositoryId, pullRequestId, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, deleteSourceBranch, transitionWorkItems, bypassReason }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - // Build update object with only provided fields - const updateRequest: Record = {}; - - if (title !== undefined) updateRequest.title = title; - if (description !== undefined) updateRequest.description = description; - if (isDraft !== undefined) updateRequest.isDraft = isDraft; - if (targetRefName !== undefined) updateRequest.targetRefName = targetRefName; - if (status !== undefined) { - updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf(); - } + ), + + server.tool( + REPO_TOOLS.update_pull_request_reviewers, + "Add or remove reviewers for an existing pull request.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request exists."), + pullRequestId: z.number().describe("The ID of the pull request to update."), + reviewerIds: z.array(z.string()).describe("List of reviewer ids to add or remove from the pull request."), + action: z.enum(["add", "remove"]).describe("Action to perform on the reviewers. Can be 'add' or 'remove'."), + }, + { + readOnlyHint: false, + }, + async ({ repositoryId, pullRequestId, reviewerIds, action }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); - if (autoComplete !== undefined) { - if (autoComplete) { - const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); - const autoCompleteUserId = data.authenticatedUser.id; - updateRequest.autoCompleteSetBy = { id: autoCompleteUserId }; + let updatedPullRequest; + if (action === "add") { + updatedPullRequest = await gitApi.createPullRequestReviewers( + reviewerIds.map((id) => ({ id: id })), + repositoryId, + pullRequestId + ); + + const trimmedResponse = updatedPullRequest.map((item) => ({ + displayName: item.displayName, + id: item.id, + uniqueName: item.uniqueName, + vote: item.vote, + hasDeclined: item.hasDeclined, + isFlagged: item.isFlagged, + })); - const completionOptions: GitPullRequestCompletionOptions = { - deleteSourceBranch: deleteSourceBranch || false, - transitionWorkItems: transitionWorkItems !== false, // Default to true unless explicitly set to false - bypassPolicy: !!bypassReason, // Automatically set to true if bypassReason is provided + return { + content: [{ type: "text", text: JSON.stringify(trimmedResponse, null, 2) }], }; - - if (mergeStrategy) { - completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy as keyof typeof GitPullRequestMergeStrategy]; - } - - if (bypassReason) { - completionOptions.bypassReason = bypassReason; + } else { + for (const reviewerId of reviewerIds) { + await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId); } - updateRequest.completionOptions = completionOptions; - } else { - updateRequest.autoCompleteSetBy = null; - updateRequest.completionOptions = null; + return { + content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }], + }; } } + ), + + server.tool( + REPO_TOOLS.list_repos_by_project, + "Retrieve a list of repositories for a given project", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + top: z.number().default(100).describe("The maximum number of repositories to return."), + skip: z.number().default(0).describe("The number of repositories to skip. Defaults to 0."), + repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. If provided, only repositories with names containing this string will be returned."), + }, + { + readOnlyHint: true, + }, + async ({ project, top, skip, repoNameFilter }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + const repositories = await gitApi.getRepositories(project, false, false, false); - // Validate that at least one field is provided for update - if (Object.keys(updateRequest).length === 0) { - return { - content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, status, or autoComplete options) must be provided for update." }], - isError: true, - }; - } - - const updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId); - const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true); + const filteredRepositories = repoNameFilter ? filterReposByName(repositories, repoNameFilter) : repositories; - return { - content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.update_pull_request_reviewers, - "Add or remove reviewers for an existing pull request.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request exists."), - pullRequestId: z.number().describe("The ID of the pull request to update."), - reviewerIds: z.array(z.string()).describe("List of reviewer ids to add or remove from the pull request."), - action: z.enum(["add", "remove"]).describe("Action to perform on the reviewers. Can be 'add' or 'remove'."), - }, - async ({ repositoryId, pullRequestId, reviewerIds, action }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - let updatedPullRequest; - if (action === "add") { - updatedPullRequest = await gitApi.createPullRequestReviewers( - reviewerIds.map((id) => ({ id: id })), - repositoryId, - pullRequestId - ); + const paginatedRepositories = filteredRepositories?.sort((a, b) => a.name?.localeCompare(b.name ?? "") ?? 0).slice(skip, skip + top); - const trimmedResponse = updatedPullRequest.map((item) => ({ - displayName: item.displayName, - id: item.id, - uniqueName: item.uniqueName, - vote: item.vote, - hasDeclined: item.hasDeclined, - isFlagged: item.isFlagged, + // Filter out the irrelevant properties + const trimmedRepositories = paginatedRepositories?.map((repo) => ({ + id: repo.id, + name: repo.name, + isDisabled: repo.isDisabled, + isFork: repo.isFork, + isInMaintenance: repo.isInMaintenance, + webUrl: repo.webUrl, + size: repo.size, })); return { - content: [{ type: "text", text: JSON.stringify(trimmedResponse, null, 2) }], - }; - } else { - for (const reviewerId of reviewerIds) { - await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId); - } - - return { - content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }], + content: [{ type: "text", text: JSON.stringify(trimmedRepositories, null, 2) }], }; } - } - ); - - server.tool( - REPO_TOOLS.list_repos_by_project, - "Retrieve a list of repositories for a given project", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - top: z.number().default(100).describe("The maximum number of repositories to return."), - skip: z.number().default(0).describe("The number of repositories to skip. Defaults to 0."), - repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. If provided, only repositories with names containing this string will be returned."), - }, - async ({ project, top, skip, repoNameFilter }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const repositories = await gitApi.getRepositories(project, false, false, false); - - const filteredRepositories = repoNameFilter ? filterReposByName(repositories, repoNameFilter) : repositories; - - const paginatedRepositories = filteredRepositories?.sort((a, b) => a.name?.localeCompare(b.name ?? "") ?? 0).slice(skip, skip + top); - - // Filter out the irrelevant properties - const trimmedRepositories = paginatedRepositories?.map((repo) => ({ - id: repo.id, - name: repo.name, - isDisabled: repo.isDisabled, - isFork: repo.isFork, - isInMaintenance: repo.isInMaintenance, - webUrl: repo.webUrl, - size: repo.size, - })); - - return { - content: [{ type: "text", text: JSON.stringify(trimmedRepositories, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.list_pull_requests_by_repo_or_project, - "Retrieve a list of pull requests for a given repository. Either repositoryId or project must be provided.", - { - repositoryId: z.string().optional().describe("The ID of the repository where the pull requests are located."), - project: z.string().optional().describe("The ID of the project where the pull requests are located."), - top: z.number().default(100).describe("The maximum number of pull requests to return."), - skip: z.number().default(0).describe("The number of pull requests to skip."), - created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."), - created_by_user: z.string().optional().describe("Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided."), - i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."), - user_is_reviewer: z - .string() - .optional() - .describe("Filter pull requests where a specific user is a reviewer (provide email or unique name). Takes precedence over i_am_reviewer if both are provided."), - status: z - .enum(getEnumKeys(PullRequestStatus) as [string, ...string[]]) - .default("Active") - .describe("Filter pull requests by status. Defaults to 'Active'."), - sourceRefName: z.string().optional().describe("Filter pull requests from this source branch (e.g., 'refs/heads/feature-branch')."), - targetRefName: z.string().optional().describe("Filter pull requests into this target branch (e.g., 'refs/heads/main')."), - }, - async ({ repositoryId, project, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - // Build the search criteria - const searchCriteria: { - status: number; - repositoryId?: string; - creatorId?: string; - reviewerId?: string; - sourceRefName?: string; - targetRefName?: string; - } = { - status: pullRequestStatusStringToInt(status), - }; - - if (!repositoryId && !project) { - return { - content: [ - { - type: "text", - text: "Either repositoryId or project must be provided.", - }, - ], - isError: true, - }; - } - - if (repositoryId) { - searchCriteria.repositoryId = repositoryId; - } - - if (sourceRefName) { - searchCriteria.sourceRefName = sourceRefName; - } + ), + + server.tool( + REPO_TOOLS.list_pull_requests_by_repo_or_project, + "Retrieve a list of pull requests for a given repository. Either repositoryId or project must be provided.", + { + repositoryId: z.string().optional().describe("The ID of the repository where the pull requests are located."), + project: z.string().optional().describe("The ID of the project where the pull requests are located."), + top: z.number().default(100).describe("The maximum number of pull requests to return."), + skip: z.number().default(0).describe("The number of pull requests to skip."), + created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."), + created_by_user: z.string().optional().describe("Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided."), + i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."), + user_is_reviewer: z + .string() + .optional() + .describe("Filter pull requests where a specific user is a reviewer (provide email or unique name). Takes precedence over i_am_reviewer if both are provided."), + status: z + .enum(getEnumKeys(PullRequestStatus) as [string, ...string[]]) + .default("Active") + .describe("Filter pull requests by status. Defaults to 'Active'."), + sourceRefName: z.string().optional().describe("Filter pull requests from this source branch (e.g., 'refs/heads/feature-branch')."), + targetRefName: z.string().optional().describe("Filter pull requests into this target branch (e.g., 'refs/heads/main')."), + }, + { + readOnlyHint: true, + }, + async ({ repositoryId, project, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); - if (targetRefName) { - searchCriteria.targetRefName = targetRefName; - } + // Build the search criteria + const searchCriteria: { + status: number; + repositoryId?: string; + creatorId?: string; + reviewerId?: string; + sourceRefName?: string; + targetRefName?: string; + } = { + status: pullRequestStatusStringToInt(status), + }; - if (created_by_user) { - try { - const userId = await getUserIdFromEmail(created_by_user, tokenProvider, connectionProvider, userAgentProvider); - searchCriteria.creatorId = userId; - } catch (error) { + if (!repositoryId && !project) { return { content: [ { type: "text", - text: `Error finding user with email ${created_by_user}: ${error instanceof Error ? error.message : String(error)}`, + text: "Either repositoryId or project must be provided.", }, ], isError: true, }; } - } else if (created_by_me) { - const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); - const userId = data.authenticatedUser.id; - searchCriteria.creatorId = userId; - } - if (user_is_reviewer) { - try { - const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider); - searchCriteria.reviewerId = reviewerUserId; - } catch (error) { + if (repositoryId) { + searchCriteria.repositoryId = repositoryId; + } + + if (sourceRefName) { + searchCriteria.sourceRefName = sourceRefName; + } + + if (targetRefName) { + searchCriteria.targetRefName = targetRefName; + } + + if (created_by_user) { + try { + const userId = await getUserIdFromEmail(created_by_user, tokenProvider, connectionProvider, userAgentProvider); + searchCriteria.creatorId = userId; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error finding user with email ${created_by_user}: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + } else if (created_by_me) { + const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); + const userId = data.authenticatedUser.id; + searchCriteria.creatorId = userId; + } + + if (user_is_reviewer) { + try { + const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider); + searchCriteria.reviewerId = reviewerUserId; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + } else if (i_am_reviewer) { + const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); + const userId = data.authenticatedUser.id; + searchCriteria.reviewerId = userId; + } + + let pullRequests; + if (repositoryId) { + pullRequests = await gitApi.getPullRequests( + repositoryId, + searchCriteria, + project, // project + undefined, // maxCommentLength + skip, + top + ); + } else if (project) { + // If only project is provided, use getPullRequestsByProject + pullRequests = await gitApi.getPullRequestsByProject( + project, + searchCriteria, + undefined, // maxCommentLength + skip, + top + ); + } else { + // This case should not occur due to earlier validation, but added for completeness return { content: [ { type: "text", - text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}`, + text: "Either repositoryId or project must be provided.", }, ], isError: true, }; } - } else if (i_am_reviewer) { - const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); - const userId = data.authenticatedUser.id; - searchCriteria.reviewerId = userId; - } - let pullRequests; - if (repositoryId) { - pullRequests = await gitApi.getPullRequests( - repositoryId, - searchCriteria, - project, // project - undefined, // maxCommentLength - skip, - top - ); - } else if (project) { - // If only project is provided, use getPullRequestsByProject - pullRequests = await gitApi.getPullRequestsByProject( - project, - searchCriteria, - undefined, // maxCommentLength - skip, - top - ); - } else { - // This case should not occur due to earlier validation, but added for completeness + const filteredPullRequests = pullRequests?.map((pr) => trimPullRequest(pr)); + return { - content: [ - { - type: "text", - text: "Either repositoryId or project must be provided.", - }, - ], - isError: true, + content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }], }; } + ), + + server.tool( + REPO_TOOLS.list_pull_request_threads, + "Retrieve a list of comment threads for a pull request.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request is located."), + pullRequestId: z.number().describe("The ID of the pull request for which to retrieve threads."), + project: z.string().optional().describe("Project ID or project name (optional)"), + iteration: z.number().optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."), + baseIteration: z.number().optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."), + top: z.number().default(100).describe("The maximum number of threads to return."), + skip: z.number().default(0).describe("The number of threads to skip."), + fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of trimmed data."), + }, + { + readOnlyHint: true, + }, + async ({ repositoryId, pullRequestId, project, iteration, baseIteration, top, skip, fullResponse }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); - const filteredPullRequests = pullRequests?.map((pr) => trimPullRequest(pr)); + const threads = await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration); - return { - content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.list_pull_request_threads, - "Retrieve a list of comment threads for a pull request.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request is located."), - pullRequestId: z.number().describe("The ID of the pull request for which to retrieve threads."), - project: z.string().optional().describe("Project ID or project name (optional)"), - iteration: z.number().optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."), - baseIteration: z.number().optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."), - top: z.number().default(100).describe("The maximum number of threads to return."), - skip: z.number().default(0).describe("The number of threads to skip."), - fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of trimmed data."), - }, - async ({ repositoryId, pullRequestId, project, iteration, baseIteration, top, skip, fullResponse }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); + const paginatedThreads = threads?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top); - const threads = await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration); + if (fullResponse) { + return { + content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }], + }; + } - const paginatedThreads = threads?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top); + // Return trimmed thread data focusing on essential information + const trimmedThreads = paginatedThreads?.map((thread) => trimPullRequestThread(thread)); - if (fullResponse) { return { - content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }], + content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }], }; } + ), + + server.tool( + REPO_TOOLS.list_pull_request_thread_comments, + "Retrieve a list of comments in a pull request thread.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request is located."), + pullRequestId: z.number().describe("The ID of the pull request for which to retrieve thread comments."), + threadId: z.number().describe("The ID of the thread for which to retrieve comments."), + project: z.string().optional().describe("Project ID or project name (optional)"), + top: z.number().default(100).describe("The maximum number of comments to return."), + skip: z.number().default(0).describe("The number of comments to skip."), + fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."), + }, + { + readOnlyHint: true, + }, + async ({ repositoryId, pullRequestId, threadId, project, top, skip, fullResponse }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); - // Return trimmed thread data focusing on essential information - const trimmedThreads = paginatedThreads?.map((thread) => trimPullRequestThread(thread)); + // Get thread comments - GitApi uses getComments for retrieving comments from a specific thread + const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project); - return { - content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.list_pull_request_thread_comments, - "Retrieve a list of comments in a pull request thread.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request is located."), - pullRequestId: z.number().describe("The ID of the pull request for which to retrieve thread comments."), - threadId: z.number().describe("The ID of the thread for which to retrieve comments."), - project: z.string().optional().describe("Project ID or project name (optional)"), - top: z.number().default(100).describe("The maximum number of comments to return."), - skip: z.number().default(0).describe("The number of comments to skip."), - fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."), - }, - async ({ repositoryId, pullRequestId, threadId, project, top, skip, fullResponse }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); + const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top); - // Get thread comments - GitApi uses getComments for retrieving comments from a specific thread - const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project); + if (fullResponse) { + return { + content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }], + }; + } - const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top); + // Return trimmed comment data focusing on essential information + const trimmedComments = trimComments(paginatedComments); - if (fullResponse) { return { - content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }], + content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }], }; } + ), + + server.tool( + REPO_TOOLS.list_branches_by_repo, + "Retrieve a list of branches for a given repository.", + { + repositoryId: z.string().describe("The ID of the repository where the branches are located."), + top: z.number().default(100).describe("The maximum number of branches to return. Defaults to 100."), + filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."), + }, + { + readOnlyHint: true, + }, + async ({ repositoryId, top, filterContains }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains); - // Return trimmed comment data focusing on essential information - const trimmedComments = trimComments(paginatedComments); - - return { - content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.list_branches_by_repo, - "Retrieve a list of branches for a given repository.", - { - repositoryId: z.string().describe("The ID of the repository where the branches are located."), - top: z.number().default(100).describe("The maximum number of branches to return. Defaults to 100."), - filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."), - }, - async ({ repositoryId, top, filterContains }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains); - - const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top); + const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top); - return { - content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.list_my_branches_by_repo, - "Retrieve a list of my branches for a given repository Id.", - { - repositoryId: z.string().describe("The ID of the repository where the branches are located."), - top: z.number().default(100).describe("The maximum number of branches to return."), - filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."), - }, - async ({ repositoryId, top, filterContains }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", undefined, undefined, true, undefined, undefined, filterContains); + return { + content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }], + }; + } + ), + + server.tool( + REPO_TOOLS.list_my_branches_by_repo, + "Retrieve a list of my branches for a given repository Id.", + { + repositoryId: z.string().describe("The ID of the repository where the branches are located."), + top: z.number().default(100).describe("The maximum number of branches to return."), + filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."), + }, + { + readOnlyHint: true, + }, + async ({ repositoryId, top, filterContains }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", undefined, undefined, true, undefined, undefined, filterContains); - const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top); + const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top); - return { - content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.get_repo_by_name_or_id, - "Get the repository by project and repository name or ID.", - { - project: z.string().describe("Project name or ID where the repository is located."), - repositoryNameOrId: z.string().describe("Repository name or ID."), - }, - async ({ project, repositoryNameOrId }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const repositories = await gitApi.getRepositories(project); + return { + content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }], + }; + } + ), + + server.tool( + REPO_TOOLS.get_repo_by_name_or_id, + "Get the repository by project and repository name or ID.", + { + project: z.string().describe("Project name or ID where the repository is located."), + repositoryNameOrId: z.string().describe("Repository name or ID."), + }, + { + readOnlyHint: true, + }, + async ({ project, repositoryNameOrId }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + const repositories = await gitApi.getRepositories(project); - const repository = repositories?.find((repo) => repo.name === repositoryNameOrId || repo.id === repositoryNameOrId); + const repository = repositories?.find((repo) => repo.name === repositoryNameOrId || repo.id === repositoryNameOrId); - if (!repository) { - throw new Error(`Repository ${repositoryNameOrId} not found in project ${project}`); - } + if (!repository) { + throw new Error(`Repository ${repositoryNameOrId} not found in project ${project}`); + } - return { - content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.get_branch_by_name, - "Get a branch by its name.", - { - repositoryId: z.string().describe("The ID of the repository where the branch is located."), - branchName: z.string().describe("The name of the branch to retrieve, e.g., 'main' or 'feature-branch'."), - }, - async ({ repositoryId, branchName }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, branchName); - const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName); - if (!branch) { return { - content: [ - { - type: "text", - text: `Branch ${branchName} not found in repository ${repositoryId}`, - }, - ], - isError: true, + content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], }; } - return { - content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.get_pull_request_by_id, - "Get a pull request by its ID.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request is located."), - pullRequestId: z.number().describe("The ID of the pull request to retrieve."), - includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."), - }, - async ({ repositoryId, pullRequestId, includeWorkItemRefs }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, undefined, undefined, undefined, undefined, undefined, includeWorkItemRefs); - return { - content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.reply_to_comment, - "Replies to a specific comment on a pull request.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request is located."), - pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."), - threadId: z.number().describe("The ID of the thread to which the comment will be added."), - content: z.string().describe("The content of the comment to be added."), - project: z.string().optional().describe("Project ID or project name (optional)"), - fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."), - }, - async ({ repositoryId, pullRequestId, threadId, content, project, fullResponse }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const comment = await gitApi.createComment({ content }, repositoryId, pullRequestId, threadId, project); - - // Check if the comment was successfully created - if (!comment) { + ), + + server.tool( + REPO_TOOLS.get_branch_by_name, + "Get a branch by its name.", + { + repositoryId: z.string().describe("The ID of the repository where the branch is located."), + branchName: z.string().describe("The name of the branch to retrieve, e.g., 'main' or 'feature-branch'."), + }, + { + readOnlyHint: true, + }, + async ({ repositoryId, branchName }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + const branches = await gitApi.getRefs(repositoryId, undefined, "heads/", false, false, undefined, false, undefined, branchName); + const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName); + if (!branch) { + return { + content: [ + { + type: "text", + text: `Branch ${branchName} not found in repository ${repositoryId}`, + }, + ], + isError: true, + }; + } return { - content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }], - isError: true, + content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], }; } - - if (fullResponse) { + ), + + server.tool( + REPO_TOOLS.get_pull_request_by_id, + "Get a pull request by its ID.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request is located."), + pullRequestId: z.number().describe("The ID of the pull request to retrieve."), + includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."), + }, + { + readOnlyHint: true, + }, + async ({ repositoryId, pullRequestId, includeWorkItemRefs }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, undefined, undefined, undefined, undefined, undefined, includeWorkItemRefs); return { - content: [{ type: "text", text: JSON.stringify(comment, null, 2) }], + content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }], }; } + ), + + server.tool( + REPO_TOOLS.reply_to_comment, + "Replies to a specific comment on a pull request.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request is located."), + pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."), + threadId: z.number().describe("The ID of the thread to which the comment will be added."), + content: z.string().describe("The content of the comment to be added."), + project: z.string().optional().describe("Project ID or project name (optional)"), + fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."), + }, + { + readOnlyHint: false, + }, + async ({ repositoryId, pullRequestId, threadId, content, project, fullResponse }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + const comment = await gitApi.createComment({ content }, repositoryId, pullRequestId, threadId, project); - return { - content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }], - }; - } - ); - - server.tool( - REPO_TOOLS.create_pull_request_thread, - "Creates a new comment thread on a pull request.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request is located."), - pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."), - content: z.string().describe("The content of the comment to be added."), - project: z.string().optional().describe("Project ID or project name (optional)"), - filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"), - status: z - .enum(getEnumKeys(CommentThreadStatus) as [string, ...string[]]) - .optional() - .default(CommentThreadStatus[CommentThreadStatus.Active]) - .describe("The status of the comment thread. Defaults to 'Active'."), - rightFileStartLine: z.number().optional().describe("Position of first character of the thread's span in right file. The line number of a thread's position. Starts at 1. (optional)"), - rightFileStartOffset: z - .number() - .optional() - .describe( - "Position of first character of the thread's span in right file. The line number of a thread's position. The character offset of a thread's position inside of a line. Starts at 1. Must only be set if rightFileStartLine is also specified. (optional)" - ), - rightFileEndLine: z - .number() - .optional() - .describe( - "Position of last character of the thread's span in right file. The line number of a thread's position. Starts at 1. Must only be set if rightFileStartLine is also specified. (optional)" - ), - rightFileEndOffset: z - .number() - .optional() - .describe( - "Position of last character of the thread's span in right file. The character offset of a thread's position inside of a line. Must only be set if rightFileEndLine is also specified. (optional)" - ), - }, - async ({ repositoryId, pullRequestId, content, project, filePath, status, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - const normalizedFilePath = filePath && !filePath.startsWith("/") ? `/${filePath}` : filePath; - const threadContext: CommentThreadContext = { filePath: normalizedFilePath }; + // Check if the comment was successfully created + if (!comment) { + return { + content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }], + isError: true, + }; + } - if (rightFileStartLine !== undefined) { - if (rightFileStartLine < 1) { - throw new Error("rightFileStartLine must be greater than or equal to 1."); + if (fullResponse) { + return { + content: [{ type: "text", text: JSON.stringify(comment, null, 2) }], + }; } - threadContext.rightFileStart = { line: rightFileStartLine }; + return { + content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }], + }; + } + ), + + server.tool( + REPO_TOOLS.create_pull_request_thread, + "Creates a new comment thread on a pull request.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request is located."), + pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."), + content: z.string().describe("The content of the comment to be added."), + project: z.string().optional().describe("Project ID or project name (optional)"), + filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"), + status: z + .enum(getEnumKeys(CommentThreadStatus) as [string, ...string[]]) + .optional() + .default(CommentThreadStatus[CommentThreadStatus.Active]) + .describe("The status of the comment thread. Defaults to 'Active'."), + rightFileStartLine: z.number().optional().describe("Position of first character of the thread's span in right file. The line number of a thread's position. Starts at 1. (optional)"), + rightFileStartOffset: z + .number() + .optional() + .describe( + "Position of first character of the thread's span in right file. The line number of a thread's position. The character offset of a thread's position inside of a line. Starts at 1. Must only be set if rightFileStartLine is also specified. (optional)" + ), + rightFileEndLine: z + .number() + .optional() + .describe( + "Position of last character of the thread's span in right file. The line number of a thread's position. Starts at 1. Must only be set if rightFileStartLine is also specified. (optional)" + ), + rightFileEndOffset: z + .number() + .optional() + .describe( + "Position of last character of the thread's span in right file. The character offset of a thread's position inside of a line. Must only be set if rightFileEndLine is also specified. (optional)" + ), + }, + { + readOnlyHint: false, + }, + async ({ repositoryId, pullRequestId, content, project, filePath, status, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); - if (rightFileStartOffset !== undefined) { - if (rightFileStartOffset < 1) { - throw new Error("rightFileStartOffset must be greater than or equal to 1."); + const normalizedFilePath = filePath && !filePath.startsWith("/") ? `/${filePath}` : filePath; + const threadContext: CommentThreadContext = { filePath: normalizedFilePath }; + + if (rightFileStartLine !== undefined) { + if (rightFileStartLine < 1) { + throw new Error("rightFileStartLine must be greater than or equal to 1."); } - threadContext.rightFileStart.offset = rightFileStartOffset; - } - } + threadContext.rightFileStart = { line: rightFileStartLine }; - if (rightFileEndLine !== undefined) { - if (rightFileStartLine === undefined) { - throw new Error("rightFileEndLine must only be specified if rightFileStartLine is also specified."); - } + if (rightFileStartOffset !== undefined) { + if (rightFileStartOffset < 1) { + throw new Error("rightFileStartOffset must be greater than or equal to 1."); + } - if (rightFileEndLine < 1) { - throw new Error("rightFileEndLine must be greater than or equal to 1."); + threadContext.rightFileStart.offset = rightFileStartOffset; + } } - threadContext.rightFileEnd = { line: rightFileEndLine }; + if (rightFileEndLine !== undefined) { + if (rightFileStartLine === undefined) { + throw new Error("rightFileEndLine must only be specified if rightFileStartLine is also specified."); + } - if (rightFileEndOffset !== undefined) { - if (rightFileEndOffset < 1) { - throw new Error("rightFileEndOffset must be greater than or equal to 1."); + if (rightFileEndLine < 1) { + throw new Error("rightFileEndLine must be greater than or equal to 1."); } - threadContext.rightFileEnd.offset = rightFileEndOffset; - } - } + threadContext.rightFileEnd = { line: rightFileEndLine }; - const thread = await gitApi.createThread( - { comments: [{ content: content }], threadContext: threadContext, status: CommentThreadStatus[status as keyof typeof CommentThreadStatus] }, - repositoryId, - pullRequestId, - project - ); + if (rightFileEndOffset !== undefined) { + if (rightFileEndOffset < 1) { + throw new Error("rightFileEndOffset must be greater than or equal to 1."); + } - const trimmedThread = trimPullRequestThread(thread); + threadContext.rightFileEnd.offset = rightFileEndOffset; + } + } - return { - content: [{ type: "text", text: JSON.stringify(trimmedThread, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.resolve_comment, - "Resolves a specific comment thread on a pull request.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request is located."), - pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."), - threadId: z.number().describe("The ID of the thread to be resolved."), - fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of a simple confirmation message."), - }, - async ({ repositoryId, pullRequestId, threadId, fullResponse }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const thread = await gitApi.updateThread( - { status: 2 }, // 2 corresponds to "Resolved" status - repositoryId, - pullRequestId, - threadId - ); + const thread = await gitApi.createThread( + { comments: [{ content: content }], threadContext: threadContext, status: CommentThreadStatus[status as keyof typeof CommentThreadStatus] }, + repositoryId, + pullRequestId, + project + ); - // Check if the thread was successfully resolved - if (!thread) { - return { - content: [{ type: "text", text: `Error: Failed to resolve thread ${threadId}. The thread status was not updated successfully.` }], - isError: true, - }; - } + const trimmedThread = trimPullRequestThread(thread); - if (fullResponse) { return { - content: [{ type: "text", text: JSON.stringify(thread, null, 2) }], + content: [{ type: "text", text: JSON.stringify(trimmedThread, null, 2) }], }; } - - return { - content: [{ type: "text", text: `Thread ${threadId} was successfully resolved.` }], - }; - } - ); - - const gitVersionTypeStrings = Object.values(GitVersionType).filter((value): value is string => typeof value === "string"); - - server.tool( - REPO_TOOLS.search_commits, - "Searches for commits in a repository", - { - project: z.string().describe("Project name or ID"), - repository: z.string().describe("Repository name or ID"), - fromCommit: z.string().optional().describe("Starting commit ID"), - toCommit: z.string().optional().describe("Ending commit ID"), - version: z.string().optional().describe("The name of the branch, tag or commit to filter commits by"), - versionType: z - .enum(gitVersionTypeStrings as [string, ...string[]]) - .optional() - .default(GitVersionType[GitVersionType.Branch]) - .describe("The meaning of the version parameter, e.g., branch, tag or commit"), - skip: z.number().optional().default(0).describe("Number of commits to skip"), - top: z.number().optional().default(10).describe("Maximum number of commits to return"), - includeLinks: z.boolean().optional().default(false).describe("Include commit links"), - includeWorkItems: z.boolean().optional().default(false).describe("Include associated work items"), - }, - async ({ project, repository, fromCommit, toCommit, version, versionType, skip, top, includeLinks, includeWorkItems }) => { - try { + ), + + server.tool( + REPO_TOOLS.resolve_comment, + "Resolves a specific comment thread on a pull request.", + { + repositoryId: z.string().describe("The ID of the repository where the pull request is located."), + pullRequestId: z.number().describe("The ID of the pull request where the comment thread exists."), + threadId: z.number().describe("The ID of the thread to be resolved."), + fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of a simple confirmation message."), + }, + { + readOnlyHint: false, + }, + async ({ repositoryId, pullRequestId, threadId, fullResponse }) => { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); + const thread = await gitApi.updateThread( + { status: 2 }, // 2 corresponds to "Resolved" status + repositoryId, + pullRequestId, + threadId + ); - const searchCriteria: GitQueryCommitsCriteria = { - fromCommitId: fromCommit, - toCommitId: toCommit, - includeLinks: includeLinks, - includeWorkItems: includeWorkItems, - }; - - if (version) { - const itemVersion: GitVersionDescriptor = { - version: version, - versionType: GitVersionType[versionType as keyof typeof GitVersionType], + // Check if the thread was successfully resolved + if (!thread) { + return { + content: [{ type: "text", text: `Error: Failed to resolve thread ${threadId}. The thread status was not updated successfully.` }], + isError: true, }; - searchCriteria.itemVersion = itemVersion; } - const commits = await gitApi.getCommits( - repository, - searchCriteria, - project, - skip, // skip - top - ); + if (fullResponse) { + return { + content: [{ type: "text", text: JSON.stringify(thread, null, 2) }], + }; + } return { - content: [{ type: "text", text: JSON.stringify(commits, null, 2) }], - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error searching commits: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, + content: [{ type: "text", text: `Thread ${threadId} was successfully resolved.` }], }; } - } - ); - - const pullRequestQueryTypesStrings = Object.values(GitPullRequestQueryType).filter((value): value is string => typeof value === "string"); - - server.tool( - REPO_TOOLS.list_pull_requests_by_commits, - "Lists pull requests by commit IDs to find which pull requests contain specific commits", - { - project: z.string().describe("Project name or ID"), - repository: z.string().describe("Repository name or ID"), - commits: z.array(z.string()).describe("Array of commit IDs to query for"), - queryType: z - .enum(pullRequestQueryTypesStrings as [string, ...string[]]) - .optional() - .default(GitPullRequestQueryType[GitPullRequestQueryType.LastMergeCommit]) - .describe("Type of query to perform"), - }, - async ({ project, repository, commits, queryType }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); + ), - const query: GitPullRequestQuery = { - queries: [ - { - items: commits, - type: GitPullRequestQueryType[queryType as keyof typeof GitPullRequestQueryType], - } as GitPullRequestQueryInput, - ], - }; + (() => { + const gitVersionTypeStrings = Object.values(GitVersionType).filter((value): value is string => typeof value === "string"); + return server.tool( + REPO_TOOLS.search_commits, + "Searches for commits in a repository", + { + project: z.string().describe("Project name or ID"), + repository: z.string().describe("Repository name or ID"), + fromCommit: z.string().optional().describe("Starting commit ID"), + toCommit: z.string().optional().describe("Ending commit ID"), + version: z.string().optional().describe("The name of the branch, tag or commit to filter commits by"), + versionType: z + .enum(gitVersionTypeStrings as [string, ...string[]]) + .optional() + .default(GitVersionType[GitVersionType.Branch]) + .describe("The meaning of the version parameter, e.g., branch, tag or commit"), + skip: z.number().optional().default(0).describe("Number of commits to skip"), + top: z.number().optional().default(10).describe("Maximum number of commits to return"), + includeLinks: z.boolean().optional().default(false).describe("Include commit links"), + includeWorkItems: z.boolean().optional().default(false).describe("Include associated work items"), + }, + { + readOnlyHint: true, + }, + async ({ project, repository, fromCommit, toCommit, version, versionType, skip, top, includeLinks, includeWorkItems }) => { + try { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + + const searchCriteria: GitQueryCommitsCriteria = { + fromCommitId: fromCommit, + toCommitId: toCommit, + includeLinks: includeLinks, + includeWorkItems: includeWorkItems, + }; - const queryResult = await gitApi.getPullRequestQuery(query, repository, project); + if (version) { + const itemVersion: GitVersionDescriptor = { + version: version, + versionType: GitVersionType[versionType as keyof typeof GitVersionType], + }; + searchCriteria.itemVersion = itemVersion; + } + + const commits = await gitApi.getCommits( + repository, + searchCriteria, + project, + skip, // skip + top + ); - return { - content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }], - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error querying pull requests by commits: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; + return { + content: [{ type: "text", text: JSON.stringify(commits, null, 2) }], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error searching commits: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + } + ); + })(), + + (() => { + const pullRequestQueryTypesStrings = Object.values(GitPullRequestQueryType).filter((value): value is string => typeof value === "string"); + + return server.tool( + REPO_TOOLS.list_pull_requests_by_commits, + "Lists pull requests by commit IDs to find which pull requests contain specific commits", + { + project: z.string().describe("Project name or ID"), + repository: z.string().describe("Repository name or ID"), + commits: z.array(z.string()).describe("Array of commit IDs to query for"), + queryType: z + .enum(pullRequestQueryTypesStrings as [string, ...string[]]) + .optional() + .default(GitPullRequestQueryType[GitPullRequestQueryType.LastMergeCommit]) + .describe("Type of query to perform"), + }, + { + readOnlyHint: true, + }, + async ({ project, repository, commits, queryType }) => { + try { + const connection = await connectionProvider(); + const gitApi = await connection.getGitApi(); + + const query: GitPullRequestQuery = { + queries: [ + { + items: commits, + type: GitPullRequestQueryType[queryType as keyof typeof GitPullRequestQueryType], + } as GitPullRequestQueryInput, + ], + }; + + const queryResult = await gitApi.getPullRequestQuery(query, repository, project); + + return { + content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error querying pull requests by commits: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + } + ); + })(), + ]; + + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); } } - ); + } } export { REPO_TOOLS, configureRepoTools }; diff --git a/src/tools/search.ts b/src/tools/search.ts index 8bdd40a5..8ac0cb72 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { IGitApi } from "azure-devops-node-api/GitApi.js"; import { z } from "zod"; @@ -16,175 +16,194 @@ const SEARCH_TOOLS = { search_workitem: "search_workitem", }; -function configureSearchTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { - server.tool( - SEARCH_TOOLS.search_code, - "Search Azure DevOps Repositories for a given search text", - { - searchText: z.string().describe("Keywords to search for in code repositories"), - project: z.array(z.string()).optional().describe("Filter by projects"), - repository: z.array(z.string()).optional().describe("Filter by repositories"), - path: z.array(z.string()).optional().describe("Filter by paths"), - branch: z.array(z.string()).optional().describe("Filter by branches"), - includeFacets: z.boolean().default(false).describe("Include facets in the search results"), - skip: z.number().default(0).describe("Number of results to skip"), - top: z.number().default(5).describe("Maximum number of results to return"), - }, - async ({ searchText, project, repository, path, branch, includeFacets, skip, top }) => { - const accessToken = await tokenProvider(); - const connection = await connectionProvider(); - const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/codesearchresults?api-version=${apiVersion}`; - - const requestBody: Record = { - searchText, - includeFacets, - $skip: skip, - $top: top, - }; - - const filters: Record = {}; - if (project && project.length > 0) filters.Project = project; - if (repository && repository.length > 0) filters.Repository = repository; - if (path && path.length > 0) filters.Path = path; - if (branch && branch.length > 0) filters.Branch = branch; - - if (Object.keys(filters).length > 0) { - requestBody.filters = filters; - } +function configureSearchTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + SEARCH_TOOLS.search_code, + "Search Azure DevOps Repositories for a given search text", + { + searchText: z.string().describe("Keywords to search for in code repositories"), + project: z.array(z.string()).optional().describe("Filter by projects"), + repository: z.array(z.string()).optional().describe("Filter by repositories"), + path: z.array(z.string()).optional().describe("Filter by paths"), + branch: z.array(z.string()).optional().describe("Filter by branches"), + includeFacets: z.boolean().default(false).describe("Include facets in the search results"), + skip: z.number().default(0).describe("Number of results to skip"), + top: z.number().default(5).describe("Maximum number of results to return"), + }, + { + readOnlyHint: true, + }, + async ({ searchText, project, repository, path, branch, includeFacets, skip, top }) => { + const accessToken = await tokenProvider(); + const connection = await connectionProvider(); + const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/codesearchresults?api-version=${apiVersion}`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${accessToken}`, - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(requestBody), - }); + const requestBody: Record = { + searchText, + includeFacets, + $skip: skip, + $top: top, + }; - if (!response.ok) { - throw new Error(`Azure DevOps Code Search API error: ${response.status} ${response.statusText}`); - } + const filters: Record = {}; + if (project && project.length > 0) filters.Project = project; + if (repository && repository.length > 0) filters.Repository = repository; + if (path && path.length > 0) filters.Path = path; + if (branch && branch.length > 0) filters.Branch = branch; - const resultText = await response.text(); - const resultJson = JSON.parse(resultText) as { results?: SearchResult[] }; + if (Object.keys(filters).length > 0) { + requestBody.filters = filters; + } - const gitApi = await connection.getGitApi(); - const combinedResults = await fetchCombinedResults(resultJson.results ?? [], gitApi); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(requestBody), + }); - return { - content: [{ type: "text", text: resultText + JSON.stringify(combinedResults) }], - }; - } - ); - - server.tool( - SEARCH_TOOLS.search_wiki, - "Search Azure DevOps Wiki for a given search text", - { - searchText: z.string().describe("Keywords to search for wiki pages"), - project: z.array(z.string()).optional().describe("Filter by projects"), - wiki: z.array(z.string()).optional().describe("Filter by wiki names"), - includeFacets: z.boolean().default(false).describe("Include facets in the search results"), - skip: z.number().default(0).describe("Number of results to skip"), - top: z.number().default(10).describe("Maximum number of results to return"), - }, - async ({ searchText, project, wiki, includeFacets, skip, top }) => { - const accessToken = await tokenProvider(); - const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/wikisearchresults?api-version=${apiVersion}`; - - const requestBody: Record = { - searchText, - includeFacets, - $skip: skip, - $top: top, - }; - - const filters: Record = {}; - if (project && project.length > 0) filters.Project = project; - if (wiki && wiki.length > 0) filters.Wiki = wiki; - - if (Object.keys(filters).length > 0) { - requestBody.filters = filters; - } + if (!response.ok) { + throw new Error(`Azure DevOps Code Search API error: ${response.status} ${response.statusText}`); + } - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${accessToken}`, - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(requestBody), - }); + const resultText = await response.text(); + const resultJson = JSON.parse(resultText) as { results?: SearchResult[] }; - if (!response.ok) { - throw new Error(`Azure DevOps Wiki Search API error: ${response.status} ${response.statusText}`); + const gitApi = await connection.getGitApi(); + const combinedResults = await fetchCombinedResults(resultJson.results ?? [], gitApi); + + return { + content: [{ type: "text", text: resultText + JSON.stringify(combinedResults) }], + }; } + ), - const result = await response.text(); - return { - content: [{ type: "text", text: result }], - }; - } - ); - - server.tool( - SEARCH_TOOLS.search_workitem, - "Get Azure DevOps Work Item search results for a given search text", - { - searchText: z.string().describe("Search text to find in work items"), - project: z.array(z.string()).optional().describe("Filter by projects"), - areaPath: z.array(z.string()).optional().describe("Filter by area paths"), - workItemType: z.array(z.string()).optional().describe("Filter by work item types"), - state: z.array(z.string()).optional().describe("Filter by work item states"), - assignedTo: z.array(z.string()).optional().describe("Filter by assigned to users"), - includeFacets: z.boolean().default(false).describe("Include facets in the search results"), - skip: z.number().default(0).describe("Number of results to skip for pagination"), - top: z.number().default(10).describe("Number of results to return"), - }, - async ({ searchText, project, areaPath, workItemType, state, assignedTo, includeFacets, skip, top }) => { - const accessToken = await tokenProvider(); - const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/workitemsearchresults?api-version=${apiVersion}`; - - const requestBody: Record = { - searchText, - includeFacets, - $skip: skip, - $top: top, - }; - - const filters: Record = {}; - if (project && project.length > 0) filters["System.TeamProject"] = project; - if (areaPath && areaPath.length > 0) filters["System.AreaPath"] = areaPath; - if (workItemType && workItemType.length > 0) filters["System.WorkItemType"] = workItemType; - if (state && state.length > 0) filters["System.State"] = state; - if (assignedTo && assignedTo.length > 0) filters["System.AssignedTo"] = assignedTo; - - if (Object.keys(filters).length > 0) { - requestBody.filters = filters; + server.tool( + SEARCH_TOOLS.search_wiki, + "Search Azure DevOps Wiki for a given search text", + { + searchText: z.string().describe("Keywords to search for wiki pages"), + project: z.array(z.string()).optional().describe("Filter by projects"), + wiki: z.array(z.string()).optional().describe("Filter by wiki names"), + includeFacets: z.boolean().default(false).describe("Include facets in the search results"), + skip: z.number().default(0).describe("Number of results to skip"), + top: z.number().default(10).describe("Maximum number of results to return"), + }, + { + readOnlyHint: true, + }, + async ({ searchText, project, wiki, includeFacets, skip, top }) => { + const accessToken = await tokenProvider(); + const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/wikisearchresults?api-version=${apiVersion}`; + + const requestBody: Record = { + searchText, + includeFacets, + $skip: skip, + $top: top, + }; + + const filters: Record = {}; + if (project && project.length > 0) filters.Project = project; + if (wiki && wiki.length > 0) filters.Wiki = wiki; + + if (Object.keys(filters).length > 0) { + requestBody.filters = filters; + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Azure DevOps Wiki Search API error: ${response.status} ${response.statusText}`); + } + + const result = await response.text(); + return { + content: [{ type: "text", text: result }], + }; } + ), - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${accessToken}`, - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(requestBody), - }); + server.tool( + SEARCH_TOOLS.search_workitem, + "Get Azure DevOps Work Item search results for a given search text", + { + searchText: z.string().describe("Search text to find in work items"), + project: z.array(z.string()).optional().describe("Filter by projects"), + areaPath: z.array(z.string()).optional().describe("Filter by area paths"), + workItemType: z.array(z.string()).optional().describe("Filter by work item types"), + state: z.array(z.string()).optional().describe("Filter by work item states"), + assignedTo: z.array(z.string()).optional().describe("Filter by assigned to users"), + includeFacets: z.boolean().default(false).describe("Include facets in the search results"), + skip: z.number().default(0).describe("Number of results to skip for pagination"), + top: z.number().default(10).describe("Number of results to return"), + }, + { + readOnlyHint: true, + }, + async ({ searchText, project, areaPath, workItemType, state, assignedTo, includeFacets, skip, top }) => { + const accessToken = await tokenProvider(); + const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/workitemsearchresults?api-version=${apiVersion}`; + + const requestBody: Record = { + searchText, + includeFacets, + $skip: skip, + $top: top, + }; + + const filters: Record = {}; + if (project && project.length > 0) filters["System.TeamProject"] = project; + if (areaPath && areaPath.length > 0) filters["System.AreaPath"] = areaPath; + if (workItemType && workItemType.length > 0) filters["System.WorkItemType"] = workItemType; + if (state && state.length > 0) filters["System.State"] = state; + if (assignedTo && assignedTo.length > 0) filters["System.AssignedTo"] = assignedTo; - if (!response.ok) { - throw new Error(`Azure DevOps Work Item Search API error: ${response.status} ${response.statusText}`); + if (Object.keys(filters).length > 0) { + requestBody.filters = filters; + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Azure DevOps Work Item Search API error: ${response.status} ${response.statusText}`); + } + + const result = await response.text(); + return { + content: [{ type: "text", text: result }], + }; } + ), + ]; - const result = await response.text(); - return { - content: [{ type: "text", text: result }], - }; + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); + } } - ); + } } interface SearchResult { diff --git a/src/tools/test-plans.ts b/src/tools/test-plans.ts index 5e0179b5..139f61c7 100644 --- a/src/tools/test-plans.ts +++ b/src/tools/test-plans.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { TestPlanCreateParams } from "azure-devops-node-api/interfaces/TestPlanInterfaces.js"; import { z } from "zod"; @@ -17,277 +17,311 @@ const Test_Plan_Tools = { create_test_suite: "testplan_create_test_suite", }; -function configureTestPlanTools(server: McpServer, _: () => Promise, connectionProvider: () => Promise) { - server.tool( - Test_Plan_Tools.list_test_plans, - "Retrieve a paginated list of test plans from an Azure DevOps project. Allows filtering for active plans and toggling detailed information.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Defaults to true."), - includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan."), - continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."), - }, - async ({ project, filterActivePlans, includePlanDetails, continuationToken }) => { - const owner = ""; //making owner an empty string untill we can figure out how to get owner id - const connection = await connectionProvider(); - const testPlanApi = await connection.getTestPlanApi(); - - const testPlans = await testPlanApi.getTestPlans(project, owner, continuationToken, includePlanDetails, filterActivePlans); - - return { - content: [{ type: "text", text: JSON.stringify(testPlans, null, 2) }], - }; - } - ); - - server.tool( - Test_Plan_Tools.create_test_plan, - "Creates a new test plan in the project.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project where the test plan will be created."), - name: z.string().describe("The name of the test plan to be created."), - iteration: z.string().describe("The iteration path for the test plan"), - description: z.string().optional().describe("The description of the test plan"), - startDate: z.string().optional().describe("The start date of the test plan"), - endDate: z.string().optional().describe("The end date of the test plan"), - areaPath: z.string().optional().describe("The area path for the test plan"), - }, - async ({ project, name, iteration, description, startDate, endDate, areaPath }) => { - const connection = await connectionProvider(); - const testPlanApi = await connection.getTestPlanApi(); - - const testPlanToCreate: TestPlanCreateParams = { - name, - iteration, - description, - startDate: startDate ? new Date(startDate) : undefined, - endDate: endDate ? new Date(endDate) : undefined, - areaPath, - }; - - const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project); - - return { - content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }], - }; - } - ); - - server.tool( - Test_Plan_Tools.create_test_suite, - "Creates a new test suite in a test plan.", - { - project: z.string().describe("Project ID or project name"), - planId: z.number().describe("ID of the test plan that contains the suites"), - parentSuiteId: z.number().describe("ID of the parent suite under which the new suite will be created, if not given by user this can be id of a root suite of the test plan"), - name: z.string().describe("Name of the child test suite"), - }, - async ({ project, planId, parentSuiteId, name }) => { - const connection = await connectionProvider(); - const testPlanApi = await connection.getTestPlanApi(); - - const testSuiteToCreate = { - name, - parentSuite: { - id: parentSuiteId, - name: "", - }, - suiteType: 2, - }; - - const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId); - - return { - content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }], - }; - } - ); - - server.tool( - Test_Plan_Tools.add_test_cases_to_suite, - "Adds existing test cases to a test suite.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - planId: z.number().describe("The ID of the test plan."), - suiteId: z.number().describe("The ID of the test suite."), - testCaseIds: z.string().or(z.array(z.string())).describe("The ID(s) of the test case(s) to add. "), - }, - async ({ project, planId, suiteId, testCaseIds }) => { - const connection = await connectionProvider(); - const testApi = await connection.getTestApi(); - - // If testCaseIds is an array, convert it to comma-separated string - const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds; - - const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString); - - return { - content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }], - }; - } - ); - - server.tool( - Test_Plan_Tools.create_test_case, - "Creates a new test case work item.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - title: z.string().describe("The title of the test case."), - steps: z - .string() - .optional() - .describe( - "The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result." - ), - priority: z.number().optional().describe("The priority of the test case."), - areaPath: z.string().optional().describe("The area path for the test case."), - iterationPath: z.string().optional().describe("The iteration path for the test case."), - testsWorkItemId: z.number().optional().describe("Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case."), - }, - async ({ project, title, steps, priority, areaPath, iterationPath, testsWorkItemId }) => { - const connection = await connectionProvider(); - const witClient = await connection.getWorkItemTrackingApi(); - - let stepsXml; - if (steps) { - stepsXml = convertStepsToXml(steps); +function configureTestPlanTools(server: McpServer, _: () => Promise, connectionProvider: () => Promise, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + Test_Plan_Tools.list_test_plans, + "Retrieve a paginated list of test plans from an Azure DevOps project. Allows filtering for active plans and toggling detailed information.", + { + project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), + filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Defaults to true."), + includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan."), + continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."), + }, + { + readOnlyHint: true, + }, + async ({ project, filterActivePlans, includePlanDetails, continuationToken }) => { + const owner = ""; //making owner an empty string untill we can figure out how to get owner id + const connection = await connectionProvider(); + const testPlanApi = await connection.getTestPlanApi(); + + const testPlans = await testPlanApi.getTestPlans(project, owner, continuationToken, includePlanDetails, filterActivePlans); + + return { + content: [{ type: "text", text: JSON.stringify(testPlans, null, 2) }], + }; } + ), + + server.tool( + Test_Plan_Tools.create_test_plan, + "Creates a new test plan in the project.", + { + project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project where the test plan will be created."), + name: z.string().describe("The name of the test plan to be created."), + iteration: z.string().describe("The iteration path for the test plan"), + description: z.string().optional().describe("The description of the test plan"), + startDate: z.string().optional().describe("The start date of the test plan"), + endDate: z.string().optional().describe("The end date of the test plan"), + areaPath: z.string().optional().describe("The area path for the test plan"), + }, + { + readOnlyHint: false, + }, + async ({ project, name, iteration, description, startDate, endDate, areaPath }) => { + const connection = await connectionProvider(); + const testPlanApi = await connection.getTestPlanApi(); + + const testPlanToCreate: TestPlanCreateParams = { + name, + iteration, + description, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + areaPath, + }; + + const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project); + + return { + content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }], + }; + } + ), + + server.tool( + Test_Plan_Tools.create_test_suite, + "Creates a new test suite in a test plan.", + { + project: z.string().describe("Project ID or project name"), + planId: z.number().describe("ID of the test plan that contains the suites"), + parentSuiteId: z.number().describe("ID of the parent suite under which the new suite will be created, if not given by user this can be id of a root suite of the test plan"), + name: z.string().describe("Name of the child test suite"), + }, + { + readOnlyHint: false, + }, + async ({ project, planId, parentSuiteId, name }) => { + const connection = await connectionProvider(); + const testPlanApi = await connection.getTestPlanApi(); + + const testSuiteToCreate = { + name, + parentSuite: { + id: parentSuiteId, + name: "", + }, + suiteType: 2, + }; - // Create JSON patch document for work item - const patchDocument = []; - - patchDocument.push({ - op: "add", - path: "/fields/System.Title", - value: title, - }); + const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId); - if (testsWorkItemId) { - patchDocument.push({ - op: "add", - path: "/relations/-", - value: { - rel: "Microsoft.VSTS.Common.TestedBy-Reverse", - url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`, - }, - }); + return { + content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }], + }; } - - if (stepsXml) { - patchDocument.push({ - op: "add", - path: "/fields/Microsoft.VSTS.TCM.Steps", - value: stepsXml, - }); + ), + + server.tool( + Test_Plan_Tools.add_test_cases_to_suite, + "Adds existing test cases to a test suite.", + { + project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), + planId: z.number().describe("The ID of the test plan."), + suiteId: z.number().describe("The ID of the test suite."), + testCaseIds: z.string().or(z.array(z.string())).describe("The ID(s) of the test case(s) to add. "), + }, + { + readOnlyHint: false, + }, + async ({ project, planId, suiteId, testCaseIds }) => { + const connection = await connectionProvider(); + const testApi = await connection.getTestApi(); + + // If testCaseIds is an array, convert it to comma-separated string + const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds; + + const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString); + + return { + content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }], + }; } + ), + + server.tool( + Test_Plan_Tools.create_test_case, + "Creates a new test case work item.", + { + project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), + title: z.string().describe("The title of the test case."), + steps: z + .string() + .optional() + .describe( + "The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result." + ), + priority: z.number().optional().describe("The priority of the test case."), + areaPath: z.string().optional().describe("The area path for the test case."), + iterationPath: z.string().optional().describe("The iteration path for the test case."), + testsWorkItemId: z.number().optional().describe("Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case."), + }, + { + readOnlyHint: false, + }, + async ({ project, title, steps, priority, areaPath, iterationPath, testsWorkItemId }) => { + const connection = await connectionProvider(); + const witClient = await connection.getWorkItemTrackingApi(); + + let stepsXml; + if (steps) { + stepsXml = convertStepsToXml(steps); + } + + // Create JSON patch document for work item + const patchDocument = []; - if (priority) { patchDocument.push({ op: "add", - path: "/fields/Microsoft.VSTS.Common.Priority", - value: priority, + path: "/fields/System.Title", + value: title, }); - } - if (areaPath) { - patchDocument.push({ - op: "add", - path: "/fields/System.AreaPath", - value: areaPath, - }); + if (testsWorkItemId) { + patchDocument.push({ + op: "add", + path: "/relations/-", + value: { + rel: "Microsoft.VSTS.Common.TestedBy-Reverse", + url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`, + }, + }); + } + + if (stepsXml) { + patchDocument.push({ + op: "add", + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: stepsXml, + }); + } + + if (priority) { + patchDocument.push({ + op: "add", + path: "/fields/Microsoft.VSTS.Common.Priority", + value: priority, + }); + } + + if (areaPath) { + patchDocument.push({ + op: "add", + path: "/fields/System.AreaPath", + value: areaPath, + }); + } + + if (iterationPath) { + patchDocument.push({ + op: "add", + path: "/fields/System.IterationPath", + value: iterationPath, + }); + } + + const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case"); + + return { + content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], + }; } - - if (iterationPath) { - patchDocument.push({ - op: "add", - path: "/fields/System.IterationPath", - value: iterationPath, - }); + ), + + server.tool( + Test_Plan_Tools.update_test_case_steps, + "Update an existing test case work item.", + { + id: z.number().describe("The ID of the test case work item to update."), + steps: z + .string() + .describe( + "The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result." + ), + }, + { + readOnlyHint: false, + }, + async ({ id, steps }) => { + const connection = await connectionProvider(); + const witClient = await connection.getWorkItemTrackingApi(); + + let stepsXml; + if (steps) { + stepsXml = convertStepsToXml(steps); + } + + // Create JSON patch document for work item + const patchDocument = []; + + if (stepsXml) { + patchDocument.push({ + op: "add", + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: stepsXml, + }); + } + + const workItem = await witClient.updateWorkItem({}, patchDocument, id); + + return { + content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], + }; } - - const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case"); - - return { - content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], - }; - } - ); - - server.tool( - Test_Plan_Tools.update_test_case_steps, - "Update an existing test case work item.", - { - id: z.number().describe("The ID of the test case work item to update."), - steps: z - .string() - .describe( - "The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result." - ), - }, - async ({ id, steps }) => { - const connection = await connectionProvider(); - const witClient = await connection.getWorkItemTrackingApi(); - - let stepsXml; - if (steps) { - stepsXml = convertStepsToXml(steps); + ), + + server.tool( + Test_Plan_Tools.list_test_cases, + "Gets a list of test cases in the test plan.", + { + project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), + planid: z.number().describe("The ID of the test plan."), + suiteid: z.number().describe("The ID of the test suite."), + }, + { + readOnlyHint: true, + }, + async ({ project, planid, suiteid }) => { + const connection = await connectionProvider(); + const coreApi = await connection.getTestPlanApi(); + const testcases = await coreApi.getTestCaseList(project, planid, suiteid); + + return { + content: [{ type: "text", text: JSON.stringify(testcases, null, 2) }], + }; } - - // Create JSON patch document for work item - const patchDocument = []; - - if (stepsXml) { - patchDocument.push({ - op: "add", - path: "/fields/Microsoft.VSTS.TCM.Steps", - value: stepsXml, - }); + ), + + server.tool( + Test_Plan_Tools.test_results_from_build_id, + "Gets a list of test results for a given project and build ID.", + { + project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), + buildid: z.number().describe("The ID of the build."), + }, + { + readOnlyHint: true, + }, + async ({ project, buildid }) => { + const connection = await connectionProvider(); + const coreApi = await connection.getTestResultsApi(); + const testResults = await coreApi.getTestResultDetailsForBuild(project, buildid); + + return { + content: [{ type: "text", text: JSON.stringify(testResults, null, 2) }], + }; } + ), + ]; - const workItem = await witClient.updateWorkItem({}, patchDocument, id); - - return { - content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], - }; - } - ); - - server.tool( - Test_Plan_Tools.list_test_cases, - "Gets a list of test cases in the test plan.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - planid: z.number().describe("The ID of the test plan."), - suiteid: z.number().describe("The ID of the test suite."), - }, - async ({ project, planid, suiteid }) => { - const connection = await connectionProvider(); - const coreApi = await connection.getTestPlanApi(); - const testcases = await coreApi.getTestCaseList(project, planid, suiteid); - - return { - content: [{ type: "text", text: JSON.stringify(testcases, null, 2) }], - }; - } - ); - - server.tool( - Test_Plan_Tools.test_results_from_build_id, - "Gets a list of test results for a given project and build ID.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - buildid: z.number().describe("The ID of the build."), - }, - async ({ project, buildid }) => { - const connection = await connectionProvider(); - const coreApi = await connection.getTestResultsApi(); - const testResults = await coreApi.getTestResultDetailsForBuild(project, buildid); - - return { - content: [{ type: "text", text: JSON.stringify(testResults, null, 2) }], - }; + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); + } } - ); + } } /* diff --git a/src/tools/wiki.ts b/src/tools/wiki.ts index b80a4f75..c8d334f8 100644 --- a/src/tools/wiki.ts +++ b/src/tools/wiki.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { z } from "zod"; import { WikiPagesBatchRequest } from "azure-devops-node-api/interfaces/WikiInterfaces.js"; @@ -16,389 +16,417 @@ const WIKI_TOOLS = { create_or_update_page: "wiki_create_or_update_page", }; -function configureWikiTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { - server.tool( - WIKI_TOOLS.get_wiki, - "Get the wiki by wikiIdentifier", - { - wikiIdentifier: z.string().describe("The unique identifier of the wiki."), - project: z.string().optional().describe("The project name or ID where the wiki is located. If not provided, the default project will be used."), - }, - async ({ wikiIdentifier, project }) => { - try { - const connection = await connectionProvider(); - const wikiApi = await connection.getWikiApi(); - const wiki = await wikiApi.getWiki(wikiIdentifier, project); - - if (!wiki) { - return { content: [{ type: "text", text: "No wiki found" }], isError: true }; - } - - return { - content: [{ type: "text", text: JSON.stringify(wiki, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; +function configureWikiTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + WIKI_TOOLS.get_wiki, + "Get the wiki by wikiIdentifier", + { + wikiIdentifier: z.string().describe("The unique identifier of the wiki."), + project: z.string().optional().describe("The project name or ID where the wiki is located. If not provided, the default project will be used."), + }, + { + readOnlyHint: true, + }, + async ({ wikiIdentifier, project }) => { + try { + const connection = await connectionProvider(); + const wikiApi = await connection.getWikiApi(); + const wiki = await wikiApi.getWiki(wikiIdentifier, project); - return { - content: [{ type: "text", text: `Error fetching wiki: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WIKI_TOOLS.list_wikis, - "Retrieve a list of wikis for an organization or project.", - { - project: z.string().optional().describe("The project name or ID to filter wikis. If not provided, all wikis in the organization will be returned."), - }, - async ({ project }) => { - try { - const connection = await connectionProvider(); - const wikiApi = await connection.getWikiApi(); - const wikis = await wikiApi.getAllWikis(project); - - if (!wikis) { - return { content: [{ type: "text", text: "No wikis found" }], isError: true }; - } + if (!wiki) { + return { content: [{ type: "text", text: "No wiki found" }], isError: true }; + } - return { - content: [{ type: "text", text: JSON.stringify(wikis, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: JSON.stringify(wiki, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error fetching wikis: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WIKI_TOOLS.list_wiki_pages, - "Retrieve a list of wiki pages for a specific wiki and project.", - { - wikiIdentifier: z.string().describe("The unique identifier of the wiki."), - project: z.string().describe("The project name or ID where the wiki is located."), - top: z.number().default(20).describe("The maximum number of pages to return. Defaults to 20."), - continuationToken: z.string().optional().describe("Token for pagination to retrieve the next set of pages."), - pageViewsForDays: z.number().optional().describe("Number of days to retrieve page views for. If not specified, page views are not included."), - }, - async ({ wikiIdentifier, project, top = 20, continuationToken, pageViewsForDays }) => { - try { - const connection = await connectionProvider(); - const wikiApi = await connection.getWikiApi(); - - const pagesBatchRequest: WikiPagesBatchRequest = { - top, - continuationToken, - pageViewsForDays, - }; - - const pages = await wikiApi.getPagesBatch(pagesBatchRequest, project, wikiIdentifier); - - if (!pages) { - return { content: [{ type: "text", text: "No wiki pages found" }], isError: true }; + return { + content: [{ type: "text", text: `Error fetching wiki: ${errorMessage}` }], + isError: true, + }; } - - return { - content: [{ type: "text", text: JSON.stringify(pages, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error fetching wiki pages: ${errorMessage}` }], - isError: true, - }; } - } - ); - - server.tool( - WIKI_TOOLS.get_wiki_page, - "Retrieve wiki page metadata by path. This tool does not return page content.", - { - wikiIdentifier: z.string().describe("The unique identifier of the wiki."), - project: z.string().describe("The project name or ID where the wiki is located."), - path: z.string().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup')."), - recursionLevel: z - .enum(["None", "OneLevel", "OneLevelPlusNestedEmptyFolders", "Full"]) - .optional() - .describe("Recursion level for subpages. 'None' returns only the specified page. 'OneLevel' includes direct children. 'Full' includes all descendants."), - }, - async ({ wikiIdentifier, project, path, recursionLevel }) => { - try { - const connection = await connectionProvider(); - const accessToken = await tokenProvider(); - - // Normalize the path - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - //const encodedPath = encodeURIComponent(normalizedPath); - - // Build the URL for the wiki page API - const baseUrl = connection.serverUrl.replace(/\/$/, ""); - const params = new URLSearchParams({ - "path": normalizedPath, - "api-version": apiVersion, - }); - - if (recursionLevel) { - params.append("recursionLevel", recursionLevel); - } + ), + + server.tool( + WIKI_TOOLS.list_wikis, + "Retrieve a list of wikis for an organization or project.", + { + project: z.string().optional().describe("The project name or ID to filter wikis. If not provided, all wikis in the organization will be returned."), + }, + { + readOnlyHint: true, + }, + async ({ project }) => { + try { + const connection = await connectionProvider(); + const wikiApi = await connection.getWikiApi(); + const wikis = await wikiApi.getAllWikis(project); - const url = `${baseUrl}/${project}/_apis/wiki/wikis/${wikiIdentifier}/pages?${params.toString()}`; + if (!wikis) { + return { content: [{ type: "text", text: "No wikis found" }], isError: true }; + } - const response = await fetch(url, { - headers: { - "Authorization": `Bearer ${accessToken}`, - "User-Agent": userAgentProvider(), - }, - }); + return { + content: [{ type: "text", text: JSON.stringify(wikis, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to get wiki page (${response.status}): ${errorText}`); + return { + content: [{ type: "text", text: `Error fetching wikis: ${errorMessage}` }], + isError: true, + }; } - - const pageData = await response.json(); - - return { - content: [{ type: "text", text: JSON.stringify(pageData, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error fetching wiki page metadata: ${errorMessage}` }], - isError: true, - }; } - } - ); - - server.tool( - WIKI_TOOLS.get_wiki_page_content, - "Retrieve wiki page content. Provide either a 'url' parameter OR the combination of 'wikiIdentifier' and 'project' parameters.", - { - url: z - .string() - .optional() - .describe( - "The full URL of the wiki page to retrieve content for. If provided, wikiIdentifier, project, and path are ignored. Supported patterns: https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}?pagePath=%2FMy%20Page and https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}/{pageId}/Page-Title" - ), - wikiIdentifier: z.string().optional().describe("The unique identifier of the wiki. Required if url is not provided."), - project: z.string().optional().describe("The project name or ID where the wiki is located. Required if url is not provided."), - path: z.string().optional().describe("The path of the wiki page to retrieve content for. Optional, defaults to root page if not provided."), - }, - async ({ url, wikiIdentifier, project, path }: { url?: string; wikiIdentifier?: string; project?: string; path?: string }) => { - try { - const hasUrl = !!url; - const hasPair = !!wikiIdentifier && !!project; - - if (hasUrl && hasPair) { - return { content: [{ type: "text", text: "Error fetching wiki page content: Provide either 'url' OR 'wikiIdentifier' with 'project', not both." }], isError: true }; - } - if (!hasUrl && !hasPair) { - return { content: [{ type: "text", text: "Error fetching wiki page content: You must provide either 'url' OR both 'wikiIdentifier' and 'project'." }], isError: true }; - } + ), + + server.tool( + WIKI_TOOLS.list_wiki_pages, + "Retrieve a list of wiki pages for a specific wiki and project.", + { + wikiIdentifier: z.string().describe("The unique identifier of the wiki."), + project: z.string().describe("The project name or ID where the wiki is located."), + top: z.number().default(20).describe("The maximum number of pages to return. Defaults to 20."), + continuationToken: z.string().optional().describe("Token for pagination to retrieve the next set of pages."), + pageViewsForDays: z.number().optional().describe("Number of days to retrieve page views for. If not specified, page views are not included."), + }, + { + readOnlyHint: true, + }, + async ({ wikiIdentifier, project, top = 20, continuationToken, pageViewsForDays }) => { + try { + const connection = await connectionProvider(); + const wikiApi = await connection.getWikiApi(); - const connection = await connectionProvider(); - const wikiApi = await connection.getWikiApi(); - let resolvedProject = project; - let resolvedWiki = wikiIdentifier; - let resolvedPath: string | undefined = path; - let pageContent: string | undefined; + const pagesBatchRequest: WikiPagesBatchRequest = { + top, + continuationToken, + pageViewsForDays, + }; - if (url) { - const parsed = parseWikiUrl(url); + const pages = await wikiApi.getPagesBatch(pagesBatchRequest, project, wikiIdentifier); - if ("error" in parsed) { - return { content: [{ type: "text", text: `Error fetching wiki page content: ${parsed.error}` }], isError: true }; + if (!pages) { + return { content: [{ type: "text", text: "No wiki pages found" }], isError: true }; } - resolvedProject = parsed.project; - resolvedWiki = parsed.wikiIdentifier; + return { + content: [{ type: "text", text: JSON.stringify(pages, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - if (parsed.pagePath) { - resolvedPath = parsed.pagePath; - } - - if (parsed.pageId) { - try { - const accessToken = await tokenProvider(); - const baseUrl = connection.serverUrl.replace(/\/$/, ""); - const restUrl = `${baseUrl}/${resolvedProject}/_apis/wiki/wikis/${resolvedWiki}/pages/${parsed.pageId}?includeContent=true&api-version=7.1`; - const resp = await fetch(restUrl, { - headers: { - "Authorization": `Bearer ${accessToken}`, - "User-Agent": userAgentProvider(), - }, - }); - if (resp.ok) { - const json = await resp.json(); - if (json && typeof json.content === "string") { - pageContent = json.content; - } else if (json && json.path) { - resolvedPath = json.path; - } - } else if (resp.status === 404) { - return { content: [{ type: "text", text: `Error fetching wiki page content: Page with id ${parsed.pageId} not found` }], isError: true }; - } - } catch {} - } + return { + content: [{ type: "text", text: `Error fetching wiki pages: ${errorMessage}` }], + isError: true, + }; } + } + ), + + server.tool( + WIKI_TOOLS.get_wiki_page, + "Retrieve wiki page metadata by path. This tool does not return page content.", + { + wikiIdentifier: z.string().describe("The unique identifier of the wiki."), + project: z.string().describe("The project name or ID where the wiki is located."), + path: z.string().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup')."), + recursionLevel: z + .enum(["None", "OneLevel", "OneLevelPlusNestedEmptyFolders", "Full"]) + .optional() + .describe("Recursion level for subpages. 'None' returns only the specified page. 'OneLevel' includes direct children. 'Full' includes all descendants."), + }, + { + readOnlyHint: true, + }, + async ({ wikiIdentifier, project, path, recursionLevel }) => { + try { + const connection = await connectionProvider(); + const accessToken = await tokenProvider(); + + // Normalize the path + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + //const encodedPath = encodeURIComponent(normalizedPath); + + // Build the URL for the wiki page API + const baseUrl = connection.serverUrl.replace(/\/$/, ""); + const params = new URLSearchParams({ + "path": normalizedPath, + "api-version": apiVersion, + }); - if (!pageContent) { - if (!resolvedPath) { - resolvedPath = "/"; + if (recursionLevel) { + params.append("recursionLevel", recursionLevel); } - if (!resolvedProject || !resolvedWiki) { - return { content: [{ type: "text", text: "Project and wikiIdentifier must be defined to fetch wiki page content." }], isError: true }; - } - const stream = await wikiApi.getPageText(resolvedProject, resolvedWiki, resolvedPath, undefined, undefined, true); - if (!stream) { - return { content: [{ type: "text", text: "No wiki page content found" }], isError: true }; - } - pageContent = await streamToString(stream); - } - return { content: [{ type: "text", text: JSON.stringify(pageContent, null, 2) }] }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + const url = `${baseUrl}/${project}/_apis/wiki/wikis/${wikiIdentifier}/pages?${params.toString()}`; - return { - content: [{ type: "text", text: `Error fetching wiki page content: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WIKI_TOOLS.create_or_update_page, - "Create or update a wiki page with content.", - { - wikiIdentifier: z.string().describe("The unique identifier or name of the wiki."), - path: z.string().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup')."), - content: z.string().describe("The content of the wiki page in markdown format."), - project: z.string().optional().describe("The project name or ID where the wiki is located. If not provided, the default project will be used."), - etag: z.string().optional().describe("ETag for editing existing pages (optional, will be fetched if not provided)."), - branch: z.string().default("wikiMaster").describe("The branch name for the wiki repository. Defaults to 'wikiMaster' which is the default branch for Azure DevOps wikis."), - }, - async ({ wikiIdentifier, path, content, project, etag, branch = "wikiMaster" }) => { - try { - const connection = await connectionProvider(); - const accessToken = await tokenProvider(); - - // Normalize the path - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const encodedPath = encodeURIComponent(normalizedPath); - - // Build the URL for the wiki page API with version descriptor - const baseUrl = connection.serverUrl; - const projectParam = project || ""; - const url = `${baseUrl}/${projectParam}/_apis/wiki/wikis/${wikiIdentifier}/pages?path=${encodedPath}&versionDescriptor.versionType=branch&versionDescriptor.version=${encodeURIComponent(branch)}&api-version=7.1`; - - // First, try to create a new page (PUT without ETag) - try { - const createResponse = await fetch(url, { - method: "PUT", + const response = await fetch(url, { headers: { "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", "User-Agent": userAgentProvider(), }, - body: JSON.stringify({ content: content }), }); - if (createResponse.ok) { - const result = await createResponse.json(); - return { - content: [ - { - type: "text", - text: `Successfully created wiki page at path: ${normalizedPath}. Response: ${JSON.stringify(result, null, 2)}`, - }, - ], - }; + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to get wiki page (${response.status}): ${errorText}`); } - // If creation failed with 409 (Conflict) or 500 (Page exists), try to update it - if (createResponse.status === 409 || createResponse.status === 500) { - // Page exists, we need to get the ETag and update it - let currentEtag = etag; + const pageData = await response.json(); - if (!currentEtag) { - // Fetch current page to get ETag - const getResponse = await fetch(url, { - method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "User-Agent": userAgentProvider(), - }, - }); + return { + content: [{ type: "text", text: JSON.stringify(pageData, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - if (getResponse.ok) { - currentEtag = getResponse.headers.get("etag") || getResponse.headers.get("ETag") || undefined; - if (!currentEtag) { - const pageData = await getResponse.json(); - currentEtag = pageData.eTag; + return { + content: [{ type: "text", text: `Error fetching wiki page metadata: ${errorMessage}` }], + isError: true, + }; + } + } + ), + + server.tool( + WIKI_TOOLS.get_wiki_page_content, + "Retrieve wiki page content. Provide either a 'url' parameter OR the combination of 'wikiIdentifier' and 'project' parameters.", + { + url: z + .string() + .optional() + .describe( + "The full URL of the wiki page to retrieve content for. If provided, wikiIdentifier, project, and path are ignored. Supported patterns: https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}?pagePath=%2FMy%20Page and https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}/{pageId}/Page-Title" + ), + wikiIdentifier: z.string().optional().describe("The unique identifier of the wiki. Required if url is not provided."), + project: z.string().optional().describe("The project name or ID where the wiki is located. Required if url is not provided."), + path: z.string().optional().describe("The path of the wiki page to retrieve content for. Optional, defaults to root page if not provided."), + }, + { + readOnlyHint: true, + }, + async ({ url, wikiIdentifier, project, path }: { url?: string; wikiIdentifier?: string; project?: string; path?: string }) => { + try { + const hasUrl = !!url; + const hasPair = !!wikiIdentifier && !!project; + + if (hasUrl && hasPair) { + return { content: [{ type: "text", text: "Error fetching wiki page content: Provide either 'url' OR 'wikiIdentifier' with 'project', not both." }], isError: true }; + } + if (!hasUrl && !hasPair) { + return { content: [{ type: "text", text: "Error fetching wiki page content: You must provide either 'url' OR both 'wikiIdentifier' and 'project'." }], isError: true }; + } + + const connection = await connectionProvider(); + const wikiApi = await connection.getWikiApi(); + let resolvedProject = project; + let resolvedWiki = wikiIdentifier; + let resolvedPath: string | undefined = path; + let pageContent: string | undefined; + + if (url) { + const parsed = parseWikiUrl(url); + + if ("error" in parsed) { + return { content: [{ type: "text", text: `Error fetching wiki page content: ${parsed.error}` }], isError: true }; + } + + resolvedProject = parsed.project; + resolvedWiki = parsed.wikiIdentifier; + + if (parsed.pagePath) { + resolvedPath = parsed.pagePath; + } + + if (parsed.pageId) { + try { + const accessToken = await tokenProvider(); + const baseUrl = connection.serverUrl.replace(/\/$/, ""); + const restUrl = `${baseUrl}/${resolvedProject}/_apis/wiki/wikis/${resolvedWiki}/pages/${parsed.pageId}?includeContent=true&api-version=7.1`; + const resp = await fetch(restUrl, { + headers: { + "Authorization": `Bearer ${accessToken}`, + "User-Agent": userAgentProvider(), + }, + }); + if (resp.ok) { + const json = await resp.json(); + if (json && typeof json.content === "string") { + pageContent = json.content; + } else if (json && json.path) { + resolvedPath = json.path; + } + } else if (resp.status === 404) { + return { content: [{ type: "text", text: `Error fetching wiki page content: Page with id ${parsed.pageId} not found` }], isError: true }; } - } + } catch {} + } + } - if (!currentEtag) { - throw new Error("Could not retrieve ETag for existing page"); - } + if (!pageContent) { + if (!resolvedPath) { + resolvedPath = "/"; + } + if (!resolvedProject || !resolvedWiki) { + return { content: [{ type: "text", text: "Project and wikiIdentifier must be defined to fetch wiki page content." }], isError: true }; } + const stream = await wikiApi.getPageText(resolvedProject, resolvedWiki, resolvedPath, undefined, undefined, true); + if (!stream) { + return { content: [{ type: "text", text: "No wiki page content found" }], isError: true }; + } + pageContent = await streamToString(stream); + } + + return { content: [{ type: "text", text: JSON.stringify(pageContent, null, 2) }] }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - // Now update the existing page with ETag - const updateResponse = await fetch(url, { + return { + content: [{ type: "text", text: `Error fetching wiki page content: ${errorMessage}` }], + isError: true, + }; + } + } + ), + + server.tool( + WIKI_TOOLS.create_or_update_page, + "Create or update a wiki page with content.", + { + wikiIdentifier: z.string().describe("The unique identifier or name of the wiki."), + path: z.string().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup')."), + content: z.string().describe("The content of the wiki page in markdown format."), + project: z.string().optional().describe("The project name or ID where the wiki is located. If not provided, the default project will be used."), + etag: z.string().optional().describe("ETag for editing existing pages (optional, will be fetched if not provided)."), + branch: z.string().default("wikiMaster").describe("The branch name for the wiki repository. Defaults to 'wikiMaster' which is the default branch for Azure DevOps wikis."), + }, + { + readOnlyHint: false, + }, + async ({ wikiIdentifier, path, content, project, etag, branch = "wikiMaster" }) => { + try { + const connection = await connectionProvider(); + const accessToken = await tokenProvider(); + + // Normalize the path + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const encodedPath = encodeURIComponent(normalizedPath); + + // Build the URL for the wiki page API with version descriptor + const baseUrl = connection.serverUrl; + const projectParam = project || ""; + const url = `${baseUrl}/${projectParam}/_apis/wiki/wikis/${wikiIdentifier}/pages?path=${encodedPath}&versionDescriptor.versionType=branch&versionDescriptor.version=${encodeURIComponent(branch)}&api-version=7.1`; + + // First, try to create a new page (PUT without ETag) + try { + const createResponse = await fetch(url, { method: "PUT", headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": userAgentProvider(), - "If-Match": currentEtag, }, body: JSON.stringify({ content: content }), }); - if (updateResponse.ok) { - const result = await updateResponse.json(); + if (createResponse.ok) { + const result = await createResponse.json(); return { content: [ { type: "text", - text: `Successfully updated wiki page at path: ${normalizedPath}. Response: ${JSON.stringify(result, null, 2)}`, + text: `Successfully created wiki page at path: ${normalizedPath}. Response: ${JSON.stringify(result, null, 2)}`, }, ], }; + } + + // If creation failed with 409 (Conflict) or 500 (Page exists), try to update it + if (createResponse.status === 409 || createResponse.status === 500) { + // Page exists, we need to get the ETag and update it + let currentEtag = etag; + + if (!currentEtag) { + // Fetch current page to get ETag + const getResponse = await fetch(url, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "User-Agent": userAgentProvider(), + }, + }); + + if (getResponse.ok) { + currentEtag = getResponse.headers.get("etag") || getResponse.headers.get("ETag") || undefined; + if (!currentEtag) { + const pageData = await getResponse.json(); + currentEtag = pageData.eTag; + } + } + + if (!currentEtag) { + throw new Error("Could not retrieve ETag for existing page"); + } + } + + // Now update the existing page with ETag + const updateResponse = await fetch(url, { + method: "PUT", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + "If-Match": currentEtag, + }, + body: JSON.stringify({ content: content }), + }); + + if (updateResponse.ok) { + const result = await updateResponse.json(); + return { + content: [ + { + type: "text", + text: `Successfully updated wiki page at path: ${normalizedPath}. Response: ${JSON.stringify(result, null, 2)}`, + }, + ], + }; + } else { + const errorText = await updateResponse.text(); + throw new Error(`Failed to update page (${updateResponse.status}): ${errorText}`); + } } else { - const errorText = await updateResponse.text(); - throw new Error(`Failed to update page (${updateResponse.status}): ${errorText}`); + const errorText = await createResponse.text(); + throw new Error(`Failed to create page (${createResponse.status}): ${errorText}`); } - } else { - const errorText = await createResponse.text(); - throw new Error(`Failed to create page (${createResponse.status}): ${errorText}`); + } catch (fetchError) { + throw fetchError; } - } catch (fetchError) { - throw fetchError; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + return { + content: [{ type: "text", text: `Error creating/updating wiki page: ${errorMessage}` }], + isError: true, + }; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + } + ), + ]; - return { - content: [{ type: "text", text: `Error creating/updating wiki page: ${errorMessage}` }], - isError: true, - }; + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); } } - ); + } } function streamToString(stream: NodeJS.ReadableStream): Promise { diff --git a/src/tools/work-items.ts b/src/tools/work-items.ts index 7518ad72..471d52d0 100644 --- a/src/tools/work-items.ts +++ b/src/tools/work-items.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { WorkItemExpand, WorkItemRelation } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js"; import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js"; @@ -61,306 +61,694 @@ function getLinkTypeFromName(name: string) { } } -function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { - server.tool( - WORKITEM_TOOLS.list_backlogs, - "Revieve a list of backlogs for a given project and team.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - team: z.string().describe("The name or ID of the Azure DevOps team."), - }, - async ({ project, team }) => { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); - const teamContext = { project, team }; - const backlogs = await workApi.getBacklogs(teamContext); - - return { - content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.list_backlog_work_items, - "Retrieve a list of backlogs of for a given project, team, and backlog category", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - team: z.string().describe("The name or ID of the Azure DevOps team."), - backlogId: z.string().describe("The ID of the backlog category to retrieve work items from."), - }, - async ({ project, team, backlogId }) => { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); - const teamContext = { project, team }; - - const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId); - - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.my_work_items, - "Retrieve a list of work items relevent to the authenticated user.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - type: z.enum(["assignedtome", "myactivity"]).default("assignedtome").describe("The type of work items to retrieve. Defaults to 'assignedtome'."), - top: z.number().default(50).describe("The maximum number of work items to return. Defaults to 50."), - includeCompleted: z.boolean().default(false).describe("Whether to include completed work items. Defaults to false."), - }, - async ({ project, type, top, includeCompleted }) => { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); - - const workItems = await workApi.getPredefinedQueryResults(project, type, top, includeCompleted); - - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.get_work_items_batch_by_ids, - "Retrieve list of work items by IDs in batch.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - ids: z.array(z.number()).describe("The IDs of the work items to retrieve."), - fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, a hardcoded default set of fields will be used."), - }, - async ({ project, ids, fields }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"]; - - // If no fields are provided, use the default set of fields - const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields; - - const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, project); - - // List of identity fields that need to be transformed from objects to formatted strings - const identityFields = [ - "System.AssignedTo", - "System.CreatedBy", - "System.ChangedBy", - "System.AuthorizedAs", - "Microsoft.VSTS.Common.ActivatedBy", - "Microsoft.VSTS.Common.ResolvedBy", - "Microsoft.VSTS.Common.ClosedBy", - ]; - - // Format identity fields to include displayName and uniqueName - // Removing the identity object as the response. It's too much and not needed - if (workitems && Array.isArray(workitems)) { - workitems.forEach((item) => { - if (item.fields) { - identityFields.forEach((fieldName) => { - if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") { - const identityField = item.fields[fieldName]; - const name = identityField.displayName || ""; - const email = identityField.uniqueName || ""; - item.fields[fieldName] = `${name} <${email}>`.trim(); - } - }); - } - }); +function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + WORKITEM_TOOLS.list_backlogs, + "Revieve a list of backlogs for a given project and team.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + team: z.string().describe("The name or ID of the Azure DevOps team."), + }, + { + readOnlyHint: true, + }, + async ({ project, team }) => { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); + const teamContext = { project, team }; + const backlogs = await workApi.getBacklogs(teamContext); + + return { + content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }], + }; } + ), + + server.tool( + WORKITEM_TOOLS.list_backlog_work_items, + "Retrieve a list of backlogs of for a given project, team, and backlog category", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + team: z.string().describe("The name or ID of the Azure DevOps team."), + backlogId: z.string().describe("The ID of the backlog category to retrieve work items from."), + }, + { + readOnlyHint: true, + }, + async ({ project, team, backlogId }) => { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); + const teamContext = { project, team }; - return { - content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.get_work_item, - "Get a single work item by ID.", - { - id: z.number().describe("The ID of the work item to retrieve."), - project: z.string().describe("The name or ID of the Azure DevOps project."), - fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, all fields will be returned."), - asOf: z.coerce.date().optional().describe("Optional date string to retrieve the work item as of a specific time. If not provided, the current state will be returned."), - expand: z - .enum(["all", "fields", "links", "none", "relations"]) - .describe("Optional expand parameter to include additional details in the response.") - .optional() - .describe("Expand options include 'all', 'fields', 'links', 'none', and 'relations'. Relations can be used to get child workitems. Defaults to 'none'."), - }, - async ({ id, project, fields, asOf, expand }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const workItem = await workItemApi.getWorkItem(id, fields, asOf, expand as unknown as WorkItemExpand, project); - return { - content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.list_work_item_comments, - "Retrieve list of comments for a work item by ID.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - workItemId: z.number().describe("The ID of the work item to retrieve comments for."), - top: z.number().default(50).describe("Optional number of comments to retrieve. Defaults to all comments."), - }, - async ({ project, workItemId, top }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const comments = await workItemApi.getComments(project, workItemId, top); - - return { - content: [{ type: "text", text: JSON.stringify(comments, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.add_work_item_comment, - "Add comment to a work item by ID.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - workItemId: z.number().describe("The ID of the work item to add a comment to."), - comment: z.string().describe("The text of the comment to add to the work item."), - format: z.enum(["markdown", "html"]).optional().default("html"), - }, - async ({ project, workItemId, comment, format }) => { - const connection = await connectionProvider(); - - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - - const body = { - text: comment, - }; - - const formatParameter = format === "markdown" ? 0 : 1; - const response = await fetch(`${orgUrl}/${project}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, { - method: "POST", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to add a work item comment: ${response.statusText}}`); + const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId); + + return { + content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], + }; } + ), + + server.tool( + WORKITEM_TOOLS.my_work_items, + "Retrieve a list of work items relevent to the authenticated user.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + type: z.enum(["assignedtome", "myactivity"]).default("assignedtome").describe("The type of work items to retrieve. Defaults to 'assignedtome'."), + top: z.number().default(50).describe("The maximum number of work items to return. Defaults to 50."), + includeCompleted: z.boolean().default(false).describe("Whether to include completed work items. Defaults to false."), + }, + { + readOnlyHint: true, + }, + async ({ project, type, top, includeCompleted }) => { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); - const comments = await response.text(); + const workItems = await workApi.getPredefinedQueryResults(project, type, top, includeCompleted); - return { - content: [{ type: "text", text: comments }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.add_child_work_items, - "Create one or many child work items from a parent by work item type and parent id.", - { - parentId: z.number().describe("The ID of the parent work item to create a child work item under."), - project: z.string().describe("The name or ID of the Azure DevOps project."), - workItemType: z.string().describe("The type of the child work item to create."), - items: z.array( - z.object({ - title: z.string().describe("The title of the child work item."), - description: z.string().describe("The description of the child work item."), - format: z.enum(["Markdown", "Html"]).default("Html").describe("Format for the description on the child work item, e.g., 'Markdown', 'Html'. Defaults to 'Html'."), - areaPath: z.string().optional().describe("Optional area path for the child work item."), - iterationPath: z.string().optional().describe("Optional iteration path for the child work item."), - }) - ), - }, - async ({ parentId, project, workItemType, items }) => { - try { + return { + content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.get_work_items_batch_by_ids, + "Retrieve list of work items by IDs in batch.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + ids: z.array(z.number()).describe("The IDs of the work items to retrieve."), + fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, a hardcoded default set of fields will be used."), + }, + { + readOnlyHint: true, + }, + async ({ project, ids, fields }) => { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"]; + + // If no fields are provided, use the default set of fields + const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields; + + const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, project); + + // List of identity fields that need to be transformed from objects to formatted strings + const identityFields = [ + "System.AssignedTo", + "System.CreatedBy", + "System.ChangedBy", + "System.AuthorizedAs", + "Microsoft.VSTS.Common.ActivatedBy", + "Microsoft.VSTS.Common.ResolvedBy", + "Microsoft.VSTS.Common.ClosedBy", + ]; + + // Format identity fields to include displayName and uniqueName + // Removing the identity object as the response. It's too much and not needed + if (workitems && Array.isArray(workitems)) { + workitems.forEach((item) => { + if (item.fields) { + identityFields.forEach((fieldName) => { + if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") { + const identityField = item.fields[fieldName]; + const name = identityField.displayName || ""; + const email = identityField.uniqueName || ""; + item.fields[fieldName] = `${name} <${email}>`.trim(); + } + }); + } + }); + } + + return { + content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.get_work_item, + "Get a single work item by ID.", + { + id: z.number().describe("The ID of the work item to retrieve."), + project: z.string().describe("The name or ID of the Azure DevOps project."), + fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, all fields will be returned."), + asOf: z.coerce.date().optional().describe("Optional date string to retrieve the work item as of a specific time. If not provided, the current state will be returned."), + expand: z + .enum(["all", "fields", "links", "none", "relations"]) + .describe("Optional expand parameter to include additional details in the response.") + .optional() + .describe("Expand options include 'all', 'fields', 'links', 'none', and 'relations'. Relations can be used to get child workitems. Defaults to 'none'."), + }, + { + readOnlyHint: true, + }, + async ({ id, project, fields, asOf, expand }) => { const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const workItem = await workItemApi.getWorkItem(id, fields, asOf, expand as unknown as WorkItemExpand, project); + return { + content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.list_work_item_comments, + "Retrieve list of comments for a work item by ID.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + workItemId: z.number().describe("The ID of the work item to retrieve comments for."), + top: z.number().default(50).describe("Optional number of comments to retrieve. Defaults to all comments."), + }, + { + readOnlyHint: true, + }, + async ({ project, workItemId, top }) => { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const comments = await workItemApi.getComments(project, workItemId, top); + + return { + content: [{ type: "text", text: JSON.stringify(comments, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.add_work_item_comment, + "Add comment to a work item by ID.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + workItemId: z.number().describe("The ID of the work item to add a comment to."), + comment: z.string().describe("The text of the comment to add to the work item."), + format: z.enum(["markdown", "html"]).optional().default("html"), + }, + { + readOnlyHint: false, + }, + async ({ project, workItemId, comment, format }) => { + const connection = await connectionProvider(); + const orgUrl = connection.serverUrl; const accessToken = await tokenProvider(); - if (items.length > 50) { - return { - content: [{ type: "text", text: `A maximum of 50 child work items can be created in a single call.` }], - isError: true, - }; + const body = { + text: comment, + }; + + const formatParameter = format === "markdown" ? 0 : 1; + const response = await fetch(`${orgUrl}/${project}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Failed to add a work item comment: ${response.statusText}}`); } - const body = items.map((item, x) => { - const encodedDescription = encodeFormattedValue(item.description, item.format); + const comments = await response.text(); - const ops = [ - { - op: "add", - path: "/id", - value: `-${x + 1}`, - }, - { - op: "add", - path: "/fields/System.Title", - value: item.title, - }, - { - op: "add", - path: "/fields/System.Description", - value: encodedDescription, - }, - { - op: "add", - path: "/fields/Microsoft.VSTS.TCM.ReproSteps", - value: encodedDescription, + return { + content: [{ type: "text", text: comments }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.add_child_work_items, + "Create one or many child work items from a parent by work item type and parent id.", + { + parentId: z.number().describe("The ID of the parent work item to create a child work item under."), + project: z.string().describe("The name or ID of the Azure DevOps project."), + workItemType: z.string().describe("The type of the child work item to create."), + items: z.array( + z.object({ + title: z.string().describe("The title of the child work item."), + description: z.string().describe("The description of the child work item."), + format: z.enum(["Markdown", "Html"]).default("Html").describe("Format for the description on the child work item, e.g., 'Markdown', 'Html'. Defaults to 'Html'."), + areaPath: z.string().optional().describe("Optional area path for the child work item."), + iterationPath: z.string().optional().describe("Optional iteration path for the child work item."), + }) + ), + }, + { + readOnlyHint: false, + }, + async ({ parentId, project, workItemType, items }) => { + try { + const connection = await connectionProvider(); + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); + + if (items.length > 50) { + return { + content: [{ type: "text", text: `A maximum of 50 child work items can be created in a single call.` }], + isError: true, + }; + } + + const body = items.map((item, x) => { + const encodedDescription = encodeFormattedValue(item.description, item.format); + + const ops = [ + { + op: "add", + path: "/id", + value: `-${x + 1}`, + }, + { + op: "add", + path: "/fields/System.Title", + value: item.title, + }, + { + op: "add", + path: "/fields/System.Description", + value: encodedDescription, + }, + { + op: "add", + path: "/fields/Microsoft.VSTS.TCM.ReproSteps", + value: encodedDescription, + }, + { + op: "add", + path: "/relations/-", + value: { + rel: "System.LinkTypes.Hierarchy-Reverse", + url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${parentId}`, + }, + }, + ]; + + if (item.areaPath && item.areaPath.trim().length > 0) { + ops.push({ + op: "add", + path: "/fields/System.AreaPath", + value: item.areaPath, + }); + } + + if (item.iterationPath && item.iterationPath.trim().length > 0) { + ops.push({ + op: "add", + path: "/fields/System.IterationPath", + value: item.iterationPath, + }); + } + + if (item.format && item.format === "Markdown") { + ops.push({ + op: "add", + path: "/multilineFieldsFormat/System.Description", + value: item.format, + }); + + ops.push({ + op: "add", + path: "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps", + value: item.format, + }); + } + + return { + method: "PATCH", + uri: `/${project}/_apis/wit/workitems/$${workItemType}?api-version=${batchApiVersion}`, + headers: { + "Content-Type": "application/json-patch+json", + }, + body: ops, + }; + }); + + const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Failed to update work items in batch: ${response.statusText}`); + } + + const result = await response.json(); + + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + return { + content: [{ type: "text", text: `Error creating child work items: ${errorMessage}` }], + isError: true, + }; + } + } + ), + + server.tool( + WORKITEM_TOOLS.link_work_item_to_pull_request, + "Link a single work item to an existing pull request.", + { + projectId: z.string().describe("The project ID of the Azure DevOps project (note: project name is not valid)."), + repositoryId: z.string().describe("The ID of the repository containing the pull request. Do not use the repository name here, use the ID instead."), + pullRequestId: z.number().describe("The ID of the pull request to link to."), + workItemId: z.number().describe("The ID of the work item to link to the pull request."), + pullRequestProjectId: z.string().optional().describe("The project ID containing the pull request. If not provided, defaults to the work item's project ID (for same-project linking)."), + }, + { + readOnlyHint: false, + }, + async ({ projectId, repositoryId, pullRequestId, workItemId, pullRequestProjectId }) => { + try { + const connection = await connectionProvider(); + const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Create artifact link relation using vstfs format + // Format: vstfs:///Git/PullRequestId/{project}/{repositoryId}/{pullRequestId} + const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId; + const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`; + const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`; + + // Use the PATCH document format for adding a relation + const patchDocument = [ { op: "add", path: "/relations/-", value: { - rel: "System.LinkTypes.Hierarchy-Reverse", - url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${parentId}`, + rel: "ArtifactLink", + url: vstfsUrl, + attributes: { + name: "Pull Request", + }, }, }, ]; - if (item.areaPath && item.areaPath.trim().length > 0) { - ops.push({ - op: "add", - path: "/fields/System.AreaPath", - value: item.areaPath, - }); - } + // Use the WorkItem API to update the work item with the new relation + const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, projectId); - if (item.iterationPath && item.iterationPath.trim().length > 0) { - ops.push({ - op: "add", - path: "/fields/System.IterationPath", - value: item.iterationPath, - }); + if (!workItem) { + return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; } - if (item.format && item.format === "Markdown") { - ops.push({ - op: "add", - path: "/multilineFieldsFormat/System.Description", - value: item.format, - }); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + workItemId, + pullRequestId, + success: true, + }, + null, + 2 + ), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - ops.push({ - op: "add", - path: "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps", - value: item.format, - }); + return { + content: [{ type: "text", text: `Error linking work item to pull request: ${errorMessage}` }], + isError: true, + }; + } + } + ), + + server.tool( + WORKITEM_TOOLS.get_work_items_for_iteration, + "Retrieve a list of work items for a specified iteration.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."), + iterationId: z.string().describe("The ID of the iteration to retrieve work items for."), + }, + { + readOnlyHint: true, + }, + async ({ project, team, iterationId }) => { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); + + //get the work items for the current iteration + const workItems = await workApi.getIterationWorkItems({ project, team }, iterationId); + + return { + content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.update_work_item, + "Update a work item by ID with specified fields.", + { + id: z.number().describe("The ID of the work item to update."), + updates: z + .array( + z.object({ + op: z + .string() + .transform((val) => val.toLowerCase()) + .pipe(z.enum(["add", "replace", "remove"])) + .default("add") + .describe("The operation to perform on the field."), + path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."), + value: z.string().describe("The new value for the field. This is required for 'Add' and 'Replace' operations, and should be omitted for 'Remove' operations."), + }) + ) + .describe("An array of field updates to apply to the work item."), + }, + { + readOnlyHint: false, + }, + async ({ id, updates }) => { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + + // Convert operation names to lowercase for API + const apiUpdates = updates.map((update) => ({ + ...update, + op: update.op, + })); + + const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id); + + return { + content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.get_work_item_type, + "Get a specific work item type.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + workItemType: z.string().describe("The name of the work item type to retrieve."), + }, + { + readOnlyHint: true, + }, + async ({ project, workItemType }) => { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + + const workItemTypeInfo = await workItemApi.getWorkItemType(project, workItemType); + + return { + content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.create_work_item, + "Create a new work item in a specified project and work item type.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + workItemType: z.string().describe("The type of work item to create, e.g., 'Task', 'Bug', etc."), + fields: z + .array( + z.object({ + name: z.string().describe("The name of the field, e.g., 'System.Title'."), + value: z.string().describe("The value of the field."), + format: z.enum(["Html", "Markdown"]).optional().describe("the format of the field value, e.g., 'Html', 'Markdown'. Optional, defaults to 'Html'."), + }) + ) + .describe("A record of field names and values to set on the new work item. Each fild is the field name and each value is the corresponding value to set for that field."), + }, + { + readOnlyHint: false, + }, + async ({ project, workItemType, fields }) => { + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + + const document = fields.map(({ name, value, format }) => ({ + op: "add", + path: `/fields/${name}`, + value: encodeFormattedValue(value, format), + })); + + // Check if any field has format === "Markdown" and add the multilineFieldsFormat operation + // this should only happen for large text fields, but since we dont't know by field name, lets assume if the users + // passes a value longer than 50 characters, then we can set the format to Markdown + fields.forEach(({ name, value, format }) => { + if (value.length > 50 && format === "Markdown") { + document.push({ + op: "add", + path: `/multilineFieldsFormat/${name}`, + value: "Markdown", + }); + } + }); + + const newWorkItem = await workItemApi.createWorkItem(null, document, project, workItemType); + + if (!newWorkItem) { + return { content: [{ type: "text", text: "Work item was not created" }], isError: true }; } + return { + content: [{ type: "text", text: JSON.stringify(newWorkItem, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + return { + content: [{ type: "text", text: `Error creating work item: ${errorMessage}` }], + isError: true, + }; + } + } + ), + + server.tool( + WORKITEM_TOOLS.get_query, + "Get a query by its ID or path.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + query: z.string().describe("The ID or path of the query to retrieve."), + expand: z + .enum(getEnumKeys(QueryExpand) as [string, ...string[]]) + .optional() + .describe("Optional expand parameter to include additional details in the response. Defaults to 'None'."), + depth: z.number().default(0).describe("Optional depth parameter to specify how deep to expand the query. Defaults to 0."), + includeDeleted: z.boolean().default(false).describe("Whether to include deleted items in the query results. Defaults to false."), + useIsoDateFormat: z.boolean().default(false).describe("Whether to use ISO date format in the response. Defaults to false."), + }, + { + readOnlyHint: true, + }, + async ({ project, query, expand, depth, includeDeleted, useIsoDateFormat }) => { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + + const queryDetails = await workItemApi.getQuery(project, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat); + + return { + content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.get_query_results_by_id, + "Retrieve the results of a work item query given the query ID.", + { + id: z.string().describe("The ID of the query to retrieve results for."), + project: z.string().optional().describe("The name or ID of the Azure DevOps project. If not provided, the default project will be used."), + team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."), + timePrecision: z.boolean().optional().describe("Whether to include time precision in the results. Defaults to false."), + top: z.number().default(50).describe("The maximum number of results to return. Defaults to 50."), + }, + { + readOnlyHint: true, + }, + async ({ id, project, team, timePrecision, top }) => { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const teamContext = { project, team }; + const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top); + + return { + content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }], + }; + } + ), + + server.tool( + WORKITEM_TOOLS.update_work_items_batch, + "Update work items in batch", + { + updates: z + .array( + z.object({ + op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."), + id: z.number().describe("The ID of the work item to update."), + path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."), + value: z.string().describe("The new value for the field. This is required for 'add' and 'replace' operations, and should be omitted for 'remove' operations."), + format: z.enum(["Html", "Markdown"]).optional().describe("The format of the field value. Only to be used for large text fields. e.g., 'Html', 'Markdown'. Optional, defaults to 'Html'."), + }) + ) + .describe("An array of updates to apply to work items. Each update should include the operation (op), work item ID (id), field path (path), and new value (value)."), + }, + { + readOnlyHint: false, + }, + async ({ updates }) => { + const connection = await connectionProvider(); + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); + + // Extract unique IDs from the updates array + const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); + + const body = uniqueIds.map((id) => { + const workItemUpdates = updates.filter((update) => update.id === id); + const operations = workItemUpdates.map(({ op, path, value, format }) => ({ + op: op, + path: path, + value: encodeFormattedValue(value, format), + })); + + // Add format operations for Markdown fields + workItemUpdates.forEach(({ path, value, format }) => { + if (format === "Markdown" && value && value.length > 50) { + operations.push({ + op: "Add", + path: `/multilineFieldsFormat${path.replace("/fields", "")}`, + value: "Markdown", + }); + } + }); + return { method: "PATCH", - uri: `/${project}/_apis/wit/workitems/$${workItemType}?api-version=${batchApiVersion}`, + uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, headers: { "Content-Type": "application/json-patch+json", }, - body: ops, + body: operations, }; }); @@ -383,652 +771,331 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error creating child work items: ${errorMessage}` }], - isError: true, - }; } - } - ); - - server.tool( - WORKITEM_TOOLS.link_work_item_to_pull_request, - "Link a single work item to an existing pull request.", - { - projectId: z.string().describe("The project ID of the Azure DevOps project (note: project name is not valid)."), - repositoryId: z.string().describe("The ID of the repository containing the pull request. Do not use the repository name here, use the ID instead."), - pullRequestId: z.number().describe("The ID of the pull request to link to."), - workItemId: z.number().describe("The ID of the work item to link to the pull request."), - pullRequestProjectId: z.string().optional().describe("The project ID containing the pull request. If not provided, defaults to the work item's project ID (for same-project linking)."), - }, - async ({ projectId, repositoryId, pullRequestId, workItemId, pullRequestProjectId }) => { - try { + ), + + server.tool( + WORKITEM_TOOLS.work_items_link, + "Link work items together in batch.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + updates: z + .array( + z.object({ + id: z.number().describe("The ID of the work item to update."), + linkToId: z.number().describe("The ID of the work item to link to."), + type: z + .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by"]) + .default("related") + .describe( + "Type of link to create between the work items. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', and 'affected by'. Defaults to 'related'." + ), + comment: z.string().optional().describe("Optional comment to include with the link. This can be used to provide additional context for the link being created."), + }) + ) + .describe(""), + }, + { + readOnlyHint: false, + }, + async ({ project, updates }) => { const connection = await connectionProvider(); - const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); - // Create artifact link relation using vstfs format - // Format: vstfs:///Git/PullRequestId/{project}/{repositoryId}/{pullRequestId} - const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId; - const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`; - const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`; + // Extract unique IDs from the updates array + const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); - // Use the PATCH document format for adding a relation - const patchDocument = [ - { - op: "add", - path: "/relations/-", - value: { - rel: "ArtifactLink", - url: vstfsUrl, - attributes: { - name: "Pull Request", - }, - }, + const body = uniqueIds.map((id) => ({ + method: "PATCH", + uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, + headers: { + "Content-Type": "application/json-patch+json", }, - ]; - - // Use the WorkItem API to update the work item with the new relation - const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, projectId); - - if (!workItem) { - return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; - } - - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - workItemId, - pullRequestId, - success: true, + body: updates + .filter((update) => update.id === id) + .map(({ linkToId, type, comment }) => ({ + op: "add", + path: "/relations/-", + value: { + rel: `${getLinkTypeFromName(type)}`, + url: `${orgUrl}/${project}/_apis/wit/workItems/${linkToId}`, + attributes: { + comment: comment || "", }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error linking work item to pull request: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.get_work_items_for_iteration, - "Retrieve a list of work items for a specified iteration.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."), - iterationId: z.string().describe("The ID of the iteration to retrieve work items for."), - }, - async ({ project, team, iterationId }) => { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); - - //get the work items for the current iteration - const workItems = await workApi.getIterationWorkItems({ project, team }, iterationId); - - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.update_work_item, - "Update a work item by ID with specified fields.", - { - id: z.number().describe("The ID of the work item to update."), - updates: z - .array( - z.object({ - op: z - .string() - .transform((val) => val.toLowerCase()) - .pipe(z.enum(["add", "replace", "remove"])) - .default("add") - .describe("The operation to perform on the field."), - path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."), - value: z.string().describe("The new value for the field. This is required for 'Add' and 'Replace' operations, and should be omitted for 'Remove' operations."), - }) - ) - .describe("An array of field updates to apply to the work item."), - }, - async ({ id, updates }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - - // Convert operation names to lowercase for API - const apiUpdates = updates.map((update) => ({ - ...update, - op: update.op, - })); - - const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id); - - return { - content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.get_work_item_type, - "Get a specific work item type.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - workItemType: z.string().describe("The name of the work item type to retrieve."), - }, - async ({ project, workItemType }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - - const workItemTypeInfo = await workItemApi.getWorkItemType(project, workItemType); - - return { - content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.create_work_item, - "Create a new work item in a specified project and work item type.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - workItemType: z.string().describe("The type of work item to create, e.g., 'Task', 'Bug', etc."), - fields: z - .array( - z.object({ - name: z.string().describe("The name of the field, e.g., 'System.Title'."), - value: z.string().describe("The value of the field."), - format: z.enum(["Html", "Markdown"]).optional().describe("the format of the field value, e.g., 'Html', 'Markdown'. Optional, defaults to 'Html'."), - }) - ) - .describe("A record of field names and values to set on the new work item. Each fild is the field name and each value is the corresponding value to set for that field."), - }, - async ({ project, workItemType, fields }) => { - try { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - - const document = fields.map(({ name, value, format }) => ({ - op: "add", - path: `/fields/${name}`, - value: encodeFormattedValue(value, format), + }, + })), })); - // Check if any field has format === "Markdown" and add the multilineFieldsFormat operation - // this should only happen for large text fields, but since we dont't know by field name, lets assume if the users - // passes a value longer than 50 characters, then we can set the format to Markdown - fields.forEach(({ name, value, format }) => { - if (value.length > 50 && format === "Markdown") { - document.push({ - op: "add", - path: `/multilineFieldsFormat/${name}`, - value: "Markdown", - }); - } + const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(body), }); - const newWorkItem = await workItemApi.createWorkItem(null, document, project, workItemType); - - if (!newWorkItem) { - return { content: [{ type: "text", text: "Work item was not created" }], isError: true }; + if (!response.ok) { + throw new Error(`Failed to update work items in batch: ${response.statusText}`); } - return { - content: [{ type: "text", text: JSON.stringify(newWorkItem, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + const result = await response.json(); return { - content: [{ type: "text", text: `Error creating work item: ${errorMessage}` }], - isError: true, + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } - } - ); - - server.tool( - WORKITEM_TOOLS.get_query, - "Get a query by its ID or path.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - query: z.string().describe("The ID or path of the query to retrieve."), - expand: z - .enum(getEnumKeys(QueryExpand) as [string, ...string[]]) - .optional() - .describe("Optional expand parameter to include additional details in the response. Defaults to 'None'."), - depth: z.number().default(0).describe("Optional depth parameter to specify how deep to expand the query. Defaults to 0."), - includeDeleted: z.boolean().default(false).describe("Whether to include deleted items in the query results. Defaults to false."), - useIsoDateFormat: z.boolean().default(false).describe("Whether to use ISO date format in the response. Defaults to false."), - }, - async ({ project, query, expand, depth, includeDeleted, useIsoDateFormat }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - - const queryDetails = await workItemApi.getQuery(project, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat); - - return { - content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.get_query_results_by_id, - "Retrieve the results of a work item query given the query ID.", - { - id: z.string().describe("The ID of the query to retrieve results for."), - project: z.string().optional().describe("The name or ID of the Azure DevOps project. If not provided, the default project will be used."), - team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."), - timePrecision: z.boolean().optional().describe("Whether to include time precision in the results. Defaults to false."), - top: z.number().default(50).describe("The maximum number of results to return. Defaults to 50."), - }, - async ({ id, project, team, timePrecision, top }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const teamContext = { project, team }; - const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top); - - return { - content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.update_work_items_batch, - "Update work items in batch", - { - updates: z - .array( - z.object({ - op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."), - id: z.number().describe("The ID of the work item to update."), - path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."), - value: z.string().describe("The new value for the field. This is required for 'add' and 'replace' operations, and should be omitted for 'remove' operations."), - format: z.enum(["Html", "Markdown"]).optional().describe("The format of the field value. Only to be used for large text fields. e.g., 'Html', 'Markdown'. Optional, defaults to 'Html'."), - }) - ) - .describe("An array of updates to apply to work items. Each update should include the operation (op), work item ID (id), field path (path), and new value (value)."), - }, - async ({ updates }) => { - const connection = await connectionProvider(); - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - - // Extract unique IDs from the updates array - const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); - - const body = uniqueIds.map((id) => { - const workItemUpdates = updates.filter((update) => update.id === id); - const operations = workItemUpdates.map(({ op, path, value, format }) => ({ - op: op, - path: path, - value: encodeFormattedValue(value, format), - })); - - // Add format operations for Markdown fields - workItemUpdates.forEach(({ path, value, format }) => { - if (format === "Markdown" && value && value.length > 50) { - operations.push({ - op: "Add", - path: `/multilineFieldsFormat${path.replace("/fields", "")}`, - value: "Markdown", - }); + ), + + server.tool( + WORKITEM_TOOLS.work_item_unlink, + "Remove one or many links from a single work item", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + id: z.number().describe("The ID of the work item to remove the links from."), + type: z + .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact"]) + .default("related") + .describe( + "Type of link to remove. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', 'affected by', and 'artifact'. Defaults to 'related'." + ), + url: z.string().optional().describe("Optional URL to match for the link to remove. If not provided, all links of the specified type will be removed."), + }, + { + readOnlyHint: false, + }, + async ({ project, id, type, url }) => { + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, project); + const relations: WorkItemRelation[] = workItem.relations ?? []; + const linkType = getLinkTypeFromName(type); + + let relationIndexes: number[] = []; + + if (url && url.trim().length > 0) { + // If url is provided, find relations matching both rel type and url + relationIndexes = relations.map((relation, idx) => (relation.url === url ? idx : -1)).filter((idx) => idx !== -1); + } else { + // If url is not provided, find all relations matching rel type + relationIndexes = relations.map((relation, idx) => (relation.rel === linkType ? idx : -1)).filter((idx) => idx !== -1); } - }); - return { - method: "PATCH", - uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, - headers: { - "Content-Type": "application/json-patch+json", - }, - body: operations, - }; - }); - - const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { - method: "PATCH", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to update work items in batch: ${response.statusText}`); - } + if (relationIndexes.length === 0) { + return { + content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }], + isError: true, + }; + } - const result = await response.json(); + // Get the relations that will be removed for logging + const removedRelations = relationIndexes.map((idx) => relations[idx]); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.work_items_link, - "Link work items together in batch.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - updates: z - .array( - z.object({ - id: z.number().describe("The ID of the work item to update."), - linkToId: z.number().describe("The ID of the work item to link to."), - type: z - .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by"]) - .default("related") - .describe( - "Type of link to create between the work items. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', and 'affected by'. Defaults to 'related'." - ), - comment: z.string().optional().describe("Optional comment to include with the link. This can be used to provide additional context for the link being created."), - }) - ) - .describe(""), - }, - async ({ project, updates }) => { - const connection = await connectionProvider(); - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - - // Extract unique IDs from the updates array - const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); - - const body = uniqueIds.map((id) => ({ - method: "PATCH", - uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, - headers: { - "Content-Type": "application/json-patch+json", - }, - body: updates - .filter((update) => update.id === id) - .map(({ linkToId, type, comment }) => ({ - op: "add", - path: "/relations/-", - value: { - rel: `${getLinkTypeFromName(type)}`, - url: `${orgUrl}/${project}/_apis/wit/workItems/${linkToId}`, - attributes: { - comment: comment || "", - }, - }, - })), - })); - - const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { - method: "PATCH", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to update work items in batch: ${response.statusText}`); - } + // Sort indexes in descending order to avoid index shifting when removing + relationIndexes.sort((a, b) => b - a); - const result = await response.json(); + const apiUpdates = relationIndexes.map((idx) => ({ + op: "remove", + path: `/relations/${idx}`, + })); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - ); - - server.tool( - WORKITEM_TOOLS.work_item_unlink, - "Remove one or many links from a single work item", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - id: z.number().describe("The ID of the work item to remove the links from."), - type: z - .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact"]) - .default("related") - .describe( - "Type of link to remove. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', 'affected by', and 'artifact'. Defaults to 'related'." - ), - url: z.string().optional().describe("Optional URL to match for the link to remove. If not provided, all links of the specified type will be removed."), - }, - async ({ project, id, type, url }) => { - try { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, project); - const relations: WorkItemRelation[] = workItem.relations ?? []; - const linkType = getLinkTypeFromName(type); - - let relationIndexes: number[] = []; - - if (url && url.trim().length > 0) { - // If url is provided, find relations matching both rel type and url - relationIndexes = relations.map((relation, idx) => (relation.url === url ? idx : -1)).filter((idx) => idx !== -1); - } else { - // If url is not provided, find all relations matching rel type - relationIndexes = relations.map((relation, idx) => (relation.rel === linkType ? idx : -1)).filter((idx) => idx !== -1); - } + const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, project); - if (relationIndexes.length === 0) { return { - content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }], + content: [ + { + type: "text", + text: + `Removed ${removedRelations.length} link(s) of type '${type}':\n` + + JSON.stringify(removedRelations, null, 2) + + `\n\nUpdated work item result:\n` + + JSON.stringify(updatedWorkItem, null, 2), + }, + ], + isError: false, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error unlinking work item: ${error instanceof Error ? error.message : "Unknown error occurred"}`, + }, + ], isError: true, }; } - - // Get the relations that will be removed for logging - const removedRelations = relationIndexes.map((idx) => relations[idx]); - - // Sort indexes in descending order to avoid index shifting when removing - relationIndexes.sort((a, b) => b - a); - - const apiUpdates = relationIndexes.map((idx) => ({ - op: "remove", - path: `/relations/${idx}`, - })); - - const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, project); - - return { - content: [ - { - type: "text", - text: - `Removed ${removedRelations.length} link(s) of type '${type}':\n` + - JSON.stringify(removedRelations, null, 2) + - `\n\nUpdated work item result:\n` + - JSON.stringify(updatedWorkItem, null, 2), - }, - ], - isError: false, - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error unlinking work item: ${error instanceof Error ? error.message : "Unknown error occurred"}`, - }, - ], - isError: true, - }; } - } - ); - - server.tool( - WORKITEM_TOOLS.add_artifact_link, - "Add artifact links (repository, branch, commit, builds) to work items. You can either provide the full vstfs URI or the individual components to build it automatically.", - { - workItemId: z.number().describe("The ID of the work item to add the artifact link to."), - project: z.string().describe("The name or ID of the Azure DevOps project."), - - // Option 1: Provide full URI directly - artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact to link. If provided, individual component parameters are ignored."), - - // Option 2: Provide individual components to build URI automatically based on linkType - projectId: z.string().optional().describe("The project ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."), - repositoryId: z.string().optional().describe("The repository ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."), - branchName: z.string().optional().describe("The branch name (e.g., 'main'). Required when linkType is 'Branch'."), - commitId: z.string().optional().describe("The commit SHA hash. Required when linkType is 'Fixed in Commit'."), - pullRequestId: z.number().optional().describe("The pull request ID. Required when linkType is 'Pull Request'."), - buildId: z.number().optional().describe("The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'."), - - linkType: z - .enum([ - "Branch", - "Build", - "Fixed in Changeset", - "Fixed in Commit", - "Found in build", - "Integrated in build", - "Model Link", - "Pull Request", - "Related Workitem", - "Result Attachment", - "Source Code File", - "Tag", - "Test Result", - "Wiki", - ]) - .default("Branch") - .describe("Type of artifact link, defaults to 'Branch'. This determines both the link type and how to build the VSTFS URI from individual components."), - comment: z.string().optional().describe("Comment to include with the artifact link."), - }, - async ({ workItemId, project, artifactUri, projectId, repositoryId, branchName, commitId, pullRequestId, buildId, linkType, comment }) => { - try { - const connection = await connectionProvider(); - const workItemTrackingApi = await connection.getWorkItemTrackingApi(); - - let finalArtifactUri: string; - - if (artifactUri) { - // Use the provided full URI - finalArtifactUri = artifactUri; - } else { - // Build the URI from individual components based on linkType - switch (linkType) { - case "Branch": - if (!projectId || !repositoryId || !branchName) { + ), + + server.tool( + WORKITEM_TOOLS.add_artifact_link, + "Add artifact links (repository, branch, commit, builds) to work items. You can either provide the full vstfs URI or the individual components to build it automatically.", + { + workItemId: z.number().describe("The ID of the work item to add the artifact link to."), + project: z.string().describe("The name or ID of the Azure DevOps project."), + + // Option 1: Provide full URI directly + artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact to link. If provided, individual component parameters are ignored."), + + // Option 2: Provide individual components to build URI automatically based on linkType + projectId: z.string().optional().describe("The project ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."), + repositoryId: z.string().optional().describe("The repository ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."), + branchName: z.string().optional().describe("The branch name (e.g., 'main'). Required when linkType is 'Branch'."), + commitId: z.string().optional().describe("The commit SHA hash. Required when linkType is 'Fixed in Commit'."), + pullRequestId: z.number().optional().describe("The pull request ID. Required when linkType is 'Pull Request'."), + buildId: z.number().optional().describe("The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'."), + + linkType: z + .enum([ + "Branch", + "Build", + "Fixed in Changeset", + "Fixed in Commit", + "Found in build", + "Integrated in build", + "Model Link", + "Pull Request", + "Related Workitem", + "Result Attachment", + "Source Code File", + "Tag", + "Test Result", + "Wiki", + ]) + .default("Branch") + .describe("Type of artifact link, defaults to 'Branch'. This determines both the link type and how to build the VSTFS URI from individual components."), + comment: z.string().optional().describe("Comment to include with the artifact link."), + }, + { + readOnlyHint: false, + }, + async ({ workItemId, project, artifactUri, projectId, repositoryId, branchName, commitId, pullRequestId, buildId, linkType, comment }) => { + try { + const connection = await connectionProvider(); + const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + + let finalArtifactUri: string; + + if (artifactUri) { + // Use the provided full URI + finalArtifactUri = artifactUri; + } else { + // Build the URI from individual components based on linkType + switch (linkType) { + case "Branch": + if (!projectId || !repositoryId || !branchName) { + return { + content: [{ type: "text", text: "For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required." }], + isError: true, + }; + } + finalArtifactUri = `vstfs:///Git/Ref/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2FGB${encodeURIComponent(branchName)}`; + break; + + case "Fixed in Commit": + if (!projectId || !repositoryId || !commitId) { + return { + content: [{ type: "text", text: "For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required." }], + isError: true, + }; + } + finalArtifactUri = `vstfs:///Git/Commit/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(commitId)}`; + break; + + case "Pull Request": + if (!projectId || !repositoryId || pullRequestId === undefined) { + return { + content: [{ type: "text", text: "For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required." }], + isError: true, + }; + } + finalArtifactUri = `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(pullRequestId.toString())}`; + break; + + case "Build": + case "Found in build": + case "Integrated in build": + if (buildId === undefined) { + return { + content: [{ type: "text", text: `For '${linkType}' links, 'buildId' is required.` }], + isError: true, + }; + } + finalArtifactUri = `vstfs:///Build/Build/${encodeURIComponent(buildId.toString())}`; + break; + + default: return { - content: [{ type: "text", text: "For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required." }], + content: [{ type: "text", text: `URI building from components is not supported for link type '${linkType}'. Please provide the full 'artifactUri' instead.` }], isError: true, }; - } - finalArtifactUri = `vstfs:///Git/Ref/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2FGB${encodeURIComponent(branchName)}`; - break; - - case "Fixed in Commit": - if (!projectId || !repositoryId || !commitId) { - return { - content: [{ type: "text", text: "For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required." }], - isError: true, - }; - } - finalArtifactUri = `vstfs:///Git/Commit/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(commitId)}`; - break; - - case "Pull Request": - if (!projectId || !repositoryId || pullRequestId === undefined) { - return { - content: [{ type: "text", text: "For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required." }], - isError: true, - }; - } - finalArtifactUri = `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(pullRequestId.toString())}`; - break; - - case "Build": - case "Found in build": - case "Integrated in build": - if (buildId === undefined) { - return { - content: [{ type: "text", text: `For '${linkType}' links, 'buildId' is required.` }], - isError: true, - }; - } - finalArtifactUri = `vstfs:///Build/Build/${encodeURIComponent(buildId.toString())}`; - break; - - default: - return { - content: [{ type: "text", text: `URI building from components is not supported for link type '${linkType}'. Please provide the full 'artifactUri' instead.` }], - isError: true, - }; + } } - } - // Create the patch document for adding an artifact link relation - const patchDocument = [ - { - op: "add", - path: "/relations/-", - value: { - rel: "ArtifactLink", - url: finalArtifactUri, - attributes: { - name: linkType, - ...(comment && { comment }), + // Create the patch document for adding an artifact link relation + const patchDocument = [ + { + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: finalArtifactUri, + attributes: { + name: linkType, + ...(comment && { comment }), + }, }, }, - }, - ]; + ]; - // Use the WorkItem API to update the work item with the new relation - const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, project); + // Use the WorkItem API to update the work item with the new relation + const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, project); - if (!workItem) { - return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; - } + if (!workItem) { + return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; + } - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - workItemId, - artifactUri: finalArtifactUri, - linkType, - comment: comment || null, - success: true, - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + workItemId, + artifactUri: finalArtifactUri, + linkType, + comment: comment || null, + success: true, + }, + null, + 2 + ), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error adding artifact link to work item: ${errorMessage}` }], - isError: true, - }; + return { + content: [{ type: "text", text: `Error adding artifact link to work item: ${errorMessage}` }], + isError: true, + }; + } + } + ), + ]; + + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); } } - ); + } } export { WORKITEM_TOOLS, configureWorkItemTools }; diff --git a/src/tools/work.ts b/src/tools/work.ts index 2188d552..5b07d5e8 100644 --- a/src/tools/work.ts +++ b/src/tools/work.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { z } from "zod"; import { TreeStructureGroup } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js"; @@ -12,144 +12,163 @@ const WORK_TOOLS = { assign_iterations: "work_assign_iterations", }; -function configureWorkTools(server: McpServer, _: () => Promise, connectionProvider: () => Promise) { - server.tool( - WORK_TOOLS.list_team_iterations, - "Retrieve a list of iterations for a specific team in a project.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - team: z.string().describe("The name or ID of the Azure DevOps team."), - timeframe: z.enum(["current"]).optional().describe("The timeframe for which to retrieve iterations. Currently, only 'current' is supported."), - }, - async ({ project, team, timeframe }) => { - try { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); - const iterations = await workApi.getTeamIterations({ project, team }, timeframe); - - if (!iterations) { - return { content: [{ type: "text", text: "No iterations found" }], isError: true }; - } +function configureWorkTools(server: McpServer, _: () => Promise, connectionProvider: () => Promise, isReadOnlyMode: boolean) { + const registeredTools: RegisteredTool[] = [ + server.tool( + WORK_TOOLS.list_team_iterations, + "Retrieve a list of iterations for a specific team in a project.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + team: z.string().describe("The name or ID of the Azure DevOps team."), + timeframe: z.enum(["current"]).optional().describe("The timeframe for which to retrieve iterations. Currently, only 'current' is supported."), + }, + { + readOnlyHint: true, + }, + async ({ project, team, timeframe }) => { + try { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); + const iterations = await workApi.getTeamIterations({ project, team }, timeframe); + + if (!iterations) { + return { content: [{ type: "text", text: "No iterations found" }], isError: true }; + } - return { - content: [{ type: "text", text: JSON.stringify(iterations, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: JSON.stringify(iterations, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error fetching team iterations: ${errorMessage}` }], - isError: true, - }; + return { + content: [{ type: "text", text: `Error fetching team iterations: ${errorMessage}` }], + isError: true, + }; + } } - } - ); - - server.tool( - WORK_TOOLS.create_iterations, - "Create new iterations in a specified Azure DevOps project.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - iterations: z - .array( - z.object({ - iterationName: z.string().describe("The name of the iteration to create."), - startDate: z.string().optional().describe("The start date of the iteration in ISO format (e.g., '2023-01-01T00:00:00Z'). Optional."), - finishDate: z.string().optional().describe("The finish date of the iteration in ISO format (e.g., '2023-01-31T23:59:59Z'). Optional."), - }) - ) - .describe("An array of iterations to create. Each iteration must have a name and can optionally have start and finish dates in ISO format."), - }, - async ({ project, iterations }) => { - try { - const connection = await connectionProvider(); - const workItemTrackingApi = await connection.getWorkItemTrackingApi(); - const results = []; - - for (const { iterationName, startDate, finishDate } of iterations) { - // Step 1: Create the iteration - const iteration = await workItemTrackingApi.createOrUpdateClassificationNode( - { - name: iterationName, - attributes: { - startDate: startDate ? new Date(startDate) : undefined, - finishDate: finishDate ? new Date(finishDate) : undefined, + ), + + server.tool( + WORK_TOOLS.create_iterations, + "Create new iterations in a specified Azure DevOps project.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + iterations: z + .array( + z.object({ + iterationName: z.string().describe("The name of the iteration to create."), + startDate: z.string().optional().describe("The start date of the iteration in ISO format (e.g., '2023-01-01T00:00:00Z'). Optional."), + finishDate: z.string().optional().describe("The finish date of the iteration in ISO format (e.g., '2023-01-31T23:59:59Z'). Optional."), + }) + ) + .describe("An array of iterations to create. Each iteration must have a name and can optionally have start and finish dates in ISO format."), + }, + { + readOnlyHint: false, + }, + async ({ project, iterations }) => { + try { + const connection = await connectionProvider(); + const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + const results = []; + + for (const { iterationName, startDate, finishDate } of iterations) { + // Step 1: Create the iteration + const iteration = await workItemTrackingApi.createOrUpdateClassificationNode( + { + name: iterationName, + attributes: { + startDate: startDate ? new Date(startDate) : undefined, + finishDate: finishDate ? new Date(finishDate) : undefined, + }, }, - }, - project, - TreeStructureGroup.Iterations - ); + project, + TreeStructureGroup.Iterations + ); - if (iteration) { - results.push(iteration); + if (iteration) { + results.push(iteration); + } } - } - if (results.length === 0) { - return { content: [{ type: "text", text: "No iterations were created" }], isError: true }; - } + if (results.length === 0) { + return { content: [{ type: "text", text: "No iterations were created" }], isError: true }; + } - return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error creating iterations: ${errorMessage}` }], - isError: true, - }; + return { + content: [{ type: "text", text: `Error creating iterations: ${errorMessage}` }], + isError: true, + }; + } } - } - ); - - server.tool( - WORK_TOOLS.assign_iterations, - "Assign existing iterations to a specific team in a project.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - team: z.string().describe("The name or ID of the Azure DevOps team."), - iterations: z - .array( - z.object({ - identifier: z.string().describe("The identifier of the iteration to assign."), - path: z.string().describe("The path of the iteration to assign, e.g., 'Project/Iteration'."), - }) - ) - .describe("An array of iterations to assign. Each iteration must have an identifier and a path."), - }, - async ({ project, team, iterations }) => { - try { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); - const teamContext = { project, team }; - const results = []; - - for (const { identifier, path } of iterations) { - const assignment = await workApi.postTeamIteration({ path: path, id: identifier }, teamContext); - - if (assignment) { - results.push(assignment); + ), + + server.tool( + WORK_TOOLS.assign_iterations, + "Assign existing iterations to a specific team in a project.", + { + project: z.string().describe("The name or ID of the Azure DevOps project."), + team: z.string().describe("The name or ID of the Azure DevOps team."), + iterations: z + .array( + z.object({ + identifier: z.string().describe("The identifier of the iteration to assign."), + path: z.string().describe("The path of the iteration to assign, e.g., 'Project/Iteration'."), + }) + ) + .describe("An array of iterations to assign. Each iteration must have an identifier and a path."), + }, + { + readOnlyHint: false, + }, + async ({ project, team, iterations }) => { + try { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); + const teamContext = { project, team }; + const results = []; + + for (const { identifier, path } of iterations) { + const assignment = await workApi.postTeamIteration({ path: path, id: identifier }, teamContext); + + if (assignment) { + results.push(assignment); + } } - } - if (results.length === 0) { - return { content: [{ type: "text", text: "No iterations were assigned to the team" }], isError: true }; - } + if (results.length === 0) { + return { content: [{ type: "text", text: "No iterations were assigned to the team" }], isError: true }; + } - return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + return { + content: [{ type: "text", text: `Error assigning iterations: ${errorMessage}` }], + isError: true, + }; + } + } + ), + ]; - return { - content: [{ type: "text", text: `Error assigning iterations: ${errorMessage}` }], - isError: true, - }; + if (isReadOnlyMode) { + for (const tool of registeredTools) { + if (!tool.annotations?.readOnlyHint) { + tool.remove(); } } - ); + } } export { WORK_TOOLS, configureWorkTools }; diff --git a/src/version.ts b/src/version.ts index 5d5a0e83..cb78b981 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const packageVersion = "2.2.1"; +export const packageVersion = "2.3.0"; diff --git a/test/src/tools/advanced-security.test.ts b/test/src/tools/advanced-security.test.ts index e17c219b..144c27b7 100644 --- a/test/src/tools/advanced-security.test.ts +++ b/test/src/tools/advanced-security.test.ts @@ -1,4 +1,3 @@ -import { AccessToken } from "@azure/identity"; import { describe, expect, it } from "@jest/globals"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; @@ -20,6 +19,7 @@ describe("configureAdvSecTools", () => { let connectionProvider: ConnectionProviderMock; let mockConnection: { getAlertApi: jest.Mock }; let mockAlertApi: AlertApiMock; + let isReadOnlyMode: boolean; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -35,22 +35,46 @@ describe("configureAdvSecTools", () => { }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); + + isReadOnlyMode = false; }); describe("tool registration", () => { it("registers Advanced Security tools on the server", () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); expect(server.tool as jest.Mock).toHaveBeenCalled(); }); + + describe("read-only mode", () => { + it("removes write tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: false } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); + + expect(mockTool.remove).toHaveBeenCalled(); + }); + + it("keeps read-only tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: true } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); + + expect(mockTool.remove).not.toHaveBeenCalled(); + }); + }); }); describe("advsec_get_alerts tool", () => { it("should call getAlerts API with correct parameters and return multiple alerts", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResult: PagedList = [ { @@ -142,11 +166,11 @@ describe("configureAdvSecTools", () => { }); it("should handle pagination with continuation token", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // First call - returns first page (simulating PagedList without continuation token in response) const firstPageMockResult: PagedList = [ @@ -302,11 +326,11 @@ describe("configureAdvSecTools", () => { }); it("should handle API errors gracefully", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Failed to retrieve alerts"); (mockAlertApi.getAlerts as jest.Mock).mockRejectedValue(testError); @@ -324,11 +348,11 @@ describe("configureAdvSecTools", () => { }); it("should handle null API results correctly", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockAlertApi.getAlerts as jest.Mock).mockResolvedValue(null); @@ -344,11 +368,11 @@ describe("configureAdvSecTools", () => { }); it("should conditionally include confidenceLevels and validity only for secret alerts", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResult: PagedList = [ { @@ -440,11 +464,11 @@ describe("configureAdvSecTools", () => { }); it("should handle optional parameters correctly when not provided", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResult: PagedList = []; (mockAlertApi.getAlerts as jest.Mock).mockResolvedValue(mockResult); @@ -470,11 +494,11 @@ describe("configureAdvSecTools", () => { }); it("should include all optional parameters when provided", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResult: PagedList = []; (mockAlertApi.getAlerts as jest.Mock).mockResolvedValue(mockResult); @@ -531,11 +555,11 @@ describe("configureAdvSecTools", () => { }); it("should handle onlyDefaultBranch parameter correctly", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResult: PagedList = []; (mockAlertApi.getAlerts as jest.Mock).mockResolvedValue(mockResult); @@ -584,11 +608,11 @@ describe("configureAdvSecTools", () => { }); it("should handle secret alerts without confidenceLevels or validity", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResult: PagedList = []; (mockAlertApi.getAlerts as jest.Mock).mockResolvedValue(mockResult); @@ -611,11 +635,11 @@ describe("configureAdvSecTools", () => { }); it("should handle non-Error exception types", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alerts"); if (!call) throw new Error("advsec_get_alerts tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Test with non-Error exception (string) (mockAlertApi.getAlerts as jest.Mock).mockRejectedValue("String error"); @@ -634,11 +658,11 @@ describe("configureAdvSecTools", () => { describe("advsec_get_alert_details tool", () => { it("should fetch specific alert details", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alert_details"); if (!call) throw new Error("advsec_get_alert_details tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResult: Alert = { alertId: 1, @@ -680,11 +704,11 @@ describe("configureAdvSecTools", () => { }); it("should fetch specific alert details with ref parameter", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alert_details"); if (!call) throw new Error("advsec_get_alert_details tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResult: Alert = { alertId: 1, @@ -718,11 +742,11 @@ describe("configureAdvSecTools", () => { }); it("should handle API errors correctly", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alert_details"); if (!call) throw new Error("advsec_get_alert_details tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Alert not found"); (mockAlertApi.getAlert as jest.Mock).mockRejectedValue(testError); @@ -741,11 +765,11 @@ describe("configureAdvSecTools", () => { }); it("should handle non-Error exception types", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alert_details"); if (!call) throw new Error("advsec_get_alert_details tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Test with non-Error exception (string) (mockAlertApi.getAlert as jest.Mock).mockRejectedValue("String error"); @@ -763,11 +787,11 @@ describe("configureAdvSecTools", () => { }); it("should handle null API results correctly", async () => { - configureAdvSecTools(server, tokenProvider, connectionProvider); + configureAdvSecTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "advsec_get_alert_details"); if (!call) throw new Error("advsec_get_alert_details tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockAlertApi.getAlert as jest.Mock).mockResolvedValue(null); diff --git a/test/src/tools/core.test.ts b/test/src/tools/core.test.ts index e494aa7e..90bb78d3 100644 --- a/test/src/tools/core.test.ts +++ b/test/src/tools/core.test.ts @@ -1,4 +1,3 @@ -import { AccessToken } from "@azure/identity"; import { describe, expect, it } from "@jest/globals"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { configureCoreTools } from "../../../src/tools/core"; @@ -19,6 +18,7 @@ describe("configureCoreTools", () => { let userAgentProvider: () => string; let mockConnection: { getCoreApi: jest.Mock }; let mockCoreApi: CoreApiMock; + let isReadOnlyMode: boolean; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -35,23 +35,47 @@ describe("configureCoreTools", () => { }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); + + isReadOnlyMode = false; }); describe("tool registration", () => { it("registers core tools on the server", () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); expect(server.tool as jest.Mock).toHaveBeenCalled(); }); + + describe("read-only mode", () => { + it("removes write tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: false } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).toHaveBeenCalled(); + }); + + it("keeps read-only tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: true } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).not.toHaveBeenCalled(); + }); + }); }); describe("list_projects tool", () => { it("should call getProjects API with the correct parameters and return the expected result", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_projects"); if (!call) throw new Error("core_list_projects tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockCoreApi.getProjects as jest.Mock).mockResolvedValue([ { @@ -119,12 +143,12 @@ describe("configureCoreTools", () => { }); it("should handle API errors correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_projects"); if (!call) throw new Error("core_list_projects tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("API connection failed"); (mockCoreApi.getProjects as jest.Mock).mockRejectedValue(testError); @@ -144,12 +168,12 @@ describe("configureCoreTools", () => { }); it("should handle null API results correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_projects"); if (!call) throw new Error("core_list_projects tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockCoreApi.getProjects as jest.Mock).mockResolvedValue(null); @@ -168,12 +192,12 @@ describe("configureCoreTools", () => { }); it("should handle unknown error type correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_projects"); if (!call) throw new Error("core_list_projects tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockCoreApi.getProjects as jest.Mock).mockRejectedValue("string error"); @@ -192,12 +216,12 @@ describe("configureCoreTools", () => { }); it("should filter projects by name when projectNameFilter is provided", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_projects"); if (!call) throw new Error("core_list_projects tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockCoreApi.getProjects as jest.Mock).mockResolvedValue([ { @@ -241,12 +265,12 @@ describe("configureCoreTools", () => { }); it("should handle case-insensitive filtering", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_projects"); if (!call) throw new Error("core_list_projects tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockCoreApi.getProjects as jest.Mock).mockResolvedValue([ { @@ -282,12 +306,12 @@ describe("configureCoreTools", () => { describe("list_project_teams tool", () => { it("should call getTeams API with the correct parameters and return the expected result", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_project_teams"); if (!call) throw new Error("core_list_project_teams tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockCoreApi.getTeams as jest.Mock).mockResolvedValue([ { @@ -343,12 +367,12 @@ describe("configureCoreTools", () => { }); it("should handle API errors correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_project_teams"); if (!call) throw new Error("core_list_project_teams tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Team not found"); (mockCoreApi.getTeams as jest.Mock).mockRejectedValue(testError); @@ -368,12 +392,12 @@ describe("configureCoreTools", () => { }); it("should handle null API results correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_project_teams"); if (!call) throw new Error("core_list_project_teams tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockCoreApi.getTeams as jest.Mock).mockResolvedValue(null); @@ -392,12 +416,12 @@ describe("configureCoreTools", () => { }); it("should handle unknown error type correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_list_project_teams"); if (!call) throw new Error("core_list_project_teams tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockCoreApi.getTeams as jest.Mock).mockRejectedValue("string error"); @@ -427,11 +451,11 @@ describe("configureCoreTools", () => { }); it("should fetch identity IDs with correct parameters and return expected result", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_get_identity_ids"); if (!call) throw new Error("core_get_identity_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock token provider (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -493,11 +517,11 @@ describe("configureCoreTools", () => { }); it("should handle HTTP error responses correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_get_identity_ids"); if (!call) throw new Error("core_get_identity_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); const mockConnectionWithUrl = { @@ -521,11 +545,11 @@ describe("configureCoreTools", () => { }); it("should handle empty results correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_get_identity_ids"); if (!call) throw new Error("core_get_identity_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); const mockConnectionWithUrl = { @@ -548,11 +572,11 @@ describe("configureCoreTools", () => { }); it("should handle null response correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_get_identity_ids"); if (!call) throw new Error("core_get_identity_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); const mockConnectionWithUrl = { @@ -575,11 +599,11 @@ describe("configureCoreTools", () => { }); it("should handle network errors correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_get_identity_ids"); if (!call) throw new Error("core_get_identity_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); const mockConnectionWithUrl = { @@ -599,11 +623,11 @@ describe("configureCoreTools", () => { }); it("should handle unknown error types correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_get_identity_ids"); if (!call) throw new Error("core_get_identity_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); const mockConnectionWithUrl = { @@ -623,11 +647,11 @@ describe("configureCoreTools", () => { }); it("should handle token provider errors correctly", async () => { - configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureCoreTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "core_get_identity_ids"); if (!call) throw new Error("core_get_identity_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock token provider error (tokenProvider as jest.Mock).mockRejectedValue(new Error("Token acquisition failed")); diff --git a/test/src/tools/pipelines.test.ts b/test/src/tools/pipelines.test.ts index 0d77f11f..e453f45e 100644 --- a/test/src/tools/pipelines.test.ts +++ b/test/src/tools/pipelines.test.ts @@ -1,4 +1,3 @@ -import { AccessToken } from "@azure/identity"; import { describe, expect, it, beforeEach } from "@jest/globals"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; @@ -19,6 +18,7 @@ describe("configurePipelineTools", () => { let connectionProvider: ConnectionProviderMock; let userAgentProvider: () => string; let mockConnection: { getBuildApi: jest.Mock; getPipelinesApi: jest.Mock; serverUrl: string }; + let isReadOnlyMode: boolean; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -31,21 +31,44 @@ describe("configurePipelineTools", () => { }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); (global.fetch as jest.MockedFunction).mockClear(); + isReadOnlyMode = false; }); describe("tool registration", () => { it("registers build tools on the server", () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); expect(server.tool as jest.Mock).toHaveBeenCalled(); }); + + describe("read-only mode", () => { + it("removes write tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: false } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).toHaveBeenCalled(); + }); + + it("keeps read-only tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: true } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).not.toHaveBeenCalled(); + }); + }); }); describe("update_build_stage tool", () => { it("should update build stage with correct parameters and return the expected result", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_update_build_stage"); if (!call) throw new Error("pipelines_update_build_stage tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock the token provider (tokenProvider as jest.Mock).mockResolvedValue("mock-token"); @@ -84,10 +107,10 @@ describe("configurePipelineTools", () => { }); it("should handle HTTP errors correctly", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_update_build_stage"); if (!call) throw new Error("pipelines_update_build_stage tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock the token provider (tokenProvider as jest.Mock).mockResolvedValue("mock-token"); @@ -125,10 +148,10 @@ describe("configurePipelineTools", () => { }); it("should handle network errors correctly", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_update_build_stage"); if (!call) throw new Error("pipelines_update_build_stage tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock the token provider (tokenProvider as jest.Mock).mockResolvedValue("mock-token"); @@ -162,10 +185,10 @@ describe("configurePipelineTools", () => { }); it("should handle token provider errors correctly", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_update_build_stage"); if (!call) throw new Error("pipelines_update_build_stage tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock token provider error const tokenError = new Error("Failed to get access token"); @@ -186,10 +209,10 @@ describe("configurePipelineTools", () => { }); it("should handle different StageUpdateType values correctly", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_update_build_stage"); if (!call) throw new Error("pipelines_update_build_stage tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValue("mock-token"); @@ -223,10 +246,10 @@ describe("configurePipelineTools", () => { describe("get_definitions tool", () => { it("should call getDefinitions with correct parameters and return expected result", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getDefinitions: jest.fn().mockResolvedValue([ @@ -279,10 +302,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for get_definitions", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getDefinitions: jest.fn().mockRejectedValue(new Error("API Error")), @@ -297,10 +320,10 @@ describe("configurePipelineTools", () => { describe("get_definition_revisions tool", () => { it("should call getDefinitionRevisions with correct parameters", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definition_revisions"); if (!call) throw new Error("pipelines_get_build_definition_revisions tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getDefinitionRevisions: jest.fn().mockResolvedValue([ @@ -331,10 +354,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for get_definition_revisions", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definition_revisions"); if (!call) throw new Error("pipelines_get_build_definition_revisions tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getDefinitionRevisions: jest.fn().mockRejectedValue(new Error("Definition not found")), @@ -352,10 +375,10 @@ describe("configurePipelineTools", () => { describe("get_builds tool", () => { it("should call getBuilds with correct parameters", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_builds"); if (!call) throw new Error("pipelines_get_builds tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuilds: jest.fn().mockResolvedValue([ @@ -411,10 +434,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for get_builds", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_builds"); if (!call) throw new Error("pipelines_get_builds tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuilds: jest.fn().mockRejectedValue(new Error("Project not found")), @@ -429,10 +452,10 @@ describe("configurePipelineTools", () => { describe("get_log tool", () => { it("should call getBuildLogs with correct parameters", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_log"); if (!call) throw new Error("pipelines_get_build_log tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuildLogs: jest.fn().mockResolvedValue([ @@ -463,10 +486,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for get_log", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_log"); if (!call) throw new Error("pipelines_get_build_log tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuildLogs: jest.fn().mockRejectedValue(new Error("Build not found")), @@ -484,10 +507,10 @@ describe("configurePipelineTools", () => { describe("get_log_by_id tool", () => { it("should call getBuildLogLines with correct parameters", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_log_by_id"); if (!call) throw new Error("pipelines_get_build_log_by_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuildLogLines: jest.fn().mockResolvedValue(["2024-12-01T10:00:00.000Z Starting build...", "2024-12-01T10:01:00.000Z Build completed successfully"]), @@ -509,10 +532,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for get_log_by_id", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_log_by_id"); if (!call) throw new Error("pipelines_get_build_log_by_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuildLogLines: jest.fn().mockRejectedValue(new Error("Log not found")), @@ -531,10 +554,10 @@ describe("configurePipelineTools", () => { describe("get_changes tool", () => { it("should call getBuildChanges with correct parameters", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_changes"); if (!call) throw new Error("pipelines_get_build_changes tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuildChanges: jest.fn().mockResolvedValue([ @@ -568,10 +591,10 @@ describe("configurePipelineTools", () => { }); it("should use default top value when not provided", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_changes"); if (!call) throw new Error("pipelines_get_build_changes tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuildChanges: jest.fn().mockResolvedValue([]), @@ -589,10 +612,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for get_changes", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_changes"); if (!call) throw new Error("pipelines_get_build_changes tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBuildApi = { getBuildChanges: jest.fn().mockRejectedValue(new Error("Changes not available")), @@ -610,10 +633,10 @@ describe("configurePipelineTools", () => { describe("pipelines_get_run tool", () => { it("should call getRun with correct parameters", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_run"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPipelinesApi = { getRun: jest.fn().mockResolvedValue({ id: 1, name: "run-1" }), @@ -633,10 +656,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for pipelines_get_run", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_run"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPipelinesApi = { getRun: jest.fn().mockRejectedValue(new Error("Run not found")), @@ -655,10 +678,10 @@ describe("configurePipelineTools", () => { describe("pipelines_list_runs tool", () => { it("should call listRuns with correct parameters", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_list_runs"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPipelinesApi = { listRuns: jest.fn().mockResolvedValue([{ id: 1, name: "run-1" }]), @@ -677,10 +700,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for pipelines_list_runs", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_list_runs"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPipelinesApi = { listRuns: jest.fn().mockRejectedValue(new Error("Pipeline not found")), @@ -698,10 +721,10 @@ describe("configurePipelineTools", () => { describe("pipelines_run_pipeline tool", () => { it("should trigger pipeline with correct parameters", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPipelinesApi = { runPipeline: jest.fn().mockResolvedValue({ id: 456 }), @@ -746,10 +769,10 @@ describe("configurePipelineTools", () => { }); it("should handle preview run", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPipelinesApi = { runPipeline: jest.fn().mockResolvedValue({ id: 456, finalYaml: "final yaml" }), @@ -775,10 +798,10 @@ describe("configurePipelineTools", () => { }); it("should throw error for previewRun and yamlOverride", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { project: "test-project", @@ -791,10 +814,10 @@ describe("configurePipelineTools", () => { }); it("should handle missing build ID from pipeline run", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPipelinesApi = { runPipeline: jest.fn().mockResolvedValue({}), @@ -810,10 +833,10 @@ describe("configurePipelineTools", () => { }); it("should handle API errors for pipelines_run_pipeline", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_run_pipeline"); if (!call) fail("Tool not found"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPipelinesApi = { runPipeline: jest.fn().mockRejectedValue(new Error("API Error")), diff --git a/test/src/tools/repositories.test.ts b/test/src/tools/repositories.test.ts index 7d127e85..e6bd7f5f 100644 --- a/test/src/tools/repositories.test.ts +++ b/test/src/tools/repositories.test.ts @@ -2,7 +2,6 @@ // Licensed under the MIT License. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { AccessToken } from "@azure/identity"; import { WebApi } from "azure-devops-node-api"; import { configureRepoTools, REPO_TOOLS } from "../../../src/tools/repositories"; import { PullRequestStatus, GitVersionType, GitPullRequestQueryType, CommentThreadStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js"; @@ -41,6 +40,7 @@ describe("repos tools", () => { getPullRequestQuery: jest.MockedFunction<(...args: unknown[]) => Promise>; updateRefs: jest.MockedFunction<(...args: unknown[]) => Promise>; }; + let isReadOnlyMode: boolean; beforeEach(() => { server = { @@ -77,16 +77,47 @@ describe("repos tools", () => { mockGetCurrentUserDetails.mockResolvedValue({ authenticatedUser: { id: "user123", uniqueName: "testuser@example.com", displayName: "Test User" }, } as any); + + isReadOnlyMode = false; + }); + + describe("tool registration", () => { + it("registers repo tools on the server", () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + expect(server.tool as jest.Mock).toHaveBeenCalled(); + }); + + describe("read-only mode", () => { + it("removes write tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: false } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).toHaveBeenCalled(); + }); + + it("keeps read-only tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: true } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).not.toHaveBeenCalled(); + }); + }); }); describe("repo_update_pull_request", () => { it("should update pull request with all provided fields", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -148,12 +179,12 @@ describe("repos tools", () => { }); it("should update pull request with only title", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -208,12 +239,12 @@ describe("repos tools", () => { }); it("should update pull request status to Active", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -268,12 +299,12 @@ describe("repos tools", () => { }); it("should update pull request status to Abandoned", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -328,12 +359,12 @@ describe("repos tools", () => { }); it("should update pull request with status and other fields", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -390,12 +421,12 @@ describe("repos tools", () => { }); it("should return error when no fields provided", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -410,11 +441,11 @@ describe("repos tools", () => { }); it("should update pull request with autocomplete enabled", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -464,11 +495,11 @@ describe("repos tools", () => { }); it("should disable autocomplete when autoComplete is false", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -499,11 +530,11 @@ describe("repos tools", () => { }); it("should not bypass policies when bypassReason is not provided", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -544,11 +575,11 @@ describe("repos tools", () => { }); it("should automatically bypass policies when bypassReason is provided", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); if (!call) throw new Error("repo_update_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdatedPR = { pullRequestId: 123, @@ -593,11 +624,11 @@ describe("repos tools", () => { describe("repo_create_pull_request", () => { it("should create pull request with basic fields", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); if (!call) throw new Error("repo_create_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreatedPR = { pullRequestId: 456, @@ -658,11 +689,11 @@ describe("repos tools", () => { }); it("should create pull request with all optional fields", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); if (!call) throw new Error("repo_create_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreatedPR = { pullRequestId: 456, @@ -734,11 +765,11 @@ describe("repos tools", () => { describe("repo_create_branch", () => { it("should create branch with default source branch (main)", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockSourceBranch = [ { @@ -780,11 +811,11 @@ describe("repos tools", () => { }); it("should create branch with custom source branch", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockSourceBranch = [ { @@ -826,11 +857,11 @@ describe("repos tools", () => { }); it("should create branch with specific commit ID", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockUpdateResult = [ { @@ -867,11 +898,11 @@ describe("repos tools", () => { }); it("should handle source branch not found error", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getRefs.mockResolvedValue([]); @@ -888,11 +919,11 @@ describe("repos tools", () => { }); it("should handle getRefs API error", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockError = new Error("API Error"); mockGitApi.getRefs.mockRejectedValue(mockError); @@ -910,11 +941,11 @@ describe("repos tools", () => { }); it("should handle updateRefs failure", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockSourceBranch = [ { @@ -945,11 +976,11 @@ describe("repos tools", () => { }); it("should handle updateRefs failure without custom message", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockSourceBranch = [ { @@ -979,11 +1010,11 @@ describe("repos tools", () => { }); it("should handle updateRefs API error", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockSourceBranch = [ { @@ -1009,11 +1040,11 @@ describe("repos tools", () => { }); it("should handle source branch with missing objectId", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockSourceBranch = [ { @@ -1039,11 +1070,11 @@ describe("repos tools", () => { describe("repo_update_pull_request_reviewers", () => { it("should add reviewers to pull request", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_reviewers); if (!call) throw new Error("repo_update_pull_request_reviewers tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockReviewers = [{ id: "reviewer1" }, { id: "reviewer2" }]; mockGitApi.createPullRequestReviewers.mockResolvedValue(mockReviewers); @@ -1063,11 +1094,11 @@ describe("repos tools", () => { }); it("should remove reviewers from pull request", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_reviewers); if (!call) throw new Error("repo_update_pull_request_reviewers tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.deletePullRequestReviewer.mockResolvedValue({}); @@ -1090,11 +1121,11 @@ describe("repos tools", () => { describe("repo_list_repos_by_project", () => { it("should list repositories by project", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); if (!call) throw new Error("repo_list_repos_by_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockRepos = [ { @@ -1142,11 +1173,11 @@ describe("repos tools", () => { }); it("should filter repositories by name", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); if (!call) throw new Error("repo_list_repos_by_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockRepos = [ { id: "repo1", name: "frontend-app", isDisabled: false, isFork: false, isInMaintenance: false, webUrl: "url1", size: 1024 }, @@ -1172,11 +1203,11 @@ describe("repos tools", () => { describe("repo_list_pull_requests_by_repo_or_project - repository tests", () => { it("should list pull requests by repository", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPRs = [ { @@ -1210,11 +1241,11 @@ describe("repos tools", () => { }); it("should filter pull requests created by me", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1233,11 +1264,11 @@ describe("repos tools", () => { }); it("should filter pull requests where I am a reviewer", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1256,11 +1287,11 @@ describe("repos tools", () => { }); it("should filter pull requests created by me and where I am a reviewer", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1287,11 +1318,11 @@ describe("repos tools", () => { }); it("should filter pull requests created by specific user successfully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock successful user lookup mockGetUserIdFromEmail.mockResolvedValue("specific-user-123"); @@ -1312,11 +1343,11 @@ describe("repos tools", () => { }); it("should filter pull requests by source branch", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1345,11 +1376,11 @@ describe("repos tools", () => { }); it("should filter pull requests by target branch", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1378,11 +1409,11 @@ describe("repos tools", () => { }); it("should filter pull requests by both source and target branches", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1413,11 +1444,11 @@ describe("repos tools", () => { }); it("should combine branch filters with user filters", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1453,11 +1484,11 @@ describe("repos tools", () => { }); it("should filter pull requests by specific reviewer successfully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock successful user lookup mockGetUserIdFromEmail.mockResolvedValue("reviewer-user-123"); @@ -1478,11 +1509,11 @@ describe("repos tools", () => { }); it("should prioritize user_is_reviewer over i_am_reviewer flag", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock successful user lookup mockGetUserIdFromEmail.mockResolvedValue("specific-reviewer-123"); @@ -1512,11 +1543,11 @@ describe("repos tools", () => { }); it("should handle error when user_is_reviewer user not found", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock user lookup failure mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); @@ -1540,11 +1571,11 @@ describe("repos tools", () => { describe("repo_list_pull_requests_by_repo_or_project - project tests", () => { it("should list pull requests by project", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPRs = [ { @@ -1592,11 +1623,11 @@ describe("repos tools", () => { }); it("should filter by current user when created_by_me is true", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPRs = [ { @@ -1646,11 +1677,11 @@ describe("repos tools", () => { }); it("should filter by current user as reviewer when i_am_reviewer is true", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPRs = [ { @@ -1700,11 +1731,11 @@ describe("repos tools", () => { }); it("should filter by both creator and reviewer when both created_by_me and i_am_reviewer are true", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPRs = [ { @@ -1755,11 +1786,11 @@ describe("repos tools", () => { }); it("should prioritize created_by_user over created_by_me flag", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock getUserIdFromEmail to return a specific user ID mockGetUserIdFromEmail.mockResolvedValue("specific-user-123"); @@ -1814,11 +1845,11 @@ describe("repos tools", () => { }); it("should filter pull requests by source branch", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -1845,11 +1876,11 @@ describe("repos tools", () => { }); it("should filter pull requests by target branch", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -1876,11 +1907,11 @@ describe("repos tools", () => { }); it("should filter pull requests by both source and target branches", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -1909,11 +1940,11 @@ describe("repos tools", () => { }); it("should combine branch filters with user filters", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -1945,11 +1976,11 @@ describe("repos tools", () => { }); it("should filter pull requests by specific reviewer successfully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock successful user lookup mockGetUserIdFromEmail.mockResolvedValue("reviewer-user-123"); @@ -2001,11 +2032,11 @@ describe("repos tools", () => { }); it("should prioritize user_is_reviewer over i_am_reviewer flag", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock successful user lookup mockGetUserIdFromEmail.mockResolvedValue("specific-reviewer-123"); @@ -2028,11 +2059,11 @@ describe("repos tools", () => { }); it("should handle error when user_is_reviewer user not found", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock user lookup failure mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); @@ -2054,11 +2085,11 @@ describe("repos tools", () => { }); it("should support both created_by_user and user_is_reviewer filters simultaneously", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock both user lookups mockGetUserIdFromEmail @@ -2096,11 +2127,11 @@ describe("repos tools", () => { describe("repo_list_pull_request_threads", () => { it("should list pull request threads", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThreads = [ { @@ -2157,11 +2188,11 @@ describe("repos tools", () => { }); it("should return full response when requested", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThreads = [{ id: 1, fullData: "complete" }]; mockGitApi.getThreads.mockResolvedValue(mockThreads); @@ -2182,11 +2213,11 @@ describe("repos tools", () => { describe("repo_list_pull_request_thread_comments", () => { it("should list pull request thread comments", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_thread_comments); if (!call) throw new Error("repo_list_pull_request_thread_comments tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockComments = [ { @@ -2235,11 +2266,11 @@ describe("repos tools", () => { }); it("should list pull request thread comments with full response", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_thread_comments); if (!call) throw new Error("repo_list_pull_request_thread_comments tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockComments = [ { @@ -2288,11 +2319,11 @@ describe("repos tools", () => { describe("repo_list_branches_by_repo", () => { it("should list branches by repository", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_branches_by_repo); if (!call) throw new Error("repo_list_branches_by_repo tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBranches = [ { name: "refs/heads/main" }, @@ -2318,11 +2349,11 @@ describe("repos tools", () => { describe("repo_list_my_branches_by_repo", () => { it("should list my branches by repository", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_my_branches_by_repo); if (!call) throw new Error("repo_list_my_branches_by_repo tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBranches = [{ name: "refs/heads/main" }, { name: "refs/heads/my-feature" }]; mockGitApi.getRefs.mockResolvedValue(mockBranches); @@ -2343,11 +2374,11 @@ describe("repos tools", () => { describe("repo_get_repo_by_name_or_id", () => { it("should get repository by name", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_repo_by_name_or_id); if (!call) throw new Error("repo_get_repo_by_name_or_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockRepos = [ { id: "repo1", name: "test-repo" }, @@ -2367,11 +2398,11 @@ describe("repos tools", () => { }); it("should get repository by ID", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_repo_by_name_or_id); if (!call) throw new Error("repo_get_repo_by_name_or_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockRepos = [ { id: "repo1", name: "test-repo" }, @@ -2390,11 +2421,11 @@ describe("repos tools", () => { }); it("should throw error when repository not found", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_repo_by_name_or_id); if (!call) throw new Error("repo_get_repo_by_name_or_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getRepositories.mockResolvedValue([]); @@ -2409,11 +2440,11 @@ describe("repos tools", () => { describe("repo_get_branch_by_name", () => { it("should get branch by name", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); if (!call) throw new Error("repo_get_branch_by_name tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockBranches = [ { name: "refs/heads/main", objectId: "abc123" }, @@ -2433,11 +2464,11 @@ describe("repos tools", () => { }); it("should return error message when branch not found", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); if (!call) throw new Error("repo_get_branch_by_name tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getRefs.mockResolvedValue([]); @@ -2454,11 +2485,11 @@ describe("repos tools", () => { describe("repo_get_pull_request_by_id", () => { it("should get pull request by ID", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPR = { pullRequestId: 123, @@ -2480,11 +2511,11 @@ describe("repos tools", () => { }); it("should include work item refs when requested", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequest.mockResolvedValue({}); @@ -2502,11 +2533,11 @@ describe("repos tools", () => { describe("repo_reply_to_comment", () => { it("should reply to comment successfully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); if (!call) throw new Error("repo_reply_to_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockComment = { id: 789, content: "Reply content" }; mockGitApi.createComment.mockResolvedValue(mockComment); @@ -2525,11 +2556,11 @@ describe("repos tools", () => { }); it("should return full response when requested", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); if (!call) throw new Error("repo_reply_to_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockComment = { id: 789, content: "Reply content" }; mockGitApi.createComment.mockResolvedValue(mockComment); @@ -2548,11 +2579,11 @@ describe("repos tools", () => { }); it("should return error when comment creation fails", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); if (!call) throw new Error("repo_reply_to_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.createComment.mockResolvedValue(null); @@ -2572,11 +2603,11 @@ describe("repos tools", () => { describe("repo_create_pull_request_thread", () => { it("should create pull request thread with basic content", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 123, status: 1 }; mockGitApi.createThread.mockResolvedValue(mockThread); @@ -2604,11 +2635,11 @@ describe("repos tools", () => { }); it("should create pull request thread with file context and position", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 123 }; mockGitApi.createThread.mockResolvedValue(mockThread); @@ -2645,11 +2676,11 @@ describe("repos tools", () => { }); it("should normalize file path by adding leading slash if missing", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 123 }; mockGitApi.createThread.mockResolvedValue(mockThread); @@ -2680,11 +2711,11 @@ describe("repos tools", () => { }); it("should preserve file path if it already starts with slash", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 123 }; mockGitApi.createThread.mockResolvedValue(mockThread); @@ -2715,11 +2746,11 @@ describe("repos tools", () => { }); it("should throw error for invalid line numbers", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -2734,11 +2765,11 @@ describe("repos tools", () => { describe("repo_resolve_comment", () => { it("should resolve comment thread successfully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.resolve_comment); if (!call) throw new Error("repo_resolve_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 123, status: CommentThreadStatus.Fixed }; mockGitApi.updateThread.mockResolvedValue(mockThread); @@ -2756,11 +2787,11 @@ describe("repos tools", () => { }); it("should return full response when requested", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.resolve_comment); if (!call) throw new Error("repo_resolve_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 123, status: CommentThreadStatus.Fixed }; mockGitApi.updateThread.mockResolvedValue(mockThread); @@ -2778,11 +2809,11 @@ describe("repos tools", () => { }); it("should return error when thread resolution fails", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.resolve_comment); if (!call) throw new Error("repo_resolve_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.updateThread.mockResolvedValue(null); @@ -2801,11 +2832,11 @@ describe("repos tools", () => { describe("repo_search_commits", () => { it("should search commits successfully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCommits = [ { commitId: "abc123", comment: "Initial commit" }, @@ -2845,11 +2876,11 @@ describe("repos tools", () => { }); it("should handle commit search errors", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getCommits.mockRejectedValue(new Error("API Error")); @@ -2867,11 +2898,11 @@ describe("repos tools", () => { describe("repo_list_pull_requests_by_commits", () => { it("should list pull requests by commits successfully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockQueryResult = { results: [ @@ -2909,11 +2940,11 @@ describe("repos tools", () => { }); it("should handle pull request query errors", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequestQuery.mockRejectedValue(new Error("Query Error")); @@ -2932,11 +2963,11 @@ describe("repos tools", () => { describe("pullRequestStatusStringToInt function coverage", () => { it("should handle Completed status", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGetCurrentUserDetails.mockResolvedValue({ authenticatedUser: { id: "user123" }, @@ -2957,11 +2988,11 @@ describe("repos tools", () => { }); it("should handle All status", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -2978,11 +3009,11 @@ describe("repos tools", () => { }); it("should handle NotSet status", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -2999,11 +3030,11 @@ describe("repos tools", () => { }); it("should handle Abandoned status", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -3020,11 +3051,11 @@ describe("repos tools", () => { }); it("should throw error for unknown status", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -3039,11 +3070,11 @@ describe("repos tools", () => { describe("error handling coverage", () => { it("should handle getUserIdFromEmail error in list_pull_requests_by_repo", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock getUserIdFromEmail to throw an error mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); @@ -3063,11 +3094,11 @@ describe("repos tools", () => { }); it("should handle getUserIdFromEmail error in list_pull_requests_by_project", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock getUserIdFromEmail to throw an error mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); @@ -3087,11 +3118,11 @@ describe("repos tools", () => { }); it("should handle commit search error in search_commits", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getCommits.mockRejectedValue(new Error("API Error")); @@ -3107,11 +3138,11 @@ describe("repos tools", () => { }); it("should handle thread creation error", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.createThread.mockRejectedValue(new Error("Thread creation failed")); @@ -3125,11 +3156,11 @@ describe("repos tools", () => { }); it("should handle thread resolution error", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.resolve_comment); if (!call) throw new Error("repo_resolve_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.updateThread.mockRejectedValue(new Error("Thread resolution failed")); @@ -3143,11 +3174,11 @@ describe("repos tools", () => { }); it("should handle comment reply error", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); if (!call) throw new Error("repo_reply_to_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.createComment.mockRejectedValue(new Error("Comment creation failed")); @@ -3164,11 +3195,11 @@ describe("repos tools", () => { describe("edge cases and validation", () => { it("should handle invalid line numbers in create_pull_request_thread", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -3182,11 +3213,11 @@ describe("repos tools", () => { }); it("should handle create_pull_request with undefined forkSourceRepositoryId", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); if (!call) throw new Error("repo_create_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPR = { pullRequestId: 123, @@ -3250,11 +3281,11 @@ describe("repos tools", () => { }); it("should handle trimComments with undefined comments", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock threads with undefined comments to test the trimComments function const mockThreads = [ @@ -3292,11 +3323,11 @@ describe("repos tools", () => { }); it("should handle trimComments with deleted comments", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock threads with deleted comments to test the trimComments function const mockThreads = [ @@ -3340,11 +3371,11 @@ describe("repos tools", () => { }); it("should handle list_repos_by_project without repoNameFilter", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); if (!call) throw new Error("repo_list_repos_by_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockRepos = [ { id: "1", name: "repo1", isDisabled: false, isFork: false, isInMaintenance: false, webUrl: "http://example.com/repo1", size: 100 }, @@ -3369,11 +3400,11 @@ describe("repos tools", () => { }); it("should handle branches.find returning undefined (branch name mismatch)", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); if (!call) throw new Error("repo_get_branch_by_name tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock branches that don't match the requested branch name const mockBranches = [ @@ -3395,11 +3426,11 @@ describe("repos tools", () => { }); it("should handle branch.name with exact branchName match", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); if (!call) throw new Error("repo_get_branch_by_name tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock branches where one matches exactly with the branchName (second condition in the find) const mockBranches = [ @@ -3421,11 +3452,11 @@ describe("repos tools", () => { }); it("should handle list_pull_requests_by_repo with created_by_user and i_am_reviewer both false", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -3454,11 +3485,11 @@ describe("repos tools", () => { }); it("should handle list_pull_requests_by_project with created_by_user and i_am_reviewer both false", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -3486,11 +3517,11 @@ describe("repos tools", () => { }); it("should handle comments?.flatMap with null/undefined branch in branchesFilterOutIrrelevantProperties", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_branches_by_repo); if (!call) throw new Error("repo_list_branches_by_repo tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock branches with some having null/undefined names to test the flatMap filter const mockBranches = [ @@ -3515,11 +3546,11 @@ describe("repos tools", () => { }); it("should handle rightFileStartOffset without validation error", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 123, status: 1, comments: [] }; mockGitApi.createThread.mockResolvedValue(mockThread); @@ -3554,11 +3585,11 @@ describe("repos tools", () => { }); it("should handle rightFileEndOffset without validation error", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 123, status: 1, comments: [] }; mockGitApi.createThread.mockResolvedValue(mockThread); @@ -3595,11 +3626,11 @@ describe("repos tools", () => { }); it("should handle search_commits with version parameter", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCommits = [{ commitId: "abc123", comment: "Test commit" }]; mockGitApi.getCommits.mockResolvedValue(mockCommits); @@ -3638,11 +3669,11 @@ describe("repos tools", () => { }); it("should handle search_commits without version parameter", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCommits = [{ commitId: "abc123", comment: "Test commit" }]; mockGitApi.getCommits.mockResolvedValue(mockCommits); @@ -3677,11 +3708,11 @@ describe("repos tools", () => { }); it("should handle rightFileEndLine without rightFileStartLine", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -3695,11 +3726,11 @@ describe("repos tools", () => { }); it("should handle invalid rightFileEndLine value", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -3714,11 +3745,11 @@ describe("repos tools", () => { }); it("should handle invalid rightFileStartOffset value", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -3733,11 +3764,11 @@ describe("repos tools", () => { }); it("should handle invalid rightFileEndOffset value", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -3753,11 +3784,11 @@ describe("repos tools", () => { }); it("should test pullRequestStatusStringToInt with unknown status", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -3770,11 +3801,11 @@ describe("repos tools", () => { }); it("should handle threads?.sort with undefined id values", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock threads with undefined/null id values to test the sort function const mockThreads = [ @@ -3817,11 +3848,11 @@ describe("repos tools", () => { }); it("should handle comments?.sort with undefined id values", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_thread_comments); if (!call) throw new Error("repo_list_pull_request_thread_comments tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock comments with undefined/null id values to test the sort function const mockComments = [ @@ -3862,11 +3893,11 @@ describe("repos tools", () => { }); it("should handle workItemRefs when workItems is undefined", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); if (!call) throw new Error("repo_create_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; mockGitApi.createPullRequest.mockResolvedValue(mockPR); @@ -3890,11 +3921,11 @@ describe("repos tools", () => { }); it("should handle workItemRefs when workItems is provided", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); if (!call) throw new Error("repo_create_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; mockGitApi.createPullRequest.mockResolvedValue(mockPR); @@ -3918,11 +3949,11 @@ describe("repos tools", () => { }); it("should handle empty repoNameFilter in list_repos_by_project", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); if (!call) throw new Error("repo_list_repos_by_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockRepos = [{ id: "repo1", name: "Repository 1", isDisabled: false, isFork: false, isInMaintenance: false, webUrl: "url1", size: 1024 }]; mockGitApi.getRepositories.mockResolvedValue(mockRepos); @@ -3943,11 +3974,11 @@ describe("repos tools", () => { }); it("should handle getUserIdFromEmail error with created_by_user in list_pull_requests_by_repo", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); @@ -3966,11 +3997,11 @@ describe("repos tools", () => { }); it("should handle getUserIdFromEmail error with created_by_user in list_pull_requests_by_project", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); @@ -3989,11 +4020,11 @@ describe("repos tools", () => { }); it("should handle rightFileEndOffset set without rightFileEndLine in create_pull_request_thread", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 1, status: CommentThreadStatus.Active }; mockGitApi.createThread.mockResolvedValue(mockThread); @@ -4015,11 +4046,11 @@ describe("repos tools", () => { }); it("should handle error in list_pull_requests_by_commits", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequestQuery.mockRejectedValue(new Error("API error")); @@ -4037,11 +4068,11 @@ describe("repos tools", () => { }); it("should handle different queryType values in list_pull_requests_by_commits", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockQueryResult = { results: [] }; mockGitApi.getPullRequestQuery.mockResolvedValue(mockQueryResult); @@ -4071,11 +4102,11 @@ describe("repos tools", () => { }); it("should handle repositories with null/undefined names in sorting", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); if (!call) throw new Error("repo_list_repos_by_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockRepos = [ { id: "repo1", name: undefined, isDisabled: false, isFork: false, isInMaintenance: false, webUrl: "url1", size: 1024 }, @@ -4098,11 +4129,11 @@ describe("repos tools", () => { }); it("should handle non-Error exceptions in list_pull_requests_by_repo", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue("String error"); // Non-Error exception @@ -4121,11 +4152,11 @@ describe("repos tools", () => { }); it("should handle non-Error exceptions in list_pull_requests_by_project", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue("String error"); // Non-Error exception @@ -4144,11 +4175,11 @@ describe("repos tools", () => { }); it("should handle non-Error exceptions in list_pull_requests_by_commits", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getPullRequestQuery.mockRejectedValue("String error"); // Non-Error exception @@ -4166,11 +4197,11 @@ describe("repos tools", () => { }); it("should handle invalid rightFileEndOffset with rightFileEndLine in create_pull_request_thread", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { repositoryId: "repo123", @@ -4186,11 +4217,11 @@ describe("repos tools", () => { }); it("should handle non-Error exceptions in search_commits", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockGitApi.getCommits.mockRejectedValue("String error"); // Non-Error exception @@ -4208,11 +4239,11 @@ describe("repos tools", () => { }); it("should handle valid rightFileEndOffset with rightFileEndLine in create_pull_request_thread", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockThread = { id: 1, status: CommentThreadStatus.Active }; mockGitApi.createThread.mockResolvedValue(mockThread); diff --git a/test/src/tools/test-plan.test.ts b/test/src/tools/test-plan.test.ts index 9d5dd4a8..8ebec9c1 100644 --- a/test/src/tools/test-plan.test.ts +++ b/test/src/tools/test-plan.test.ts @@ -25,6 +25,7 @@ describe("configureTestPlanTools", () => { let mockTestResultsApi: ITestResultsApi; let mockWitApi: IWorkItemTrackingApi; let mockTestApi: ITestApi; + let isReadOnlyMode: boolean; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -54,11 +55,12 @@ describe("configureTestPlanTools", () => { serverUrl: "https://dev.azure.com/testorg", }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); + isReadOnlyMode = false; }); describe("tool registration", () => { it("registers test plan tools on the server", () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); expect((server.tool as jest.Mock).mock.calls.map((call) => call[0])).toEqual( expect.arrayContaining([ "testplan_list_test_plans", @@ -72,14 +74,36 @@ describe("configureTestPlanTools", () => { ]) ); }); + + describe("read-only mode", () => { + it("removes write tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: false } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); + + expect(mockTool.remove).toHaveBeenCalled(); + }); + + it("keeps read-only tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: true } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); + + expect(mockTool.remove).not.toHaveBeenCalled(); + }); + }); }); describe("list_test_plans tool", () => { it("should call getTestPlans with the correct parameters and return the expected result", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_plans"); if (!call) throw new Error("testplan_list_test_plans tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestPlanApi.getTestPlans as jest.Mock).mockResolvedValue([{ id: 1, name: "Test Plan 1" }]); const params = { @@ -97,10 +121,10 @@ describe("configureTestPlanTools", () => { describe("create_test_plan tool", () => { it("should call createTestPlan with the correct parameters and return the expected result", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_plan"); if (!call) throw new Error("testplan_create_test_plan tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestPlanApi.createTestPlan as jest.Mock).mockResolvedValue({ id: 1, name: "New Test Plan" }); const params = { @@ -131,10 +155,10 @@ describe("configureTestPlanTools", () => { describe("create_test_suite tool", () => { it("should call createTestSuite with the correct parameters and return the expected result", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_suite"); if (!call) throw new Error("testplan_create_test_suite tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestPlanApi.createTestSuite as jest.Mock).mockResolvedValue({ id: 10, name: "New Test Suite" }); const params = { @@ -161,10 +185,10 @@ describe("configureTestPlanTools", () => { }); it("should handle API errors when creating test suite", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_suite"); if (!call) throw new Error("testplan_create_test_suite tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestPlanApi.createTestSuite as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -179,10 +203,10 @@ describe("configureTestPlanTools", () => { }); it("should create test suite with different parent suite IDs", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_suite"); if (!call) throw new Error("testplan_create_test_suite tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestPlanApi.createTestSuite as jest.Mock).mockResolvedValue({ id: 15, @@ -223,10 +247,10 @@ describe("configureTestPlanTools", () => { }); it("should handle empty or null response from createTestSuite", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_suite"); if (!call) throw new Error("testplan_create_test_suite tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestPlanApi.createTestSuite as jest.Mock).mockResolvedValue(null); const params = { @@ -243,10 +267,10 @@ describe("configureTestPlanTools", () => { describe("list_test_cases tool", () => { it("should call getTestCaseList with the correct parameters and return the expected result", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_cases"); if (!call) throw new Error("testplan_list_test_cases tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestPlanApi.getTestCaseList as jest.Mock).mockResolvedValue([{ id: 1, name: "Test Case 1" }]); const params = { @@ -263,10 +287,10 @@ describe("configureTestPlanTools", () => { describe("test_results_from_build_id tool", () => { it("should call getTestResultDetailsForBuild with the correct parameters and return the expected result", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_show_test_results_from_build_id"); if (!call) throw new Error("testplan_show_test_results_from_build_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestResultsApi.getTestResultDetailsForBuild as jest.Mock).mockResolvedValue({ results: ["Result 1"] }); const params = { @@ -282,10 +306,10 @@ describe("configureTestPlanTools", () => { describe("create_test_case tool", () => { it("should create test case with proper parameters", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1001, @@ -319,10 +343,10 @@ describe("configureTestPlanTools", () => { }); it("should create test case & expected result with proper parameters", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1001, @@ -356,10 +380,10 @@ describe("configureTestPlanTools", () => { }); it("should handle multiple steps in test case", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1002, @@ -390,10 +414,10 @@ describe("configureTestPlanTools", () => { }); it("should handle API errors in test case creation", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -407,10 +431,10 @@ describe("configureTestPlanTools", () => { }); it("should create test case with all optional parameters", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1004, @@ -474,10 +498,10 @@ describe("configureTestPlanTools", () => { }); it("should handle non-numbered step formats", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1005, @@ -531,10 +555,10 @@ describe("configureTestPlanTools", () => { }); it("should handle empty lines in steps", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1006, @@ -566,10 +590,10 @@ describe("configureTestPlanTools", () => { }); it("should create test case without steps", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1007, @@ -611,10 +635,10 @@ describe("configureTestPlanTools", () => { }); it("should handle edge case XML characters", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1008, @@ -656,10 +680,10 @@ describe("configureTestPlanTools", () => { }); it("should handle empty string steps", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1009, @@ -690,10 +714,10 @@ describe("configureTestPlanTools", () => { }); it("should handle only whitespace steps", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1010, @@ -724,10 +748,10 @@ describe("configureTestPlanTools", () => { }); it("should handle steps with pipe delimiter for expected results", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1011, @@ -774,10 +798,10 @@ describe("configureTestPlanTools", () => { }); it("should handle steps without pipe delimiter using default expected result", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1012, @@ -816,10 +840,10 @@ describe("configureTestPlanTools", () => { }); it("should handle mixed steps with and without pipe delimiter", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1013, @@ -870,10 +894,10 @@ describe("configureTestPlanTools", () => { }); it("should handle empty expected result after pipe delimiter", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1014, @@ -912,10 +936,10 @@ describe("configureTestPlanTools", () => { }); it("should handle multiple pipe characters in expected result", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1015, @@ -954,10 +978,10 @@ describe("configureTestPlanTools", () => { }); it("should handle whitespace around pipe delimiter", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1016, @@ -1008,10 +1032,10 @@ describe("configureTestPlanTools", () => { }); it("should handle special characters in expected results", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1017, @@ -1058,10 +1082,10 @@ describe("configureTestPlanTools", () => { }); it("should handle non-numbered steps with pipe delimiter", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1018, @@ -1112,10 +1136,10 @@ describe("configureTestPlanTools", () => { }); it("should create test case with testsWorkItemId relationship", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 2001, @@ -1178,10 +1202,10 @@ describe("configureTestPlanTools", () => { }); it("should create test case without testsWorkItemId when not provided", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 2002, @@ -1228,10 +1252,10 @@ describe("configureTestPlanTools", () => { }); it("should create test case with testsWorkItemId and all other optional parameters", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); if (!call) throw new Error("testplan_create_test_case tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 2003, @@ -1297,10 +1321,10 @@ describe("configureTestPlanTools", () => { describe("update_test_case_steps tool", () => { it("should update test case steps with proper parameters", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 136717, @@ -1335,10 +1359,10 @@ describe("configureTestPlanTools", () => { }); it("should handle steps with pipe delimiter for expected results", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 136718, @@ -1380,10 +1404,10 @@ describe("configureTestPlanTools", () => { }); it("should handle steps without pipe delimiter using default expected result", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 136719, @@ -1425,10 +1449,10 @@ describe("configureTestPlanTools", () => { }); it("should handle XML special characters in steps", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 136720, @@ -1462,10 +1486,10 @@ describe("configureTestPlanTools", () => { }); it("should handle empty or whitespace-only steps", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 136721, @@ -1499,10 +1523,10 @@ describe("configureTestPlanTools", () => { }); it("should handle API errors when updating test case steps", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -1515,10 +1539,10 @@ describe("configureTestPlanTools", () => { }); it("should handle mixed numbered and non-numbered steps", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 136723, @@ -1564,10 +1588,10 @@ describe("configureTestPlanTools", () => { }); it("should handle multiple pipe characters in expected results", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 136724, @@ -1601,10 +1625,10 @@ describe("configureTestPlanTools", () => { }); it("should handle empty expected results after pipe delimiter", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 136725, @@ -1644,10 +1668,10 @@ describe("configureTestPlanTools", () => { describe("add_test_cases_to_suite tool", () => { it("should add test cases to suite with array of IDs", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_add_test_cases_to_suite"); if (!call) throw new Error("testplan_add_test_cases_to_suite tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestApi.addTestCasesToSuite as jest.Mock).mockResolvedValue([{ testCase: { id: 1001 } }, { testCase: { id: 1002 } }]); @@ -1664,10 +1688,10 @@ describe("configureTestPlanTools", () => { }); it("should add test cases to suite with comma-separated string", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_add_test_cases_to_suite"); if (!call) throw new Error("testplan_add_test_cases_to_suite tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestApi.addTestCasesToSuite as jest.Mock).mockResolvedValue([{ testCase: { id: 1003 } }, { testCase: { id: 1004 } }]); @@ -1684,10 +1708,10 @@ describe("configureTestPlanTools", () => { }); it("should handle empty results when adding test cases", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_add_test_cases_to_suite"); if (!call) throw new Error("testplan_add_test_cases_to_suite tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestApi.addTestCasesToSuite as jest.Mock).mockResolvedValue([]); @@ -1703,10 +1727,10 @@ describe("configureTestPlanTools", () => { }); it("should handle API errors when adding test cases to suite", async () => { - configureTestPlanTools(server, tokenProvider, connectionProvider); + configureTestPlanTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_add_test_cases_to_suite"); if (!call) throw new Error("testplan_add_test_cases_to_suite tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockTestApi.addTestCasesToSuite as jest.Mock).mockRejectedValue(new Error("API Error")); diff --git a/test/src/tools/wiki.test.ts b/test/src/tools/wiki.test.ts index 18231ff0..5d4d4400 100644 --- a/test/src/tools/wiki.test.ts +++ b/test/src/tools/wiki.test.ts @@ -23,6 +23,7 @@ describe("configureWikiTools", () => { serverUrl: string; }; let mockWikiApi: WikiApiMock; + let isReadOnlyMode: boolean; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -39,21 +40,44 @@ describe("configureWikiTools", () => { serverUrl: "https://dev.azure.com/testorg", }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); + isReadOnlyMode = false; }); describe("tool registration", () => { it("registers wiki tools on the server", () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); expect(server.tool as jest.Mock).toHaveBeenCalled(); }); + + describe("read-only mode", () => { + it("removes write tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: false } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).toHaveBeenCalled(); + }); + + it("keeps read-only tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: true } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).not.toHaveBeenCalled(); + }); + }); }); describe("get_wiki tool", () => { it("should call getWiki with the correct parameters and return the expected result", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_wiki"); if (!call) throw new Error("wiki_get_wiki tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWiki = { id: "wiki1", name: "Test Wiki" }; mockWikiApi.getWiki.mockResolvedValue(mockWiki); @@ -71,10 +95,10 @@ describe("configureWikiTools", () => { }); it("should handle API errors correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_wiki"); if (!call) throw new Error("wiki_get_wiki tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Wiki not found"); mockWikiApi.getWiki.mockRejectedValue(testError); @@ -92,10 +116,10 @@ describe("configureWikiTools", () => { }); it("should handle null API results correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_wiki"); if (!call) throw new Error("wiki_get_wiki tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getWiki.mockResolvedValue(null); @@ -112,10 +136,10 @@ describe("configureWikiTools", () => { }); it("should handle unknown error type correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_wiki"); if (!call) throw new Error("wiki_get_wiki tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getWiki.mockRejectedValue("string error"); @@ -134,10 +158,10 @@ describe("configureWikiTools", () => { describe("list_wikis tool", () => { it("should call getAllWikis with the correct parameters and return the expected result", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_wikis"); if (!call) throw new Error("wiki_list_wikis tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWikis = [ { id: "wiki1", name: "Wiki 1" }, @@ -157,10 +181,10 @@ describe("configureWikiTools", () => { }); it("should handle API errors correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_wikis"); if (!call) throw new Error("wiki_list_wikis tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Failed to fetch wikis"); mockWikiApi.getAllWikis.mockRejectedValue(testError); @@ -177,10 +201,10 @@ describe("configureWikiTools", () => { }); it("should handle null API results correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_wikis"); if (!call) throw new Error("wiki_list_wikis tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getAllWikis.mockResolvedValue(null); @@ -196,10 +220,10 @@ describe("configureWikiTools", () => { }); it("should handle unknown error type correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_wikis"); if (!call) throw new Error("wiki_list_wikis tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getAllWikis.mockRejectedValue("string error"); @@ -217,10 +241,10 @@ describe("configureWikiTools", () => { describe("list_wiki_pages tool", () => { it("should call getPagesBatch with the correct parameters and return the expected result", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_pages"); if (!call) throw new Error("wiki_list_pages tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getPagesBatch.mockResolvedValue({ value: ["page1", "page2"] }); const params = { @@ -247,10 +271,10 @@ describe("configureWikiTools", () => { }); it("should use default top parameter when not provided", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_pages"); if (!call) throw new Error("wiki_list_pages tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getPagesBatch.mockResolvedValue({ value: ["page1", "page2"] }); const params = { @@ -272,10 +296,10 @@ describe("configureWikiTools", () => { }); it("should handle API errors correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_pages"); if (!call) throw new Error("wiki_list_pages tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Failed to fetch wiki pages"); mockWikiApi.getPagesBatch.mockRejectedValue(testError); @@ -294,10 +318,10 @@ describe("configureWikiTools", () => { }); it("should handle null API results correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_pages"); if (!call) throw new Error("wiki_list_pages tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getPagesBatch.mockResolvedValue(null); @@ -315,10 +339,10 @@ describe("configureWikiTools", () => { }); it("should handle unknown error type correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_list_pages"); if (!call) throw new Error("wiki_list_pages tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getPagesBatch.mockRejectedValue("string error"); @@ -346,10 +370,10 @@ describe("configureWikiTools", () => { }); it("should fetch page metadata with correct parameters", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); if (!call) throw new Error("wiki_get_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPageData = { id: 123, @@ -385,10 +409,10 @@ describe("configureWikiTools", () => { }); it("should handle path without leading slash", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); if (!call) throw new Error("wiki_get_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockPageData = { id: 456, path: "/Documentation" }; @@ -410,10 +434,10 @@ describe("configureWikiTools", () => { }); it("should include optional parameters when provided", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); if (!call) throw new Error("wiki_get_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockFetch.mockResolvedValue({ ok: true, @@ -434,10 +458,10 @@ describe("configureWikiTools", () => { }); it("should handle API errors", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); if (!call) throw new Error("wiki_get_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockFetch.mockResolvedValue({ ok: false, @@ -459,10 +483,10 @@ describe("configureWikiTools", () => { }); it("should handle fetch errors", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); if (!call) throw new Error("wiki_get_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockFetch.mockRejectedValue(new Error("Network error")); @@ -481,10 +505,10 @@ describe("configureWikiTools", () => { describe("get_page_content tool", () => { it("should call getPageText with the correct parameters and return the expected result", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock a stream-like object for getPageText const mockStream = { @@ -515,10 +539,10 @@ describe("configureWikiTools", () => { }); it("should handle API errors correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Page not found"); mockWikiApi.getPageText.mockRejectedValue(testError); @@ -537,10 +561,10 @@ describe("configureWikiTools", () => { }); it("should handle null API results correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getPageText.mockResolvedValue(null); @@ -558,10 +582,10 @@ describe("configureWikiTools", () => { }); it("should handle stream errors correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock a stream that emits an error const mockStream = { @@ -589,10 +613,10 @@ describe("configureWikiTools", () => { }); it("should handle unknown error type correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWikiApi.getPageText.mockRejectedValue("string error"); @@ -610,10 +634,10 @@ describe("configureWikiTools", () => { }); it("should retrieve content via URL with pagePath", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockStream = { setEncoding: jest.fn(), @@ -633,10 +657,10 @@ describe("configureWikiTools", () => { }); it("should retrieve content via URL with pageId (may fallback to root path)", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Ensure token is returned (tokenProvider as jest.Mock).mockResolvedValueOnce("abc"); const mockStream = { @@ -667,10 +691,10 @@ describe("configureWikiTools", () => { }); it("should fallback to getPageText when REST call lacks content but returns path (root path fallback)", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValueOnce("abc"); const mockFetch = jest.fn(); @@ -699,30 +723,30 @@ describe("configureWikiTools", () => { }); it("should error when both url and wikiIdentifier provided", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const result = await handler({ url: "https://dev.azure.com/org/project/_wiki/wikis/wiki1?pagePath=%2FHome", wikiIdentifier: "wiki1", project: "project" }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Provide either 'url' OR 'wikiIdentifier'"); }); it("should error when neither url nor identifiers provided", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const result = await handler({ path: "/Home" }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("You must provide either 'url' OR both 'wikiIdentifier' and 'project'"); }); it("should error on malformed wiki URL", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const result = await handler({ url: "https://dev.azure.com/org/project/notwiki/wikis/wiki1?pagePath=%2FHome" }); expect(result.isError).toBe(true); @@ -730,10 +754,10 @@ describe("configureWikiTools", () => { }); it("should handle invalid URL format", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const result = await handler({ url: "not-a-valid-url" }); expect(result.isError).toBe(true); @@ -741,10 +765,10 @@ describe("configureWikiTools", () => { }); it("should handle URL with pageId that returns 404", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValueOnce({ token: "abc", expiresOnTimestamp: Date.now() + 10000 }); @@ -763,10 +787,10 @@ describe("configureWikiTools", () => { }); it("should handle URL that resolves but project/wiki end up undefined", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const url = "https://dev.azure.com/org//_wiki/wikis/?pagePath=%2FHome"; const result = await handler({ url }); @@ -776,10 +800,10 @@ describe("configureWikiTools", () => { }); it("should handle URL with non-numeric pageId", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockStream = { setEncoding: jest.fn(), @@ -799,10 +823,10 @@ describe("configureWikiTools", () => { }); it("should use default root path when resolvedPath is undefined", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockStream = { setEncoding: jest.fn(), @@ -822,10 +846,10 @@ describe("configureWikiTools", () => { }); it("should handle scenario where resolvedProject/Wiki become null after URL processing", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (tokenProvider as jest.Mock).mockResolvedValueOnce({ token: "abc", expiresOnTimestamp: Date.now() + 10000 }); @@ -868,10 +892,10 @@ describe("configureWikiTools", () => { }); it("should create a new wiki page successfully", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResponse = { path: "/Home", @@ -913,10 +937,10 @@ describe("configureWikiTools", () => { }); it("should update an existing wiki page with ETag", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreateResponse = { ok: false, @@ -959,10 +983,10 @@ describe("configureWikiTools", () => { }); it("should handle API errors correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockFetch.mockResolvedValueOnce({ ok: false, @@ -984,10 +1008,10 @@ describe("configureWikiTools", () => { }); it("should handle fetch errors correctly", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockFetch.mockRejectedValue(new Error("Network error")); @@ -1005,10 +1029,10 @@ describe("configureWikiTools", () => { }); it("should get ETag from response body when not in headers", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreateResponse = { ok: false, @@ -1054,10 +1078,10 @@ describe("configureWikiTools", () => { }); it("should handle when ETag is found directly in headers (case-sensitive)", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreateResponse = { ok: false, @@ -1104,10 +1128,10 @@ describe("configureWikiTools", () => { }); it("should handle missing ETag error when not in headers or body", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreateResponse = { ok: false, @@ -1145,10 +1169,10 @@ describe("configureWikiTools", () => { }); it("should update existing page when ETag is provided as parameter", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreateResponse = { ok: false, @@ -1195,10 +1219,10 @@ describe("configureWikiTools", () => { }); it("should handle missing ETag error when neither headers nor body contain ETag", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreateResponse = { ok: false, @@ -1233,10 +1257,10 @@ describe("configureWikiTools", () => { }); it("should handle update failure after getting ETag", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreateResponse = { ok: false, @@ -1275,10 +1299,10 @@ describe("configureWikiTools", () => { }); it("should handle non-Error exceptions", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Throw a non-Error object mockFetch.mockRejectedValue("String error message"); @@ -1297,10 +1321,10 @@ describe("configureWikiTools", () => { }); it("should handle path without leading slash", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResponse = { ok: true, @@ -1330,10 +1354,10 @@ describe("configureWikiTools", () => { }); it("should handle missing project parameter", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResponse = { ok: true, @@ -1363,10 +1387,10 @@ describe("configureWikiTools", () => { }); it("should handle failed GET request for ETag", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockCreateResponse = { ok: false, @@ -1396,10 +1420,10 @@ describe("configureWikiTools", () => { }); it("should use custom branch when specified", async () => { - configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_create_or_update_page"); if (!call) throw new Error("wiki_create_or_update_page tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockResponse = { path: "/Home", diff --git a/test/src/tools/work-items.test.ts b/test/src/tools/work-items.test.ts index ddf4c425..3bd6016d 100644 --- a/test/src/tools/work-items.test.ts +++ b/test/src/tools/work-items.test.ts @@ -52,6 +52,7 @@ describe("configureWorkItemTools", () => { let mockConnection: MockConnection; let mockWorkApi: WorkApiMock; let mockWorkItemTrackingApi: WorkItemTrackingApiMock; + let isReadOnlyMode: boolean; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -85,22 +86,46 @@ describe("configureWorkItemTools", () => { connectionProvider = jest.fn().mockResolvedValue(mockConnection); userAgentProvider = () => "Jest"; + + isReadOnlyMode = false; }); describe("tool registration", () => { it("registers core tools on the server", () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); expect(server.tool as jest.Mock).toHaveBeenCalled(); }); + + describe("read-only mode", () => { + it("removes write tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: false } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).toHaveBeenCalled(); + }); + + it("keeps read-only tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: true } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); + + expect(mockTool.remove).not.toHaveBeenCalled(); + }); + }); }); describe("list_backlogs tool", () => { it("should call getBacklogs API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs"); if (!call) throw new Error("wit_list_backlogs tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.getBacklogs as jest.Mock).mockResolvedValue([_mockBacklogs]); @@ -122,11 +147,11 @@ describe("configureWorkItemTools", () => { describe("list_backlog_work_items tool", () => { it("should call getBacklogLevelWorkItems API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items"); if (!call) throw new Error("wit_list_backlog_work_items tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.getBacklogLevelWorkItems as jest.Mock).mockResolvedValue([ { @@ -190,11 +215,11 @@ describe("configureWorkItemTools", () => { describe("my_work_items tool", () => { it("should call getPredefinedQueryResults API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_my_work_items"); if (!call) throw new Error("wit_my_work_items tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.getPredefinedQueryResults as jest.Mock).mockResolvedValue([ { @@ -265,12 +290,12 @@ describe("configureWorkItemTools", () => { describe("getWorkItemsBatch tool", () => { it("should call workItemApi.getWorkItemsBatch API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockResolvedValue([_mockWorkItems]); @@ -293,12 +318,12 @@ describe("configureWorkItemTools", () => { }); it("should call workItemApi.getWorkItemsBatch API with custom fields when fields parameter is provided", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemsWithCustomFields = [ { @@ -332,12 +357,12 @@ describe("configureWorkItemTools", () => { }); it("should use default fields when an empty fields array is provided", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockResolvedValue([_mockWorkItems]); @@ -361,12 +386,12 @@ describe("configureWorkItemTools", () => { }); it("should transform System.AssignedTo object to formatted string", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock work items with System.AssignedTo as objects const mockWorkItemsWithAssignedTo = [ @@ -415,12 +440,12 @@ describe("configureWorkItemTools", () => { }); it("should handle System.AssignedTo with only displayName", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemsWithPartialAssignedTo = [ { @@ -451,12 +476,12 @@ describe("configureWorkItemTools", () => { }); it("should handle System.AssignedTo with only uniqueName", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemsWithPartialAssignedTo = [ { @@ -487,12 +512,12 @@ describe("configureWorkItemTools", () => { }); it("should not transform System.AssignedTo if it's not an object", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemsWithStringAssignedTo = [ { @@ -520,12 +545,12 @@ describe("configureWorkItemTools", () => { }); it("should handle work items without System.AssignedTo field", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemsWithoutAssignedTo = [ { @@ -552,12 +577,12 @@ describe("configureWorkItemTools", () => { }); it("should handle null or undefined workitems response", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockResolvedValue(null); @@ -572,12 +597,12 @@ describe("configureWorkItemTools", () => { }); it("should transform all user fields to formatted strings", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock work items with all user fields as objects const mockWorkItemsWithUserFields = [ @@ -651,12 +676,12 @@ describe("configureWorkItemTools", () => { describe("get_work_item tool", () => { it("should call workItemApi.getWorkItem API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); if (!call) throw new Error("wit_get_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -678,12 +703,12 @@ describe("configureWorkItemTools", () => { describe("list_work_item_comments tool", () => { it("should call workItemApi.getComments API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_comments"); if (!call) throw new Error("wit_list_work_item_comments tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.getComments as jest.Mock).mockResolvedValue([_mockWorkItemComments]); @@ -703,12 +728,12 @@ describe("configureWorkItemTools", () => { describe("add_work_item_comment tool", () => { it("should call Add Work Item Comments API with the correct parameters and return the expected result with no format specified", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); if (!call) throw new Error("wit_add_work_item_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -743,12 +768,12 @@ describe("configureWorkItemTools", () => { }); it("should call Add Work Item Comments API with the correct parameters and return the expected result with markdown format", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); if (!call) throw new Error("wit_add_work_item_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -784,12 +809,12 @@ describe("configureWorkItemTools", () => { }); it("should handle fetch failure response", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); if (!call) throw new Error("wit_add_work_item_comment tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -813,12 +838,12 @@ describe("configureWorkItemTools", () => { describe("link_work_item_to_pull_request tool", () => { it("should call workItemApi.updateWorkItem API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -864,12 +889,12 @@ describe("configureWorkItemTools", () => { }); it("should handle errors from updateWorkItem and return a descriptive error", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("API failure")); const params = { @@ -885,11 +910,11 @@ describe("configureWorkItemTools", () => { }); it("should encode special characters in projectId and repositoryId for vstfsUrl", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); const params = { @@ -918,11 +943,11 @@ describe("configureWorkItemTools", () => { }); it("should use pullRequestProjectId instead of projectId when provided", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); const params = { @@ -957,11 +982,11 @@ describe("configureWorkItemTools", () => { }); it("should fall back to projectId when pullRequestProjectId is empty", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); // Testing with empty string for pullRequestProjectId @@ -996,11 +1021,11 @@ describe("configureWorkItemTools", () => { }); it("should handle link_work_item_to_pull_request unknown error type", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Simulate an unknown error type (not an Error instance) (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockRejectedValue("String error"); @@ -1022,12 +1047,12 @@ describe("configureWorkItemTools", () => { describe("get_work_items_for_iteration tool", () => { it("should call workApi.getIterationWorkItems API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_for_iteration"); if (!call) throw new Error("wit_get_work_items_for_iterationt tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.getIterationWorkItems as jest.Mock).mockResolvedValue([_mockWorkItemsForIteration]); @@ -1053,12 +1078,12 @@ describe("configureWorkItemTools", () => { describe("update_work_item tool", () => { it("should call workItemApi.updateWorkItem API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item"); if (!call) throw new Error("wit_update_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -1092,12 +1117,12 @@ describe("configureWorkItemTools", () => { describe("get_work_item_type tool", () => { it("should call workItemApi.getWorkItemType API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_type"); if (!call) throw new Error("wit_get_work_item_type tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.getWorkItemType as jest.Mock).mockResolvedValue([_mockWorkItemType]); @@ -1116,12 +1141,12 @@ describe("configureWorkItemTools", () => { describe("create_work_item tool", () => { it("should call workItemApi.createWorkItem API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); if (!call) throw new Error("wit_create_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(_mockWorkItem); @@ -1149,12 +1174,12 @@ describe("configureWorkItemTools", () => { }); it("should handle Markdown format for long fields", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); if (!call) throw new Error("wit_create_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(_mockWorkItem); @@ -1183,12 +1208,12 @@ describe("configureWorkItemTools", () => { }); it("should handle null response from createWorkItem", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); if (!call) throw new Error("wit_create_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(null); @@ -1205,12 +1230,12 @@ describe("configureWorkItemTools", () => { }); it("should handle errors from createWorkItem", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); if (!call) throw new Error("wit_create_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockRejectedValue(new Error("API failure")); @@ -1227,12 +1252,12 @@ describe("configureWorkItemTools", () => { }); it("should handle unknown error types", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); if (!call) throw new Error("wit_create_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockRejectedValue("String error"); @@ -1251,12 +1276,12 @@ describe("configureWorkItemTools", () => { describe("get_query tool", () => { it("should call workItemApi.getQuery API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query"); if (!call) throw new Error("wit_get_query tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.getQuery as jest.Mock).mockResolvedValue([_mockQuery]); @@ -1279,12 +1304,12 @@ describe("configureWorkItemTools", () => { describe("get_query_results_by_id tool", () => { it("should call workItemApi.getQueryById API with the correct parameters and return the expected result", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query_results_by_id"); if (!call) throw new Error("wit_get_query_results_by_id tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.queryById as jest.Mock).mockResolvedValue([_mockQueryResults]); @@ -1306,11 +1331,11 @@ describe("configureWorkItemTools", () => { describe("getLinkTypeFromName function coverage", () => { it("should handle all link types through work_items_link tool", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); if (!call) throw new Error("wit_work_items_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock the connection and serverUrl mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1347,11 +1372,11 @@ describe("configureWorkItemTools", () => { }); it("should throw error for unknown link type", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); if (!call) throw new Error("wit_work_items_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1373,11 +1398,11 @@ describe("configureWorkItemTools", () => { describe("update_work_items_batch tool", () => { it("should update work items in batch successfully", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_items_batch"); if (!call) throw new Error("wit_update_work_items_batch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -1438,11 +1463,11 @@ describe("configureWorkItemTools", () => { }); it("should handle Markdown format for large text fields", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_items_batch"); if (!call) throw new Error("wit_update_work_items_batch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -1508,11 +1533,11 @@ describe("configureWorkItemTools", () => { }); it("should handle batch update failure", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_items_batch"); if (!call) throw new Error("wit_update_work_items_batch tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -1539,11 +1564,11 @@ describe("configureWorkItemTools", () => { describe("work_items_link tool", () => { it("should link work items successfully", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); if (!call) throw new Error("wit_work_items_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -1582,11 +1607,11 @@ describe("configureWorkItemTools", () => { }); it("should handle linking failure", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); if (!call) throw new Error("wit_work_items_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -1614,11 +1639,11 @@ describe("configureWorkItemTools", () => { describe("work_item_unlink tool", () => { it("should unlink work items successfully", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock work item with relations const mockWorkItemWithRelations = { @@ -1670,11 +1695,11 @@ describe("configureWorkItemTools", () => { }); it("should unlink all links of a specific type when no URL is provided", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock work item with multiple related links const mockWorkItemWithRelations = { @@ -1737,11 +1762,11 @@ describe("configureWorkItemTools", () => { }); it("should handle artifact link removal", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemWithRelations = { id: 1, @@ -1790,11 +1815,11 @@ describe("configureWorkItemTools", () => { }); it("should handle when no matching relations are found", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemWithRelations = { id: 1, @@ -1824,11 +1849,11 @@ describe("configureWorkItemTools", () => { }); it("should handle updateWorkItem API failure", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemWithRelations = { id: 1, @@ -1858,11 +1883,11 @@ describe("configureWorkItemTools", () => { }); it("should handle getWorkItem API failure", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockRejectedValue(new Error("Work item not found")); @@ -1879,11 +1904,11 @@ describe("configureWorkItemTools", () => { }); it("should handle work items with no relations", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemWithNoRelations = { id: 1, @@ -1905,11 +1930,11 @@ describe("configureWorkItemTools", () => { }); it("should handle specific URL matching correctly", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItemWithRelations = { id: 1, @@ -1957,11 +1982,11 @@ describe("configureWorkItemTools", () => { }); it("should throw error for unknown link type in work_item_unlink", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock a work item with some relations (this won't matter since we'll hit the error before processing them) const mockWorkItemWithRelations = { @@ -1984,11 +2009,11 @@ describe("configureWorkItemTools", () => { }); it("should handle unknown error types in work_item_unlink", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); if (!call) throw new Error("wit_work_item_unlink tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Simulate an unknown error type (not an Error instance) (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockRejectedValue("String error"); @@ -2009,11 +2034,11 @@ describe("configureWorkItemTools", () => { // Add error handling tests for existing tools describe("error handling coverage", () => { it("should handle create_work_item errors", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); if (!call) throw new Error("wit_create_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -2033,11 +2058,11 @@ describe("configureWorkItemTools", () => { }); it("should handle create_work_item null response", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); if (!call) throw new Error("wit_create_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(null); @@ -2054,11 +2079,11 @@ describe("configureWorkItemTools", () => { }); it("should handle link_work_item_to_pull_request errors", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("Linking failed")); @@ -2077,11 +2102,11 @@ describe("configureWorkItemTools", () => { }); it("should handle link_work_item_to_pull_request null response", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue(null); @@ -2100,11 +2125,11 @@ describe("configureWorkItemTools", () => { }); it("should handle create_work_item unknown error type", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); if (!call) throw new Error("wit_create_work_item tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Simulate an unknown error type (not an Error instance) (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockRejectedValue({ message: "Complex error object" }); @@ -2122,11 +2147,11 @@ describe("configureWorkItemTools", () => { }); it("should handle work_items_link with empty comment", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); if (!call) throw new Error("wit_work_items_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -2158,11 +2183,11 @@ describe("configureWorkItemTools", () => { // Add tests for optional parameters and edge cases describe("optional parameters coverage", () => { it("should handle add_child_work_item with optional parameters", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); if (!call) throw new Error("wit_add_child_work_items tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -2204,11 +2229,11 @@ describe("configureWorkItemTools", () => { }); it("should handle add_child_work_item with empty optional parameters", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); if (!call) throw new Error("wit_add_child_work_items tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -2253,11 +2278,11 @@ describe("configureWorkItemTools", () => { }); it("should reject when more than 50 items are provided", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); if (!call) throw new Error("wit_add_child_work_items tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -2282,11 +2307,11 @@ describe("configureWorkItemTools", () => { }); it("should handle Markdown format correctly", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); if (!call) throw new Error("wit_add_child_work_items tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -2330,11 +2355,11 @@ describe("configureWorkItemTools", () => { }); it("should handle fetch failure response", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); if (!call) throw new Error("wit_add_child_work_items tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); @@ -2365,11 +2390,11 @@ describe("configureWorkItemTools", () => { }); it("should handle unknown error types in add_child_work_items", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); if (!call) throw new Error("wit_add_child_work_items tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2398,11 +2423,11 @@ describe("configureWorkItemTools", () => { describe("artifact link tools", () => { describe("wit_add_artifact_link", () => { it("should add artifact link to work item successfully", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); @@ -2446,11 +2471,11 @@ describe("configureWorkItemTools", () => { }); it("should add artifact link without comment", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); @@ -2488,11 +2513,11 @@ describe("configureWorkItemTools", () => { }); it("should handle errors when adding artifact link", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; mockWorkItemTrackingApi.updateWorkItem.mockRejectedValue(new Error("API Error")); @@ -2511,11 +2536,11 @@ describe("configureWorkItemTools", () => { // Tests to cover lines 929-973: URI building switch statement logic it("should build Branch URI from components", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); @@ -2555,11 +2580,11 @@ describe("configureWorkItemTools", () => { }); it("should return error for Branch link missing required parameters", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { workItemId: 1234, @@ -2576,11 +2601,11 @@ describe("configureWorkItemTools", () => { }); it("should build Fixed in Commit URI from components", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); @@ -2622,11 +2647,11 @@ describe("configureWorkItemTools", () => { }); it("should return error for Fixed in Commit link missing required parameters", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { workItemId: 1234, @@ -2643,11 +2668,11 @@ describe("configureWorkItemTools", () => { }); it("should build Pull Request URI from components", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); @@ -2687,11 +2712,11 @@ describe("configureWorkItemTools", () => { }); it("should return error for Pull Request link missing required parameters", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { workItemId: 1234, @@ -2709,11 +2734,11 @@ describe("configureWorkItemTools", () => { }); it("should build Build URI from components", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); @@ -2751,11 +2776,11 @@ describe("configureWorkItemTools", () => { }); it("should build Found in build URI from components", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); @@ -2793,11 +2818,11 @@ describe("configureWorkItemTools", () => { }); it("should build Integrated in build URI from components", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); @@ -2835,11 +2860,11 @@ describe("configureWorkItemTools", () => { }); it("should return error for build link types missing buildId", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { workItemId: 1234, @@ -2855,11 +2880,11 @@ describe("configureWorkItemTools", () => { }); it("should return error for unsupported link type in URI building", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const params = { workItemId: 1234, @@ -2874,11 +2899,11 @@ describe("configureWorkItemTools", () => { }); it("should handle null response from updateWorkItem (line 1000 coverage)", async () => { - configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); if (!call) throw new Error("wit_add_artifact_link tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; // Mock updateWorkItem to return null mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(null); diff --git a/test/src/tools/work.test.ts b/test/src/tools/work.test.ts index e7e7ed28..ca37fa25 100644 --- a/test/src/tools/work.test.ts +++ b/test/src/tools/work.test.ts @@ -1,4 +1,3 @@ -import { AccessToken } from "@azure/identity"; import { describe, expect, it } from "@jest/globals"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { configureWorkTools } from "../../../src/tools/work"; @@ -24,6 +23,7 @@ describe("configureWorkTools", () => { let mockConnection: { getWorkApi: jest.Mock; getWorkItemTrackingApi: jest.Mock }; let mockWorkApi: WorkApiMock; let mockWorkItemTrackingApi: WorkItemTrackingApiMock; + let isReadOnlyMode: boolean; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -44,22 +44,46 @@ describe("configureWorkTools", () => { }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); + + isReadOnlyMode = false; }); describe("tool registration", () => { it("registers core tools on the server", () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); expect(server.tool as jest.Mock).toHaveBeenCalled(); }); + + describe("read-only mode", () => { + it("removes write tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: false } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); + + expect(mockTool.remove).toHaveBeenCalled(); + }); + + it("keeps read-only tools when in read-only mode", () => { + const mockTool = { remove: jest.fn(), annotations: { readOnlyHint: true } }; + (server.tool as jest.Mock).mockReturnValue(mockTool); + isReadOnlyMode = true; + + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); + + expect(mockTool.remove).not.toHaveBeenCalled(); + }); + }); }); describe("list_team_iterations tool", () => { it("should call getTeamIterations API with the correct parameters and return the expected result", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_list_team_iterations"); if (!call) throw new Error("work_list_team_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.getTeamIterations as jest.Mock).mockResolvedValue([ { @@ -103,11 +127,11 @@ describe("configureWorkTools", () => { }); it("should handle API errors correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_list_team_iterations"); if (!call) throw new Error("work_list_team_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Failed to retrieve iterations"); (mockWorkApi.getTeamIterations as jest.Mock).mockRejectedValue(testError); @@ -126,11 +150,11 @@ describe("configureWorkTools", () => { }); it("should handle null API results correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_list_team_iterations"); if (!call) throw new Error("work_list_team_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.getTeamIterations as jest.Mock).mockResolvedValue(null); @@ -148,11 +172,11 @@ describe("configureWorkTools", () => { }); it("should handle unknown error type correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_list_team_iterations"); if (!call) throw new Error("work_list_team_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.getTeamIterations as jest.Mock).mockRejectedValue("string error"); @@ -172,12 +196,12 @@ describe("configureWorkTools", () => { describe("assign_iterations", () => { it("should call postTeamIteration API with the correct parameters and return the expected result", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_assign_iterations"); if (!call) throw new Error("work_assign_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.postTeamIteration as jest.Mock).mockResolvedValue({ id: "a589a806-bf11-4d4f-a031-c19813331553", @@ -235,12 +259,12 @@ describe("configureWorkTools", () => { }); it("should handle API errors correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_assign_iterations"); if (!call) throw new Error("work_assign_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Failed to assign iteration"); (mockWorkApi.postTeamIteration as jest.Mock).mockRejectedValue(testError); @@ -264,12 +288,12 @@ describe("configureWorkTools", () => { }); it("should handle null API results correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_assign_iterations"); if (!call) throw new Error("work_assign_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.postTeamIteration as jest.Mock).mockResolvedValue(null); @@ -292,12 +316,12 @@ describe("configureWorkTools", () => { }); it("should handle unknown error type correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_assign_iterations"); if (!call) throw new Error("work_assign_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkApi.postTeamIteration as jest.Mock).mockRejectedValue("string error"); @@ -322,12 +346,12 @@ describe("configureWorkTools", () => { describe("create_iterations", () => { it("should call createOrUpdateClassificationNode API with the correct parameters and return the expected result", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_create_iterations"); if (!call) throw new Error("work_create_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createOrUpdateClassificationNode as jest.Mock).mockResolvedValue({ id: 126391, @@ -400,12 +424,12 @@ describe("configureWorkTools", () => { }); it("should handle API errors correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_create_iterations"); if (!call) throw new Error("work_create_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; const testError = new Error("Failed to create iteration"); (mockWorkItemTrackingApi.createOrUpdateClassificationNode as jest.Mock).mockRejectedValue(testError); @@ -429,12 +453,12 @@ describe("configureWorkTools", () => { }); it("should handle null API results correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_create_iterations"); if (!call) throw new Error("work_create_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createOrUpdateClassificationNode as jest.Mock).mockResolvedValue(null); @@ -457,12 +481,12 @@ describe("configureWorkTools", () => { }); it("should handle unknown error type correctly", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_create_iterations"); if (!call) throw new Error("work_create_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createOrUpdateClassificationNode as jest.Mock).mockRejectedValue("string error"); @@ -485,12 +509,12 @@ describe("configureWorkTools", () => { }); it("should handle iterations without start and finish dates", async () => { - configureWorkTools(server, tokenProvider, connectionProvider); + configureWorkTools(server, tokenProvider, connectionProvider, isReadOnlyMode); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "work_create_iterations"); if (!call) throw new Error("work_create_iterations tool not registered"); - const [, , , handler] = call; + const [, , , , handler] = call; (mockWorkItemTrackingApi.createOrUpdateClassificationNode as jest.Mock).mockResolvedValue({ id: 126391,