Set Stateless = true on the internal (/mcp on the SignalR port) and external MCP HTTP transports. Two effects: - No Mcp-Session-Id, so a worker restart can no longer 404 a session that outlives it -- ConPTY tiles in the UI process and externally registered claude sessions keep working across a restart. - A 2026-07-28 client is no longer refused back to the initialize handshake. A stateful server rejects that revision on purpose (see the SDK's StreamableHttpHandler.s_sessionSupportingProtocolVersions), so the new per-request protocol path was unreachable regardless of SDK version. Nothing here used the stateful-only features (sampling, elicitation, resource subscriptions, unsolicited notifications). In-tool progress notifications ride the POST's own response stream and are unaffected -- which matters, since ~20 tools use them to hold off the client's 300s idle abort. All three consumers (TaskRunner, PlanningSessionManager, the installer's RegisterMcpStep) already register type: "http", so dropping the legacy SSE endpoint breaks nothing. Verified against the running worker across two restarts: tools/list returns 57 tools over a bare POST with no initialize and no session id, and a full 2026-07-28 tools/call round-trip returns real data.
388 lines
18 KiB
C#
388 lines
18 KiB
C#
using System.Threading;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Agents;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Findings;
|
|
using ClaudeDo.Worker.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Logging;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.State;
|
|
using ClaudeDo.Worker.Online;
|
|
using ClaudeDo.Worker.Online.Interfaces;
|
|
using ClaudeDo.Worker.Prime;
|
|
using ClaudeDo.Worker.Refine;
|
|
using ClaudeDo.Worker.Report;
|
|
using ClaudeDo.Worker.Report.Interfaces;
|
|
using ClaudeDo.Worker.Skills;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Usage.Interfaces;
|
|
using ClaudeDo.Worker.Usage.TokenTracker;
|
|
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Serilog;
|
|
|
|
// Single-instance per user session. Multiple launch paths exist (logon task,
|
|
// app ensure-running, Restart button); a second instance exits cleanly instead
|
|
// of fighting over the SignalR port.
|
|
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
|
|
if (!createdNew)
|
|
return; // another instance already owns the port; exit 0
|
|
|
|
var cfg = WorkerConfig.Load();
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
var logRoot = cfg.LogRoot;
|
|
Directory.CreateDirectory(logRoot);
|
|
|
|
// In-memory ring + broadcast sink power the footer log strip and the Log Visualizer
|
|
// overlay. Created pre-build so Serilog can write to the sink; the SignalR broadcaster
|
|
// is attached once the host is built (see below).
|
|
var logBuffer = new LogRingBuffer(TimeSpan.FromMinutes(30));
|
|
var broadcastSink = new BroadcastLogSink(logBuffer);
|
|
|
|
builder.Host.UseSerilog((ctx, lc) => lc
|
|
.MinimumLevel.Information()
|
|
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
|
|
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
|
|
.WriteTo.File(
|
|
System.IO.Path.Combine(logRoot, "worker-.log"),
|
|
rollingInterval: RollingInterval.Day,
|
|
retainedFileCountLimit: 7,
|
|
shared: true)
|
|
.WriteTo.Sink(broadcastSink));
|
|
builder.Services.AddSingleton(logBuffer);
|
|
|
|
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
|
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
|
|
|
builder.Services.AddSingleton(cfg);
|
|
builder.Services.AddSingleton<AttachmentStore>();
|
|
// Singleton so the always-on and in-task MCP servers share the same index write lock.
|
|
builder.Services.AddSingleton<FindingsStore>();
|
|
builder.Services.AddHostedService<StaleTaskRecovery>();
|
|
builder.Services.AddHostedService<OrphanRecovery>();
|
|
builder.Services.AddHostedService<AttachmentOrphanRecovery>();
|
|
builder.Services.AddHostedService<PromptFileRecovery>();
|
|
builder.Services.AddHostedService<LegacyWorktreeFolderRecovery>();
|
|
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
|
{
|
|
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
|
});
|
|
|
|
// Runner stack.
|
|
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
|
|
builder.Services.AddSingleton<HubBroadcaster>();
|
|
builder.Services.AddSingleton<GitService>();
|
|
builder.Services.AddSingleton<WorktreeManager>();
|
|
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
|
builder.Services.AddSingleton<TaskRunTokenRegistry>();
|
|
builder.Services.AddSingleton<PendingQuestionRegistry>();
|
|
builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
|
|
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
|
|
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
|
|
builder.Services.AddSingleton<IInteractiveLaunchSpecService, InteractiveLaunchSpecService>();
|
|
builder.Services.AddSingleton<TaskRunner>();
|
|
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
|
builder.Services.AddSingleton<TaskResetService>();
|
|
builder.Services.AddSingleton<IVerifyCommandRunner, VerifyCommandRunner>();
|
|
builder.Services.AddSingleton<TaskMergeService>();
|
|
builder.Services.AddSingleton<InteractiveReviewSubmissionService>();
|
|
builder.Services.AddSingleton<PlanningAggregator>();
|
|
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
|
|
builder.Services.AddSingleton<PlanningChainCoordinator>();
|
|
|
|
// Queue dispatch primitives. QueueWaker holds the wake semaphore; the queue picker
|
|
// performs atomic Queued→Running claim. Both injected into the state service so it
|
|
// can wake the dispatcher without depending on QueueService directly.
|
|
builder.Services.AddSingleton<QueueWaker>();
|
|
builder.Services.AddSingleton<IQueueWaker>(sp => sp.GetRequiredService<QueueWaker>());
|
|
builder.Services.AddSingleton<IQueuePicker, QueuePicker>();
|
|
builder.Services.AddSingleton<RunCancellationRegistry>();
|
|
|
|
builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequiredService<ITaskStateService>());
|
|
// PlanningMergeOrchestrator itself depends on ITaskStateService, so TaskStateService can only
|
|
// reach it lazily (Func<IActiveMergeState>) — same cycle-breaking shape as the Func above.
|
|
builder.Services.AddSingleton<Func<IActiveMergeState>>(sp => () => sp.GetRequiredService<PlanningMergeOrchestrator>());
|
|
builder.Services.AddSingleton<IBaseDirtyChecker, BaseDirtyChecker>();
|
|
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
|
sp.GetRequiredService<HubBroadcaster>(),
|
|
sp.GetRequiredService<IQueueWaker>(),
|
|
sp.GetRequiredService<PlanningChainCoordinator>(),
|
|
sp.GetRequiredService<RunCancellationRegistry>(),
|
|
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
|
sp.GetRequiredService<IBaseDirtyChecker>(),
|
|
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
|
|
|
// Agent file management.
|
|
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
|
Directory.CreateDirectory(agentsDir);
|
|
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
|
|
|
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
|
|
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
|
defaultAgentsBundleDir,
|
|
agentsDir,
|
|
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
|
|
|
|
// Override slot owns RunNow / ContinueTask. Queue slot is the BackgroundService.
|
|
builder.Services.AddSingleton<OverrideSlotService>();
|
|
|
|
builder.Services.AddSingleton<IClaudeHistoryReader>(_ =>
|
|
new ClaudeHistoryReader(Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects")));
|
|
builder.Services.AddSingleton<WeekReportService>();
|
|
|
|
// Usage
|
|
builder.Services.AddSingleton<ITranscriptUsageReader, TranscriptUsageReader>();
|
|
|
|
// TokenTracker — the analytics backend. Optional at runtime: every consumer degrades when the
|
|
// CLI is missing, so nothing here is a startup requirement.
|
|
builder.Services.AddSingleton<ITokenTrackerClient, TokenTrackerClient>();
|
|
builder.Services.AddSingleton<TokenTrackerState>();
|
|
builder.Services.AddSingleton<TokenTrackerService>();
|
|
|
|
// Prime Claude
|
|
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
|
|
builder.Services.AddSingleton<PrimeScheduleSignal>();
|
|
builder.Services.AddSingleton<IPrimeRunner, PrimeRunner>();
|
|
builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
|
|
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
|
builder.Services.AddHostedService<PrimeScheduler>();
|
|
|
|
// Refine
|
|
builder.Services.AddSingleton<RefineRunner>();
|
|
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
|
|
|
// "Continue on session limit reset" toggle — depends on UsageState, registered below.
|
|
builder.Services.AddSingleton<UsageLimitAutoContinueCoordinator>();
|
|
|
|
// QueueService: singleton + hosted service (same instance).
|
|
builder.Services.AddSingleton<QueueService>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
|
|
|
// Planning session services.
|
|
var planningSessionsDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".todo-app", "planning-sessions");
|
|
builder.Services.AddSingleton(sp =>
|
|
new PlanningSessionManager(
|
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
|
sp.GetRequiredService<GitService>(),
|
|
cfg,
|
|
sp.GetRequiredService<ITaskStateService>(),
|
|
sp.GetRequiredService<PlanningChainCoordinator>(),
|
|
planningSessionsDir));
|
|
builder.Services.AddHostedService(sp => new PlanningLineageRecovery(
|
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
|
planningSessionsDir,
|
|
sp.GetRequiredService<HubBroadcaster>(),
|
|
sp.GetRequiredService<ILogger<PlanningLineageRecovery>>()));
|
|
builder.Services.AddSingleton<ITerminalLauncher>(sp =>
|
|
new WindowsTerminalLauncher("wt.exe", cfg.ClaudeBin));
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
|
builder.Services.AddScoped<TaskRunMcpContextAccessor>();
|
|
builder.Services.AddScoped<TaskRunMcpService>();
|
|
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
|
builder.Services.AddScoped<TaskRepository>();
|
|
builder.Services.AddScoped<ListRepository>();
|
|
builder.Services.AddScoped<PlanningMcpService>();
|
|
builder.Services.AddScoped<FindingsStoreLocator>();
|
|
builder.Services.AddScoped<TaskRunFindingsMcpTools>();
|
|
builder.Services.AddMcpServer()
|
|
// Stateless: no Mcp-Session-Id, so a worker restart doesn't 404 the sessions that
|
|
// outlive it (ConPTY tiles in the UI process, externally registered claude sessions),
|
|
// and a 2026-07-28 client isn't refused back to the initialize handshake. Nothing here
|
|
// needs the stateful-only features (sampling, elicitation, resource subscriptions,
|
|
// unsolicited notifications); in-tool progress works in both modes. Implies no legacy
|
|
// SSE endpoint — setting EnableLegacySse alongside this throws at startup.
|
|
.WithHttpTransport(o => o.Stateless = true)
|
|
.WithTools<PlanningMcpService>()
|
|
.WithTools<TaskRunMcpService>()
|
|
.WithTools<TaskRunFindingsMcpTools>();
|
|
|
|
// OnlineInboxConfig and OnlineTokenStore are always registered so hub methods work
|
|
// even when sync is disabled. The sync stack (api client, auth, hosted service) is
|
|
// only registered when enabled.
|
|
builder.Services.AddSingleton(cfg.OnlineInbox);
|
|
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
|
|
builder.Services.AddSingleton<OnlineTokenStore>();
|
|
#pragma warning restore CA1416
|
|
|
|
if (cfg.OnlineInbox.Enabled)
|
|
{
|
|
OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl);
|
|
builder.Services.AddHttpClient();
|
|
#pragma warning disable CA1416
|
|
builder.Services.AddSingleton<IOnlineAuthProvider, ZitadelAuthProvider>();
|
|
#pragma warning restore CA1416
|
|
builder.Services.AddHttpClient<IOnlineInboxApi, OnlineInboxApiClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(cfg.OnlineInbox.ApiBaseUrl.TrimEnd('/') + "/");
|
|
});
|
|
builder.Services.AddHostedService<OnlineSyncService>();
|
|
}
|
|
|
|
// OAuth usage monitor: reads the access token Claude Code keeps fresh in
|
|
// ~/.claude/.credentials.json and polls Anthropic's usage endpoint.
|
|
builder.Services.AddSingleton<UsageState>();
|
|
builder.Services.AddHttpClient<IUsageClient, ClaudeOAuthUsageClient>(client =>
|
|
{
|
|
client.Timeout = TimeSpan.FromSeconds(5);
|
|
});
|
|
builder.Services.AddSingleton<IUsageGate, UsageGate>();
|
|
builder.Services.AddSingleton<UsageSnapshotBuilder>();
|
|
builder.Services.AddSingleton<IRunningTaskProbe, RunningTaskProbe>();
|
|
// Singleton + hosted service (same instance) so WorkerHub.RefreshUsage can drive a manual poll.
|
|
builder.Services.AddSingleton<UsageMonitorService>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<UsageMonitorService>());
|
|
|
|
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
|
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
|
|
|
var app = builder.Build();
|
|
|
|
// Now that the hub context exists, let the broadcast sink push Warn/Error to the footer.
|
|
var logBroadcaster = app.Services.GetRequiredService<HubBroadcaster>();
|
|
broadcastSink.Attach((message, level, ts) => logBroadcaster.WorkerLog(message, level, ts));
|
|
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
ClaudeDoDbContext.MigrateAndConfigure(
|
|
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
|
}
|
|
|
|
try
|
|
{
|
|
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
|
|
var seedResult = await seeder.SeedMissingAsync();
|
|
app.Logger.LogInformation(
|
|
"Default agents seeded: {Copied} copied, {Skipped} already present",
|
|
seedResult.Copied, seedResult.Skipped);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
|
}
|
|
|
|
app.UseMiddleware<PlanningTokenAuthMiddleware>();
|
|
app.MapHub<WorkerHub>("/hub");
|
|
app.MapMcp("/mcp");
|
|
|
|
// Claude CLI preflight: fail fast if the configured binary is unreachable or non-zero.
|
|
// Skippable via CLAUDEDO_SKIP_CLI_PREFLIGHT=1 for environments without the CLI (e.g. tests).
|
|
if (Environment.GetEnvironmentVariable("CLAUDEDO_SKIP_CLI_PREFLIGHT") != "1")
|
|
{
|
|
var preflight = await ClaudeCliPreflight.CheckAsync(cfg.ClaudeBin);
|
|
if (!preflight.Ok)
|
|
{
|
|
app.Logger.LogCritical(
|
|
"Claude CLI preflight failed (bin: '{Bin}', exit: {Exit}): {Error}. " +
|
|
"Fix `claude_bin` in worker.config.json or set CLAUDEDO_SKIP_CLI_PREFLIGHT=1 to bypass.",
|
|
cfg.ClaudeBin, preflight.ExitCode, preflight.Error);
|
|
Environment.Exit(1);
|
|
}
|
|
app.Logger.LogInformation("Claude CLI preflight OK: {Version}", preflight.Version);
|
|
}
|
|
|
|
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
|
cfg.SignalRPort, cfg.DbPath);
|
|
|
|
// Build the external MCP endpoint as a separate WebApplication on its own port.
|
|
// Rationale: ModelContextProtocol.AspNetCore registers one server per DI container,
|
|
// so we need a second app to expose a different tool set under different auth.
|
|
// Shared singletons (QueueService, HubBroadcaster, WorkerConfig, db factory) are
|
|
// injected by instance so both apps operate on the same runtime state.
|
|
WebApplication? externalApp = null;
|
|
if (cfg.ExternalMcpPort > 0)
|
|
{
|
|
var externalBuilder = WebApplication.CreateBuilder();
|
|
externalBuilder.Services.AddSingleton(cfg);
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<HubBroadcaster>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<QueueService>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<OverrideSlotService>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<ITaskStateService>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IQueueWaker>());
|
|
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
|
externalBuilder.Services.AddScoped<TaskRepository>();
|
|
externalBuilder.Services.AddScoped<ListRepository>();
|
|
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<GitService>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IBaseDirtyChecker>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<PlanningMergeOrchestrator>());
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<InteractiveReviewSubmissionService>());
|
|
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
|
externalBuilder.Services.AddScoped<BatchMcpTools>();
|
|
externalBuilder.Services.AddScoped<ListMcpTools>();
|
|
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
|
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
|
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
|
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
|
externalBuilder.Services.AddSingleton<HandoffRoundTracker>();
|
|
externalBuilder.Services.AddScoped<HandoffMcpTools>();
|
|
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
|
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
|
externalBuilder.Services.AddScoped<QueueStateMcpTools>();
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AttachmentStore>());
|
|
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
|
|
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
|
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<FindingsStore>());
|
|
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
|
|
externalBuilder.Services.AddScoped<FindingsMcpTools>();
|
|
externalBuilder.Services.AddMcpServer()
|
|
.WithHttpTransport(o => o.Stateless = true)
|
|
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
|
.WithTools<ExternalMcpService>()
|
|
.WithTools<BatchMcpTools>()
|
|
.WithTools<ListMcpTools>()
|
|
.WithTools<ConfigMcpTools>()
|
|
.WithTools<RunHistoryMcpTools>()
|
|
.WithTools<AgentMcpTools>()
|
|
.WithTools<LifecycleMcpTools>()
|
|
.WithTools<HandoffMcpTools>()
|
|
.WithTools<AppSettingsMcpTools>()
|
|
.WithTools<TaskWaitMcpTools>()
|
|
.WithTools<QueueStateMcpTools>()
|
|
.WithTools<AttachmentMcpTools>()
|
|
.WithTools<FindingsMcpTools>();
|
|
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
|
|
|
externalApp = externalBuilder.Build();
|
|
externalApp.UseMiddleware<ExternalMcpAuthMiddleware>();
|
|
externalApp.MapMcp("/mcp");
|
|
|
|
externalApp.Logger.LogInformation(
|
|
"ClaudeDo.Worker external MCP listening on http://127.0.0.1:{Port} (auth: {Auth})",
|
|
cfg.ExternalMcpPort,
|
|
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
|
|
}
|
|
|
|
if (externalApp is null)
|
|
{
|
|
await app.RunAsync();
|
|
}
|
|
else
|
|
{
|
|
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
|
|
}
|
|
|
|
GC.KeepAlive(mutex);
|