diff --git a/src/ClaudeDo.Worker/External/TicketMcpTools.cs b/src/ClaudeDo.Worker/External/TicketMcpTools.cs new file mode 100644 index 00000000..571bd17d --- /dev/null +++ b/src/ClaudeDo.Worker/External/TicketMcpTools.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tickets; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +/// get_ticket_import_candidates payload; Available=false carries the reason instead of an error. +public sealed record TicketImportCandidatesDto( + bool Available, + string? Reason, + IReadOnlyList Items, + IReadOnlyList Lists, + ImportTargetList? FallbackList); + +/// import_ticket confirmation; Created=false always carries SkippedReason. +public sealed record ImportTicketResult( + bool Created, int? TaskNumber, string? ListName, bool UsedFallback, string? SkippedReason); + +/// Cross-project ticket import for the ImportTickets Prime run. Claude never needs the ticket MCP or the PAT. +[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 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 # when reporting.")] + public async Task 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); + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index cef3743f..d8838fda 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -417,7 +417,8 @@ if (cfg.ExternalMcpPort > 0) .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}"); externalApp = externalBuilder.Build(); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs index 0e87a888..c686cbf7 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs @@ -113,6 +113,7 @@ public sealed class ExternalMcpToolSchemaTests "add_task_attachment", "list_task_attachments", "remove_task_attachment", "save_finding", "add_note", + "get_ticket_import_candidates", "import_ticket", ]; [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/TicketMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/TicketMcpToolsTests.cs new file mode 100644 index 00000000..f47fffc3 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/TicketMcpToolsTests.cs @@ -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; + +/// Only the not-configured path — the import logic itself is covered by TicketImportAllTests. +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.Instance); + var import = new TicketImportService(clients, _db.CreateFactory(), NullLogger.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)); + } +}