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
1 change: 1 addition & 0 deletions FORK_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This matrix compares capabilities from the original Azure DevOps MCP Server with
| Capability | Upstream equivalent (toolset) | Fork tool | Status |
| --- | --- | --- | --- |
| Get work item context (details + comments) | `mcp_ado_wit_get_work_item`, `mcp_ado_wit_list_work_item_comments` | `wit_get_work_item` | Implemented |
| Create work item | `mcp_ado_wit_create_work_item` | `wit_work_item_write_create` | Implemented |
| Add comment to work item | `mcp_ado_wit_add_work_item_comment` | `wit_add_work_item_comment` | Implemented |
| Update comment on work item | `mcp_ado_wit_update_work_item_comment` | `wit_update_work_item_comment` | Implemented |
| Create feature branch | `mcp_ado_repo_create_branch` | `repo_create_branch` | Implemented |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ public class UpdateCommentResult
public string? Url { get; set; }
}

/// <summary>
/// Result of creating a new work item.
/// </summary>
public class CreateWorkItemResult
{
public int WorkItemId { get; set; }
public string Title { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string? Url { get; set; }
}

/// <summary>
/// Service for retrieving and updating work item context from Azure DevOps.
/// </summary>
Expand Down Expand Up @@ -81,4 +92,15 @@ public interface IWorkItemContextService
/// <param name="text">The updated comment text (supports plain text and HTML formatting)</param>
/// <param name="cancellationToken">Cancellation token</param>
Task<UpdateCommentResult> UpdateCommentAsync(string collection, string project, int workItemId, int commentId, string text, CancellationToken cancellationToken = default);

/// <summary>
/// Creates a new work item in a project.
/// </summary>
/// <param name="collection">The collection name</param>
/// <param name="project">The project name or ID</param>
/// <param name="workItemType">The work item type (e.g., "Task", "Bug")</param>
/// <param name="title">The work item title</param>
/// <param name="description">The work item description (optional)</param>
/// <param name="cancellationToken">Cancellation token</param>
Task<CreateWorkItemResult> CreateWorkItemAsync(string collection, string project, string workItemType, string title, string? description = null, CancellationToken cancellationToken = default);
}
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;

namespace G5e.AzureDevOpsServerMCP.Infrastructure.AzureDevOps.Services;

Expand Down Expand Up @@ -158,5 +160,65 @@ private static string GetField(WorkItem wit, string fieldName) =>
? value.ToString()
: null;
}
}

public async Task<CreateWorkItemResult> CreateWorkItemAsync(
string collection,
string project,
string workItemType,
string title,
string? description = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(collection))
throw new ArgumentException("Collection cannot be empty", nameof(collection));
if (string.IsNullOrWhiteSpace(project))
throw new ArgumentException("Project cannot be empty", nameof(project));
if (string.IsNullOrWhiteSpace(workItemType))
throw new ArgumentException("Work item type cannot be empty", nameof(workItemType));
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Title cannot be empty", nameof(title));

using var connection = _connectionFactory.CreateConnection(collection);
var witClient = connection.GetClient<WorkItemTrackingHttpClient>();

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

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

try
{
var workItem = await witClient.CreateWorkItemAsync(patchDocument, project, workItemType, cancellationToken: cancellationToken);

if (workItem == null)
throw new InvalidOperationException("Failed to create work item");

return new CreateWorkItemResult
{
WorkItemId = workItem.Id ?? 0,
Title = GetField(workItem, "System.Title"),
Type = GetField(workItem, "System.WorkItemType"),
Url = workItem.Url
};
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to create work item in project {project}: {ex.Message}", ex);
}
}
}
32 changes: 32 additions & 0 deletions dotnet/src/G5e.AzureDevOpsServerMCP.Tools/WorkItemTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,36 @@ public async Task<string> UpdateWorkItemComment(string collection, string projec
return JsonSerializer.Serialize(new { error = ex.Message, type = ex.GetType().Name });
}
}

