Skip to content

Commit 87972d0

Browse files
authored
fix: enhance wiki search tool to include repository path and pagePath… (#1700)
This pull request enhances the Azure DevOps Wiki search tool by enriching search results with a wiki-relative `pagePath` for each result, improving downstream usability. It also introduces comprehensive tests to ensure the new behavior works correctly and maintains compatibility with responses that lack results. **Wiki Search Result Enrichment:** - Added a new function `addWikiPagePath` in `src/tools/search.ts` to compute and attach a `pagePath` property to each wiki search result, making it easier to reference wiki pages relative to their mapped paths. - Updated the search tool handler to parse the API response as JSON, enrich results with `pagePath`, and return the modified result as a stringified JSON object. - Improved the documentation for the `SEARCH_TOOLS.search_wiki` tool to clarify that results include both repository paths and wiki-relative `pagePath` values. **Testing Improvements:** - Added and updated tests in `test/src/tools/search.test.ts` to verify that: - Responses without results are preserved unmodified. - The enrichment logic correctly adds `pagePath` to various wiki search results, including edge cases. - Enhanced the test mock to support both `.text()` and `.json()` fetch responses for more accurate simulation. ## GitHub issue number #1695 ## **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?** Ran thru several manual tests and updated auto tests
1 parent 6eef0a7 commit 87972d0

2 files changed

Lines changed: 227 additions & 3 deletions

File tree

‎src/tools/search.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<st
8181

8282
server.tool(
8383
SEARCH_TOOLS.search_wiki,
84-
"Search Azure DevOps Wiki for a given search text",
84+
"Search Azure DevOps Wiki for a given search text. Results include the repository path and a wiki-relative pagePath that can be used with the wiki tool.",
8585
{
8686
searchText: z.string().describe("Keywords to search for wiki pages"),
8787
project: z.array(z.string()).optional().describe("Filter by projects"),
@@ -123,8 +123,11 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<st
123123
throw new Error(`Azure DevOps Wiki Search API error: ${response.status} ${response.statusText}`);
124124
}
125125

126-
const result = await response.text();
127-
return createExternalContentResponse(result, "wiki search results");
126+
const result = (await response.json()) as WikiSearchResponse;
127+
const results = result.results?.map(addWikiPagePath);
128+
const enrichedResult = results ? { ...result, results } : result;
129+
130+
return createExternalContentResponse(JSON.stringify(enrichedResult), "wiki search results");
128131
}
129132
);
130133

@@ -192,8 +195,48 @@ interface SearchResult {
192195
[key: string]: unknown;
193196
}
194197

198+
interface WikiSearchResult {
199+
path?: string;
200+
pagePath?: string;
201+
wiki?: {
202+
mappedPath?: string;
203+
[key: string]: unknown;
204+
};
205+
[key: string]: unknown;
206+
}
207+
208+
interface WikiSearchResponse {
209+
results?: (WikiSearchResult | null)[];
210+
[key: string]: unknown;
211+
}
212+
195213
type CombinedResult = { gitItem: GitItem } | { error: string };
196214

