diff --git a/README.md b/README.md index 10df8014..07393e4f 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ This TypeScript project provides a **local** MCP server for Azure DevOps, enabli 2. [πŸ† Expectations](#-expectations) 3. [βš™οΈ Supported Tools](#️-supported-tools) 4. [πŸ”Œ Installation & Getting Started](#-installation--getting-started) -5. [πŸ“ Troubleshooting](#-troubleshooting) -6. [🎩 Examples & Best Practices](#-examples--best-practices) -7. [πŸ™‹β€β™€οΈ Frequently Asked Questions](#️-frequently-asked-questions) -8. [πŸ“Œ Contributing](#-contributing) +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) ## πŸ“Ί Overview @@ -110,9 +111,11 @@ Interact with these Azure DevOps services: - **build_get_log**: Retrieve the logs for a specific build. - **build_get_log_by_id**: Get a specific build log by log ID. - **build_get_changes**: Get the changes associated with a specific build. -- **build_run_build**: Trigger a new build for a specified definition. - **build_get_status**: Fetch the status of a specific build. - **build_update_build_stage**: Update the stage of a specific build. +- **pipelines_get_run**: Gets a run for a particular pipeline. +- **pipelines_list_runs**: Gets top 10000 runs for a particular pipeline. +- **pipelines_run_pipeline**: Starts a new run of a pipeline. ### πŸš€ Releases @@ -242,6 +245,37 @@ Open GitHub Copilot Chat and try a prompt like `List ADO projects`. See the [getting started documentation](./docs/GETTINGSTARTED.md) to use our MCP Server with other tools such as Visual Studio 2022, Claude Code, and Cursor. +## 🌏 Using Domains + +Azure DevOps exposes a large surface area. As a result, our Azure DevOps MCP Server includes many tools. To keep the toolset manageable, avoid confusing the model, and respect client limits on loaded tools, use Domains to load only the areas you need. Domains are named groups of related tools (for example: core, work, work-items, repositories, wiki). Add the `-d` argument and the domain names to the server args in your `mcp.json` to list the domains to enable. + +For example, use `"-d", "core", "work", "work-items"` to load only Work Item related tools (see the example below). + +```json +{ + "inputs": [ + { + "id": "ado_org", + "type": "promptString", + "description": "Azure DevOps organization name (e.g. 'contoso')" + } + ], + "servers": { + "ado": { + "type": "stdio", + "command": "mcp-server-azuredevops", + "args": ["${input:ado_org}", "-d", "core", "work", "work-items"] + } + } +} +``` + +Domains that are available are: `core`, `work`, `work-items`, `search`, `test-plans`, `repositories`, `wiki`, `builds`, `releases`, `advanced-security` + +We recommend that you always enable `core` tools so that you can fetch project level information. + +> By default all domains are loaded + ## πŸ“ Troubleshooting See the [Troubleshooting guide](./docs/TROUBLESHOOTING.md) for help with common issues and logging. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 76f25f8f..b5e606a8 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -23,7 +23,9 @@ If tools do not appear, click "Add Context" in Agent Mode and ensure all tools starting with `ado_` are selected. 4. **Too Many Tools Selected (Over 128 Limit)** - VS Code supports a maximum of 128 tools. If you exceed this limit, ensure you do not have multiple MCP Servers running. Check both your project's `mcp.json` and your VS Code `settings.json` to confirm that the MCP Server is configured in only one locationβ€”not both. + Some tools have a default maximum limot of 128 tools. If you exceed this limit, ensure you do not have multiple MCP Servers running. Check both your project's `mcp.json` and your VS Code `settings.json` to confirm that the MCP Server is configured in only one locationβ€”not both. + + You can also use [Domains](../README.md?tab=readme-ov-file#-using-domains) as a way to limit the number of tools you load for the Azure DevOps MCP Server. ## Project-Specific Issues diff --git a/intTest/domains/mcp.json b/intTest/domains/mcp.json deleted file mode 100644 index 1ed38060..00000000 --- a/intTest/domains/mcp.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "servers": { - "ado": { - "type": "stdio", - "command": "mcp-server-azuredevops", - "args": [ - "${input:ado_org}", - "-d", - "core", - "work", - "workitems" - // ... any other domain to enable, you can also use 'all' (which is already the default) - ] - } - }, - "inputs": [ - { - "id": "ado_org", - "type": "promptString", - "description": "Azure DevOps organization name (e.g. 'contoso')" - } - ] -} diff --git a/package-lock.json b/package-lock.json index c5dc8b1e..2fc14264 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure-devops/mcp", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure-devops/mcp", - "version": "2.0.0", + "version": "2.1.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 21bd53ff..bd3c3e93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure-devops/mcp", - "version": "2.0.0", + "version": "2.1.0", "description": "MCP server for interacting with Azure DevOps", "license": "MIT", "author": "Microsoft Corporation", diff --git a/src/tools/builds.ts b/src/tools/builds.ts index 0ebec46e..46bec25c 100644 --- a/src/tools/builds.ts +++ b/src/tools/builds.ts @@ -10,14 +10,16 @@ import { z } from "zod"; import { StageUpdateType } from "azure-devops-node-api/interfaces/BuildInterfaces.js"; const BUILD_TOOLS = { + get_builds: "build_get_builds", + get_changes: "build_get_changes", get_definitions: "build_get_definitions", get_definition_revisions: "build_get_definition_revisions", - get_builds: "build_get_builds", get_log: "build_get_log", get_log_by_id: "build_get_log_by_id", - get_changes: "build_get_changes", - run_build: "build_run_build", get_status: "build_get_status", + pipelines_get_run: "pipelines_get_run", + pipelines_list_runs: "pipelines_list_runs", + pipelines_run_pipeline: "pipelines_run_pipeline", update_build_stage: "build_update_build_stage", }; @@ -258,40 +260,133 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise { + 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( + BUILD_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."), + }) + ), + 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( + BUILD_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'."); + } + const connection = await connectionProvider(); - const buildApi = await connection.getBuildApi(); const pipelinesApi = await connection.getPipelinesApi(); - const definition = await buildApi.getDefinition(project, definitionId); const runRequest = { + previewRun: previewRun, resources: { - repositories: { - self: { - refName: sourceBranch || definition.repository?.defaultBranch || "refs/heads/main", - }, - }, + ...resources, }, - templateParameters: parameters, + stagesToSkip: stagesToSkip, + templateParameters: templateParameters, + variables: variables, + yamlOverride: yamlOverride, }; - const pipelineRun = await pipelinesApi.runPipeline(runRequest, project, definitionId); + 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"); } - const buildReport = await buildApi.getBuildReport(project, buildId); return { - content: [{ type: "text", text: JSON.stringify(buildReport, null, 2) }], + content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }], }; } ); diff --git a/test/src/tools/builds.test.ts b/test/src/tools/builds.test.ts index 216d7231..057d5ee8 100644 --- a/test/src/tools/builds.test.ts +++ b/test/src/tools/builds.test.ts @@ -612,263 +612,224 @@ describe("configureBuildTools", () => { }); }); - describe("run_build tool", () => { - it("should trigger build with correct parameters", async () => { + describe("pipelines_get_run tool", () => { + it("should call getRun with correct parameters", async () => { configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build"); - if (!call) throw new Error("build_run_build tool not registered"); + 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 mockBuildApi = { - getDefinition: jest.fn().mockResolvedValue({ - repository: { - defaultBranch: "refs/heads/main", - }, - }), - getBuildReport: jest.fn().mockResolvedValue({ - id: 456, - status: "queued", - }), - }; - const mockPipelinesApi = { - runPipeline: jest.fn().mockResolvedValue({ - id: 456, - }), + getRun: jest.fn().mockResolvedValue({ id: 1, name: "run-1" }), }; - - mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi); const params = { project: "test-project", - definitionId: 123, - sourceBranch: "refs/heads/feature/new-feature", - parameters: { key1: "value1", key2: "value2" }, + pipelineId: 123, + runId: 456, }; const result = await handler(params); - expect(mockBuildApi.getDefinition).toHaveBeenCalledWith("test-project", 123); - expect(mockPipelinesApi.runPipeline).toHaveBeenCalledWith( - { - resources: { - repositories: { - self: { - refName: "refs/heads/feature/new-feature", - }, - }, - }, - templateParameters: { key1: "value1", key2: "value2" }, - }, - "test-project", - 123 - ); - expect(mockBuildApi.getBuildReport).toHaveBeenCalledWith("test-project", 456); - expect(result.content[0].text).toBe( - JSON.stringify( - { - id: 456, - status: "queued", - }, - null, - 2 - ) - ); + expect(mockPipelinesApi.getRun).toHaveBeenCalledWith("test-project", 123, 456); + expect(result.content[0].text).toBe(JSON.stringify({ id: 1, name: "run-1" }, null, 2)); }); - it("should use default branch when sourceBranch not provided", async () => { + it("should handle API errors for pipelines_get_run", async () => { configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build"); - if (!call) throw new Error("build_run_build tool not registered"); + 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 mockBuildApi = { - getDefinition: jest.fn().mockResolvedValue({ - repository: { - defaultBranch: "refs/heads/develop", - }, - }), - getBuildReport: jest.fn().mockResolvedValue({ id: 456 }), + const mockPipelinesApi = { + getRun: jest.fn().mockRejectedValue(new Error("Run not found")), }; + mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi); - const mockPipelinesApi = { - runPipeline: jest.fn().mockResolvedValue({ id: 456 }), + const params = { + project: "test-project", + pipelineId: 123, + runId: 999, }; - mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + await expect(handler(params)).rejects.toThrow("Run not found"); + }); + }); + + describe("pipelines_list_runs tool", () => { + it("should call listRuns with correct parameters", async () => { + configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 mockPipelinesApi = { + listRuns: jest.fn().mockResolvedValue([{ id: 1, name: "run-1" }]), + }; mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi); const params = { project: "test-project", - definitionId: 123, + pipelineId: 123, }; - await handler(params); + const result = await handler(params); - expect(mockPipelinesApi.runPipeline).toHaveBeenCalledWith( - { - resources: { - repositories: { - self: { - refName: "refs/heads/develop", - }, - }, - }, - templateParameters: undefined, - }, - "test-project", - 123 - ); + expect(mockPipelinesApi.listRuns).toHaveBeenCalledWith("test-project", 123); + expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, name: "run-1" }], null, 2)); }); - it("should use fallback branch when no repository default branch", async () => { + it("should handle API errors for pipelines_list_runs", async () => { configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build"); - if (!call) throw new Error("build_run_build tool not registered"); + 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 mockBuildApi = { - getDefinition: jest.fn().mockResolvedValue({ - repository: null, - }), - getBuildReport: jest.fn().mockResolvedValue({ id: 456 }), + const mockPipelinesApi = { + listRuns: jest.fn().mockRejectedValue(new Error("Pipeline not found")), + }; + mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi); + + const params = { + project: "test-project", + pipelineId: 999, }; + await expect(handler(params)).rejects.toThrow("Pipeline not found"); + }); + }); + + describe("pipelines_run_pipeline tool", () => { + it("should trigger pipeline with correct parameters", async () => { + configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 mockPipelinesApi = { runPipeline: jest.fn().mockResolvedValue({ id: 456 }), }; - - mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi); const params = { project: "test-project", - definitionId: 123, + pipelineId: 123, + resources: { + repositories: { + self: { + refName: "refs/heads/feature/new-feature", + }, + }, + }, + templateParameters: { key1: "value1", key2: "value2" }, }; - await handler(params); + const result = await handler(params); expect(mockPipelinesApi.runPipeline).toHaveBeenCalledWith( { + previewRun: undefined, resources: { repositories: { self: { - refName: "refs/heads/main", + refName: "refs/heads/feature/new-feature", }, }, }, - templateParameters: undefined, + stagesToSkip: undefined, + templateParameters: { key1: "value1", key2: "value2" }, + variables: undefined, + yamlOverride: undefined, }, "test-project", - 123 + 123, + undefined ); + expect(result.content[0].text).toBe(JSON.stringify({ id: 456 }, null, 2)); }); - it("should handle missing build ID from pipeline run", async () => { + it("should handle preview run", async () => { configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build"); - if (!call) throw new Error("build_run_build tool not registered"); + 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 mockBuildApi = { - getDefinition: jest.fn().mockResolvedValue({ - repository: { defaultBranch: "refs/heads/main" }, - }), - }; - const mockPipelinesApi = { - runPipeline: jest.fn().mockResolvedValue({ - id: undefined, // Missing build ID - }), + runPipeline: jest.fn().mockResolvedValue({ id: 456, finalYaml: "final yaml" }), }; - - mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); - mockConnection.getPipelinesApi = jest.fn().mockResolvedValue(mockPipelinesApi); + mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi); const params = { project: "test-project", - definitionId: 123, + pipelineId: 123, + previewRun: true, }; - await expect(handler(params)).rejects.toThrow("Failed to get build ID from pipeline run"); + await handler(params); + + expect(mockPipelinesApi.runPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + previewRun: true, + }), + "test-project", + 123, + undefined + ); }); - it("should handle API errors for run_build", async () => { + it("should throw error for previewRun and yamlOverride", async () => { configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_run_build"); - if (!call) throw new Error("build_run_build tool not registered"); + 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 mockBuildApi = { - getDefinition: jest.fn().mockRejectedValue(new Error("Definition not found")), - }; - - mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); - const params = { project: "test-project", - definitionId: 999, + pipelineId: 123, + previewRun: false, + yamlOverride: "some yaml", }; - await expect(handler(params)).rejects.toThrow("Definition not found"); + await expect(handler(params)).rejects.toThrow("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'."); }); - }); - describe("get_status tool", () => { - it("should call getBuildReport with correct parameters", async () => { + it("should handle missing build ID from pipeline run", async () => { configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_get_status"); - if (!call) throw new Error("build_get_status tool not registered"); + 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 mockBuildApi = { - getBuildReport: jest.fn().mockResolvedValue({ - id: 123, - status: "completed", - result: "succeeded", - }), + const mockPipelinesApi = { + runPipeline: jest.fn().mockResolvedValue({}), }; - mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi); const params = { project: "test-project", - buildId: 123, + pipelineId: 123, }; - const result = await handler(params); - - expect(mockBuildApi.getBuildReport).toHaveBeenCalledWith("test-project", 123); - expect(result.content[0].text).toBe( - JSON.stringify( - { - id: 123, - status: "completed", - result: "succeeded", - }, - null, - 2 - ) - ); + await expect(handler(params)).rejects.toThrow("Failed to get build ID from pipeline run"); }); - it("should handle API errors for get_status", async () => { + it("should handle API errors for pipelines_run_pipeline", async () => { configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_get_status"); - if (!call) throw new Error("build_get_status tool not registered"); + 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 mockBuildApi = { - getBuildReport: jest.fn().mockRejectedValue(new Error("Build not found")), + const mockPipelinesApi = { + runPipeline: jest.fn().mockRejectedValue(new Error("API Error")), }; - mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + mockConnection.getPipelinesApi.mockResolvedValue(mockPipelinesApi); const params = { project: "test-project", - buildId: 999, + pipelineId: 123, }; - await expect(handler(params)).rejects.toThrow("Build not found"); + await expect(handler(params)).rejects.toThrow("API Error"); }); }); }); diff --git a/test/src/tools/wiki.test.ts b/test/src/tools/wiki.test.ts index 428d1ab5..9727b2a9 100644 --- a/test/src/tools/wiki.test.ts +++ b/test/src/tools/wiki.test.ts @@ -17,7 +17,10 @@ describe("configureWikiTools", () => { let server: McpServer; let tokenProvider: TokenProviderMock; let connectionProvider: ConnectionProviderMock; - let mockConnection: { getWikiApi: jest.Mock }; + let mockConnection: { + getWikiApi: jest.Mock; + serverUrl: string; + }; let mockWikiApi: WikiApiMock; beforeEach(() => { @@ -31,6 +34,7 @@ describe("configureWikiTools", () => { }; mockConnection = { getWikiApi: jest.fn().mockResolvedValue(mockWikiApi), + serverUrl: "https://dev.azure.com/testorg", }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); }); @@ -502,7 +506,7 @@ describe("configureWikiTools", () => { // Mock fetch for REST page by id returning content const mockFetch = jest.fn(); - global.fetch = mockFetch as any; + global.fetch = mockFetch as typeof fetch; mockFetch.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue({ content: "# Page Title\nBody" }), @@ -512,7 +516,7 @@ describe("configureWikiTools", () => { const result = await handler({ url }); // Current implementation may fallback to root path stream retrieval - expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(mockWikiApi.getPageText).not.toHaveBeenCalled(); // Content either direct or from stream JSON string wrapping expect(result.content[0].text).toContain("Page Title"); }); @@ -525,7 +529,7 @@ describe("configureWikiTools", () => { (tokenProvider as jest.Mock).mockResolvedValueOnce({ token: "abc", expiresOnTimestamp: Date.now() + 10000 }); const mockFetch = jest.fn(); - global.fetch = mockFetch as any; + global.fetch = mockFetch as typeof fetch; mockFetch.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue({ path: "/Some/Page" }), @@ -545,7 +549,7 @@ describe("configureWikiTools", () => { const result = await handler({ url }); // Implementation currently falls back to root path if path not resolved prior to fallback - expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/Some/Page", undefined, undefined, true); expect(result.content[0].text).toBe('"fallback content"'); }); @@ -579,6 +583,120 @@ describe("configureWikiTools", () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Error fetching wiki page content: URL does not match expected wiki pattern"); }); + + it("should handle invalid URL format", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 result = await handler({ url: "not-a-valid-url" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error fetching wiki page content: Invalid URL format"); + }); + + it("should handle URL with pageId that returns 404", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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; + + (tokenProvider as jest.Mock).mockResolvedValueOnce({ token: "abc", expiresOnTimestamp: Date.now() + 10000 }); + + const mockFetch = jest.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/999/NonExistent-Page"; + const result = await handler({ url }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error fetching wiki page content: Page with id 999 not found"); + }); + + it("should handle URL that resolves but project/wiki end up undefined", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 url = "https://dev.azure.com/org//_wiki/wikis/?pagePath=%2FHome"; + const result = await handler({ url }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error fetching wiki page content: Could not extract project or wikiIdentifier from URL"); + }); + + it("should handle URL with non-numeric pageId", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("content for non-numeric path")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/not-a-number/Some-Page"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(result.content[0].text).toBe('"content for non-numeric path"'); + }); + + it("should use default root path when resolvedPath is undefined", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("root page content")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const result = await handler({ wikiIdentifier: "wiki1", project: "project1" }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project1", "wiki1", "/", undefined, undefined, true); + expect(result.content[0].text).toBe('"root page content"'); + expect(result.isError).toBeUndefined(); + }); + + it("should handle scenario where resolvedProject/Wiki become null after URL processing", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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; + + (tokenProvider as jest.Mock).mockResolvedValueOnce({ token: "abc", expiresOnTimestamp: Date.now() + 10000 }); + + const mockFetch = jest.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const url = "https://dev.azure.com//_wiki/wikis//123/Page"; + const result = await handler({ url }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("URL does not match expected wiki pattern"); + }); }); describe("create_or_update_page tool", () => {