feat(mcp): resolve per-run tokens in MCP auth + register TaskRunMcpService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Planning;
|
namespace ClaudeDo.Worker.Planning;
|
||||||
@@ -9,7 +10,7 @@ public sealed class PlanningTokenAuthMiddleware
|
|||||||
|
|
||||||
public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next;
|
public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks)
|
public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks, TaskRunTokenRegistry runTokens)
|
||||||
{
|
{
|
||||||
if (!ctx.Request.Path.StartsWithSegments("/mcp"))
|
if (!ctx.Request.Path.StartsWithSegments("/mcp"))
|
||||||
{
|
{
|
||||||
@@ -26,15 +27,23 @@ public sealed class PlanningTokenAuthMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
var token = auth.Substring("Bearer ".Length).Trim();
|
var token = auth.Substring("Bearer ".Length).Trim();
|
||||||
|
|
||||||
var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
|
var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
|
||||||
if (parent is null || parent.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.Active)
|
if (parent is not null && parent.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.Active)
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 401;
|
ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||||
await ctx.Response.WriteAsync("Invalid or expired planning token");
|
await _next(ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
if (runTokens.TryResolve(token, out var callerTaskId))
|
||||||
|
{
|
||||||
|
ctx.Items["TaskRunContext"] = new TaskRunMcpContext { CallerTaskId = callerTaskId };
|
||||||
await _next(ctx);
|
await _next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
await ctx.Response.WriteAsync("Invalid or expired token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ builder.Services.AddSingleton<HubBroadcaster>();
|
|||||||
builder.Services.AddSingleton<GitService>();
|
builder.Services.AddSingleton<GitService>();
|
||||||
builder.Services.AddSingleton<WorktreeManager>();
|
builder.Services.AddSingleton<WorktreeManager>();
|
||||||
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
||||||
|
builder.Services.AddSingleton<TaskRunTokenRegistry>();
|
||||||
builder.Services.AddSingleton<TaskRunner>();
|
builder.Services.AddSingleton<TaskRunner>();
|
||||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||||
builder.Services.AddSingleton<TaskResetService>();
|
builder.Services.AddSingleton<TaskResetService>();
|
||||||
@@ -131,6 +132,8 @@ builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
|||||||
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
||||||
|
builder.Services.AddScoped<TaskRunMcpContextAccessor>();
|
||||||
|
builder.Services.AddScoped<TaskRunMcpService>();
|
||||||
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
builder.Services.AddScoped<TaskRepository>();
|
builder.Services.AddScoped<TaskRepository>();
|
||||||
@@ -138,7 +141,8 @@ builder.Services.AddScoped<ListRepository>();
|
|||||||
builder.Services.AddScoped<PlanningMcpService>();
|
builder.Services.AddScoped<PlanningMcpService>();
|
||||||
builder.Services.AddMcpServer()
|
builder.Services.AddMcpServer()
|
||||||
.WithHttpTransport()
|
.WithHttpTransport()
|
||||||
.WithTools<PlanningMcpService>();
|
.WithTools<PlanningMcpService>()
|
||||||
|
.WithTools<TaskRunMcpService>();
|
||||||
|
|
||||||
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
||||||
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
||||||
|
|||||||
44
tests/ClaudeDo.Worker.Tests/Hub/TaskRunTokenAuthTests.cs
Normal file
44
tests/ClaudeDo.Worker.Tests/Hub/TaskRunTokenAuthTests.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Hub;
|
||||||
|
|
||||||
|
public sealed class TaskRunTokenAuthTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
public void Dispose() => _db.Dispose();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Valid_taskRun_token_populates_TaskRunContext_and_calls_next()
|
||||||
|
{
|
||||||
|
var reg = new TaskRunTokenRegistry();
|
||||||
|
reg.Register("run-token", "task-1");
|
||||||
|
bool nextCalled = false;
|
||||||
|
var mw = new PlanningTokenAuthMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
|
||||||
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Request.Path = "/mcp";
|
||||||
|
ctx.Request.Headers["Authorization"] = "Bearer run-token";
|
||||||
|
using var db = _db.CreateContext();
|
||||||
|
await mw.InvokeAsync(ctx, new TaskRepository(db), reg);
|
||||||
|
Assert.True(nextCalled);
|
||||||
|
var resolved = ctx.Items["TaskRunContext"] as TaskRunMcpContext;
|
||||||
|
Assert.NotNull(resolved);
|
||||||
|
Assert.Equal("task-1", resolved!.CallerTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_token_returns_401()
|
||||||
|
{
|
||||||
|
var mw = new PlanningTokenAuthMiddleware(_ => Task.CompletedTask);
|
||||||
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Request.Path = "/mcp";
|
||||||
|
ctx.Request.Headers["Authorization"] = "Bearer nope";
|
||||||
|
using var db = _db.CreateContext();
|
||||||
|
await mw.InvokeAsync(ctx, new TaskRepository(db), new TaskRunTokenRegistry());
|
||||||
|
Assert.Equal(401, ctx.Response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user