215+
function addWikiPagePath(result: WikiSearchResult | null): WikiSearchResult | null {
216+
if (!result || result.pagePath || !result.path || !result.wiki?.mappedPath) {
217+
return result;
218+
}
219+
220+
const mappedPath = result.wiki.mappedPath === "/" ? "" : result.wiki.mappedPath.replace(/\/+$/, "");
221+
if (mappedPath && result.path !== mappedPath && !result.path.startsWith(`${mappedPath}/`)) {
222+
return result;
223+
}
224+
225+
let relativePath: string;
226+
try {
227+
relativePath = decodeURIComponent(result.path.slice(mappedPath.length).replace(/\.md$/i, ""));
228+
} catch (error) {
229+
if (error instanceof URIError) {
230+
return result;
231+
}
232+
throw error;
233+
}
234+
235+
const pagePath = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
236+
237+
return { ...result, pagePath };
238+
}
239+
197240
async function fetchCombinedResults(topSearchResults: SearchResult[], gitApi: IGitApi): Promise<CombinedResult[]> {
198241
const combinedResults: CombinedResult[] = [];
199242
for (const searchResult of topSearchResults) {

‎test/src/tools/search.test.ts‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ describe("search tools content spotlighting", () => {
6464
status: 200,
6565
statusText: "OK",
6666
text: jest.fn().mockResolvedValue(payload),
67+
json: jest.fn().mockResolvedValue(JSON.parse(payload)),
6768
});
6869
global.fetch = fetchMock;
6970
return fetchMock;
@@ -248,6 +249,186 @@ describe("search tools content spotlighting", () => {
248249
expect(result.content[0].text).not.toBe(payload);
249250
});
250251

252+
it("preserves wiki search responses without results", async () => {
253+
const payload = JSON.stringify({ count: 0, infoCode: 0, facets: {} });
254+
mockSuccessfulResponse(payload);
255+
256+
const result = await getHandler(SEARCH_TOOLS.search_wiki)({
257+
searchText: "missing",
258+
includeFacets: false,
259+
skip: 0,
260+
top: 10,
261+
});
262+
263+
expect(unwrapSpotlightedContent(result.content[0].text, "wiki search results")).toBe(payload);
264+
});
265+
266+
it("adds wiki-relative page paths to wiki search results", async () => {
267+
const payload = JSON.stringify({
268+
count: 6,
269+
facets: { Project: [{ name: "project", count: 6 }] },
270+
results: [
271+
{
272+
title: "Example page",
273+
path: "/docs/Guides/Defining%2DGood%2DEpics.md",
274+
wiki: { id: "code-wiki", mappedPath: "/docs" },
275+
},
276+
{
277+
title: "Root page",
278+
path: "/Home.md",
279+
wiki: { id: "project-wiki", mappedPath: "/" },
280+
},
281+
{
282+
title: "Root page without leading slash",
283+
path: "Home.md",
284+
wiki: { id: "project-wiki", mappedPath: "/" },
285+
},
286+
{
287+
title: "Existing page path",
288+
path: "/docs/Existing.md",
289+
pagePath: "/Existing",
290+
wiki: { id: "code-wiki", mappedPath: "/docs" },
291+
},
292+
{
293+
title: "Unrelated mapping",
294+
path: "/documentation/Unchanged.md",
295+
wiki: { id: "code-wiki", mappedPath: "/docs" },
296+
},
297+
{
298+
title: "Missing mapping",
299+
path: "/docs/Unchanged.md",
300+
wiki: { id: "code-wiki" },
301+
},
302+
],
303+
});
304+
mockSuccessfulResponse(payload);
305+
306+
const result = await getHandler(SEARCH_TOOLS.search_wiki)({
307+
searchText: "page",
308+
includeFacets: true,
309+
skip: 0,
310+
top: 10,
311+
});
312+
313+
const content = JSON.parse(unwrapSpotlightedContent(result.content[0].text, "wiki search results"));
314+
expect(content).toEqual({
315+
count: 6,
316+
facets: { Project: [{ name: "project", count: 6 }] },
317+
results: [
318+
{
319+
title: "Example page",
320+
path: "/docs/Guides/Defining%2DGood%2DEpics.md",
321+
pagePath: "/Guides/Defining-Good-Epics",
322+
wiki: { id: "code-wiki", mappedPath: "/docs" },
323+
},
324+
{
325+
title: "Root page",
326+
path: "/Home.md",
327+
pagePath: "/Home",
328+
wiki: { id: "project-wiki", mappedPath: "/" },
329+
},
330+
{
331+
title: "Root page without leading slash",
332+
path: "Home.md",
333+
pagePath: "/Home",
334+
wiki: { id: "project-wiki", mappedPath: "/" },
335+
},
336+
{
337+
title: "Existing page path",
338+
path: "/docs/Existing.md",
339+
pagePath: "/Existing",
340+
wiki: { id: "code-wiki", mappedPath: "/docs" },
341+
},
342+
{
343+
title: "Unrelated mapping",
344+
path: "/documentation/Unchanged.md",
345+
wiki: { id: "code-wiki", mappedPath: "/docs" },
346+
},
347+
{
348+
title: "Missing mapping",
349+
path: "/docs/Unchanged.md",
350+
wiki: { id: "code-wiki" },
351+
},
352+
],
353+
});
354+
});
355+
356+
it("preserves null results and paths with malformed URI encoding", async () => {
357+
const payload = JSON.stringify({
358+
count: 3,
359+
results: [
360+
null,
361+
{
362+
title: "Malformed path",
363+
path: "/docs/Invalid%ZZ.md",
364+
wiki: { id: "code-wiki", mappedPath: "/docs" },
365+
},
366+
{
367+
title: "Valid path",
368+
path: "/docs/Valid%20Page.md",
369+
wiki: { id: "code-wiki", mappedPath: "/docs" },
370+
},
371+
],
372+
});
373+
mockSuccessfulResponse(payload);
374+
375+
const result = await getHandler(SEARCH_TOOLS.search_wiki)({
376+
searchText: "page",
377+
includeFacets: false,
378+
skip: 0,
379+
top: 10,
380+
});
381+
382+
const content = JSON.parse(unwrapSpotlightedContent(result.content[0].text, "wiki search results"));
383+
expect(content).toEqual({
384+
count: 3,
385+
results: [
386+
null,
387+
{
388+
title: "Malformed path",
389+
path: "/docs/Invalid%ZZ.md",
390+
wiki: { id: "code-wiki", mappedPath: "/docs" },
391+
},
392+
{
393+
title: "Valid path",
394+
path: "/docs/Valid%20Page.md",
395+
pagePath: "/Valid Page",
396+
wiki: { id: "code-wiki", mappedPath: "/docs" },
397+
},
398+
],
399+
});
400+
});
401+
402+
it("rethrows unexpected wiki path decoding errors", async () => {
403+
const payload = JSON.stringify({
404+
results: [
405+
{
406+
path: "/docs/Page.md",
407+
wiki: { mappedPath: "/docs" },
408+
},
409+
],
410+
});
411+
mockSuccessfulResponse(payload);
412+
413+
const unexpectedError = new Error("Unexpected decoding failure");
414+
const decodeURIComponentSpy = jest.spyOn(globalThis, "decodeURIComponent").mockImplementationOnce(() => {
415+
throw unexpectedError;
416+
});
417+
418+
try {
419+
await expect(
420+
getHandler(SEARCH_TOOLS.search_wiki)({
421+
searchText: "page",
422+
includeFacets: false,
423+
skip: 0,
424+
top: 10,
425+
})
426+
).rejects.toBe(unexpectedError);
427+
} finally {
428+
decodeURIComponentSpy.mockRestore();
429+
}
430+
});
431+
251432
it("sends wiki search filters", async () => {
252433
const fetchMock = mockSuccessfulResponse(JSON.stringify({ results: [] }));
253434

0 commit comments

Comments
 (0)