diff --git a/README.md b/README.md index 94c160b..ec58202 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,25 @@ The Azure DevOps MCP Server brings Azure DevOps context to your agents. Try prom - "List iterations for project 'Contoso'" - "List my work items for project 'Contoso'" - "List work items in current iteration for 'Contoso' project and 'Contoso Team'" +- "List all wikis in the 'Contoso' project" +- "Create a wiki page '/Architecture/Overview' with content about system design" +- "Update the wiki page '/Getting Started' with new onboarding instructions" +- "Get the content of the wiki page '/API/Authentication' from the Documentation wiki" ## πŸ† Expectations The Azure DevOps MCP Server is built from tools that are concise, simple, focused, and easy to useβ€”each designed for a specific scenario. We intentionally avoid complex tools that try to do too much. The goal is to provide a thin abstraction layer over the REST APIs, making data access straightforward and letting the language model handle complex reasoning. +## ✨ Recent Enhancements + +### πŸ“– **Enhanced Wiki Support** + +- **Full Content Management**: Create and update wiki pages with complete content using the native Azure DevOps REST API +- **Automatic ETag Handling**: Safe updates with built-in conflict resolution for concurrent edits +- **Immediate Visibility**: Pages appear instantly in the Azure DevOps wiki interface +- **Hierarchical Structure**: Support for organized page structures within existing folder hierarchies +- **Robust Error Handling**: Comprehensive error management for various HTTP status codes and edge cases + ## βš™οΈ Supported Tools Interact with these Azure DevOps services: @@ -133,6 +147,14 @@ Interact with these Azure DevOps services: - **testplan_list_test_cases**: Get a list of test cases in the test plan. - **testplan_show_test_results_from_build_id**: Get a list of test results for a given project and build ID. +### πŸ“– Wiki + +- **wiki_list_wikis**: Retrieve a list of wikis for an organization or project. +- **wiki_get_wiki**: Get the wiki by wikiIdentifier. +- **wiki_list_pages**: Retrieve a list of wiki pages for a specific wiki and project. +- **wiki_get_page_content**: Retrieve wiki page content by wikiIdentifier and path. +- **wiki_create_or_update_page**: ✨ **Enhanced** - Create or update wiki pages with full content support using Azure DevOps REST API. Features automatic ETag handling for safe updates, immediate content visibility, and proper conflict resolution. + ### πŸ”Ž Search - **search_code**: Get code search results for a given search text. diff --git a/src/tools/wiki.ts b/src/tools/wiki.ts index 5936762..a3bb6fc 100644 --- a/src/tools/wiki.ts +++ b/src/tools/wiki.ts @@ -12,6 +12,7 @@ const WIKI_TOOLS = { get_wiki: "wiki_get_wiki", list_wiki_pages: "wiki_list_pages", get_wiki_page_content: "wiki_get_page_content", + create_or_update_page: "wiki_create_or_update_page", }; function configureWikiTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise) { @@ -151,6 +152,124 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise { + 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; + const projectParam = project || ""; + const url = `${baseUrl}/${projectParam}/_apis/wiki/wikis/${wikiIdentifier}/pages?path=${encodedPath}&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.token}`, + "Content-Type": "application/json", + }, + 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 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.token}`, + }, + }); + + 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.token}`, + "Content-Type": "application/json", + "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 createResponse.text(); + throw new Error(`Failed to create page (${createResponse.status}): ${errorText}`); + } + } 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, + }; + } + } + ); } function streamToString(stream: NodeJS.ReadableStream): Promise { diff --git a/test/src/tools/wiki.test.ts b/test/src/tools/wiki.test.ts index b9df854..986d1b7 100644 --- a/test/src/tools/wiki.test.ts +++ b/test/src/tools/wiki.test.ts @@ -460,4 +460,409 @@ describe("configureWikiTools", () => { expect(result.content[0].text).toContain("Error fetching wiki page content: Unknown error occurred"); }); }); + + describe("create_or_update_page tool", () => { + let mockFetch: jest.Mock; + let mockAccessToken: AccessToken; + let mockConnection: { getWikiApi: jest.Mock; serverUrl: string }; + + beforeEach(() => { + // Mock fetch for REST API calls + mockFetch = jest.fn(); + global.fetch = mockFetch; + + mockAccessToken = { + token: "test-token", + expiresOnTimestamp: Date.now() + 3600000, + }; + tokenProvider = jest.fn().mockResolvedValue(mockAccessToken); + + mockConnection = { + getWikiApi: jest.fn().mockResolvedValue(mockWikiApi), + serverUrl: "https://dev.azure.com/testorg", + }; + connectionProvider = jest.fn().mockResolvedValue(mockConnection); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should create a new wiki page successfully", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockResponse = { + path: "/Home", + id: 123, + content: "# Welcome\nThis is the home page.", + url: "https://dev.azure.com/testorg/proj1/_apis/wiki/wikis/wiki1/pages/%2FHome", + remoteUrl: "https://dev.azure.com/testorg/proj1/_wiki/wikis/wiki1?pagePath=%2FHome", + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Welcome\nThis is the home page.", + project: "proj1", + comment: "Initial page creation", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenCalledWith("https://dev.azure.com/testorg/proj1/_apis/wiki/wikis/wiki1/pages?path=%2FHome&api-version=7.1", { + method: "PUT", + headers: { + "Authorization": "Bearer test-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ content: "# Welcome\nThis is the home page." }), + }); + expect(result.content[0].text).toContain("Successfully created wiki page at path: /Home"); + expect(result.isError).toBeUndefined(); + }); + + it("should update an existing wiki page with ETag", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockCreateResponse = { + ok: false, + status: 409, // Conflict - page exists + }; + + const mockGetResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue('W/"test-etag"'), + }, + }; + + const mockUpdateResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + path: "/Home", + id: 123, + content: "# Updated Welcome\nThis is the updated home page.", + }), + }; + + mockFetch + .mockResolvedValueOnce(mockCreateResponse) // First PUT fails with 409 + .mockResolvedValueOnce(mockGetResponse) // GET to retrieve ETag + .mockResolvedValueOnce(mockUpdateResponse); // Second PUT succeeds with ETag + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Updated Welcome\nThis is the updated home page.", + project: "proj1", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(result.content[0].text).toContain("Successfully updated wiki page at path: /Home"); + expect(result.isError).toBeUndefined(); + }); + + it("should handle API errors correctly", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValue("Wiki not found"), + }); + + const params = { + wikiIdentifier: "nonexistent", + path: "/Home", + content: "# Welcome", + project: "proj1", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Failed to create page (404): Wiki not found"); + }); + + it("should handle fetch errors correctly", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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; + + mockFetch.mockRejectedValue(new Error("Network error")); + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Welcome", + project: "proj1", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Network error"); + }); + + it("should get ETag from response body when not in headers", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockCreateResponse = { + ok: false, + status: 409, // Conflict - page exists + }; + + const mockGetResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue(null), // No ETag in headers + }, + json: jest.fn().mockResolvedValue({ + eTag: 'W/"body-etag"', // ETag in response body + }), + }; + + const mockUpdateResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + path: "/Home", + id: 123, + content: "# Updated Welcome", + }), + }; + + mockFetch + .mockResolvedValueOnce(mockCreateResponse) // First PUT fails with 409 + .mockResolvedValueOnce(mockGetResponse) // GET to retrieve ETag from body + .mockResolvedValueOnce(mockUpdateResponse); // Second PUT succeeds with ETag + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Updated Welcome", + project: "proj1", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(result.content[0].text).toContain("Successfully updated wiki page at path: /Home"); + expect(result.isError).toBeUndefined(); + }); + + it("should handle missing ETag error", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockCreateResponse = { + ok: false, + status: 409, // Conflict - page exists + }; + + const mockGetResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue(null), // No ETag in headers + }, + json: jest.fn().mockResolvedValue({ + // No eTag in response body either + }), + }; + + mockFetch + .mockResolvedValueOnce(mockCreateResponse) // First PUT fails with 409 + .mockResolvedValueOnce(mockGetResponse); // GET fails to retrieve ETag + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Updated Welcome", + project: "proj1", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Could not retrieve ETag for existing page"); + }); + + it("should handle update failure after getting ETag", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockCreateResponse = { + ok: false, + status: 409, // Conflict - page exists + }; + + const mockGetResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue('W/"test-etag"'), + }, + }; + + const mockUpdateResponse = { + ok: false, + status: 412, // Precondition failed + text: jest.fn().mockResolvedValue("ETag mismatch"), + }; + + mockFetch + .mockResolvedValueOnce(mockCreateResponse) // First PUT fails with 409 + .mockResolvedValueOnce(mockGetResponse) // GET to retrieve ETag + .mockResolvedValueOnce(mockUpdateResponse); // Second PUT fails with 412 + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Updated Welcome", + project: "proj1", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Failed to update page (412): ETag mismatch"); + }); + + it("should handle non-Error exceptions", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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; + + // Throw a non-Error object + mockFetch.mockRejectedValue("String error message"); + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Welcome", + project: "proj1", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Unknown error occurred"); + }); + + it("should handle path without leading slash", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + path: "/Home", + id: 123, + content: "# Welcome", + }), + }; + + mockFetch.mockResolvedValueOnce(mockResponse); + + const params = { + wikiIdentifier: "wiki1", + path: "Home", // No leading slash + content: "# Welcome", + project: "proj1", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenCalledWith("https://dev.azure.com/testorg/proj1/_apis/wiki/wikis/wiki1/pages?path=%2FHome&api-version=7.1", expect.any(Object)); + expect(result.content[0].text).toContain("Successfully created wiki page at path: /Home"); + }); + + it("should handle missing project parameter", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + path: "/Home", + id: 123, + content: "# Welcome", + }), + }; + + mockFetch.mockResolvedValueOnce(mockResponse); + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Welcome", + // project parameter omitted + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenCalledWith("https://dev.azure.com/testorg//_apis/wiki/wikis/wiki1/pages?path=%2FHome&api-version=7.1", expect.any(Object)); + expect(result.content[0].text).toContain("Successfully created wiki page at path: /Home"); + }); + + it("should handle failed GET request for ETag", async () => { + configureWikiTools(server, tokenProvider, connectionProvider); + 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 mockCreateResponse = { + ok: false, + status: 409, // Conflict - page exists + }; + + const mockGetResponse = { + ok: false, // GET fails + status: 404, + }; + + mockFetch + .mockResolvedValueOnce(mockCreateResponse) // First PUT fails with 409 + .mockResolvedValueOnce(mockGetResponse); // GET fails + + const params = { + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Updated Welcome", + project: "proj1", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Could not retrieve ETag for existing page"); + }); + }); });