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); + } +}