Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Taskdeck.Api.RateLimiting;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Exceptions;
using Taskdeck.Infrastructure.Mcp;

namespace Taskdeck.Api.Extensions;

Expand Down Expand Up @@ -106,11 +107,21 @@ private static RateLimitPartition<string> BuildFixedWindowPartition(string parti

private static string ResolveUserOrClientIdentifier(HttpContext context)
{
// Check JWT claims first (REST API path).
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.FindFirstValue("sub");
return !string.IsNullOrWhiteSpace(userId)
? userId
: ResolveClientAddress(context);
if (!string.IsNullOrWhiteSpace(userId))
return userId;

// Check API key user ID from HttpContext.Items (MCP HTTP path).
// ApiKeyMiddleware sets this after validating the Bearer token.
if (context.Items.TryGetValue(HttpUserContextProvider.UserIdItemKey, out var apiKeyUserId)
&& apiKeyUserId is Guid apiKeyGuid)
{
return apiKeyGuid.ToString();
}

return ResolveClientAddress(context);
}

private static string ResolveClientAddress(HttpContext context)
Expand Down
148 changes: 141 additions & 7 deletions backend/src/Taskdeck.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,145 @@
using Taskdeck.Infrastructure;
using Taskdeck.Infrastructure.Mcp;

// ── MCP stdio mode ──────────────────────────────────────────────────────────
// When launched with "--mcp", run as a stdio MCP server instead of a web API.
// This path intentionally skips JWT, CORS, SignalR, rate limiting, and the
// HTTP pipeline — none of those are meaningful over a local stdio connection.
// ── MCP modes ───────────────────────────────────────────────────────────────
// When launched with "--mcp", run as an MCP server instead of the full web API.
// --mcp → stdio transport (default, for Claude Code / Cursor)
// --mcp --transport http → HTTP transport with API key auth (for cloud/remote)
// --mcp --transport http --port 5001 → HTTP transport on a specific port
if (args.Contains("--mcp"))
{
var transport = "stdio";
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--transport", StringComparison.OrdinalIgnoreCase))
transport = args[i + 1].ToLowerInvariant();
}

if (transport != "stdio" && transport != "http")
{
Console.Error.WriteLine($"Error: unknown transport '{transport}'. Supported values: stdio, http");
return 1;
}

if (transport == "http")
{
// ── MCP HTTP mode ───────────────────────────────────────────────────
// Minimal web server exposing only the MCP endpoint with API key auth.
// No controllers, no SignalR, no Swagger, no frontend — just MCP.
var mcpPort = 5001;
Comment on lines +32 to +38
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--port parsing accepts any int (including 0/negative/out-of-range), which can lead to confusing startup failures from Kestrel. Validate the port is within 1–65535 and return a usage/error message when invalid.

Copilot uses AI. Check for mistakes.
var mcpBindHost = "0.0.0.0";
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--port", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(args[i + 1], out var parsedPort)
&& parsedPort is >= 1 and <= 65535)
{
mcpPort = parsedPort;
}
else
{
Console.Error.WriteLine($"Error: invalid --port value '{args[i + 1]}'. Must be an integer between 1 and 65535.");
return 1;
}
}
else if (string.Equals(args[i], "--host", StringComparison.OrdinalIgnoreCase))
{
mcpBindHost = args[i + 1];
}
}

var mcpHttpBuilder = WebApplication.CreateBuilder(args);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load local config file in MCP HTTP startup mode

This branch creates a fresh WebApplication builder but never adds appsettings.local.json, unlike the stdio MCP path immediately below. Any installation that keeps local overrides there (for example ConnectionStrings:DefaultConnection) will be ignored in HTTP transport mode, causing the dedicated MCP server to start against fallback/default settings and potentially the wrong database.

Useful? React with 👍 / 👎.


// Load appsettings.local.json for locally-generated secrets (mirrors stdio mode).
mcpHttpBuilder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: false);

mcpHttpBuilder.WebHost.UseUrls($"http://{mcpBindHost}:{mcpPort}");

// Infrastructure (DbContext, Repositories, UoW)
mcpHttpBuilder.Services.AddInfrastructure(mcpHttpBuilder.Configuration);