/// <summary>
/// Creates a new work item in a project.
/// </summary>
/// <param name="collection">The Azure DevOps collection name</param>
/// <param name="project">The Azure DevOps project name or ID</param>
/// <param name="workItemType">The work item type (e.g., "Task", "Bug", "User Story")</param>
/// <param name="title">The work item title</param>
/// <param name="description">The work item description (optional)</param>
/// <returns>JSON object with the created work item ID, title, type, and URL</returns>
[McpServerTool(Name = "wit_work_item_write_create")]
[Description("Creates a new work item in an Azure DevOps project with a specified type, title, and optional description. Returns the work item ID, URL, and confirmation details.")]
public async Task<string> CreateWorkItem(string collection, string project, string workItemType, string title, string? description = null)
{
try
{
var result = await _workItemContextService.CreateWorkItemAsync(collection, project, workItemType, title, description);

return JsonSerializer.Serialize(new
{
workItemId = result.WorkItemId,
title = result.Title,
type = result.Type,
url = result.Url,
success = true
}, new JsonSerializerOptions { WriteIndented = true });
}
catch (Exception ex)
{
return JsonSerializer.Serialize(new { error = ex.Message, type = ex.GetType().Name });
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Text.Json;
using System.Text.Json;
using G5e.AzureDevOpsServerMCP.Application.Services;
using G5e.AzureDevOpsServerMCP.Tools;

Expand Down Expand Up @@ -71,6 +71,9 @@ public Task<AddCommentResult> AddCommentAsync(string collection, string project,

public Task<UpdateCommentResult> UpdateCommentAsync(string collection, string project, int workItemId, int commentId, string text, CancellationToken cancellationToken = default)
=> Task.FromResult(new UpdateCommentResult { CommentId = commentId, WorkItemId = workItemId, Text = text, Version = 2, Url = string.Empty });

public Task<CreateWorkItemResult> CreateWorkItemAsync(string collection, string project, string workItemType, string title, string? description = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new CreateWorkItemResult { WorkItemId = 2, Title = title, Type = workItemType, Url = string.Empty });
}

private sealed class ThrowingWorkItemContextService : IWorkItemContextService
Expand All @@ -90,6 +93,9 @@ public Task<AddCommentResult> AddCommentAsync(string collection, string project,

public Task<UpdateCommentResult> UpdateCommentAsync(string collection, string project, int workItemId, int commentId, string text, CancellationToken cancellationToken = default)
=> Task.FromException<UpdateCommentResult>(_exception);

public Task<CreateWorkItemResult> CreateWorkItemAsync(string collection, string project, string workItemType, string title, string? description = null, CancellationToken cancellationToken = default)
=> Task.FromException<CreateWorkItemResult>(_exception);
}

[Fact]
Expand Down Expand Up @@ -130,6 +136,9 @@ public Task<AddCommentResult> AddCommentAsync(string collection, string project,

public Task<UpdateCommentResult> UpdateCommentAsync(string collection, string project, int workItemId, int commentId, string text, CancellationToken cancellationToken = default)
=> Task.FromResult(new UpdateCommentResult { CommentId = commentId, WorkItemId = workItemId, Text = text, Version = 2, Url = "https://example.invalid/comment/" + commentId });

public Task<CreateWorkItemResult> CreateWorkItemAsync(string collection, string project, string workItemType, string title, string? description = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new CreateWorkItemResult { WorkItemId = 3, Title = title, Type = workItemType, Url = string.Empty });
}

[Fact]
Expand Down Expand Up @@ -162,4 +171,53 @@ public async Task UpdateWorkItemComment_WhenServiceThrows_ReturnsSerializedError
Assert.Equal("update failed", root.GetProperty("error").GetString());
Assert.Equal("InvalidOperationException", root.GetProperty("type").GetString());
}
}

[Fact]
public async Task CreateWorkItem_ReturnsSerializedWorkItemDetails()
{
var sut = new WorkItemTools(new FakeCreateWorkItemService());
var json = await sut.CreateWorkItem("DefaultCollection", "UZG.IZ.PrestIZ", "Task", "New task via MCP");
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.Equal(99, root.GetProperty("workItemId").GetInt32());
Assert.Equal("New task via MCP", root.GetProperty("title").GetString());
Assert.Equal("Task", root.GetProperty("type").GetString());
Assert.True(root.GetProperty("success").GetBoolean());
}

