Skip to content

Commit cea35e6

Browse files
authored
Added new tool to get wiki page (#542)
Added new tool to fetch wiki page meta data ## GitHub issue number #537 ## **Associated Risks** None ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Manual testing Added unit tests and ran
1 parent 7a12474 commit cea35e6

File tree

2 files changed

+213
-1
lines changed

2 files changed

+213
-1
lines changed

src/tools/wiki.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55
import { WebApi } from "azure-devops-node-api";
66
import { z } from "zod";
77
import { WikiPagesBatchRequest } from "azure-devops-node-api/interfaces/WikiInterfaces.js";
8+
import { apiVersion } from "../utils.js";
89

910
const WIKI_TOOLS = {
1011
list_wikis: "wiki_list_wikis",
1112
get_wiki: "wiki_get_wiki",
1213
list_wiki_pages: "wiki_list_pages",
14+
get_wiki_page: "wiki_get_page",
1315
get_wiki_page_content: "wiki_get_page_content",
1416
create_or_update_page: "wiki_create_or_update_page",
1517
};
@@ -117,6 +119,68 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise<stri
117119
}
118120
);
119121

122+
server.tool(
123+
WIKI_TOOLS.get_wiki_page,
124+
"Retrieve wiki page metadata by path. This tool does not return page content.",
125+
{
126+
wikiIdentifier: z.string().describe("The unique identifier of the wiki."),
127+
project: z.string().describe("The project name or ID where the wiki is located."),
128+
path: z.string().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup')."),
129+
recursionLevel: z
130+
.enum(["None", "OneLevel", "OneLevelPlusNestedEmptyFolders", "Full"])
131+
.optional()
132+
.describe("Recursion level for subpages. 'None' returns only the specified page. 'OneLevel' includes direct children. 'Full' includes all descendants."),
133+
},
134+
async ({ wikiIdentifier, project, path, recursionLevel }) => {
135+
try {
136+
const connection = await connectionProvider();
137+
const accessToken = await tokenProvider();
138+
139+
// Normalize the path
140+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
141+
//const encodedPath = encodeURIComponent(normalizedPath);
142+
143+
// Build the URL for the wiki page API
144+
const baseUrl = connection.serverUrl.replace(/\/$/, "");
145+
const params = new URLSearchParams({
146+
"path": normalizedPath,
147+
"api-version": apiVersion,
148+
});
149+
150+
if (recursionLevel) {
151+
params.append("recursionLevel", recursionLevel);
152+
}
153+
154+
const url = `${baseUrl}/${project}/_apis/wiki/wikis/${wikiIdentifier}/pages?${params.toString()}`;
155+
156+
const response = await fetch(url, {
157+
headers: {
158+
"Authorization": `Bearer ${accessToken}`,
159+
"User-Agent": userAgentProvider(),
160+
},
161+
});
162+
163+
if (!response.ok) {
164+
const errorText = await response.text();
165+
throw new Error(`Failed to get wiki page (${response.status}): ${errorText}`);
166+
}
167+
168+
const pageData = await response.json();
169+
170+
return {
171+
content: [{ type: "text", text: JSON.stringify(pageData, null, 2) }],
172+
};
173+
} catch (error) {
174+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
175+
176+
return {
177+
content: [{ type: "text", text: `Error fetching wiki page metadata: ${errorMessage}` }],
178+
isError: true,
179+
};
180+
}
181+
}
182+
);
183+
120184
server.tool(
121185
WIKI_TOOLS.get_wiki_page_content,
122186
"Retrieve wiki page content. Provide either a 'url' parameter OR the combination of 'wikiIdentifier' and 'project' parameters.",
@@ -135,12 +199,14 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise<stri
135199
try {
136200
const hasUrl = !!url;
137201
const hasPair = !!wikiIdentifier && !!project;
202+
138203
if (hasUrl && hasPair) {
139204
return { content: [{ type: "text", text: "Error fetching wiki page content: Provide either 'url' OR 'wikiIdentifier' with 'project', not both." }], isError: true };
140205
}
141206
if (!hasUrl && !hasPair) {
142207
return { content: [{ type: "text", text: "Error fetching wiki page content: You must provide either 'url' OR both 'wikiIdentifier' and 'project'." }], isError: true };
143208
}
209+
144210
const connection = await connectionProvider();
145211
const wikiApi = await connection.getWikiApi();
146212
let resolvedProject = project;
@@ -150,11 +216,14 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise<stri
150216

151217
if (url) {
152218
const parsed = parseWikiUrl(url);
219+
153220
if ("error" in parsed) {
154221
return { content: [{ type: "text", text: `Error fetching wiki page content: ${parsed.error}` }], isError: true };
155222
}
223+
156224
resolvedProject = parsed.project;
157225
resolvedWiki = parsed.wikiIdentifier;
226+
158227
if (parsed.pagePath) {
159228
resolvedPath = parsed.pagePath;
160229
}

test/src/tools/wiki.test.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,149 @@ describe("configureWikiTools", () => {
336336
});
337337
});
338338

339+
describe("get_page tool", () => {
340+
let mockFetch: jest.Mock;
341+
342+
beforeEach(() => {
343+
mockFetch = jest.fn();
344+
global.fetch = mockFetch;
345+
(tokenProvider as jest.Mock).mockResolvedValue("test-token");
346+
});
347+
348+
it("should fetch page metadata with correct parameters", async () => {
349+
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
350+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page");
351+
if (!call) throw new Error("wiki_get_page tool not registered");
352+
const [, , , handler] = call;
353+
354+
const mockPageData = {
355+
id: 123,
356+
path: "/Home",
357+
gitItemPath: "/Home.md",
358+
isParentPage: false,
359+
};
360+
361+
mockFetch.mockResolvedValue({
362+
ok: true,
363+
json: async () => mockPageData,
364+
});
365+
366+
const params = {
367+
wikiIdentifier: "wiki1",
368+
project: "proj1",
369+
path: "/Home",
370+
};
371+
372+
const result = await handler(params);
373+
374+
expect(mockFetch).toHaveBeenCalledWith(
375+
"https://dev.azure.com/testorg/proj1/_apis/wiki/wikis/wiki1/pages?path=%2FHome&api-version=7.2-preview.1",
376+
expect.objectContaining({
377+
headers: expect.objectContaining({
378+
"Authorization": "Bearer test-token",
379+
"User-Agent": "Jest",
380+
}),
381+
})
382+
);
383+
expect(result.content[0].text).toBe(JSON.stringify(mockPageData, null, 2));
384+
expect(result.isError).toBeUndefined();
385+
});
386+
387+
it("should handle path without leading slash", async () => {
388+
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
389+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page");
390+
if (!call) throw new Error("wiki_get_page tool not registered");
391+
const [, , , handler] = call;
392+
393+
const mockPageData = { id: 456, path: "/Documentation" };
394+
395+
mockFetch.mockResolvedValue({
396+
ok: true,
397+
json: async () => mockPageData,
398+
});
399+
400+
const params = {
401+
wikiIdentifier: "wiki1",
402+
project: "proj1",
403+
path: "Documentation",
404+
};
405+
406+
const result = await handler(params);
407+
408+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("path=%2FDocumentation"), expect.any(Object));
409+
expect(result.content[0].text).toContain('"id": 456');
410+
});
411+
412+
it("should include optional parameters when provided", async () => {
413+
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
414+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page");
415+
if (!call) throw new Error("wiki_get_page tool not registered");
416+
const [, , , handler] = call;
417+
418+
mockFetch.mockResolvedValue({
419+
ok: true,
420+
json: async () => ({ id: 789 }),
421+
});
422+
423+
const params = {
424+
wikiIdentifier: "wiki1",
425+
project: "proj1",
426+
path: "/Home",
427+
recursionLevel: "OneLevel" as const,
428+
};
429+
430+
const result = await handler(params);
431+
432+
const callUrl = mockFetch.mock.calls[0][0];
433+
expect(callUrl).toContain("recursionLevel=OneLevel");
434+
});
435+
436+
it("should handle API errors", async () => {
437+
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
438+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page");
439+
if (!call) throw new Error("wiki_get_page tool not registered");
440+
const [, , , handler] = call;
441+
442+
mockFetch.mockResolvedValue({
443+
ok: false,
444+
status: 404,
445+
text: async () => "Page not found",
446+
});
447+
448+
const params = {
449+
wikiIdentifier: "wiki1",
450+
project: "proj1",
451+
path: "/NonExistent",
452+
};
453+
454+
const result = await handler(params);
455+
456+
expect(result.isError).toBe(true);
457+
expect(result.content[0].text).toContain("Error fetching wiki page metadata");
458+
expect(result.content[0].text).toContain("Failed to get wiki page (404)");
459+
});
460+
461+
it("should handle fetch errors", async () => {
462+
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
463+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page");
464+
if (!call) throw new Error("wiki_get_page tool not registered");
465+
const [, , , handler] = call;
466+
467+
mockFetch.mockRejectedValue(new Error("Network error"));
468+
469+
const params = {
470+
wikiIdentifier: "wiki1",
471+
project: "proj1",
472+
path: "/Home",
473+
};
474+
475+
const result = await handler(params);
476+
477+
expect(result.isError).toBe(true);
478+
expect(result.content[0].text).toContain("Error fetching wiki page metadata: Network error");
479+
});
480+
});
481+
339482
describe("get_page_content tool", () => {
340483
it("should call getPageText with the correct parameters and return the expected result", async () => {
341484
configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider);
@@ -703,7 +846,7 @@ describe("configureWikiTools", () => {
703846

704847
describe("create_or_update_page tool", () => {
705848
let mockFetch: jest.Mock;
706-
let mockAccessToken: AccessToken;
849+
//let mockAccessToken: AccessToken;
707850
let mockConnection: { getWikiApi: jest.Mock; serverUrl: string };
708851

709852
beforeEach(() => {

0 commit comments

Comments
 (0)