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: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ src/Viamus.Azure.Devops.Mcp.Server/
│ ├── IAzureDevOpsService.cs # Service interface
│ └── AzureDevOpsService.cs # Implementation
├── Tools/
│ ├── WorkItemTools.cs # Work Item tools (9)
│ ├── WorkItemTools.cs # Work Item tools (11)
│ ├── GitTools.cs # Git Repository tools (6)
│ ├── PullRequestTools.cs # Pull Request tools (5)
│ └── PipelineTools.cs # Pipeline/Build tools (9)
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ This project implements an MCP server that exposes tools for querying and managi
| `get_recent_work_items` | Gets recently changed work items |
| `search_work_items` | Searches work items by title text |
| `add_work_item_comment` | Adds a comment to a specific work item |
| `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 |

### Git Repository Tools

Expand Down Expand Up @@ -377,6 +379,10 @@ After configuring the MCP client, you can ask questions like:
- "What work items were changed in the last 7 days?"
- "Search for work items with 'login' in the title"
- "Add a comment to work item #1234 saying the bug was fixed"
- "Create a new Bug in project X titled 'Login page crashes on submit'"
- "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'"

### Git Repositories

Expand Down
286 changes: 286 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.WebApi;
using Microsoft.VisualStudio.Services.WebApi.Patch;
using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
using Viamus.Azure.Devops.Mcp.Server.Configuration;
using Viamus.Azure.Devops.Mcp.Server.Models;

Expand Down Expand Up @@ -491,6 +493,290 @@ public async Task<WorkItemCommentDto> AddWorkItemCommentAsync(
}
}

public async Task<WorkItemDto> CreateWorkItemAsync(
string project,
string workItemType,
string title,
string? description = null,
string? assignedTo = null,
string? areaPath = null,
string? iterationPath = null,
string? state = null,
int? priority = null,
int? parentId = null,
string? tags = null,
Dictionary<string, string>? additionalFields = null,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Creating work item of type {WorkItemType} in project {Project}", workItemType, project);

var patchDocument = new JsonPatchDocument();

patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/fields/System.Title",
Value = title
});

if (!string.IsNullOrEmpty(description))
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/fields/System.Description",
Value = description
});
}

if (!string.IsNullOrEmpty(assignedTo))
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/fields/System.AssignedTo",
Value = assignedTo
});
}

if (!string.IsNullOrEmpty(areaPath))
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/fields/System.AreaPath",
Value = areaPath
});
}

if (!string.IsNullOrEmpty(iterationPath))
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/fields/System.IterationPath",
Value = iterationPath
});
}

if (!string.IsNullOrEmpty(state))
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/fields/System.State",
Value = state
});
}

if (priority.HasValue)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/fields/Microsoft.VSTS.Common.Priority",
Value = priority.Value
});
}

if (!string.IsNullOrEmpty(tags))
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/fields/System.Tags",
Value = tags
});
}

if (parentId.HasValue)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = "/relations/-",
Value = new
{
rel = "System.LinkTypes.Hierarchy-Reverse",
url = $"{_options.OrganizationUrl}/_apis/wit/workItems/{parentId.Value}",
attributes = new { comment = "Parent" }
}
});
}

if (additionalFields != null)
{
foreach (var field in additionalFields)
{
var path = field.Key.StartsWith("/fields/")
? field.Key
: $"/fields/{field.Key}";

patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Add,
Path = path,
Value = field.Value
});
}
}

var result = await _witClient.CreateWorkItemAsync(
document: patchDocument,
project: project,
type: workItemType,
cancellationToken: cancellationToken);

return MapToDto(result, includeAllFields: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating work item of type {WorkItemType} in project {Project}", workItemType, project);
throw;
}
}

public async Task<WorkItemDto> UpdateWorkItemAsync(
int workItemId,
string? title = null,
string? description = null,
string? assignedTo = null,
string? state = null,
string? areaPath = null,
string? iterationPath = null,
int? priority = null,
string? tags = null,
Dictionary<string, string>? additionalFields = null,
string? project = null,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Updating work item {WorkItemId}", workItemId);

var patchDocument = new JsonPatchDocument();

if (title != null)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = "/fields/System.Title",
Value = title
});
}

if (description != null)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = "/fields/System.Description",
Value = description
});
}

if (assignedTo != null)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = "/fields/System.AssignedTo",
Value = assignedTo
});
}

if (state != null)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = "/fields/System.State",
Value = state
});
}

if (areaPath != null)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = "/fields/System.AreaPath",
Value = areaPath
});
}

if (iterationPath != null)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = "/fields/System.IterationPath",
Value = iterationPath
});
}

if (priority.HasValue)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = "/fields/Microsoft.VSTS.Common.Priority",
Value = priority.Value
});
}

if (tags != null)
{
patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = "/fields/System.Tags",
Value = tags
});
}

if (additionalFields != null)
{
foreach (var field in additionalFields)
{
var path = field.Key.StartsWith("/fields/")
? field.Key
: $"/fields/{field.Key}";

patchDocument.Add(new JsonPatchOperation
{
Operation = Operation.Replace,
Path = path,
Value = field.Value
});
}
}

// If no fields to update, just return the current work item
if (patchDocument.Count == 0)
{
var currentWorkItem = await GetWorkItemAsync(workItemId, project, cancellationToken);
return currentWorkItem!;
}

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

return MapToDto(result, includeAllFields: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating work item {WorkItemId}", workItemId);
throw;
}
}

#region Git Operations

public async Task<IReadOnlyList<RepositoryDto>> GetRepositoriesAsync(string? project = null, CancellationToken cancellationToken = default)
Expand Down
62 changes: 62 additions & 0 deletions src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,68 @@ Task<WorkItemCommentDto> AddWorkItemCommentAsync(
string? project = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Creates a new work item in the specified project.
/// </summary>
/// <param name="project">The project name.</param>
/// <param name="workItemType">The work item type (e.g., Bug, Task, User Story).</param>
/// <param name="title">The work item title.</param>
/// <param name="description">Optional description.</param>
/// <param name="assignedTo">Optional user to assign to.</param>
/// <param name="areaPath">Optional area path.</param>
/// <param name="iterationPath">Optional iteration path.</param>
/// <param name="state">Optional initial state.</param>
/// <param name="priority">Optional priority (1-4).</param>
/// <param name="parentId">Optional parent work item ID to link as child.</param>
/// <param name="tags">Optional semicolon-separated tags.</param>
/// <param name="additionalFields">Optional dictionary of additional field reference names and values.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created work item.</returns>
Task<WorkItemDto> CreateWorkItemAsync(
string project,
string workItemType,
string title,
string? description = null,
string? assignedTo = null,
string? areaPath = null,
string? iterationPath = null,
string? state = null,
int? priority = null,
int? parentId = null,
string? tags = null,
Dictionary<string, string>? additionalFields = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Updates an existing work item.
/// </summary>
/// <param name="workItemId">The work item ID to update.</param>
/// <param name="title">Optional new title.</param>
/// <param name="description">Optional new description.</param>
/// <param name="assignedTo">Optional new assignee.</param>
/// <param name="state">Optional new state.</param>
/// <param name="areaPath">Optional new area path.</param>
/// <param name="iterationPath">Optional new iteration path.</param>
/// <param name="priority">Optional new priority (1-4).</param>
/// <param name="tags">Optional new tags.</param>
/// <param name="additionalFields">Optional dictionary of additional field reference names and values.</param>
/// <param name="project">The project name (optional if default project is configured).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated work item.</returns>
Task<WorkItemDto> UpdateWorkItemAsync(
int workItemId,
string? title = null,
string? description = null,
string? assignedTo = null,
string? state = null,
string? areaPath = null,
string? iterationPath = null,
int? priority = null,
string? tags = null,
Dictionary<string, string>? additionalFields = null,
string? project = null,
CancellationToken cancellationToken = default);

#region Git Operations

/// <summary>
Expand Down
Loading