Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ This project implements an MCP server that exposes tools for querying and managi
| `get_work_item_comments` | Reads comments (discussion history) of a work item, with pagination, sort order, and optional rendered HTML |
| `create_work_item` | Creates a new work item (Bug, Task, User Story, etc.) with support for all standard fields, parent linking, and custom fields |
| `update_work_item` | Updates an existing work item. Only specified fields are changed; omitted fields remain unchanged |
| `link_work_items` | Links an existing work item to parent, child, predecessor, successor, or related work items |

### Git Repository Tools

Expand Down Expand Up @@ -370,6 +371,7 @@ After configuring the MCP client, you can ask questions like:
- "Create a User Story assigned to John with priority 2 under parent #100"
- "Update work item #1234 to change state to 'Resolved' and assign to Jane"
- "Set the iteration path of work item #567 to 'Project\Sprint 3'"
- "Link user story #567 to predecessor #123 and parent #100"

### Git Repositories

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,65 @@ FROM WorkItemLinks
}
}

public async Task<WorkItemDto> LinkWorkItemsAsync(
int sourceWorkItemId,
IEnumerable<int> targetWorkItemIds,
string relationType,
string? comment = null,
string? project = null,
CancellationToken cancellationToken = default)
{
var targetIds = targetWorkItemIds.Distinct().ToList();
if (targetIds.Count == 0)
{
throw new ArgumentException("At least one target work item ID is required.", nameof(targetWorkItemIds));
}

try
{
_logger.LogDebug(
"Linking work item {SourceWorkItemId} to {TargetCount} work item(s) with relation {RelationType}",
sourceWorkItemId,
targetIds.Count,
relationType);

var patchDocument = new JsonPatchDocument();
var relationComment = string.IsNullOrWhiteSpace(comment) ? relationType : comment.Trim();

foreach (var targetId in targetIds)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/relations/-",
Value = new
{
rel = relationType,
url = BuildWorkItemUrl(targetId),
attributes = new { comment = relationComment }
}
});
}

var result = await _witClient.UpdateWorkItemAsync(
document: patchDocument,
id: sourceWorkItemId,
project: project ?? _options.DefaultProject,
cancellationToken: cancellationToken);

return MapToDto(result, includeAllFields: true);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error linking work item {SourceWorkItemId} with relation {RelationType}",
sourceWorkItemId,
relationType);
throw;
}
}

public async Task<PaginatedResult<WorkItemSummaryDto>> QueryWorkItemsSummaryAsync(
string wiqlQuery,
string? project = null,
Expand Down Expand Up @@ -477,6 +536,9 @@ private static (string? repositoryId, string? artifactId)? ExtractGitArtifactInf
return null;
}

private string BuildWorkItemUrl(int workItemId) =>
$"{_options.OrganizationUrl.TrimEnd('/')}/_apis/wit/workItems/{workItemId}";

public async Task<WorkItemCommentDto> AddWorkItemCommentAsync(
int workItemId,
string comment,
Expand Down Expand Up @@ -879,7 +941,7 @@ public async Task<WorkItemDto> CreateWorkItemAsync(
Value = new
{
rel = "System.LinkTypes.Hierarchy-Reverse",
url = $"{_options.OrganizationUrl}/_apis/wit/workItems/{parentId.Value}",
url = BuildWorkItemUrl(parentId.Value),
attributes = new { comment = "Parent" }
}
});
Expand Down
18 changes: 18 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ public interface IAzureDevOpsService
/// <returns>List of child work items.</returns>
Task<IReadOnlyList<WorkItemDto>> GetChildWorkItemsAsync(int parentWorkItemId, string? project = null, CancellationToken cancellationToken = default);