[Fact]
public async Task CreateWorkItem_WithDescription_ReturnsSerializedWorkItem()
{
var sut = new WorkItemTools(new FakeCreateWorkItemService());
var json = await sut.CreateWorkItem("DefaultCollection", "UZG.IZ.PrestIZ", "Bug", "Critical bug", "This is a critical issue that needs fixing");
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.Equal(99, root.GetProperty("workItemId").GetInt32());
Assert.Equal("Critical bug", root.GetProperty("title").GetString());
Assert.Equal("Bug", root.GetProperty("type").GetString());
Assert.True(root.GetProperty("success").GetBoolean());
}

[Fact]
public async Task CreateWorkItem_WhenServiceThrows_ReturnsSerializedError()
{
var sut = new WorkItemTools(new ThrowingWorkItemContextService(new InvalidOperationException("Invalid work item type")));
var json = await sut.CreateWorkItem("DefaultCollection", "UZG.IZ.PrestIZ", "InvalidType", "Test");
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.Equal("Invalid work item type", root.GetProperty("error").GetString());
Assert.Equal("InvalidOperationException", root.GetProperty("type").GetString());
}

private sealed class FakeCreateWorkItemService : IWorkItemContextService
{
public Task<WorkItemContextResult> GetWorkItemContextAsync(string collection, string project, int workItemId, CancellationToken cancellationToken = default)
=> Task.FromResult(new WorkItemContextResult());
public Task<AddCommentResult> AddCommentAsync(string collection, string project, int workItemId, string comment, CancellationToken cancellationToken = default)
=> Task.FromResult(new AddCommentResult { CommentId = 42, Url = "https://example.invalid/comment/42" });
public Task<UpdateCommentResult> UpdateCommentAsync(string collection, string project, int workItemId, int commentId, string text, CancellationToken cancellationToken = default)
=> Task.FromResult(new UpdateCommentResult { CommentId = commentId, WorkItemId = workItemId, Text = text, Version = 2, Url = "https://example.invalid/comment/" + commentId });
public Task<CreateWorkItemResult> CreateWorkItemAsync(string collection, string project, string workItemType, string title, string? description = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new CreateWorkItemResult { WorkItemId = 99, Title = title, Type = workItemType, Url = "https://example.invalid/work-item/99" });
}
}
2 changes: 2 additions & 0 deletions openspec/changes/wit-work-item-write/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-15
93 changes: 93 additions & 0 deletions openspec/changes/wit-work-item-write/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
## Context

The Azure DevOps MCP server currently supports reading work items and adding comments via the `IWorkItemContextService` and `WorkItemTools` classes. The .NET implementation follows a layered architecture:

- **Tools Layer** (`dotnet/src/G5e.AzureDevOpsServerMCP.Tools/WorkItemTools.cs`): MCP tool definitions with `[McpServerTool]` attributes
- **Application Layer** (`dotnet/src/G5e.AzureDevOpsServerMCP.Application/Services/`): Business logic and Azure DevOps API interactions
- **Infrastructure Layer**: Uses `Microsoft.TeamFoundationServer.Client` for API calls

Existing patterns:
- Async methods decorated with `[McpServerTool(Name = "...")]` and `[Description("...")]`
- Exception handling returning JSON error responses
- JSON serialization with indentation for responses
- Service methods accepting collection, project as parameters

## Goals / Non-Goals

**Goals:**
- Add `wit_work_item_write_create` tool to create new work items with title, type, and optional description
- Follow existing code patterns and error handling conventions
- Support essential work item creation parameters (type, title, description)
- Return created work item ID and URL
- Maintain consistency with TypeScript upstream tool contracts

**Non-Goals:**
- Support for complex field assignments beyond standard fields in this iteration
- Work item linking or relationships during creation
- Template-based work item creation
- Bulk creation operations
- Custom workflow state initialization

## Decisions

**Decision 1: Service Method Location**
- **Choice**: Extend `IWorkItemContextService` with a `CreateWorkItemAsync` method
- **Rationale**: Maintains separation of concerns and consistency with existing pattern. New work item operations belong with other work item context operations.
- **Alternative Considered**: Create a separate `IWorkItemWriteService` → Would fragment service responsibilities

