Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions src/tools/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<st

server.tool(
SEARCH_TOOLS.search_wiki,
"Search Azure DevOps Wiki for a given search text",
"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.",
{
searchText: z.string().describe("Keywords to search for wiki pages"),
project: z.array(z.string()).optional().describe("Filter by projects"),
Expand Down Expand Up @@ -123,8 +123,11 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<st
throw new Error(`Azure DevOps Wiki Search API error: ${response.status} ${response.statusText}`);
}

const result = await response.text();
return createExternalContentResponse(result, "wiki search results");
const result = (await response.json()) as WikiSearchResponse;
const results = result.results?.map(addWikiPagePath);
const enrichedResult = results ? { ...result, results } : result;

return createExternalContentResponse(JSON.stringify(enrichedResult), "wiki search results");
}
);

Expand Down Expand Up @@ -192,8 +195,39 @@ interface SearchResult {
[key: string]: unknown;
}

interface WikiSearchResult {
path?: string;
pagePath?: string;
wiki?: {
mappedPath?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

interface WikiSearchResponse {
results?: WikiSearchResult[];
[key: string]: unknown;
}

type CombinedResult = { gitItem: GitItem } | { error: string };

function addWikiPagePath(result: WikiSearchResult): WikiSearchResult {
if (result.pagePath || !result.path || !result.wiki?.mappedPath) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we have null check for result
if (!result || result.pagePath || !result.path || !result.wiki?.mappedPath) {
return result;
}

return result;
}

const mappedPath = result.wiki.mappedPath === "/" ? "" : result.wiki.mappedPath.replace(/\/+$/, "");
if (mappedPath && result.path !== mappedPath && !result.path.startsWith(`${mappedPath}/`)) {
return result;
}

const relativePath = decodeURIComponent(result.path.slice(mappedPath.length).replace(/\.md$/i, ""));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeURIComponent can throw URIError when result.path contains malformed encoding

const pagePath = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;

return { ...result, pagePath };
}

async function fetchCombinedResults(topSearchResults: SearchResult[], gitApi: IGitApi): Promise<CombinedResult[]> {
const combinedResults: CombinedResult[] = [];
for (const searchResult of topSearchResults) {
Expand Down
105 changes: 105 additions & 0 deletions test/src/tools/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe("search tools content spotlighting", () => {
status: 200,
statusText: "OK",
text: jest.fn().mockResolvedValue(payload),
json: jest.fn().mockResolvedValue(JSON.parse(payload)),
});
global.fetch = fetchMock;
return fetchMock;
Expand Down Expand Up @@ -248,6 +249,110 @@ describe("search tools content spotlighting", () => {
expect(result.content[0].text).not.toBe(payload);
});

it("preserves wiki search responses without results", async () => {
const payload = JSON.stringify({ count: 0, infoCode: 0, facets: {} });
mockSuccessfulResponse(payload);

const result = await getHandler(SEARCH_TOOLS.search_wiki)({
searchText: "missing",
includeFacets: false,
skip: 0,
top: 10,
});

expect(unwrapSpotlightedContent(result.content[0].text, "wiki search results")).toBe(payload);
});

it("adds wiki-relative page paths to wiki search results", async () => {
const payload = JSON.stringify({
count: 6,
facets: { Project: [{ name: "project", count: 6 }] },
results: [
{
title: "Example page",
path: "/docs/Guides/Defining%2DGood%2DEpics.md",
wiki: { id: "code-wiki", mappedPath: "/docs" },
},
{
title: "Root page",
path: "/Home.md",
wiki: { id: "project-wiki", mappedPath: "/" },
},
{
title: "Root page without leading slash",
path: "Home.md",
wiki: { id: "project-wiki", mappedPath: "/" },
},
{
title: "Existing page path",
path: "/docs/Existing.md",
pagePath: "/Existing",
wiki: { id: "code-wiki", mappedPath: "/docs" },
},
{
title: "Unrelated mapping",
path: "/documentation/Unchanged.md",
wiki: { id: "code-wiki", mappedPath: "/docs" },
},
{
title: "Missing mapping",
path: "/docs/Unchanged.md",
wiki: { id: "code-wiki" },
},
],
});
mockSuccessfulResponse(payload);

const result = await getHandler(SEARCH_TOOLS.search_wiki)({
searchText: "page",
includeFacets: true,
skip: 0,
top: 10,
});

const content = JSON.parse(unwrapSpotlightedContent(result.content[0].text, "wiki search results"));
expect(content).toEqual({
count: 6,
facets: { Project: [{ name: "project", count: 6 }] },
results: [
{
title: "Example page",
path: "/docs/Guides/Defining%2DGood%2DEpics.md",
pagePath: "/Guides/Defining-Good-Epics",
wiki: { id: "code-wiki", mappedPath: "/docs" },
},
{
title: "Root page",
path: "/Home.md",
pagePath: "/Home",
wiki: { id: "project-wiki", mappedPath: "/" },
},
{
title: "Root page without leading slash",
path: "Home.md",
pagePath: "/Home",
wiki: { id: "project-wiki", mappedPath: "/" },
},
{
title: "Existing page path",
path: "/docs/Existing.md",
pagePath: "/Existing",
wiki: { id: "code-wiki", mappedPath: "/docs" },
},
{
title: "Unrelated mapping",
path: "/documentation/Unchanged.md",
wiki: { id: "code-wiki", mappedPath: "/docs" },
},
{
title: "Missing mapping",
path: "/docs/Unchanged.md",
wiki: { id: "code-wiki" },
},
],
});
});

it("sends wiki search filters", async () => {
const fetchMock = mockSuccessfulResponse(JSON.stringify({ results: [] }));

Expand Down
Loading