From e8828ac7bf35fc3f1dd86359883b208255708815 Mon Sep 17 00:00:00 2001 From: viamu Date: Sun, 15 Mar 2026 09:09:33 -0300 Subject: [PATCH] feat: add Wiki tools for reading Azure DevOps wikis Add MCP tools to list wikis, get wiki details, read wiki page content, and browse wiki page hierarchy. Uses WikiHttpClient from the Azure DevOps SDK. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Models/WikiDto.cs | 18 ++ .../Models/WikiPageDto.cs | 29 +++ .../Services/AzureDevOpsService.cs | 172 ++++++++++++++++++ .../Services/IAzureDevOpsService.cs | 55 ++++++ .../Tools/WikiTools.cs | 122 +++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Models/WikiDto.cs create mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Models/WikiPageDto.cs create mode 100644 src/Viamus.Azure.Devops.Mcp.Server/Tools/WikiTools.cs diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WikiDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WikiDto.cs new file mode 100644 index 0000000..ca12952 --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WikiDto.cs @@ -0,0 +1,18 @@ +namespace Viamus.Azure.Devops.Mcp.Server.Models; + +/// +/// Data transfer object representing an Azure DevOps Wiki. +/// +public sealed record WikiDto +{ + public string? Id { get; init; } + public string? Name { get; init; } + public string? Type { get; init; } + public string? Url { get; init; } + public string? RemoteUrl { get; init; } + public string? ProjectId { get; init; } + public string? ProjectName { get; init; } + public string? RepositoryId { get; init; } + public string? MappedPath { get; init; } + public List? Versions { get; init; } +} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Models/WikiPageDto.cs b/src/Viamus.Azure.Devops.Mcp.Server/Models/WikiPageDto.cs new file mode 100644 index 0000000..ec6a063 --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Models/WikiPageDto.cs @@ -0,0 +1,29 @@ +namespace Viamus.Azure.Devops.Mcp.Server.Models; + +/// +/// Data transfer object representing an Azure DevOps Wiki page. +/// +public sealed record WikiPageDto +{ + public int? Id { get; init; } + public string? Path { get; init; } + public string? Content { get; init; } + public int? Order { get; init; } + public string? GitItemPath { get; init; } + public string? RemoteUrl { get; init; } + public bool IsParentPage { get; init; } + public List? SubPages { get; init; } +} + +/// +/// Lightweight summary of a Wiki page for listing operations. +/// +public sealed record WikiPageSummaryDto +{ + public int? Id { get; init; } + public string? Path { get; init; } + public int? Order { get; init; } + public string? GitItemPath { get; init; } + public string? RemoteUrl { get; init; } + public bool IsParentPage { get; init; } +} diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs index 38b9704..dabd7cc 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; using Microsoft.TeamFoundation.Build.WebApi; using Microsoft.TeamFoundation.SourceControl.WebApi; +using Microsoft.TeamFoundation.Wiki.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; @@ -23,6 +24,7 @@ public sealed class AzureDevOpsService : IAzureDevOpsService, IDisposable private readonly WorkItemTrackingHttpClient _witClient; private readonly GitHttpClient _gitClient; private readonly BuildHttpClient _buildClient; + private readonly WikiHttpClient _wikiClient; private bool _disposed; private static readonly string[] DefaultFields = @@ -71,6 +73,7 @@ public AzureDevOpsService(IOptions options, ILogger(); _gitClient = _connection.GetClient(); _buildClient = _connection.GetClient(); + _wikiClient = _connection.GetClient(); _logger.LogInformation("Azure DevOps service initialized for organization: {OrganizationUrl}", _options.OrganizationUrl); } @@ -1628,6 +1631,174 @@ private static BuildTimelineRecordDto MapToBuildTimelineRecordDto(TimelineRecord #endregion + #region Wiki Operations + + public async Task> GetWikisAsync(string? project = null, CancellationToken cancellationToken = default) + { + var projectName = project ?? _options.DefaultProject; + + _logger.LogInformation("Getting wikis for project: {Project}", projectName ?? "(all)"); + + try + { + var wikis = await _wikiClient.GetAllWikisAsync(project: projectName, cancellationToken: cancellationToken); + + _logger.LogInformation("Found {Count} wikis", wikis.Count); + + return wikis.Select(MapToWikiDto).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting wikis for project: {Project}", projectName); + throw; + } + } + + public async Task GetWikiAsync(string wikiIdentifier, string? project = null, CancellationToken cancellationToken = default) + { + var projectName = project ?? _options.DefaultProject; + + _logger.LogInformation("Getting wiki: {WikiIdentifier} in project: {Project}", wikiIdentifier, projectName ?? "(default)"); + + try + { + var wiki = await _wikiClient.GetWikiAsync(project: projectName, wikiIdentifier: wikiIdentifier, cancellationToken: cancellationToken); + return MapToWikiDto(wiki); + } + catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Wiki '{WikiIdentifier}' not found", wikiIdentifier); + return null; + } + } + + public async Task GetWikiPageAsync( + string wikiIdentifier, + string path, + bool includeContent = true, + string? version = null, + string? project = null, + CancellationToken cancellationToken = default) + { + var projectName = project ?? _options.DefaultProject; + + _logger.LogInformation("Getting wiki page: {Path} from wiki: {WikiIdentifier}", path, wikiIdentifier); + + try + { + var versionDescriptor = !string.IsNullOrWhiteSpace(version) + ? new GitVersionDescriptor { Version = version, VersionType = GitVersionType.Branch } + : null; + + var page = await _wikiClient.GetPageAsync( + project: projectName, + wikiIdentifier: wikiIdentifier, + path: path, + recursionLevel: VersionControlRecursionType.None, + versionDescriptor: versionDescriptor, + includeContent: includeContent, + cancellationToken: cancellationToken); + + return MapToWikiPageDto(page.Page, page.Page.Content); + } + catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Wiki page '{Path}' not found in wiki '{WikiIdentifier}'", path, wikiIdentifier); + return null; + } + } + + public async Task GetWikiPageTreeAsync( + string wikiIdentifier, + string path = "/", + string recursionLevel = "OneLevel", + string? project = null, + CancellationToken cancellationToken = default) + { + var projectName = project ?? _options.DefaultProject; + + _logger.LogInformation("Getting wiki page tree: {Path} from wiki: {WikiIdentifier} with recursion: {Recursion}", path, wikiIdentifier, recursionLevel); + + try + { + var recursion = recursionLevel.Equals("Full", StringComparison.OrdinalIgnoreCase) + ? VersionControlRecursionType.Full + : VersionControlRecursionType.OneLevel; + + var page = await _wikiClient.GetPageAsync( + project: projectName, + wikiIdentifier: wikiIdentifier, + path: path, + recursionLevel: recursion, + includeContent: false, + cancellationToken: cancellationToken); + + return MapToWikiPageDtoWithSubPages(page.Page); + } + catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Wiki page '{Path}' not found in wiki '{WikiIdentifier}'", path, wikiIdentifier); + return null; + } + } + + private static WikiDto MapToWikiDto(WikiV2 wiki) + { + return new WikiDto + { + Id = wiki.Id.ToString(), + Name = wiki.Name, + Type = wiki.Type.ToString(), + Url = wiki.Url, + RemoteUrl = wiki.RemoteUrl, + ProjectId = wiki.ProjectId.ToString(), + RepositoryId = wiki.RepositoryId.ToString(), + MappedPath = wiki.MappedPath, + Versions = wiki.Versions?.Select(v => v.Version).ToList() + }; + } + + private static WikiPageDto MapToWikiPageDto(WikiPage page, string? content = null) + { + return new WikiPageDto + { + Id = page.Id, + Path = page.Path, + Content = content ?? page.Content, + Order = page.Order, + GitItemPath = page.GitItemPath, + RemoteUrl = page.RemoteUrl, + IsParentPage = page.IsParentPage + }; + } + + private static WikiPageDto MapToWikiPageDtoWithSubPages(WikiPage page) + { + return new WikiPageDto + { + Id = page.Id, + Path = page.Path, + Order = page.Order, + GitItemPath = page.GitItemPath, + RemoteUrl = page.RemoteUrl, + IsParentPage = page.IsParentPage, + SubPages = page.SubPages?.Select(sp => new WikiPageSummaryDto + { + Id = sp.Id, + Path = sp.Path, + Order = sp.Order, + GitItemPath = sp.GitItemPath, + RemoteUrl = sp.RemoteUrl, + IsParentPage = sp.IsParentPage + }).ToList() + }; + } + + #endregion + public void Dispose() { if (_disposed) return; @@ -1635,6 +1806,7 @@ public void Dispose() _witClient.Dispose(); _gitClient.Dispose(); _buildClient.Dispose(); + _wikiClient.Dispose(); _connection.Dispose(); _disposed = true; } diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs index 2ef9353..d366658 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs @@ -418,4 +418,59 @@ Task> GetBuildTimelineAsync( CancellationToken cancellationToken = default); #endregion + + #region Wiki Operations + + /// + /// Gets all wikis in a project. + /// + /// The project name (optional if default project is configured). + /// Cancellation token. + /// List of wikis. + Task> GetWikisAsync(string? project = null, CancellationToken cancellationToken = default); + + /// + /// Gets a specific wiki by its identifier (name or ID). + /// + /// The wiki name or ID. + /// The project name (optional if default project is configured). + /// Cancellation token. + /// The wiki details. + Task GetWikiAsync(string wikiIdentifier, string? project = null, CancellationToken cancellationToken = default); + + /// + /// Gets a wiki page by path, including its content. + /// + /// The wiki name or ID. + /// The page path (e.g., "/Home", "/Getting-Started/Installation"). + /// Whether to include the page content (Markdown). + /// Optional version/branch of the wiki. + /// The project name (optional if default project is configured). + /// Cancellation token. + /// The wiki page details. + Task GetWikiPageAsync( + string wikiIdentifier, + string path, + bool includeContent = true, + string? version = null, + string? project = null, + CancellationToken cancellationToken = default); + + /// + /// Gets the sub-pages of a wiki page (page tree). + /// + /// The wiki name or ID. + /// The parent page path (default is root "/"). + /// How deep to recurse: "OneLevel" or "Full". + /// The project name (optional if default project is configured). + /// Cancellation token. + /// The wiki page with its sub-pages. + Task GetWikiPageTreeAsync( + string wikiIdentifier, + string path = "/", + string recursionLevel = "OneLevel", + string? project = null, + CancellationToken cancellationToken = default); + + #endregion } diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WikiTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WikiTools.cs new file mode 100644 index 0000000..f494cb5 --- /dev/null +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WikiTools.cs @@ -0,0 +1,122 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using Viamus.Azure.Devops.Mcp.Server.Services; + +namespace Viamus.Azure.Devops.Mcp.Server.Tools; + +/// +/// MCP tools for Azure DevOps Wiki operations. +/// +[McpServerToolType] +public sealed class WikiTools +{ + private readonly IAzureDevOpsService _azureDevOpsService; + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public WikiTools(IAzureDevOpsService azureDevOpsService) + { + _azureDevOpsService = azureDevOpsService; + } + + [McpServerTool(Name = "get_wikis")] + [Description("Gets all wikis in an Azure DevOps project. Returns wiki details including name, type (projectWiki or codeWiki), repository, and versions.")] + public async Task GetWikis( + [Description("The project name (optional if default project is configured)")] string? project = null, + CancellationToken cancellationToken = default) + { + var wikis = await _azureDevOpsService.GetWikisAsync(project, cancellationToken); + return JsonSerializer.Serialize(new { count = wikis.Count, wikis }, JsonOptions); + } + + [McpServerTool(Name = "get_wiki")] + [Description("Gets details of a specific wiki by name or ID. Returns wiki information including type, repository, mapped path, and versions.")] + public async Task GetWiki( + [Description("The wiki name or ID")] string wikiIdentifier, + [Description("The project name (optional if default project is configured)")] string? project = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(wikiIdentifier)) + { + return JsonSerializer.Serialize(new { error = "Wiki name or ID is required" }, JsonOptions); + } + + var wiki = await _azureDevOpsService.GetWikiAsync(wikiIdentifier, project, cancellationToken); + + if (wiki is null) + { + return JsonSerializer.Serialize(new { error = $"Wiki '{wikiIdentifier}' not found" }, JsonOptions); + } + + return JsonSerializer.Serialize(wiki, JsonOptions); + } + + [McpServerTool(Name = "get_wiki_page")] + [Description("Gets a wiki page by path, including its Markdown content. Use this to read the content of a specific wiki page.")] + public async Task GetWikiPage( + [Description("The wiki name or ID")] string wikiIdentifier, + [Description("The page path (e.g., '/Home', '/Getting-Started/Installation')")] string path, + [Description("Whether to include the page Markdown content (default true)")] bool includeContent = true, + [Description("Optional version/branch of the wiki")] string? version = null, + [Description("The project name (optional if default project is configured)")] string? project = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(wikiIdentifier)) + { + return JsonSerializer.Serialize(new { error = "Wiki name or ID is required" }, JsonOptions); + } + + if (string.IsNullOrWhiteSpace(path)) + { + return JsonSerializer.Serialize(new { error = "Page path is required" }, JsonOptions); + } + + var page = await _azureDevOpsService.GetWikiPageAsync(wikiIdentifier, path, includeContent, version, project, cancellationToken); + + if (page is null) + { + return JsonSerializer.Serialize(new { error = $"Wiki page '{path}' not found in wiki '{wikiIdentifier}'" }, JsonOptions); + } + + return JsonSerializer.Serialize(new + { + wiki = wikiIdentifier, + page + }, JsonOptions); + } + + [McpServerTool(Name = "get_wiki_page_tree")] + [Description("Gets the page hierarchy (tree structure) of a wiki. Returns the sub-pages of a given page path. Useful for browsing wiki structure and discovering available pages.")] + public async Task GetWikiPageTree( + [Description("The wiki name or ID")] string wikiIdentifier, + [Description("The parent page path to browse (default is root '/')")] string path = "/", + [Description("Recursion level: 'OneLevel' (immediate children) or 'Full' (all descendants). Default is 'OneLevel'.")] string recursionLevel = "OneLevel", + [Description("The project name (optional if default project is configured)")] string? project = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(wikiIdentifier)) + { + return JsonSerializer.Serialize(new { error = "Wiki name or ID is required" }, JsonOptions); + } + + var pageTree = await _azureDevOpsService.GetWikiPageTreeAsync(wikiIdentifier, path, recursionLevel, project, cancellationToken); + + if (pageTree is null) + { + return JsonSerializer.Serialize(new { error = $"Wiki page '{path}' not found in wiki '{wikiIdentifier}'" }, JsonOptions); + } + + return JsonSerializer.Serialize(new + { + wiki = wikiIdentifier, + path, + recursionLevel, + subPageCount = pageTree.SubPages?.Count ?? 0, + page = pageTree + }, JsonOptions); + } +}