// Register Application services needed by MCP resources and tools.
Comment on lines +68 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Manually instantiating BoardService with new and resolving dependencies via GetRequiredService is brittle and bypasses the benefits of the DI container. If the BoardService constructor changes in the future, this code will break. Since all dependencies are already registered in the service collection, you should register the service type directly.

        mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.BoardService>();

mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.AuthorizationService>();
Comment on lines +61 to +72
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BoardService is constructed with sp.GetService<IAuthorizationService>(), which can silently pass null and disable authorization scoping if DI registration changes. Prefer GetRequiredService (fail fast) or let DI construct BoardService directly now that optional params have defaults.

Copilot uses AI. Check for mistakes.
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.IAuthorizationService>(
sp => sp.GetRequiredService<Taskdeck.Application.Services.AuthorizationService>());
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.BoardService>(sp =>
new Taskdeck.Application.Services.BoardService(
sp.GetRequiredService<IUnitOfWork>(),
sp.GetRequiredService<Taskdeck.Application.Services.IAuthorizationService>()));
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.ColumnService>();
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.CardService>();
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.LabelService>();
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.AutomationProposalService>();
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.IAutomationProposalService>(
sp => sp.GetRequiredService<Taskdeck.Application.Services.AutomationProposalService>());
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.CaptureService>();
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.ICaptureService>(
sp => sp.GetRequiredService<Taskdeck.Application.Services.CaptureService>());
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.NotificationService>();
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.INotificationService>(
sp => sp.GetRequiredService<Taskdeck.Application.Services.NotificationService>());

// HTTP identity: maps API key to user via HttpUserContextProvider.
mcpHttpBuilder.Services.AddHttpContextAccessor();
mcpHttpBuilder.Services.AddScoped<IUserContextProvider, Taskdeck.Infrastructure.Mcp.HttpUserContextProvider>();

// Rate limiting: register the McpPerApiKey policy for per-key throttling.
var mcpRateLimitingSettings = mcpHttpBuilder.Configuration
.GetSection("RateLimiting")
.Get<Taskdeck.Application.Services.RateLimitingSettings>()
?? new Taskdeck.Application.Services.RateLimitingSettings();
mcpHttpBuilder.Services.AddSingleton(mcpRateLimitingSettings);
if (mcpRateLimitingSettings.Enabled)
{
mcpHttpBuilder.Services.AddTaskdeckRateLimiting(mcpRateLimitingSettings);
}

// MCP server: HTTP transport + all resources and tools.
mcpHttpBuilder.Services.AddMcpServer()
.WithHttpTransport()
.WithResources<BoardResources>()
.WithResources<CaptureResources>()
.WithResources<ProposalResources>()
.WithTools<ReadTools>()
.WithTools<WriteTools>()
.WithTools<ProposalTools>();

var mcpHttpApp = mcpHttpBuilder.Build();

// Apply EF Core migrations before starting.
using (var scope = mcpHttpApp.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<Taskdeck.Infrastructure.Persistence.TaskdeckDbContext>();
dbContext.Database.Migrate();
}
Comment on lines +89 to +124
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedicated MCP HTTP server path maps MapMcp() but does not register/use rate limiting or apply the McpPerApiKey policy (unlike the normal API pipeline). As a result, the promised per-key throttling won’t run in --mcp --transport http mode. Consider wiring the same rate limiting registration + UseRateLimiter() and requiring McpPerApiKey on the mapped MCP endpoint in this mode as well.

Copilot uses AI. Check for mistakes.

// API key authentication for MCP requests.
mcpHttpApp.UseMiddleware<Taskdeck.Api.Middleware.ApiKeyMiddleware>();

// Apply rate limiting before endpoint routing.
Comment on lines +127 to +129
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rate-limit MCP traffic before API key middleware rejects

In the new --mcp --transport http startup path, ApiKeyMiddleware runs before UseRateLimiter, and that middleware returns 401 immediately for missing/invalid keys. Because those rejected requests never continue down the pipeline, the McpPerApiKey limiter on MapMcp() is bypassed for unauthenticated traffic, so brute-force/abuse attempts are effectively unthrottled in this mode.

Useful? React with 👍 / 👎.