/// <summary>
/// Links an existing work item to one or more other work items.
/// </summary>
/// <param name="sourceWorkItemId">The work item ID to update with the new relation.</param>
/// <param name="targetWorkItemIds">The work item IDs to link to.</param>
/// <param name="relationType">The Azure DevOps relation reference name (for example, System.LinkTypes.Hierarchy-Reverse).</param>
/// <param name="comment">Optional relation comment.</param>
/// <param name="project">The project name (optional if default project is configured).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated source work item.</returns>
Task<WorkItemDto> LinkWorkItemsAsync(
int sourceWorkItemId,
IEnumerable<int> targetWorkItemIds,
string relationType,
string? comment = null,
string? project = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Queries work items using WIQL and returns paginated summary results.
/// This is optimized for large result sets with reduced payload size.
Expand Down
100 changes: 99 additions & 1 deletion src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,69 @@ public async Task<string> GetChildWorkItems(
return JsonSerializer.Serialize(new { parentWorkItemId, count = workItems.Count, children = workItems }, JsonOptions);
}

[McpServerTool(Name = "link_work_items")]
[Description("Links an existing Azure DevOps work item to one or more other work items. relationType accepts parent, child, predecessor, successor, related, or the matching System.LinkTypes.* reference name.")]
public async Task<string> LinkWorkItems(
[Description("The work item ID to update with the new link. For a story, this is the story ID.")] int sourceWorkItemId,
[Description("Comma- or semicolon-separated target work item IDs to link to (e.g., '123,456' or '123;456')")] string targetWorkItemIds,
[Description("Relation from the source work item's perspective: parent, child, predecessor, successor, related, or a System.LinkTypes.* reference name")] string relationType,
[Description("The project name (optional if default project is configured)")] string? project = null,
[Description("Optional comment to store on the relation")] string? comment = null,
CancellationToken cancellationToken = default)
{
if (sourceWorkItemId <= 0)
{
return JsonSerializer.Serialize(new { error = "sourceWorkItemId must be a positive integer" }, JsonOptions);
}

var targetIds = ParseWorkItemIds(targetWorkItemIds);
if (targetIds.Count == 0)
{
return JsonSerializer.Serialize(new { error = "No valid target work item IDs provided" }, JsonOptions);
}

if (targetIds.Contains(sourceWorkItemId))
{
return JsonSerializer.Serialize(new { error = "A work item cannot be linked to itself" }, JsonOptions);
}

var normalizedRelationType = NormalizeWorkItemRelationType(relationType);
if (normalizedRelationType is null)
{
return JsonSerializer.Serialize(new
{
error = "relationType must be one of: parent, child, predecessor, successor, related"
}, JsonOptions);
}

if (normalizedRelationType == "System.LinkTypes.Hierarchy-Reverse" && targetIds.Count > 1)
{
return JsonSerializer.Serialize(new { error = "A work item can only have one parent" }, JsonOptions);
}

var relationComment = string.IsNullOrWhiteSpace(comment)
? GetDefaultRelationComment(normalizedRelationType)
: comment.Trim();

var workItem = await _azureDevOpsService.LinkWorkItemsAsync(
sourceWorkItemId,
targetIds,
normalizedRelationType,
relationComment,
project,
cancellationToken);

return JsonSerializer.Serialize(new
{
success = true,
message = $"Work item {sourceWorkItemId} linked to {targetIds.Count} work item(s)",
sourceWorkItemId,
targetWorkItemIds = targetIds,
relationType = normalizedRelationType,
workItem
}, JsonOptions);
}

[McpServerTool(Name = "get_recent_work_items")]
[Description("Gets recently changed work items with pagination. Returns a summary view (ID, Title, Type, State, Priority) to reduce payload size. Use get_work_item to get full details of a specific item.")]
public async Task<string> GetRecentWorkItems(
Expand Down Expand Up @@ -477,6 +540,41 @@ public async Task<string> UpdateWorkItem(
}
}

private static string? NormalizeWorkItemRelationType(string? relationType)
{
if (string.IsNullOrWhiteSpace(relationType))
{
return null;
}

var normalized = relationType
.Trim()
.Replace('_', '-')
.Replace(' ', '-')
.ToLowerInvariant();

return normalized switch
{
"parent" or "hierarchy-reverse" or "system.linktypes.hierarchy-reverse" => "System.LinkTypes.Hierarchy-Reverse",
"child" or "hierarchy-forward" or "system.linktypes.hierarchy-forward" => "System.LinkTypes.Hierarchy-Forward",
"predecessor" or "blocked-by" or "depends-on" or "dependency-reverse" or "system.linktypes.dependency-reverse" => "System.LinkTypes.Dependency-Reverse",
"successor" or "blocks" or "dependency-forward" or "system.linktypes.dependency-forward" => "System.LinkTypes.Dependency-Forward",
"related" or "system.linktypes.related" => "System.LinkTypes.Related",
_ => null
};
}

private static string GetDefaultRelationComment(string relationType) =>
relationType switch
{
"System.LinkTypes.Hierarchy-Reverse" => "Parent",
"System.LinkTypes.Hierarchy-Forward" => "Child",
"System.LinkTypes.Dependency-Reverse" => "Predecessor",
"System.LinkTypes.Dependency-Forward" => "Successor",
"System.LinkTypes.Related" => "Related",
_ => relationType
};

private static List<int> ParseWorkItemIds(string workItemIds)
{
if (string.IsNullOrWhiteSpace(workItemIds))
Expand All @@ -485,7 +583,7 @@ private static List<int> ParseWorkItemIds(string workItemIds)
}

return workItemIds
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(id => int.TryParse(id, out var parsed) ? parsed : (int?)null)
.Where(id => id.HasValue)
.Select(id => id!.Value)
Expand Down
132 changes: 132 additions & 0 deletions tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,138 @@ public async Task GetChildWorkItems_WhenNoChildren_ShouldReturnEmptyList()

#endregion

#region LinkWorkItems Tests

[Fact]
public async Task LinkWorkItems_WithPredecessors_ShouldLinkUsingDependencyReverse()
{
var workItem = new WorkItemDto { Id = 100, Title = "Story" };

_mockService
.Setup(s => s.LinkWorkItemsAsync(
100,
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 10, 20 })),
"System.LinkTypes.Dependency-Reverse",
"Predecessor",
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(workItem);

var result = await _tools.LinkWorkItems(100, "10,20", "predecessor");

Assert.Contains("\"success\": true", result);
Assert.Contains("\"relationType\": \"System.LinkTypes.Dependency-Reverse\"", result);
Assert.Contains("\"targetWorkItemIds\":", result);
_mockService.Verify(s => s.LinkWorkItemsAsync(
100,
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 10, 20 })),
"System.LinkTypes.Dependency-Reverse",
"Predecessor",
null,
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task LinkWorkItems_WithParent_ShouldLinkUsingHierarchyReverse()
{
var workItem = new WorkItemDto { Id = 100, Title = "Story", ParentId = 50 };

_mockService
.Setup(s => s.LinkWorkItemsAsync(
100,
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 50 })),
"System.LinkTypes.Hierarchy-Reverse",
"Parent",
"MyProject",
It.IsAny<CancellationToken>()))
.ReturnsAsync(workItem);

