diff --git a/src/tools/wiki.ts b/src/tools/wiki.ts index b80a4f75..f8f391ab 100644 --- a/src/tools/wiki.ts +++ b/src/tools/wiki.ts @@ -281,32 +281,212 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise { + async ({ + url, + wikiIdentifier, + path, + content, + project, + etag, + branch, + }: { + url?: string; + wikiIdentifier?: string; + path?: string; + content: string; + project?: string; + etag?: string; + branch?: string; + }) => { try { + const hasUrl = !!url; + const hasIdentifierAndPath = !!wikiIdentifier && !!path; + + if (hasUrl && hasIdentifierAndPath) { + return { content: [{ type: "text", text: "Error creating/updating wiki page: Provide either 'url' OR 'wikiIdentifier' with 'path', not both." }], isError: true }; + } + if (!hasUrl && !hasIdentifierAndPath) { + return { content: [{ type: "text", text: "Error creating/updating wiki page: You must provide either 'url' OR both 'wikiIdentifier' and 'path'." }], isError: true }; + } + + if (!hasUrl && (!project || project.trim().length === 0)) { + return { content: [{ type: "text", text: "Error creating/updating wiki page: Project must be provided when url is not supplied." }], isError: true }; + } + const connection = await connectionProvider(); const accessToken = await tokenProvider(); + let resolvedProject = project; + let resolvedWiki = wikiIdentifier; + let resolvedPath = path; + let resolvedBranch = branch || "wikiMaster"; + + if (url) { + const parsed = parseWikiUrl(url); + + if ("error" in parsed) { + return { content: [{ type: "text", text: `Error creating/updating wiki page: ${parsed.error}` }], isError: true }; + } + + resolvedProject = parsed.project; + resolvedWiki = parsed.wikiIdentifier; + + if (parsed.pagePath) { + resolvedPath = parsed.pagePath; + } else if (parsed.pageId) { + // If we have a pageId, we need to resolve it to a path + try { + const baseUrl = connection.serverUrl.replace(/\/$/, ""); + const restUrl = `${baseUrl}/${resolvedProject}/_apis/wiki/wikis/${resolvedWiki}/pages/${parsed.pageId}?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 && json.path) { + resolvedPath = json.path; + } else { + // Response OK but no path in the response + return { content: [{ type: "text", text: `Error creating/updating wiki page: Could not resolve page with id ${parsed.pageId}` }], isError: true }; + } + } else { + // Response not OK + return { content: [{ type: "text", text: `Error creating/updating wiki page: Could not resolve page with id ${parsed.pageId}` }], isError: true }; + } + } catch { + // If we can't resolve the pageId, return error + return { content: [{ type: "text", text: `Error creating/updating wiki page: Could not resolve page with id ${parsed.pageId}` }], isError: true }; + } + } + + if (parsed.branch) { + resolvedBranch = parsed.branch; + } + } + + if (!resolvedWiki || !resolvedPath) { + return { content: [{ type: "text", text: "Error creating/updating wiki page: Could not determine wikiIdentifier or path." }], isError: true }; + } + + if (!resolvedProject || resolvedProject.trim().length === 0) { + return { content: [{ type: "text", text: "Error creating/updating wiki page: Could not determine project." }], isError: true }; + } + // Normalize the path - const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const normalizedPath = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`; 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`; + const projectParam = resolvedProject || ""; + const apiUrl = `${baseUrl}/${projectParam}/_apis/wiki/wikis/${resolvedWiki}/pages?path=${encodedPath}&versionDescriptor.versionType=branch&versionDescriptor.version=${encodeURIComponent(resolvedBranch)}&api-version=7.1`; - // First, try to create a new page (PUT without ETag) + const sendUpdateRequest = async (etagToUse: string) => { + const updateResponse = await fetch(apiUrl, { + method: "PUT", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + "If-Match": etagToUse, + }, + body: JSON.stringify({ content: content }), + }); + + if (updateResponse.ok) { + const result = await updateResponse.json(); + return { + content: [ + { + type: "text" as const, + text: `Successfully updated wiki page at path: ${normalizedPath}. Response: ${JSON.stringify(result, null, 2)}`, + }, + ], + }; + } + + const errorText = await updateResponse.text(); + throw new Error(`Failed to update page (${updateResponse.status}): ${errorText}`); + }; + + const fetchCurrentEtag = async ( + notFoundHandler?: () => { content: { type: "text"; text: string }[]; isError: true } + ): Promise<{ etag?: string; errorResult?: { content: { type: "text"; text: string }[]; isError: true } }> => { + const getResponse = await fetch(apiUrl, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "User-Agent": userAgentProvider(), + }, + }); + + if (getResponse.status === 404 && notFoundHandler) { + return { errorResult: notFoundHandler() }; + } + + if (!getResponse.ok) { + const errorText = await getResponse.text(); + throw new Error(`Failed to retrieve wiki page (${getResponse.status}): ${errorText}`); + } + + let 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"); + } + + return { etag: currentEtag }; + }; + + if (hasUrl) { + const notFoundResult = (): { content: { type: "text"; text: string }[]; isError: true } => ({ + content: [ + { + type: "text", + text: "Error creating/updating wiki page: Page not found for provided url. To create a new page, omit the url parameter and provide wikiIdentifier, project, and path.", + }, + ], + isError: true, + }); + + const { etag: fetchedEtag, errorResult } = await fetchCurrentEtag(notFoundResult); + if (errorResult) { + return errorResult; + } + + const currentEtag = etag ?? fetchedEtag; + if (!currentEtag) { + throw new Error("Could not retrieve ETag for existing page"); + } + + return await sendUpdateRequest(currentEtag); + } + + // First, try to create a new page (PUT without ETag) when url is not provided try { - const createResponse = await fetch(url, { + const createResponse = await fetch(apiUrl, { method: "PUT", headers: { "Authorization": `Bearer ${accessToken}`, @@ -321,71 +501,30 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise { // - https://dev.azure.com/org/project/_wiki/wikis/wikiIdentifier?wikiVersion=GBmain&pagePath=%2FHome // - https://dev.azure.com/org/project/_wiki/wikis/wikiIdentifier/123/Title-Of-Page // Returns either a structured object OR an error message inside { error }. -function parseWikiUrl(url: string): { project: string; wikiIdentifier: string; pagePath?: string; pageId?: number; error?: undefined } | { error: string } { +function parseWikiUrl(url: string): { project: string; wikiIdentifier: string; pagePath?: string; pageId?: number; branch?: string; error?: undefined } | { error: string } { try { const u = new URL(url); // Path segments after host @@ -432,12 +571,19 @@ function parseWikiUrl(url: string): { project: string; wikiIdentifier: string; p return { error: "Could not extract project or wikiIdentifier from URL." }; } + // Extract branch from wikiVersion query parameter (format: GBbranchName) + let branch: string | undefined; + const wikiVersion = u.searchParams.get("wikiVersion"); + if (wikiVersion && wikiVersion.startsWith("GB")) { + branch = wikiVersion.substring(2); // Remove "GB" prefix + } + // Query form with pagePath const pagePathParam = u.searchParams.get("pagePath"); if (pagePathParam) { let decoded = decodeURIComponent(pagePathParam); if (!decoded.startsWith("/")) decoded = "/" + decoded; - return { project, wikiIdentifier, pagePath: decoded }; + return { project, wikiIdentifier, pagePath: decoded, branch }; } // Path ID form: .../wikis/{wikiIdentifier}/{pageId}/... @@ -445,12 +591,12 @@ function parseWikiUrl(url: string): { project: string; wikiIdentifier: string; p if (afterWiki.length >= 1) { const maybeId = parseInt(afterWiki[0], 10); if (!isNaN(maybeId)) { - return { project, wikiIdentifier, pageId: maybeId }; + return { project, wikiIdentifier, pageId: maybeId, branch }; } } // If nothing else specified, treat as root page - return { project, wikiIdentifier, pagePath: "/" }; + return { project, wikiIdentifier, pagePath: "/", branch }; } catch { return { error: "Invalid URL format." }; } diff --git a/test/src/tools/wiki.test.ts b/test/src/tools/wiki.test.ts index 40c46b4d..e42a73ca 100644 --- a/test/src/tools/wiki.test.ts +++ b/test/src/tools/wiki.test.ts @@ -429,7 +429,7 @@ describe("configureWikiTools", () => { recursionLevel: "OneLevel" as const, }; - const result = await handler(params); + await handler(params); const callUrl = mockFetch.mock.calls[0][0]; expect(callUrl).toContain("recursionLevel=OneLevel"); @@ -1331,23 +1331,12 @@ describe("configureWikiTools", () => { expect(result.content[0].text).toContain("Successfully created wiki page at path: /Home"); }); - it("should handle missing project parameter", async () => { + it("should require project parameter when url is not provided", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); 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", @@ -1357,11 +1346,9 @@ describe("configureWikiTools", () => { const result = await handler(params); - expect(mockFetch).toHaveBeenCalledWith( - "https://dev.azure.com/testorg//_apis/wiki/wikis/wiki1/pages?path=%2FHome&versionDescriptor.versionType=branch&versionDescriptor.version=wikiMaster&api-version=7.1", - expect.any(Object) - ); - expect(result.content[0].text).toContain("Successfully created wiki page at path: /Home"); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Project must be provided when url is not supplied"); }); it("should handle failed GET request for ETag", async () => { @@ -1378,6 +1365,7 @@ describe("configureWikiTools", () => { const mockGetResponse = { ok: false, // GET fails status: 404, + text: jest.fn().mockResolvedValue("Not found"), }; mockFetch @@ -1394,7 +1382,7 @@ describe("configureWikiTools", () => { 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"); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Failed to retrieve wiki page (404): Not found"); }); it("should use custom branch when specified", async () => { @@ -1439,5 +1427,336 @@ describe("configureWikiTools", () => { expect(result.content[0].text).toContain("Successfully created wiki page at path: /Home"); expect(result.isError).toBeUndefined(); }); + + it("should error when attempting to create page using URL", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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, + }); + + const params = { + url: "https://dev.azure.com/org/myproject/_wiki/wikis/myWiki?pagePath=%2FDocs%2FGuide", + content: "# Guide Content", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Page not found for provided url"); + }); + + it("should update page using URL with pagePath and branch", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 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 Home", + }), + }; + + mockFetch.mockResolvedValueOnce(mockGetResponse).mockResolvedValueOnce(mockUpdateResponse); + + const params = { + url: "https://dev.azure.com/org/myproject/_wiki/wikis/myWiki?wikiVersion=GBmain&pagePath=%2FHome", + content: "# Updated Home", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + "https://dev.azure.com/testorg/myproject/_apis/wiki/wikis/myWiki/pages?path=%2FHome&versionDescriptor.versionType=branch&versionDescriptor.version=main&api-version=7.1", + expect.objectContaining({ + method: "GET", + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://dev.azure.com/testorg/myproject/_apis/wiki/wikis/myWiki/pages?path=%2FHome&versionDescriptor.versionType=branch&versionDescriptor.version=main&api-version=7.1", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ "If-Match": 'W/"test-etag"' }), + }) + ); + expect(result.content[0].text).toContain("Successfully updated wiki page at path: /Home"); + expect(result.isError).toBeUndefined(); + }); + + it("should update page using URL with pageId", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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; + + // Mock response for pageId resolution + const mockPageIdResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + id: 789, + path: "/Some/Page", + }), + }; + + const mockGetResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue('W/"etag-abc"'), + }, + }; + + const mockUpdateResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + path: "/Some/Page", + id: 789, + content: "# New Content", + }), + }; + + mockFetch.mockResolvedValueOnce(mockPageIdResponse).mockResolvedValueOnce(mockGetResponse).mockResolvedValueOnce(mockUpdateResponse); + + const params = { + url: "https://dev.azure.com/org/myproject/_wiki/wikis/myWiki/789/Some-Page", + content: "# New Content", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + "https://dev.azure.com/testorg/myproject/_apis/wiki/wikis/myWiki/pages/789?api-version=7.1", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://dev.azure.com/testorg/myproject/_apis/wiki/wikis/myWiki/pages?path=%2FSome%2FPage&versionDescriptor.versionType=branch&versionDescriptor.version=wikiMaster&api-version=7.1", + expect.objectContaining({ method: "GET" }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + "https://dev.azure.com/testorg/myproject/_apis/wiki/wikis/myWiki/pages?path=%2FSome%2FPage&versionDescriptor.versionType=branch&versionDescriptor.version=wikiMaster&api-version=7.1", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ "If-Match": 'W/"etag-abc"' }), + }) + ); + expect(result.content[0].text).toContain("Successfully updated wiki page at path: /Some/Page"); + expect(result.isError).toBeUndefined(); + }); + + it("should error when both url and wikiIdentifier provided", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 params = { + url: "https://dev.azure.com/org/project/_wiki/wikis/wiki1?pagePath=%2FHome", + wikiIdentifier: "wiki1", + path: "/Home", + content: "# Welcome", + }; + + const result = await handler(params); + + 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); + 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 params = { + content: "# Welcome", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("You must provide either 'url' OR both 'wikiIdentifier' and 'path'"); + }); + + it("should error on malformed wiki URL", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 params = { + url: "https://dev.azure.com/org/project/notwiki/wiki1?pagePath=%2FHome", + content: "# Welcome", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: URL does not match expected wiki pattern"); + }); + + it("should error when pageId cannot be resolved", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 mockPageIdResponse = { + ok: false, + status: 404, + }; + + mockFetch.mockResolvedValueOnce(mockPageIdResponse); + + const params = { + url: "https://dev.azure.com/org/project/_wiki/wikis/wiki1/999/NonExistent", + content: "# Welcome", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Could not resolve page with id 999"); + }); + + it("should error when URL parsing extracts incomplete information", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 params = { + url: "https://dev.azure.com/org//_wiki/wikis/?pagePath=%2FHome", + content: "# Welcome", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Could not extract project or wikiIdentifier from URL"); + }); + + it("should handle URL with pageId that returns no path", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 mockPageIdResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + id: 123, + // No path in response + }), + }; + + mockFetch.mockResolvedValueOnce(mockPageIdResponse); + + const params = { + url: "https://dev.azure.com/org/project/_wiki/wikis/wiki1/123/Page-Title", + content: "# Welcome", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error creating/updating wiki page: Could not resolve page with id 123"); + }); + + it("should use wikiMaster as default branch when URL has no branch", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 mockGetResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue('W/"etag-123"'), + }, + }; + + const mockUpdateResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + path: "/Home", + id: 123, + content: "# Welcome", + }), + }; + + mockFetch.mockResolvedValueOnce(mockGetResponse).mockResolvedValueOnce(mockUpdateResponse); + + const params = { + url: "https://dev.azure.com/org/project/_wiki/wikis/wiki1?pagePath=%2FHome", + content: "# Welcome", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenNthCalledWith(1, expect.stringContaining("versionDescriptor.version=wikiMaster"), expect.objectContaining({ method: "GET" })); + expect(mockFetch).toHaveBeenNthCalledWith(2, expect.stringContaining("versionDescriptor.version=wikiMaster"), expect.objectContaining({ method: "PUT" })); + expect(result.isError).toBeUndefined(); + }); + + it("should extract and use branch from URL wikiVersion parameter", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + 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 mockGetResponse = { + ok: true, + headers: { + get: jest.fn().mockReturnValue('W/"etag-456"'), + }, + }; + + const mockUpdateResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + path: "/Home", + id: 123, + content: "# Welcome", + }), + }; + + mockFetch.mockResolvedValueOnce(mockGetResponse).mockResolvedValueOnce(mockUpdateResponse); + + const params = { + url: "https://dev.azure.com/org/project/_wiki/wikis/wiki1?wikiVersion=GBdevelop&pagePath=%2FHome", + content: "# Welcome", + }; + + const result = await handler(params); + + expect(mockFetch).toHaveBeenNthCalledWith(1, expect.stringContaining("versionDescriptor.version=develop"), expect.objectContaining({ method: "GET" })); + expect(mockFetch).toHaveBeenNthCalledWith(2, expect.stringContaining("versionDescriptor.version=develop"), expect.objectContaining({ method: "PUT" })); + expect(result.isError).toBeUndefined(); + }); }); });