**Decision 2: Tool Parameter Contract**
- **Choice**: Accept `collection`, `project`, `workItemType`, `title`, and optional `description`
- **Rationale**: These are the minimum required fields for work item creation. Collection and project contextualize the operation.
- **Alternative Considered**: Accepting a generic JSON object for fields → Too open-ended, harder to validate and document

**Decision 3: API Integration Pattern**
- **Choice**: Use `WorkItemTrackingHttpClient` from `Microsoft.TeamFoundationServer.Client` (consistent with existing patterns)
- **Rationale**: Already in use throughout the codebase; provides native Azure DevOps Server support
- **Alternative Considered**: Use REST client directly → Duplicates existing abstraction; requires more error handling

**Decision 4: Response Format**
- **Choice**: Return JSON with `workItemId`, `url`, `title`, `type`, and `success: true`
- **Rationale**: Mirrors existing tool response patterns; provides caller with immediate confirmation and identifiers
- **Alternative Considered**: Return full work item context → Too verbose for a create operation; caller can fetch full context if needed

## Risks / Trade-offs

**Risk: Incomplete Field Validation**
- **Issue**: `workItemType` is passed as string; Azure DevOps may reject invalid types
- **Mitigation**: Add validation against available work item types in the project; document valid types in tool description

**Risk: Description Field Encoding**
- **Issue**: If description contains HTML/markup, encoding may be required
- **Mitigation**: Use Azure DevOps API's native HTML handling; test with rich text input

**Risk: Concurrency & Race Conditions**
- **Issue**: Multiple simultaneous creates might conflict or produce unexpected ordering
- **Mitigation**: Azure DevOps API handles concurrency; document that ordering is not guaranteed

**Trade-off: No Field Defaulting**
- **Issue**: Work items created only with type + title + description; other fields use project defaults
- **Mitigation**: Acceptable for initial implementation; future `wit_work_item_write_update` can modify additional fields post-creation

## Migration Plan

**Deployment:**
1. Update `IWorkItemContextService` with `CreateWorkItemAsync` method signature
2. Implement service method in concrete service class
3. Add `wit_work_item_write_create` tool method to `WorkItemTools`
4. Update tests to cover creation scenarios
5. Package and deploy as part of regular MCP server build

**Rollback:**
- If issues arise, remove the new tool method and revert service changes
- No database migrations required; purely additive change
- No breaking changes to existing tools

**Documentation:**
- Add tool description to MCP server documentation
- Include example usage in EXAMPLES.md or similar

## Open Questions

- Should work item creation validate the work item type against available types in the project?
- Should the tool accept optional `assignedTo` parameter in future iterations?
- How should the tool handle Azure DevOps Server permission restrictions (e.g., user cannot create work items)?
31 changes: 31 additions & 0 deletions openspec/changes/wit-work-item-write/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## Why

The .NET implementation of the Azure DevOps MCP server currently supports reading work items and adding comments, but lacks the fundamental capability to create new work items. This gap limits the utility of the fork for Azure DevOps Server (on-prem) environments where programmatic work item creation is essential for automation workflows and AI-powered agent interactions.

## What Changes

- Add a new MCP tool `wit_work_item_write_create` to create work items in Azure DevOps
- The tool will accept collection, project, work item type, title, and optional description parameters
- Return the newly created work item ID and URL

## Capabilities

### New Capabilities
- `wit-work-item-write-create`: Create new work items in Azure DevOps projects with configurable type, title, and description

### Modified Capabilities

## Impact

**Code Changes:**
- `dotnet/src/G5e.AzureDevOpsServerMCP.Application/Services/`: Add or extend the work item service to support creation
- `dotnet/src/G5e.AzureDevOpsServerMCP.Tools/WorkItemTools.cs`: Add the `wit_work_item_write` tool method with proper input validation and error handling
- Tests: Extend integration and unit tests to cover work item creation scenarios

**APIs & Contracts:**
- New MCP tool: `wit_work_item_write_create(collection, project, workItemType, title, description?)`
- Extends the existing work item tool surface area

**Dependencies:**
- Existing: `Microsoft.TeamFoundationServer.Client` (already in use)
- No new external dependencies required
Loading
Loading