if (mcpRateLimitingSettings.Enabled)
{
mcpHttpApp.UseRateLimiter();
}

// Map the MCP endpoint with per-API-key rate limiting.
var mcpEndpoint = mcpHttpApp.MapMcp();
if (mcpRateLimitingSettings.Enabled)
{
mcpEndpoint.RequireRateLimiting(Taskdeck.Api.RateLimiting.RateLimitingPolicyNames.McpPerApiKey);
}

var mcpHttpLogger = mcpHttpApp.Services.GetRequiredService<ILogger<Program>>();
mcpHttpLogger.LogInformation("Taskdeck MCP HTTP server starting on http://{Host}:{Port}", mcpBindHost, mcpPort);

await mcpHttpApp.RunAsync();
return 0;
}

// ── MCP stdio mode ──────────────────────────────────────────────────────
// This path intentionally skips JWT, CORS, SignalR, rate limiting, and the
// HTTP pipeline — none of those are meaningful over a local stdio connection.
var mcpHost = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((_, config) =>
{
Expand Down Expand Up @@ -45,7 +178,7 @@
services.AddScoped<Taskdeck.Application.Services.BoardService>(sp =>
new Taskdeck.Application.Services.BoardService(
sp.GetRequiredService<IUnitOfWork>(),
sp.GetService<Taskdeck.Application.Services.IAuthorizationService>()));
sp.GetRequiredService<Taskdeck.Application.Services.IAuthorizationService>()));
services.AddScoped<Taskdeck.Application.Services.ColumnService>();
services.AddScoped<Taskdeck.Application.Services.CardService>();
services.AddScoped<Taskdeck.Application.Services.LabelService>();
Expand Down Expand Up @@ -82,9 +215,9 @@
}

await mcpHost.RunAsync();
return;
return 0;
}
// ── End MCP stdio mode ───────────────────────────────────────────────────────
// ── End MCP modes ───────────────────────────────────────────────────────────

var builder = WebApplication.CreateBuilder(args);

Expand Down Expand Up @@ -237,5 +370,6 @@
});

app.Run();
return 0;

public partial class Program { }
187 changes: 187 additions & 0 deletions backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Entities;
using Taskdeck.Domain.Exceptions;

namespace Taskdeck.Cli.Commands;

