feat(mcp): add get_ticket_import_candidates and import_ticket tools
This commit is contained in:
+77
@@ -0,0 +1,77 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
|
/// <summary>get_ticket_import_candidates payload; Available=false carries the reason instead of an error.</summary>
|
||||||
|
public sealed record TicketImportCandidatesDto(
|
||||||
|
bool Available,
|
||||||
|
string? Reason,
|
||||||
|
IReadOnlyList<TicketCandidate> Items,
|
||||||
|
IReadOnlyList<ImportTargetList> Lists,
|
||||||
|
ImportTargetList? FallbackList);
|
||||||
|
|
||||||
|
/// <summary>import_ticket confirmation; Created=false always carries SkippedReason.</summary>
|
||||||
|
public sealed record ImportTicketResult(
|
||||||
|
bool Created, int? TaskNumber, string? ListName, bool UsedFallback, string? SkippedReason);
|
||||||
|
|
||||||
|
/// <summary>Cross-project ticket import for the ImportTickets Prime run. Claude never needs the ticket MCP or the PAT.</summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class TicketMcpTools
|
||||||
|
{
|
||||||
|
private readonly TicketImportService _import;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
|
||||||
|
public TicketMcpTools(TicketImportService import, HubBroadcaster broadcaster)
|
||||||
|
{
|
||||||
|
_import = import;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Ticket import: the open ticket-system items assigned to me that are not a ClaudeDo task yet, plus every " +
|
||||||
|
"list as a target and the fallback list. Place each item in the list whose purpose fits its content; a " +
|
||||||
|
"list's ticketProject is only a hint. available=false means the ticket system is not set up — see reason.")]
|
||||||
|
public async Task<TicketImportCandidatesDto> GetTicketImportCandidates(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var c = await _import.GetCandidatesAsync(cancellationToken);
|
||||||
|
return new TicketImportCandidatesDto(true, null, c.Items, c.Lists, c.FallbackList);
|
||||||
|
}
|
||||||
|
catch (TicketApiException ex)
|
||||||
|
{
|
||||||
|
return new TicketImportCandidatesDto(false, ex.Message, [], [], null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Ticket import: create one Idle task from an item returned by get_ticket_import_candidates. Omit list when " +
|
||||||
|
"no list clearly fits — the item then goes to the fallback list, or is skipped when none is set. " +
|
||||||
|
"created=false always carries skippedReason. Refer to a created task as #<taskNumber> when reporting.")]
|
||||||
|
public async Task<ImportTicketResult> ImportTicket(
|
||||||
|
[Description("Item id from get_ticket_import_candidates.")] int ticketId,
|
||||||
|
[Description("Target list id or name. Omit to use the fallback list.")] string? list = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
TicketItemImportResult r;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
r = await _import.ImportItemAsync(ticketId, list, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TicketApiException ex)
|
||||||
|
{
|
||||||
|
return new ImportTicketResult(false, null, null, false, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.Created)
|
||||||
|
{
|
||||||
|
// Same out-of-band-creator rule as the hub's ImportTickets: new rows only appear via TaskUpdated.
|
||||||
|
await _broadcaster.TaskUpdated(r.TaskId!);
|
||||||
|
await _broadcaster.ListUpdated(r.ListId!);
|
||||||
|
}
|
||||||
|
return new ImportTicketResult(r.Created, r.TaskNumber, r.ListName, r.UsedFallback, r.SkippedReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -417,7 +417,8 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
.WithTools<QueueStateMcpTools>()
|
.WithTools<QueueStateMcpTools>()
|
||||||
.WithTools<AttachmentMcpTools>()
|
.WithTools<AttachmentMcpTools>()
|
||||||
.WithTools<FindingsMcpTools>()
|
.WithTools<FindingsMcpTools>()
|
||||||
.WithTools<NotesMcpTools>();
|
.WithTools<NotesMcpTools>()
|
||||||
|
.WithTools<TicketMcpTools>();
|
||||||
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
||||||
|
|
||||||
externalApp = externalBuilder.Build();
|
externalApp = externalBuilder.Build();
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ public sealed class ExternalMcpToolSchemaTests
|
|||||||
"add_task_attachment", "list_task_attachments", "remove_task_attachment",
|
"add_task_attachment", "list_task_attachments", "remove_task_attachment",
|
||||||
"save_finding",
|
"save_finding",
|
||||||
"add_note",
|
"add_note",
|
||||||
|
"get_ticket_import_candidates", "import_ticket",
|
||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using ClaudeDo.Worker;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.External;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.External;
|
||||||
|
|
||||||
|
/// <summary>Only the not-configured path — the import logic itself is covered by TicketImportAllTests.</summary>
|
||||||
|
public sealed class TicketMcpToolsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly string _patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketmcp-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_db.Dispose();
|
||||||
|
try { Directory.Delete(_patDir, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private TicketMcpTools Sut()
|
||||||
|
{
|
||||||
|
var config = new TicketSystemConfig(new WorkerConfig(), new TicketPatStore(new DpapiTokenStore(Path.Combine(_patDir, "ticket.pat"))));
|
||||||
|
var clients = new TicketClientFactory(config, null!, NullLogger<BandelTicketClient>.Instance);
|
||||||
|
var import = new TicketImportService(clients, _db.CreateFactory(), NullLogger<TicketImportService>.Instance);
|
||||||
|
return new TicketMcpTools(import, new HubBroadcaster(new CapturingHubContext()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Candidates_report_unavailable_instead_of_throwing_when_not_configured()
|
||||||
|
{
|
||||||
|
var dto = await Sut().GetTicketImportCandidates(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(dto.Available);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(dto.Reason));
|
||||||
|
Assert.Empty(dto.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_reports_skipped_instead_of_throwing_when_not_configured()
|
||||||
|
{
|
||||||
|
var result = await Sut().ImportTicket(1, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Created);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result.SkippedReason));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user