Skip to main content
Glama

Azure DevOps Wiki MCP Server

by uright
azure-client.ts14.9 kB
import * as azdev from 'azure-devops-node-api'; import { WikiApi } from 'azure-devops-node-api/WikiApi'; import { IRequestHandler } from 'azure-devops-node-api/interfaces/common/VsoBaseInterfaces'; import { DefaultAzureCredential } from '@azure/identity'; import { WikiSearchRequest, WikiPageTreeRequest, WikiGetPageRequest, WikiUpdatePageRequest, WikiListRequest, WikiSearchResult, WikiPageNode, WikiPageContent, WikiPageUpdateResult, WikiInfo, AzureDevOpsConfig } from './types.js'; export class AzureDevOpsWikiClient { private connection: azdev.WebApi | null = null; private wikiApi: WikiApi | null = null; constructor(private config: AzureDevOpsConfig) {} private formatPagePath(path: string): string { if (!path) return ''; // Remove leading slash if present let formattedPath = path; // Remove .md extension if present if (formattedPath.endsWith('.md')) { formattedPath = formattedPath.substring(0, formattedPath.length - 3); } // Replace hyphens with spaces formattedPath = formattedPath.replace(/-/g, ' '); return formattedPath; } async initialize(): Promise<void> { try { let authHandler: IRequestHandler; if (this.config.personalAccessToken) { authHandler = azdev.getPersonalAccessTokenHandler(this.config.personalAccessToken); } else { const credential = new DefaultAzureCredential(); const token = await credential.getToken(['https://app.vssps.visualstudio.com/.default']); authHandler = azdev.getBearerHandler(token.token); } // Support custom Azure DevOps URL or default to dev.azure.com const orgUrl = this.config.azureDevOpsUrl || `https://dev.azure.com/${this.config.organization}`; this.connection = new azdev.WebApi(orgUrl, authHandler); this.wikiApi = await this.connection.getWikiApi(); } catch (error) { throw new Error(`Failed to initialize Azure DevOps client: ${error instanceof Error ? error.message : String(error)}`); } } async searchWiki(request: WikiSearchRequest): Promise<WikiSearchResult[]> { if (!this.wikiApi || !this.connection) { throw new Error('Azure DevOps client not initialized'); } try { const organization = request.organization || this.config.organization; const project = request.project || this.config.project; if (!organization || !project) { throw new Error('Organization and project must be provided'); } const searchApiUrl = `https://almsearch.dev.azure.com/${organization}/${project}/_apis/search/wikisearchresults?api-version=7.1`; interface WikiSearchRequestBody { searchText: string; $skip: number; $top: number; includeFacets: boolean; filters?: { Wiki?: string[]; }; } const requestBody: WikiSearchRequestBody = { searchText: request.searchText, $skip: 0, $top: 100, // Default to 100 results includeFacets: false }; // Add wiki filter if specified if (request.wikiId) { requestBody.filters = { Wiki: [request.wikiId] }; } const response = await this.connection.rest.client.post(searchApiUrl, JSON.stringify(requestBody), { 'Content-Type': 'application/json' }); if (!response.message || response.message.statusCode !== 200) { throw new Error(`Search failed: HTTP ${response.message?.statusCode || 'Unknown'}`); } const responseBody = await response.readBody(); if (!responseBody) { return []; } const data = JSON.parse(responseBody); if (!data.results || !Array.isArray(data.results)) { return []; } interface WikiSearchResultItem { fileName?: string; path?: string; url?: string; matches?: { content?: { text: string }[]; }; project?: { name: string; }; wiki?: { name: string; }; } return data.results.map((result: WikiSearchResultItem) => ({ title: result.fileName || (result.path ? result.path.split('/').pop() || 'Unknown' : 'Unknown'), url: result.url || '', content: result.matches && result.matches.content ? result.matches.content.map((match) => match.text).join(' ') : '', project: result.project?.name || project, wiki: result.wiki?.name || request.wikiId || 'Unknown', pagePath: this.formatPagePath(result.path || '') })); } catch (error) { throw new Error(`Failed to search wiki: ${error instanceof Error ? error.message : String(error)}`); } } async getPageTree(request: WikiPageTreeRequest): Promise<WikiPageNode[]> { if (!this.wikiApi || !this.connection) { throw new Error('Azure DevOps client not initialized'); } try { const organization = request.organization || this.config.organization; const project = request.project || this.config.project; if (!organization || !project) { throw new Error('Organization and project must be provided'); } const orgUrl = this.config.azureDevOpsUrl || `https://dev.azure.com/${organization}`; const recursionLevel = request.depth ? 'Full' : 'OneLevel'; const apiUrl = `${orgUrl}/${project}/_apis/wiki/wikis/${request.wikiId}/pages?recursionLevel=${recursionLevel}&api-version=7.1`; const response = await this.connection.rest.client.get(apiUrl); if (!response.message || response.message.statusCode !== 200) { return []; } const responseBody = await response.readBody(); if (!responseBody) { return []; } const data = JSON.parse(responseBody); let pages = []; if (data.value) { pages = data.value; } else if (data.subPages) { pages = [data]; } else { pages = [data]; } const processPages = (pageList: unknown[]): WikiPageNode[] => { return pageList.map((page: unknown) => { const pageData = page as { id?: number; path?: string; order?: number; gitItemPath?: string; subPages?: unknown[] }; return { id: pageData.id?.toString() || '', path: pageData.path || '', title: pageData.path ? pageData.path.split('/').pop() || '' : '', order: pageData.order || 0, gitItemPath: pageData.gitItemPath || '', subPages: pageData.subPages ? processPages(pageData.subPages) : [] }; }).sort((a, b) => a.order - b.order); }; return processPages(pages); } catch (error) { throw new Error(`Failed to get page tree: ${error instanceof Error ? error.message : String(error)}`); } } async getPage(request: WikiGetPageRequest): Promise<WikiPageContent> { if (!this.wikiApi || !this.connection) { throw new Error('Azure DevOps client not initialized'); } try { const organization = request.organization || this.config.organization; const project = request.project || this.config.project; if (!organization || !project) { throw new Error('Organization and project must be provided'); } const orgUrl = this.config.azureDevOpsUrl || `https://dev.azure.com/${organization}`; const encodedPath = encodeURIComponent(request.path); const apiUrl = `${orgUrl}/${project}/_apis/wiki/wikis/${request.wikiId}/pages?path=${encodedPath}&includeContent=true&api-version=7.1`; const response = await this.connection.rest.client.get(apiUrl); if (!response.message || response.message.statusCode !== 200) { throw new Error(`Failed to get page: HTTP ${response.message?.statusCode || 'Unknown'}`); } const responseBody = await response.readBody(); if (!responseBody) { throw new Error('Empty response body'); } const data = JSON.parse(responseBody); // Handle the response structure let pageData; if (data.value) { // If value is an array, get the first element if (Array.isArray(data.value) && data.value.length > 0) { pageData = data.value[0]; } else if (Array.isArray(data.value) && data.value.length === 0) { // Empty array means page not found throw new Error(`Page not found: ${request.path}`); } else { // value is not an array, use it directly pageData = data.value; } } else { // No value property, use data directly pageData = data; } if (!pageData || pageData === null) { throw new Error(`Page not found: ${request.path}`); } return { id: pageData.id?.toString() || '', path: pageData.path || request.path, title: pageData.path ? pageData.path.split('/').pop() || '' : '', content: pageData.content || '', gitItemPath: pageData.gitItemPath || '', order: pageData.order || 0, version: pageData.version || '', isParentPage: pageData.isParentPage || false }; } catch (error) { throw new Error(`Failed to get page: ${error instanceof Error ? error.message : String(error)}`); } } async updatePage(request: WikiUpdatePageRequest): Promise<WikiPageUpdateResult> { if (!this.wikiApi || !this.connection) { throw new Error('Azure DevOps client not initialized'); } try { const organization = request.organization || this.config.organization; const project = request.project || this.config.project; if (!organization || !project) { throw new Error('Organization and project must be provided'); } // Set encoded pagePath const encodedPath = encodeURIComponent(request.path); // Get wiki object const wiki = await this.wikiApi.getWiki(request.wikiId, project); // Fix: Access the first element of versions array safely and get its version property const wikiVersion = Array.isArray(wiki.versions) && wiki.versions.length > 0 ? wiki.versions[0].version : 'wikiMaster'; // First, check if page exists to get version for updates let pageVersion: string | undefined; let pageExists = false; try { let wikiPageResponse = await this.wikiApi.http.get(`${wiki.url}/pages?path=${encodedPath}`); if (wikiPageResponse.message && wikiPageResponse.message.statusCode === 200) { pageExists = true; pageVersion = wikiPageResponse.message.headers.etag; } } catch (checkError) { // Page doesn't exist, we'll create it pageExists = false; } // Create headers object with proper typing const headers: { [key: string]: string } = { 'Content-Type': 'application/json' }; // Only add If-Match header if page exists and we have a version // For new pages, don't include If-Match header if (pageExists && pageVersion) { headers['If-Match'] = pageVersion; } const requestBody = { content: request.content }; // TODO: Add versionDescriptor.versionType and versionDescriptor.version as optional environment variables const apiUrl = `${wiki.url}/pages?path=${encodedPath}&api-version=7.1&versionDescriptor.versionType=branch&versionDescriptor.version=${wikiVersion}`; const response = await this.wikiApi.http.put(apiUrl, JSON.stringify(requestBody), headers); if (!response.message || (response.message.statusCode !== 200 && response.message.statusCode !== 201)) { // Enhanced error information for debugging const errorDetails: { statusCode: number | undefined; statusMessage: string | undefined; headers: { [key: string]: string | string[] | undefined } | undefined; url: string; requestHeaders: { [key: string]: string }; requestBody: { content: string }; pageExists: boolean; pageVersion: string | undefined; responseBody?: string; } = { statusCode: response.message?.statusCode, statusMessage: response.message?.statusMessage, headers: response.message?.headers, url: apiUrl, requestHeaders: headers, requestBody: requestBody, pageExists, pageVersion }; throw new Error(`Failed to ${pageExists ? 'update' : 'create'} page: HTTP ${response.message?.statusCode || 'Unknown'}. Details: ${JSON.stringify(errorDetails, null, 2)}`); } const responseBody = await response.readBody(); if (!responseBody) { throw new Error('Empty response body'); } const data = JSON.parse(responseBody); // Handle the response structure let pageData = data.value || data; if (!pageData || pageData === null || (data.value !== undefined && data.value === null)) { throw new Error(`Failed to ${pageExists ? 'update' : 'create'} page: ${request.path}`); } return { id: pageData.id?.toString() || '', path: pageData.path || request.path, title: pageData.path ? pageData.path.split('/').pop() || '' : '', version: pageData.version || response.message.headers.etag || '', isParentPage: pageData.isParentPage || false, order: pageData.order || 0, gitItemPath: pageData.gitItemPath || '' }; } catch (error) { throw new Error(`Failed to update page: ${error instanceof Error ? error.message : String(error)}`); } } async listWikis(request: WikiListRequest): Promise<WikiInfo[]> { if (!this.wikiApi || !this.connection) { throw new Error('Azure DevOps client not initialized'); } try { const organization = request.organization || this.config.organization; const project = request.project || this.config.project; if (!organization || !project) { throw new Error('Organization and project must be provided'); } const wikis = await this.wikiApi.getAllWikis(project); if (!wikis || !Array.isArray(wikis)) { return []; } return wikis.map(wiki => ({ id: wiki.id || '', name: wiki.name || '', type: wiki.type?.toString() || '', url: wiki.url || '', project: project, repositoryId: wiki.repositoryId || '', mappedPath: wiki.mappedPath || '' })); } catch (error) { throw new Error(`Failed to list wikis: ${error instanceof Error ? error.message : String(error)}`); } } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/uright/azure-devops-wiki-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server