Skip to content

Commit 75423be

Browse files
viamusclaude
andcommitted
feat: add get_work_item_comments tool to read work item discussion
Exposes work item comment history through MCP with pagination (continuationToken), sort order (asc/desc), optional inclusion of deleted comments and optional rendered HTML expansion. Extends WorkItemCommentDto with modification, version, deletion, URL and format fields and adds a WorkItemCommentsResultDto for paged responses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70a214a commit 75423be

8 files changed

Lines changed: 267 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ This project implements an MCP server that exposes tools for querying and managi
125125
| `get_recent_work_items` | Gets recently changed work items |
126126
| `search_work_items` | Searches work items by title text |
127127
| `add_work_item_comment` | Adds a comment to a specific work item |
128+
| `get_work_item_comments` | Reads comments (discussion history) of a work item, with pagination, sort order, and optional rendered HTML |
128129
| `create_work_item` | Creates a new work item (Bug, Task, User Story, etc.) with support for all standard fields, parent linking, and custom fields |
129130
| `update_work_item` | Updates an existing work item. Only specified fields are changed; omitted fields remain unchanged |
130131

src/Viamus.Azure.Devops.Mcp.Server/Models/WorkItemCommentDto.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,10 @@ public sealed record WorkItemCommentDto
1010
public string? Text { get; init; }
1111
public string? CreatedBy { get; init; }
1212
public DateTime? CreatedDate { get; init; }
13+
public string? ModifiedBy { get; init; }
14+
public DateTime? ModifiedDate { get; init; }
15+
public int? Version { get; init; }
16+
public bool? IsDeleted { get; init; }
17+
public string? Url { get; init; }
18+
public string? Format { get; init; }
1319
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Viamus.Azure.Devops.Mcp.Server.Models;
2+
3+
/// <summary>
4+
/// Result wrapper for a page of work item comments.
5+
/// </summary>
6+
public sealed record WorkItemCommentsResultDto
7+
{
8+
public required IReadOnlyList<WorkItemCommentDto> Comments { get; init; }
9+
public int TotalCount { get; init; }
10+
public int Count { get; init; }
11+
public string? ContinuationToken { get; init; }
12+
public string? NextPage { get; init; }
13+
}

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

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -496,22 +496,84 @@ public async Task<WorkItemCommentDto> AddWorkItemCommentAsync(
496496
workItemId: workItemId,
497497
cancellationToken: cancellationToken);
498498

499-
return new WorkItemCommentDto
499+
return MapToCommentDto(createdComment, workItemId);
500+
}
501+
catch (Exception ex)
502+
{
503+
_logger.LogError(ex, "Error adding comment to work item {WorkItemId}", workItemId);
504+
throw;
505+
}
506+
}
507+
508+
public async Task<WorkItemCommentsResultDto> GetWorkItemCommentsAsync(
509+
int workItemId,
510+
string? project = null,
511+
int? top = null,
512+
string? continuationToken = null,
513+
bool includeDeleted = false,
514+
string? order = null,
515+
bool includeRenderedText = false,
516+
CancellationToken cancellationToken = default)
517+
{
518+
try
519+
{
520+
_logger.LogDebug("Getting comments for work item {WorkItemId}", workItemId);
521+
522+
CommentSortOrder? sortOrder = order?.Trim().ToLowerInvariant() switch
500523
{
501-
Id = createdComment.Id,
502-
WorkItemId = workItemId,
503-
Text = createdComment.Text,
504-
CreatedBy = createdComment.CreatedBy?.DisplayName,
505-
CreatedDate = createdComment.CreatedDate
524+
"asc" or "ascending" or "oldest" => CommentSortOrder.Asc,
525+
"desc" or "descending" or "newest" => CommentSortOrder.Desc,
526+
null or "" => null,
527+
_ => throw new ArgumentException($"Invalid order '{order}'. Use 'asc' or 'desc'.", nameof(order))
528+
};
529+
530+
CommentExpandOptions? expand = includeRenderedText ? CommentExpandOptions.RenderedText : null;
531+
532+
var list = await _witClient.GetCommentsAsync(
533+
project: project ?? _options.DefaultProject,
534+
workItemId: workItemId,
535+
top: top,
536+
continuationToken: continuationToken,
537+
includeDeleted: includeDeleted,
538+
expand: expand,
539+
order: sortOrder,
540+
cancellationToken: cancellationToken);
541+
542+
var mapped = (list.Comments ?? new List<Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models.Comment>())
543+
.Select(c => MapToCommentDto(c, workItemId))
544+
.ToList();
545+
546+
return new WorkItemCommentsResultDto
547+
{
548+
Comments = mapped,
549+
TotalCount = list.TotalCount,
550+
Count = list.Count,
551+
ContinuationToken = list.ContinuationToken,
552+
NextPage = list.NextPage?.ToString()
506553
};
507554
}
508555
catch (Exception ex)
509556
{
510-
_logger.LogError(ex, "Error adding comment to work item {WorkItemId}", workItemId);
557+
_logger.LogError(ex, "Error getting comments for work item {WorkItemId}", workItemId);
511558
throw;
512559
}
513560
}
514561

