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
276 changes: 211 additions & 65 deletions src/tools/wiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,32 +281,212 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise<stri

server.tool(
WIKI_TOOLS.create_or_update_page,
"Create or update a wiki page with content.",
"Create or update a wiki page with content. Provide either a 'url' parameter OR the combination of 'wikiIdentifier' and 'path' parameters.",
{
wikiIdentifier: z.string().describe("The unique identifier or name of the wiki."),
path: z.string().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup')."),
url: z
.string()
.optional()
.describe(
"The full URL of the wiki page to create or update. If provided, wikiIdentifier, project, path, and branch are ignored. Supported patterns: https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}?pagePath=%2FMy%20Page and https://dev.azure.com/{org}/{project}/_wiki/wikis/{wikiIdentifier}/{pageId}/Page-Title"
),
wikiIdentifier: z.string().optional().describe("The unique identifier or name of the wiki. Required if url is not provided."),
path: z.string().optional().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup'). Required if url is not provided."),
content: z.string().describe("The content of the wiki page in markdown format."),
project: z.string().optional().describe("The project name or ID where the wiki is located. If not provided, the default project will be used."),
etag: z.string().optional().describe("ETag for editing existing pages (optional, will be fetched if not provided)."),
branch: z.string().default("wikiMaster").describe("The branch name for the wiki repository. Defaults to 'wikiMaster' which is the default branch for Azure DevOps wikis."),
branch: z.string().optional().describe("The branch name for the wiki repository. Defaults to 'wikiMaster' if not provided. Can be extracted from URL if provided."),
},
async ({ wikiIdentifier, path, content, project, etag, branch = "wikiMaster" }) => {
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}`,
Expand All @@ -321,71 +501,30 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise<stri
return {
content: [
{
type: "text",
type: "text" as const,
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}`,
"User-Agent": userAgentProvider(),
},
});

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");
}
const { etag: fetchedEtag } = await fetchCurrentEtag();
currentEtag = fetchedEtag;
}

// Now update the existing page with ETag
const updateResponse = await fetch(url, {
method: "PUT",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": userAgentProvider(),
"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}`);
if (!currentEtag) {
throw new Error("Could not retrieve ETag for existing page");
}
} else {
const errorText = await createResponse.text();
throw new Error(`Failed to create page (${createResponse.status}): ${errorText}`);

return await sendUpdateRequest(currentEtag);
}

const errorText = await createResponse.text();
throw new Error(`Failed to create page (${createResponse.status}): ${errorText}`);
} catch (fetchError) {
throw fetchError;
}
Expand Down Expand Up @@ -416,7 +555,7 @@ function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
// - 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
Expand All @@ -432,25 +571,32 @@ 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}/...
const afterWiki = segments.slice(idx + 3); // elements after wikiIdentifier
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." };
}
Expand Down
Loading