Skip to content

Commit bf93f89

Browse files
authored
Merge pull request #10 from viamus/feature/mcp-wiki
feat: add Wiki tools for Azure DevOps
2 parents 866c7b6 + e8828ac commit bf93f89

5 files changed

Lines changed: 396 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Viamus.Azure.Devops.Mcp.Server.Models;
2+
3+
/// <summary>
4+
/// Data transfer object representing an Azure DevOps Wiki.
5+
/// </summary>
6+
public sealed record WikiDto
7+
{
8+
public string? Id { get; init; }
9+
public string? Name { get; init; }
10+
public string? Type { get; init; }
11+
public string? Url { get; init; }
12+
public string? RemoteUrl { get; init; }
13+
public string? ProjectId { get; init; }
14+
public string? ProjectName { get; init; }
15+
public string? RepositoryId { get; init; }
16+
public string? MappedPath { get; init; }
17+
public List<string>? Versions { get; init; }
18+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace Viamus.Azure.Devops.Mcp.Server.Models;
2+
3+
/// <summary>
4+
/// Data transfer object representing an Azure DevOps Wiki page.
5+
/// </summary>
6+
public sealed record WikiPageDto
7+
{
8+
public int? Id { get; init; }
9+
public string? Path { get; init; }
10+
public string? Content { get; init; }
11+
public int? Order { get; init; }
12+
public string? GitItemPath { get; init; }
13+
public string? RemoteUrl { get; init; }
14+
public bool IsParentPage { get; init; }
15+
public List<WikiPageSummaryDto>? SubPages { get; init; }
16+
}
17+
18+
/// <summary>
19+
/// Lightweight summary of a Wiki page for listing operations.
20+
/// </summary>
21+
public sealed record WikiPageSummaryDto
22+
{
23+
public int? Id { get; init; }
24+
public string? Path { get; init; }
25+
public int? Order { get; init; }
26+
public string? GitItemPath { get; init; }
27+
public string? RemoteUrl { get; init; }
28+
public bool IsParentPage { get; init; }
29+
}

src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.Extensions.Options;
22
using Microsoft.TeamFoundation.Build.WebApi;
33
using Microsoft.TeamFoundation.SourceControl.WebApi;
4+
using Microsoft.TeamFoundation.Wiki.WebApi;
45
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
56
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
67
using Microsoft.VisualStudio.Services.Common;
@@ -23,6 +24,7 @@ public sealed class AzureDevOpsService : IAzureDevOpsService, IDisposable
2324
private readonly WorkItemTrackingHttpClient _witClient;
2425
private readonly GitHttpClient _gitClient;
2526
private readonly BuildHttpClient _buildClient;
27+
private readonly WikiHttpClient _wikiClient;
2628
private bool _disposed;
2729

2830
private static readonly string[] DefaultFields =
@@ -71,6 +73,7 @@ public AzureDevOpsService(IOptions<AzureDevOpsOptions> options, ILogger<AzureDev
7173
_witClient = _connection.GetClient<WorkItemTrackingHttpClient>();
7274
_gitClient = _connection.GetClient<GitHttpClient>();
7375
_buildClient = _connection.GetClient<BuildHttpClient>();
76+
_wikiClient = _connection.GetClient<WikiHttpClient>();
7477

7578
_logger.LogInformation("Azure DevOps service initialized for organization: {OrganizationUrl}", _options.OrganizationUrl);
7679
}
@@ -1628,13 +1631,182 @@ private static BuildTimelineRecordDto MapToBuildTimelineRecordDto(TimelineRecord
16281631

16291632
#endregion
16301633

1634+
#region Wiki Operations
1635+
1636+
public async Task<IReadOnlyList<WikiDto>> GetWikisAsync(string? project = null, CancellationToken cancellationToken = default)
1637+
{
1638+
var projectName = project ?? _options.DefaultProject;
1639+
1640+
_logger.LogInformation("Getting wikis for project: {Project}", projectName ?? "(all)");
1641+
1642+
try
1643+
{
1644+
var wikis = await _wikiClient.GetAllWikisAsync(project: projectName, cancellationToken: cancellationToken);
1645+
1646+
_logger.LogInformation("Found {Count} wikis", wikis.Count);
1647+
1648+
return wikis.Select(MapToWikiDto).ToList();
1649+
}
1650+
catch (Exception ex)
1651+
{
1652+
_logger.LogError(ex, "Error getting wikis for project: {Project}", projectName);
1653+
throw;
1654+
}
1655+
}
1656+
1657+
public async Task<WikiDto?> GetWikiAsync(string wikiIdentifier, string? project = null, CancellationToken cancellationToken = default)
1658+
{
1659+
var projectName = project ?? _options.DefaultProject;
1660+
1661+
_logger.LogInformation("Getting wiki: {WikiIdentifier} in project: {Project}", wikiIdentifier, projectName ?? "(default)");
1662+
1663+
try
1664+
{
1665+
var wiki = await _wikiClient.GetWikiAsync(project: projectName, wikiIdentifier: wikiIdentifier, cancellationToken: cancellationToken);
1666+
return MapToWikiDto(wiki);
1667+
}
1668+
catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) ||
1669+
ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase))
1670+
{
1671+
_logger.LogWarning("Wiki '{WikiIdentifier}' not found", wikiIdentifier);
1672+
return null;
1673+
}
1674+
}
1675+
1676+
public async Task<WikiPageDto?> GetWikiPageAsync(
1677+
string wikiIdentifier,
1678+
string path,
1679+
bool includeContent = true,
1680+
string? version = null,
1681+
string? project = null,
1682+
CancellationToken cancellationToken = default)
1683+
{
1684+
var projectName = project ?? _options.DefaultProject;
1685+
1686+
_logger.LogInformation("Getting wiki page: {Path} from wiki: {WikiIdentifier}", path, wikiIdentifier);
1687+
1688+
try
1689+
{
1690+
var versionDescriptor = !string.IsNullOrWhiteSpace(version)
1691+
? new GitVersionDescriptor { Version = version, VersionType = GitVersionType.Branch }
1692+
: null;
1693+
1694+
var page = await _wikiClient.GetPageAsync(
1695+
project: projectName,
1696+
wikiIdentifier: wikiIdentifier,
1697+
path: path,
1698+
recursionLevel: VersionControlRecursionType.None,
1699+
versionDescriptor: versionDescriptor,
1700+
includeContent: includeContent,
1701+
cancellationToken: cancellationToken);
1702+
1703+
return MapToWikiPageDto(page.Page, page.Page.Content);
1704+
}
1705+
catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) ||
1706+
ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase))
1707+
{
1708+
_logger.LogWarning("Wiki page '{Path}' not found in wiki '{WikiIdentifier}'", path, wikiIdentifier);
1709+
return null;
1710+
}
1711+
}
1712+
1713+
public async Task<WikiPageDto?> GetWikiPageTreeAsync(
1714+
string wikiIdentifier,
1715+
string path = "/",
1716+
string recursionLevel = "OneLevel",
1717+
string? project = null,
1718+
CancellationToken cancellationToken = default)
1719+
{
1720+
var projectName = project ?? _options.DefaultProject;
1721+
1722+
_logger.LogInformation("Getting wiki page tree: {Path} from wiki: {WikiIdentifier} with recursion: {Recursion}", path, wikiIdentifier, recursionLevel);
1723+
1724+
try
1725+
{
1726+
var recursion = recursionLevel.Equals("Full", StringComparison.OrdinalIgnoreCase)
1727+
? VersionControlRecursionType.Full
1728+
: VersionControlRecursionType.OneLevel;
1729+
1730+
var page = await _wikiClient.GetPageAsync(
1731+
project: projectName,
1732+
wikiIdentifier: wikiIdentifier,
1733+
path: path,
1734+
recursionLevel: recursion,
1735+
includeContent: false,
1736+
cancellationToken: cancellationToken);
1737+
1738+
return MapToWikiPageDtoWithSubPages(page.Page);
1739+
}
1740+
catch (Exception ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase) ||
1741+
ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase))
1742+
{
1743+
_logger.LogWarning("Wiki page '{Path}' not found in wiki '{WikiIdentifier}'", path, wikiIdentifier);
1744+
return null;
1745+
}
1746+
}
1747+
1748+
private static WikiDto MapToWikiDto(WikiV2 wiki)
1749+
{
1750+
return new WikiDto
1751+
{
1752+
Id = wiki.Id.ToString(),
1753+
Name = wiki.Name,
1754+
Type = wiki.Type.ToString(),
1755+
Url = wiki.Url,
1756+
RemoteUrl = wiki.RemoteUrl,
1757+
ProjectId = wiki.ProjectId.ToString(),
1758+
RepositoryId = wiki.RepositoryId.ToString(),
1759+
MappedPath = wiki.MappedPath,
1760+
Versions = wiki.Versions?.Select(v => v.Version).ToList()
1761+
};
1762+
}
1763+
1764+
private static WikiPageDto MapToWikiPageDto(WikiPage page, string? content = null)
1765+
{
1766+
return new WikiPageDto
1767+
{
1768+
Id = page.Id,
1769+
Path = page.Path,
1770+
Content = content ?? page.Content,
1771+
Order = page.Order,
1772+
GitItemPath = page.GitItemPath,
1773+
RemoteUrl = page.RemoteUrl,
1774+
IsParentPage = page.IsParentPage
1775+
};
1776+
}
1777+
1778+
private static WikiPageDto MapToWikiPageDtoWithSubPages(WikiPage page)
1779+
{
1780+
return new WikiPageDto
1781+
{
1782+
Id = page.Id,
1783+
Path = page.Path,
1784+
Order = page.Order,
1785+
GitItemPath = page.GitItemPath,
1786+
RemoteUrl = page.RemoteUrl,
1787+
IsParentPage = page.IsParentPage,
1788+
SubPages = page.SubPages?.Select(sp => new WikiPageSummaryDto
1789+
{
1790+
Id = sp.Id,
1791+
Path = sp.Path,
1792+
Order = sp.Order,
1793+
GitItemPath = sp.GitItemPath,
1794+
RemoteUrl = sp.RemoteUrl,
1795+
IsParentPage = sp.IsParentPage
1796+
}).ToList()
1797+
};
1798+
}
1799+
1800+
#endregion
1801+
16311802
public void Dispose()
16321803
{
16331804
if (_disposed) return;
16341805

16351806
_witClient.Dispose();
16361807
_gitClient.Dispose();
16371808
_buildClient.Dispose();
1809+
_wikiClient.Dispose();
16381810
_connection.Dispose();
16391811
_disposed = true;
16401812
}

