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));
+ }
+}