internal sealed class ApiKeysCommandHandler
{
private readonly ApiKeyService _apiKeyService;
private readonly IUnitOfWork _unitOfWork;

public ApiKeysCommandHandler(ApiKeyService apiKeyService, IUnitOfWork unitOfWork)
{
_apiKeyService = apiKeyService;
_unitOfWork = unitOfWork;
}

public async Task<int> HandleAsync(string command, string[] args)
{
return command switch
{
"create" => await CreateAsync(args),
"list" => await ListAsync(args),
"revoke" => await RevokeAsync(args),
_ => ConsoleOutput.PrintUsageError(
$"Unknown api-key command: '{command}'.",
"taskdeck api-key [create|list|revoke]")
};
}

private async Task<int> CreateAsync(string[] args)
{
var name = ArgParser.GetOption(args, "--name");
if (string.IsNullOrWhiteSpace(name))
{
return ConsoleOutput.PrintUsageError(
"Missing --name.",
"taskdeck api-key create --name <name> [--expires <days>]");
}

var expiresText = ArgParser.GetOption(args, "--expires");
TimeSpan? expiresIn = null;
if (expiresText is not null)
{
// Support formats: "90d" or "90"
var daysText = expiresText.TrimEnd('d', 'D');
if (!int.TryParse(daysText, out var days) || days <= 0)
{
return ConsoleOutput.PrintUsageError(
$"Invalid --expires value: '{expiresText}'. Provide a positive number of days (e.g., 90 or 90d).",
"taskdeck api-key create --name <name> [--expires <days>]");
}
expiresIn = TimeSpan.FromDays(days);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound --expires before calling TimeSpan.FromDays

--expires is only checked for int > 0, then passed directly to TimeSpan.FromDays(days). Large values (above TimeSpan.MaxValue.TotalDays, e.g. millions of days) throw OverflowException, and this path is not caught by the current DomainException handler, so taskdeck api-key create can crash instead of returning a usage error.

Useful? React with 👍 / 👎.

}

var userId = await GetOrCreateCliActorIdAsync();

try
{
var (plaintextKey, entity) = await _apiKeyService.CreateKeyAsync(userId, name, expiresIn);

ConsoleOutput.WriteJson(new
{
id = entity.Id,
key = plaintextKey,
keyPrefix = entity.KeyPrefix_,
name = entity.Name,
createdAt = entity.CreatedAt,
expiresAt = entity.ExpiresAt,
message = "Save this key — it cannot be retrieved again."
});

return ExitCodes.Success;
}
catch (DomainException ex)
{
return ConsoleOutput.PrintFailure(ex.ErrorCode, ex.Message);
}
}

private async Task<int> ListAsync(string[] args)
{
var userId = await GetOrCreateCliActorIdAsync();
var keys = await _apiKeyService.ListKeysAsync(userId);

var items = keys.Select(k => new
{
id = k.Id,
keyPrefix = k.KeyPrefix_,
name = k.Name,
createdAt = k.CreatedAt,
expiresAt = k.ExpiresAt,
revokedAt = k.RevokedAt,
lastUsedAt = k.LastUsedAt,
isActive = k.IsActive
});

ConsoleOutput.WriteJson(items);
return ExitCodes.Success;
}

private async Task<int> RevokeAsync(string[] args)
{
var name = ArgParser.GetOption(args, "--name");
var idText = ArgParser.GetOption(args, "--id");

if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(idText))
{
return ConsoleOutput.PrintUsageError(
"Provide --name or --id to identify the key to revoke.",
"taskdeck api-key revoke --name <name> | --id <key-id>");
}

var userId = await GetOrCreateCliActorIdAsync();

try
{
if (!string.IsNullOrWhiteSpace(idText))
{
if (!Guid.TryParse(idText, out var keyId))
{
return ConsoleOutput.PrintUsageError(
$"Invalid --id value: '{idText}'.",
"taskdeck api-key revoke --id <key-id>");
}

await _apiKeyService.RevokeKeyAsync(keyId, userId);
ConsoleOutput.WriteJson(new { revoked = keyId, status = "ok" });
return ExitCodes.Success;
}

// Revoke by name: find active keys with this name
var keys = await _apiKeyService.ListKeysAsync(userId);
var matches = keys.Where(k => k.Name == name && k.IsActive).ToList();
if (matches.Count == 0)
{
return ConsoleOutput.PrintFailure(
ErrorCodes.NotFound,
$"No active API key found with name '{name}'.");
}

if (matches.Count > 1)
{
// Ambiguous: multiple active keys share this name — require --id.
Console.Error.WriteLine($"Multiple active keys found with name '{name}'. Use --id to specify which key to revoke:");
foreach (var match in matches)
{
Console.Error.WriteLine($" --id {match.Id} (prefix: {match.KeyPrefix_}, created: {match.CreatedAt:u})");
}
return ExitCodes.Failure;
}

var target = matches[0];
await _apiKeyService.RevokeKeyAsync(target.Id, userId);
ConsoleOutput.WriteJson(new { revoked = target.Id, name = target.Name, status = "ok" });
return ExitCodes.Success;
}
catch (DomainException ex)
{
return ConsoleOutput.PrintFailure(ex.ErrorCode, ex.Message);
}
}

/// <summary>
/// Returns the CLI system actor's user ID, creating the actor if it does not exist.
/// Looks up by email (not username) to prevent identity hijacking: the
/// <c>@system.taskdeck</c> domain is non-routable and cannot be registered
/// through the normal authentication flow, which checks email uniqueness.
/// </summary>
private async Task<Guid> GetOrCreateCliActorIdAsync()
{
var existingActor = await _unitOfWork.Users.GetByEmailAsync(CliActorIdentity.ActorEmail);
if (existingActor is not null)
{
return existingActor.Id;
}

var actor = new User(
CliActorIdentity.ActorUsername,
CliActorIdentity.ActorEmail,
Guid.NewGuid().ToString("N"));
await _unitOfWork.Users.AddAsync(actor);
await _unitOfWork.SaveChangesAsync();
return actor.Id;
}
}
Loading
Loading