562+
private static WorkItemCommentDto MapToCommentDto(Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models.Comment comment, int workItemId) => new()
563+
{
564+
Id = comment.Id,
565+
WorkItemId = comment.WorkItemId != 0 ? comment.WorkItemId : workItemId,
566+
Text = comment.Text,
567+
CreatedBy = comment.CreatedBy?.DisplayName,
568+
CreatedDate = comment.CreatedDate,
569+
ModifiedBy = comment.ModifiedBy?.DisplayName,
570+
ModifiedDate = comment.ModifiedDate,
571+
Version = comment.Version,
572+
IsDeleted = comment.IsDeleted,
573+
Url = comment.Url,
574+
Format = comment.Format.ToString()
575+
};
576+
515577
public async Task<IReadOnlyList<WorkItemAttachmentDto>> GetWorkItemAttachmentsAsync(
516578
int workItemId,
517579
string? project = null,

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,28 @@ Task<WorkItemCommentDto> AddWorkItemCommentAsync(
7575
string? project = null,
7676
CancellationToken cancellationToken = default);
7777

78+
/// <summary>
79+
/// Gets the comments for a work item, pageable.
80+
/// </summary>
81+
/// <param name="workItemId">The work item ID.</param>
82+
/// <param name="project">The project name (optional if default project is configured).</param>
83+
/// <param name="top">Maximum number of comments to return per page.</param>
84+
/// <param name="continuationToken">Continuation token from a prior call to fetch the next page.</param>
85+
/// <param name="includeDeleted">Whether to include deleted comments.</param>
86+
/// <param name="order">Sort order: "asc" (oldest first) or "desc" (newest first).</param>
87+
/// <param name="includeRenderedText">If true, expand comments to include rendered HTML text.</param>
88+
/// <param name="cancellationToken">Cancellation token.</param>
89+
/// <returns>A page of work item comments with pagination metadata.</returns>
90+
Task<WorkItemCommentsResultDto> GetWorkItemCommentsAsync(
91+
int workItemId,
92+
string? project = null,
93+
int? top = null,
94+
string? continuationToken = null,
95+
bool includeDeleted = false,
96+
string? order = null,
97+
bool includeRenderedText = false,
98+
CancellationToken cancellationToken = default);
99+
78100
/// <summary>
79101
/// Gets the attachments linked to a work item.
80102
/// </summary>

src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,50 @@ public async Task<string> AddWorkItemComment(
307307
}, JsonOptions);
308308
}
309309

310+
[McpServerTool(Name = "get_work_item_comments")]
311+
[Description("Gets the comments (discussion history) of a specific Azure DevOps work item. Returns each comment's author, text, creation/modification timestamps, and supports pagination via continuationToken.")]
312+
public async Task<string> GetWorkItemComments(
313+
[Description("The ID of the work item whose comments will be retrieved")] int workItemId,
314+
[Description("The project name (optional if default project is configured)")] string? project = null,
315+
[Description("Maximum number of comments to return per page (default: 200)")] int? top = null,
316+
[Description("Continuation token from a previous response to fetch the next page")] string? continuationToken = null,
317+
[Description("Whether to include deleted comments (default: false)")] bool includeDeleted = false,
318+
[Description("Sort order: 'asc' (oldest first) or 'desc' (newest first). Defaults to server order.")] string? order = null,
319+
[Description("If true, includes the rendered HTML of each comment in addition to its Markdown text (default: false)")] bool includeRenderedText = false,
320+
CancellationToken cancellationToken = default)
321+
{
322+
if (workItemId <= 0)
323+
{
324+
return JsonSerializer.Serialize(new { error = "workItemId must be a positive integer" }, JsonOptions);
325+
}
326+
327+
if (top.HasValue && top.Value <= 0)
328+
{
329+
return JsonSerializer.Serialize(new { error = "top must be a positive integer" }, JsonOptions);
330+
}
331+
332+
if (!string.IsNullOrWhiteSpace(order))
333+
{
334+
var normalized = order.Trim().ToLowerInvariant();
335+
if (normalized is not ("asc" or "desc" or "ascending" or "descending" or "oldest" or "newest"))
336+
{
337+
return JsonSerializer.Serialize(new { error = "order must be 'asc' or 'desc'" }, JsonOptions);
338+
}
339+
}
340+
341+
var result = await _azureDevOpsService.GetWorkItemCommentsAsync(
342+
workItemId,
343+
project,
344+
top,
345+
continuationToken,
346+
includeDeleted,
347+
order,
348+
includeRenderedText,
349+
cancellationToken);
350+
351+
return JsonSerializer.Serialize(result, JsonOptions);
352+
}
353+
310354
[McpServerTool(Name = "create_work_item")]
311355
[Description("Creates a new work item in Azure DevOps. Supports setting all standard fields plus custom fields via additionalFields.")]
312356
public async Task<string> CreateWorkItem(

tests/Viamus.Azure.Devops.Mcp.Server.Tests/Models/WorkItemCommentDtoTests.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ public void WorkItemCommentDto_ContainsExpectedProperties()
128128
Assert.Contains("Text", properties);
129129
Assert.Contains("CreatedBy", properties);
130130
Assert.Contains("CreatedDate", properties);
131-
Assert.Equal(5, properties.Count);
131+
Assert.Contains("ModifiedBy", properties);
132+
Assert.Contains("ModifiedDate", properties);
133+
Assert.Contains("Version", properties);
134+
Assert.Contains("IsDeleted", properties);
135+
Assert.Contains("Url", properties);
136+
Assert.Contains("Format", properties);
132137
}
133138
}

tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,112 @@ public async Task AddWorkItemComment_WithProject_ShouldPassProjectToService()
614614

615615
#endregion
616616

617+
#region GetWorkItemComments Tests
618+
619+
[Fact]
620+
public async Task GetWorkItemComments_WithValidId_ShouldReturnSerializedResult()
621+
{
622+
var serviceResult = new WorkItemCommentsResultDto
623+
{
624+
Comments = new List<WorkItemCommentDto>
625+
{
626+
new() { Id = 1, WorkItemId = 123, Text = "First comment", CreatedBy = "Alice" },
627+
new() { Id = 2, WorkItemId = 123, Text = "Second comment", CreatedBy = "Bob" }
628+
},
629+
TotalCount = 2,
630+
Count = 2
631+
};
632+
633+
_mockService
634+
.Setup(s => s.GetWorkItemCommentsAsync(123, null, null, null, false, null, false, It.IsAny<CancellationToken>()))
635+
.ReturnsAsync(serviceResult);
636+
637+
var result = await _tools.GetWorkItemComments(123);
638+
639+
Assert.Contains("\"totalCount\": 2", result);
640+
Assert.Contains("First comment", result);
641+
Assert.Contains("Second comment", result);
642+
}
643+
644+
[Fact]
645+
public async Task GetWorkItemComments_WithInvalidId_ShouldReturnError()
646+
{
647+
var result = await _tools.GetWorkItemComments(0);
648+
649+
Assert.Contains("error", result);
650+
Assert.Contains("workItemId", result);
651+
}
652+
653+
[Fact]
654+
public async Task GetWorkItemComments_WithNonPositiveTop_ShouldReturnError()
655+
{
656+
var result = await _tools.GetWorkItemComments(123, top: 0);
657+
658+
Assert.Contains("error", result);
659+
Assert.Contains("top", result);
660+
}
661+
662+
[Fact]
663+
public async Task GetWorkItemComments_WithInvalidOrder_ShouldReturnError()
664+
{
665+
var result = await _tools.GetWorkItemComments(123, order: "sideways");
666+
667+
Assert.Contains("error", result);
668+
Assert.Contains("order", result);
669+
}
670+
671+
[Fact]
672+
public async Task GetWorkItemComments_WithAllOptionalArgs_ShouldPassThemToService()
673+
{
674+
var serviceResult = new WorkItemCommentsResultDto
675+
{
676+
Comments = Array.Empty<WorkItemCommentDto>(),
677+
TotalCount = 0,
678+
Count = 0,
679+
ContinuationToken = null
680+
};
681+
682+
_mockService
683+
.Setup(s => s.GetWorkItemCommentsAsync(
684+
123, "MyProject", 50, "token-xyz", true, "desc", true, It.IsAny<CancellationToken>()))
685+
.ReturnsAsync(serviceResult);
686+
687+
await _tools.GetWorkItemComments(
688+
workItemId: 123,
689+
project: "MyProject",
690+
top: 50,
691+
continuationToken: "token-xyz",
692+
includeDeleted: true,
693+
order: "desc",
694+
includeRenderedText: true);
695+
696+
_mockService.Verify(s => s.GetWorkItemCommentsAsync(
697+
123, "MyProject", 50, "token-xyz", true, "desc", true, It.IsAny<CancellationToken>()), Times.Once);
698+
}
699+
700+
[Fact]
701+
public async Task GetWorkItemComments_WithContinuationToken_ShouldReturnTokenInResponse()
702+
{
703+
var serviceResult = new WorkItemCommentsResultDto
704+
{
705+
Comments = new List<WorkItemCommentDto> { new() { Id = 1, WorkItemId = 123 } },
706+
TotalCount = 250,
707+
Count = 1,
708+
ContinuationToken = "next-page-token"
709+
};
710+
711+
_mockService
712+
.Setup(s => s.GetWorkItemCommentsAsync(123, null, null, null, false, null, false, It.IsAny<CancellationToken>()))
713+
.ReturnsAsync(serviceResult);
714+
715+
var result = await _tools.GetWorkItemComments(123);
716+
717+
Assert.Contains("\"continuationToken\": \"next-page-token\"", result);
718+
Assert.Contains("\"totalCount\": 250", result);
719+
}
720+
721+
#endregion
722+
617723
#region CreateWorkItem Tests
618724

619725
[Fact]

0 commit comments

Comments
 (0)