src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,59 @@ Task<IReadOnlyList<BuildTimelineRecordDto>> GetBuildTimelineAsync(
418418
CancellationToken cancellationToken = default);
419419

420420
#endregion
421+
422+
#region Wiki Operations
423+
424+
/// <summary>
425+
/// Gets all wikis in a project.
426+
/// </summary>
427+
/// <param name="project">The project name (optional if default project is configured).</param>
428+
/// <param name="cancellationToken">Cancellation token.</param>
429+
/// <returns>List of wikis.</returns>
430+
Task<IReadOnlyList<WikiDto>> GetWikisAsync(string? project = null, CancellationToken cancellationToken = default);
431+
432+
/// <summary>
433+
/// Gets a specific wiki by its identifier (name or ID).
434+
/// </summary>
435+
/// <param name="wikiIdentifier">The wiki name or ID.</param>
436+
/// <param name="project">The project name (optional if default project is configured).</param>
437+
/// <param name="cancellationToken">Cancellation token.</param>
438+
/// <returns>The wiki details.</returns>
439+
Task<WikiDto?> GetWikiAsync(string wikiIdentifier, string? project = null, CancellationToken cancellationToken = default);
440+
441+
/// <summary>
442+
/// Gets a wiki page by path, including its content.
443+
/// </summary>
444+
/// <param name="wikiIdentifier">The wiki name or ID.</param>
445+
/// <param name="path">The page path (e.g., "/Home", "/Getting-Started/Installation").</param>
446+
/// <param name="includeContent">Whether to include the page content (Markdown).</param>
447+
/// <param name="version">Optional version/branch of the wiki.</param>
448+
/// <param name="project">The project name (optional if default project is configured).</param>
449+
/// <param name="cancellationToken">Cancellation token.</param>
450+
/// <returns>The wiki page details.</returns>
451+
Task<WikiPageDto?> GetWikiPageAsync(
452+
string wikiIdentifier,
453+
string path,
454+
bool includeContent = true,
455+
string? version = null,
456+
string? project = null,
457+
CancellationToken cancellationToken = default);
458+
459+
/// <summary>
460+
/// Gets the sub-pages of a wiki page (page tree).
461+
/// </summary>
462+
/// <param name="wikiIdentifier">The wiki name or ID.</param>
463+
/// <param name="path">The parent page path (default is root "/").</param>
464+
/// <param name="recursionLevel">How deep to recurse: "OneLevel" or "Full".</param>
465+
/// <param name="project">The project name (optional if default project is configured).</param>
466+
/// <param name="cancellationToken">Cancellation token.</param>
467+
/// <returns>The wiki page with its sub-pages.</returns>
468+
Task<WikiPageDto?> GetWikiPageTreeAsync(
469+
string wikiIdentifier,
470+
string path = "/",
471+
string recursionLevel = "OneLevel",
472+
string? project = null,
473+
CancellationToken cancellationToken = default);
474+
475+
#endregion
421476
}

0 commit comments

Comments
 (0)