var result = await _tools.LinkWorkItems(100, "50", "parent", project: "MyProject");

Assert.Contains("\"success\": true", result);
Assert.Contains("\"relationType\": \"System.LinkTypes.Hierarchy-Reverse\"", result);
_mockService.Verify(s => s.LinkWorkItemsAsync(
100,
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 50 })),
"System.LinkTypes.Hierarchy-Reverse",
"Parent",
"MyProject",
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task LinkWorkItems_WithCustomComment_ShouldPassCommentToService()
{
var workItem = new WorkItemDto { Id = 100, Title = "Story" };

_mockService
.Setup(s => s.LinkWorkItemsAsync(
100,
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 30 })),
"System.LinkTypes.Dependency-Forward",
"Blocks downstream story",
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(workItem);

await _tools.LinkWorkItems(100, "30", "successor", comment: "Blocks downstream story");

_mockService.Verify(s => s.LinkWorkItemsAsync(
100,
It.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 30 })),
"System.LinkTypes.Dependency-Forward",
"Blocks downstream story",
null,
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task LinkWorkItems_WithInvalidSourceId_ShouldReturnError()
{
var result = await _tools.LinkWorkItems(0, "10", "predecessor");

Assert.Contains("error", result);
Assert.Contains("sourceWorkItemId", result);
}

[Fact]
public async Task LinkWorkItems_WithNoValidTargets_ShouldReturnError()
{
var result = await _tools.LinkWorkItems(100, "abc", "predecessor");

Assert.Contains("error", result);
Assert.Contains("No valid target work item IDs provided", result);
}

[Fact]
public async Task LinkWorkItems_WithSelfLink_ShouldReturnError()
{
var result = await _tools.LinkWorkItems(100, "100", "related");

Assert.Contains("error", result);
Assert.Contains("cannot be linked to itself", result);
}

[Fact]
public async Task LinkWorkItems_WithInvalidRelationType_ShouldReturnError()
{
var result = await _tools.LinkWorkItems(100, "10", "duplicate");

Assert.Contains("error", result);
Assert.Contains("relationType", result);
}

[Fact]
public async Task LinkWorkItems_WithMultipleParents_ShouldReturnError()
{
var result = await _tools.LinkWorkItems(100, "10;20", "parent");

Assert.Contains("error", result);
Assert.Contains("only have one parent", result);
}

#endregion

#region GetRecentWorkItems Tests

[Fact]
Expand Down
Loading