@sonyjv/azure-devops-mcp 2.9.0-onprem.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ // Copyright (c) Microsoft Corporation.
2
+ // Licensed under the MIT License.
3
+ import { z } from "zod";
4
+ import { apiVersion, extractAdoStreamError, getOrgFromUrl } from "../utils.js";
5
+ import { createExternalContentResponse } from "../shared/content-safety.js";
6
+ const WIKI_TOOLS = {
7
+ wiki: "wiki",
8
+ wiki_upsert_page: "wiki_upsert_page",
9
+ };
10
+ function configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider) {
11
+ server.tool(WIKI_TOOLS.wiki, "Retrieve wiki data for an organization or project. Use the action parameter to specify the operation.", {
12
+ action: z
13
+ .enum(["list_wikis", "get_wiki", "list_pages", "get_page", "get_page_content"])
14
+ .describe("The action to perform. Options: list_wikis (list all wikis in an organization or project), get_wiki (get details of a specific wiki), list_pages (list pages in a wiki), get_page (get wiki page metadata without content), get_page_content (retrieve wiki page content)."),
15
+ wikiIdentifier: z.string().optional().describe("The unique identifier of the wiki. Required for get_wiki, list_pages, get_page, and get_page_content (unless url is provided)."),
16
+ project: z.string().optional().describe("The project name or ID. Required for list_pages and get_page. Optional for list_wikis, get_wiki, and get_page_content."),
17
+ path: z.string().optional().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup'). Required for get_page. Optional for get_page_content."),
18
+ url: z
19
+ .string()
20
+ .optional()
21
+ .describe("The full URL of the wiki page. Used for get_page_content. If provided, wikiIdentifier, project, and path 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"),
22
+ top: z.coerce.number().default(20).describe("The maximum number of pages to return. Used for list_pages. Defaults to 20."),
23
+ continuationToken: z.string().optional().describe("Token for pagination to retrieve the next set of pages. Used for list_pages."),
24
+ pageViewsForDays: z.coerce.number().optional().describe("Number of days to retrieve page views for. Used for list_pages. If not specified, page views are not included."),
25
+ recursionLevel: z
26
+ .enum(["None", "OneLevel", "OneLevelPlusNestedEmptyFolders", "Full"])
27
+ .optional()
28
+ .describe("Recursion level for subpages. Used for get_page. 'None' returns only the specified page. 'OneLevel' includes direct children. 'Full' includes all descendants."),
29
+ }, async ({ action, wikiIdentifier, project, path, url, top = 20, continuationToken, pageViewsForDays, recursionLevel }) => {
30
+ try {
31
+ const connection = await connectionProvider();
32
+ if (action === "list_wikis") {
33
+ const wikiApi = await connection.getWikiApi();
34
+ const wikis = await wikiApi.getAllWikis(project);
35
+ if (!wikis) {
36
+ return { content: [{ type: "text", text: "No wikis found" }], isError: true };
37
+ }
38
+ return {
39
+ content: [{ type: "text", text: JSON.stringify(wikis, null, 2) }],
40
+ };
41
+ }
42
+ if (action === "get_wiki") {
43
+ if (!wikiIdentifier) {
44
+ return { content: [{ type: "text", text: "wikiIdentifier is required for get_wiki" }], isError: true };
45
+ }
46
+ const wikiApi = await connection.getWikiApi();
47
+ const wiki = await wikiApi.getWiki(wikiIdentifier, project);
48
+ if (!wiki) {
49
+ return { content: [{ type: "text", text: "No wiki found" }], isError: true };
50
+ }
51
+ return {
52
+ content: [{ type: "text", text: JSON.stringify(wiki, null, 2) }],
53
+ };
54
+ }
55
+ if (action === "list_pages") {
56
+ if (!wikiIdentifier) {
57
+ return { content: [{ type: "text", text: "wikiIdentifier is required for list_pages" }], isError: true };
58
+ }
59
+ if (!project) {
60
+ return { content: [{ type: "text", text: "project is required for list_pages" }], isError: true };
61
+ }
62
+ const wikiApi = await connection.getWikiApi();
63
+ const pagesBatchRequest = {
64
+ top,
65
+ continuationToken,
66
+ pageViewsForDays,
67
+ };
68
+ const pages = await wikiApi.getPagesBatch(pagesBatchRequest, project, wikiIdentifier);
69
+ if (!pages) {
70
+ return { content: [{ type: "text", text: "No wiki pages found" }], isError: true };
71
+ }
72
+ return {
73
+ content: [{ type: "text", text: JSON.stringify(pages, null, 2) }],
74
+ };
75
+ }
76
+ if (action === "get_page") {
77
+ if (!wikiIdentifier) {
78
+ return { content: [{ type: "text", text: "wikiIdentifier is required for get_page" }], isError: true };
79
+ }
80
+ if (!project) {
81
+ return { content: [{ type: "text", text: "project is required for get_page" }], isError: true };
82
+ }
83
+ if (!path) {
84
+ return { content: [{ type: "text", text: "path is required for get_page" }], isError: true };
85
+ }
86
+ const accessToken = await tokenProvider();
87
+ // Normalize the path
88
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
89
+ // Build the URL for the wiki page API
90
+ const baseUrl = connection.serverUrl.replace(/\/$/, "");
91
+ const params = new URLSearchParams({
92
+ "path": normalizedPath,
93
+ "api-version": apiVersion,
94
+ });
95
+ if (recursionLevel) {
96
+ params.append("recursionLevel", recursionLevel);
97
+ }
98
+ const fetchUrl = `${baseUrl}/${encodeURIComponent(project)}/_apis/wiki/wikis/${encodeURIComponent(wikiIdentifier)}/pages?${params.toString()}`;
99
+ const response = await fetch(fetchUrl, {
100
+ headers: {
101
+ "Authorization": `Bearer ${accessToken}`,
102
+ "User-Agent": userAgentProvider(),
103
+ },
104
+ });
105
+ if (!response.ok) {
106
+ const errorText = await response.text();
107
+ throw new Error(`Failed to get wiki page (${response.status}): ${errorText}`);
108
+ }
109
+ const pageData = await response.json();
110
+ return {
111
+ content: [{ type: "text", text: JSON.stringify(pageData, null, 2) }],
112
+ };
113
+ }
114
+ if (action === "get_page_content") {
115
+ const hasUrl = !!url;
116
+ const hasPair = !!wikiIdentifier && !!project;
117
+ if (hasUrl && hasPair) {
118
+ return { content: [{ type: "text", text: "Error fetching wiki page content: Provide either 'url' OR 'wikiIdentifier' with 'project', not both." }], isError: true };
119
+ }
120
+ if (!hasUrl && !hasPair) {
121
+ return { content: [{ type: "text", text: "Error fetching wiki page content: You must provide either 'url' OR both 'wikiIdentifier' and 'project'." }], isError: true };
122
+ }
123
+ const wikiApi = await connection.getWikiApi();
124
+ let resolvedProject = project;
125
+ let resolvedWiki = wikiIdentifier;
126
+ let resolvedPath = path;
127
+ let pageContent;
128
+ if (url) {
129
+ const parsed = parseWikiUrl(url);
130
+ if ("error" in parsed) {
131
+ return { content: [{ type: "text", text: `Error fetching wiki page content: ${parsed.error}` }], isError: true };
132
+ }
133
+ // Guard against cross-organization requests: a user-supplied URL must target the
134
+ // same organization the server is connected to. Otherwise the org segment in the
135
+ // URL would be silently ignored and content fetched from the configured org instead.
136
+ const configuredOrg = getOrgFromUrl(connection.serverUrl);
137
+ const urlOrg = getOrgFromUrl(url);
138
+ // On on-premises Azure DevOps Server, hosts aren't recognized by getOrgFromUrl (there's
139
+ // no dev.azure.com/*.visualstudio.com convention), so fall back to a full-origin
140
+ // comparison — otherwise the guard above would silently no-op for on-prem.
141
+ const mismatch = configuredOrg || urlOrg ? urlOrg !== configuredOrg : new URL(connection.serverUrl).origin !== new URL(url).origin;
142
+ if (mismatch) {
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: `Error fetching wiki page content: The provided URL targets organization '${urlOrg ?? "unknown"}', which does not match the configured organization '${configuredOrg ?? "unknown"}'. Cross-organization requests are not allowed.`,
148
+ },
149
+ ],
150
+ isError: true,
151
+ };
152
+ }
153
+ resolvedProject = parsed.project;
154
+ resolvedWiki = parsed.wikiIdentifier;
155
+ if (parsed.pagePath) {
156
+ resolvedPath = parsed.pagePath;
157
+ }
158
+ if (parsed.pageId) {
159
+ try {
160
+ const accessToken = await tokenProvider();
161
+ const baseUrl = connection.serverUrl.replace(/\/$/, "");
162
+ const restUrl = `${baseUrl}/${encodeURIComponent(resolvedProject)}/_apis/wiki/wikis/${encodeURIComponent(resolvedWiki)}/pages/${parsed.pageId}?includeContent=true&api-version=7.1`;
163
+ const resp = await fetch(restUrl, {
164
+ headers: {
165
+ "Authorization": `Bearer ${accessToken}`,
166
+ "User-Agent": userAgentProvider(),
167
+ },
168
+ });
169
+ if (resp.ok) {
170
+ const json = await resp.json();
171
+ if (json && typeof json.content === "string") {
172
+ pageContent = json.content;
173
+ }
174
+ else if (json && json.path) {
175
+ resolvedPath = json.path;
176
+ }
177
+ }
178
+ else if (resp.status === 404) {
179
+ return { content: [{ type: "text", text: `Error fetching wiki page content: Page with id ${parsed.pageId} not found` }], isError: true };
180
+ }
181
+ }
182
+ catch { }
183
+ }
184
+ }
185
+ if (!pageContent) {
186
+ if (!resolvedPath) {
187
+ resolvedPath = "/";
188
+ }
189
+ // resolvedProject and resolvedWiki are guaranteed to be defined here:
190
+ // - the url branch errors out in parseWikiUrl when project/wikiIdentifier are missing
191
+ // - the pair branch enforces both via the hasPair check above
192
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
193
+ const stream = await wikiApi.getPageText(resolvedProject, resolvedWiki, resolvedPath, undefined, undefined, true);
194
+ if (!stream) {
195
+ return { content: [{ type: "text", text: "No wiki page content found" }], isError: true };
196
+ }
197
+ pageContent = await streamToString(stream);
198
+ const streamError = extractAdoStreamError(pageContent);
199
+ if (streamError) {
200
+ return {
201
+ content: [{ type: "text", text: `Error fetching wiki page content: ${streamError}` }],
202
+ isError: true,
203
+ };
204
+ }
205
+ }
206
+ return createExternalContentResponse(pageContent, "wiki page");
207
+ }
208
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
209
+ }
210
+ catch (error) {
211
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
212
+ const actionErrorMessages = {
213
+ list_wikis: `Error fetching wikis: ${errorMessage}`,
214
+ get_wiki: `Error fetching wiki: ${errorMessage}`,
215
+ list_pages: `Error fetching wiki pages: ${errorMessage}`,
216
+ get_page: `Error fetching wiki page metadata: ${errorMessage}`,
217
+ get_page_content: `Error fetching wiki page content: ${errorMessage}`,
218
+ };
219
+ return {
220
+ content: [{ type: "text", text: actionErrorMessages[action] ?? `Error: ${errorMessage}` }],
221
+ isError: true,
222
+ };
223
+ }
224
+ });
225
+ server.tool(WIKI_TOOLS.wiki_upsert_page, "Create or update a wiki page with content.", {
226
+ wikiIdentifier: z.string().describe("The unique identifier or name of the wiki."),
227
+ path: z.string().describe("The path of the wiki page (e.g., '/Home' or '/Documentation/Setup')."),
228
+ content: z.string().describe("The content of the wiki page in markdown format."),
229
+ project: z.string().optional().describe("The project name or ID where the wiki is located. If not provided, the default project will be used."),
230
+ etag: z.string().optional().describe("ETag for editing existing pages (optional, will be fetched if not provided)."),
231
+ 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."),
232
+ }, async ({ wikiIdentifier, path, content, project, etag, branch = "wikiMaster" }) => {
233
+ try {
234
+ const connection = await connectionProvider();
235
+ const accessToken = await tokenProvider();
236
+ // Normalize the path
237
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
238
+ const encodedPath = encodeURIComponent(normalizedPath);
239
+ // Build the URL for the wiki page API with version descriptor
240
+ const baseUrl = connection.serverUrl;
241
+ const projectParam = project || "";
242
+ const url = `${baseUrl}/${encodeURIComponent(projectParam)}/_apis/wiki/wikis/${encodeURIComponent(wikiIdentifier)}/pages?path=${encodedPath}&versionDescriptor.versionType=branch&versionDescriptor.version=${encodeURIComponent(branch)}&api-version=7.1`;
243
+ // First, try to create a new page (PUT without ETag)
244
+ const createResponse = await fetch(url, {
245
+ method: "PUT",
246
+ headers: {
247
+ "Authorization": `Bearer ${accessToken}`,
248
+ "Content-Type": "application/json",
249
+ "User-Agent": userAgentProvider(),
250
+ },
251
+ body: JSON.stringify({ content: content }),
252
+ });
253
+ if (createResponse.ok) {
254
+ const result = await createResponse.json();
255
+ return {
256
+ content: [
257
+ {
258
+ type: "text",
259
+ text: `Successfully created wiki page at path: ${normalizedPath}. Response: ${JSON.stringify(result, null, 2)}`,
260
+ },
261
+ ],
262
+ };
263
+ }
264
+ // If creation failed with 409 (Conflict) or 500 (Page exists), try to update it
265
+ if (createResponse.status === 409 || createResponse.status === 500) {
266
+ // Page exists, we need to get the ETag and update it
267
+ let currentEtag = etag;
268
+ if (!currentEtag) {
269
+ // Fetch current page to get ETag
270
+ const getResponse = await fetch(url, {
271
+ method: "GET",
272
+ headers: {
273
+ "Authorization": `Bearer ${accessToken}`,
274
+ "User-Agent": userAgentProvider(),
275
+ },
276
+ });
277
+ if (getResponse.ok) {
278
+ currentEtag = getResponse.headers.get("etag") || getResponse.headers.get("ETag") || undefined;
279
+ if (!currentEtag) {
280
+ const pageData = await getResponse.json();
281
+ currentEtag = pageData.eTag;
282
+ }
283
+ }
284
+ if (!currentEtag) {
285
+ throw new Error("Could not retrieve ETag for existing page");
286
+ }
287
+ }
288
+ // Now update the existing page with ETag
289
+ const updateResponse = await fetch(url, {
290
+ method: "PUT",
291
+ headers: {
292
+ "Authorization": `Bearer ${accessToken}`,
293
+ "Content-Type": "application/json",
294
+ "User-Agent": userAgentProvider(),
295
+ "If-Match": currentEtag,
296
+ },
297
+ body: JSON.stringify({ content: content }),
298
+ });
299
+ if (updateResponse.ok) {
300
+ const result = await updateResponse.json();
301
+ return {
302
+ content: [
303
+ {
304
+ type: "text",
305
+ text: `Successfully updated wiki page at path: ${normalizedPath}. Response: ${JSON.stringify(result, null, 2)}`,
306
+ },
307
+ ],
308
+ };
309
+ }
310
+ else {
311
+ const errorText = await updateResponse.text();
312
+ throw new Error(`Failed to update page (${updateResponse.status}): ${errorText}`);
313
+ }
314
+ }
315
+ else {
316
+ const errorText = await createResponse.text();
317
+ throw new Error(`Failed to create page (${createResponse.status}): ${errorText}`);
318
+ }
319
+ }
320
+ catch (error) {
321
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
322
+ return {
323
+ content: [{ type: "text", text: `Error creating/updating wiki page: ${errorMessage}` }],
324
+ isError: true,
325
+ };
326
+ }
327
+ });
328
+ }
329
+ function streamToString(stream) {
330
+ return new Promise((resolve, reject) => {
331
+ let data = "";
332
+ stream.setEncoding("utf8");
333
+ stream.on("data", (chunk) => (data += chunk));
334
+ stream.on("end", () => resolve(data));
335
+ stream.on("error", reject);
336
+ });
337
+ }
338
+ // Helper to parse Azure DevOps wiki page URLs.
339
+ // Supported examples:
340
+ // - https://dev.azure.com/org/project/_wiki/wikis/wikiIdentifier?wikiVersion=GBmain&pagePath=%2FHome
341
+ // - https://dev.azure.com/org/project/_wiki/wikis/wikiIdentifier/123/Title-Of-Page
342
+ // Returns either a structured object OR an error message inside { error }.
343
+ function parseWikiUrl(url) {
344
+ try {
345
+ const u = new URL(url);
346
+ // Path segments after host
347
+ // Expect pattern: /{project}/_wiki/wikis/{wikiIdentifier}[/{pageId}/...]
348
+ const segments = u.pathname.split("/").filter(Boolean); // remove empty
349
+ const idx = segments.findIndex((s) => s === "_wiki");
350
+ if (idx < 1 || segments[idx + 1] !== "wikis") {
351
+ return { error: "URL does not match expected wiki pattern (missing /_wiki/wikis/ segment)." };
352
+ }
353
+ const project = segments[idx - 1];
354
+ const wikiIdentifier = segments[idx + 2];
355
+ if (!project || !wikiIdentifier) {
356
+ return { error: "Could not extract project or wikiIdentifier from URL." };
357
+ }
358
+ // Query form with pagePath
359
+ const pagePathParam = u.searchParams.get("pagePath");
360
+ if (pagePathParam) {
361
+ let decoded = decodeURIComponent(pagePathParam);
362
+ if (!decoded.startsWith("/"))
363
+ decoded = "/" + decoded;
364
+ return { project, wikiIdentifier, pagePath: decoded };
365
+ }
366
+ // Path ID form: .../wikis/{wikiIdentifier}/{pageId}/...
367
+ const afterWiki = segments.slice(idx + 3); // elements after wikiIdentifier
368
+ if (afterWiki.length >= 1) {
369
+ const maybeId = parseInt(afterWiki[0], 10);
370
+ if (!isNaN(maybeId)) {
371
+ return { project, wikiIdentifier, pageId: maybeId };
372
+ }
373
+ }
374
+ // If nothing else specified, treat as root page
375
+ return { project, wikiIdentifier, pagePath: "/" };
376
+ }
377
+ catch {
378
+ return { error: "Invalid URL format." };
379
+ }
380
+ }
381
+ export { WIKI_TOOLS, configureWikiTools };