From 2d2773805d6029eeeaca553749f567b5e65f8cd3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 00:57:32 +0100 Subject: [PATCH 01/11] Add --mcp --transport http --port startup option Extends the --mcp flag to support HTTP transport alongside the existing stdio transport. When launched with --mcp --transport http, builds a minimal WebApplication with only the MCP endpoint and API key middleware, skipping JWT, CORS, SignalR, Swagger, and frontend middleware. Port defaults to 5001 and can be overridden with --port. --- backend/src/Taskdeck.Api/Program.cs | 97 +++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 468b7fe6b..49d613dda 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -10,12 +10,99 @@ 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 == "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; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--port", StringComparison.OrdinalIgnoreCase) + && int.TryParse(args[i + 1], out var parsedPort)) + mcpPort = parsedPort; + } + + var mcpHttpBuilder = WebApplication.CreateBuilder(args); + mcpHttpBuilder.WebHost.UseUrls($"http://localhost:{mcpPort}"); + + // Infrastructure (DbContext, Repositories, UoW) + mcpHttpBuilder.Services.AddInfrastructure(mcpHttpBuilder.Configuration); + + // Register Application services needed by MCP resources and tools. + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped( + sp => sp.GetRequiredService()); + mcpHttpBuilder.Services.AddScoped(sp => + new Taskdeck.Application.Services.BoardService( + sp.GetRequiredService(), + sp.GetService())); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped( + sp => sp.GetRequiredService()); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped( + sp => sp.GetRequiredService()); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped( + sp => sp.GetRequiredService()); + + // HTTP identity: maps API key to user via HttpUserContextProvider. + mcpHttpBuilder.Services.AddHttpContextAccessor(); + mcpHttpBuilder.Services.AddScoped(); + + // MCP server: HTTP transport + all resources and tools. + mcpHttpBuilder.Services.AddMcpServer() + .WithHttpTransport() + .WithResources() + .WithResources() + .WithResources() + .WithTools() + .WithTools() + .WithTools(); + + var mcpHttpApp = mcpHttpBuilder.Build(); + + // Apply EF Core migrations before starting. + using (var scope = mcpHttpApp.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.Migrate(); + } + + // API key authentication for MCP requests. + mcpHttpApp.UseMiddleware(); + + // Map the MCP endpoint. + mcpHttpApp.MapMcp(); + + var mcpHttpLogger = mcpHttpApp.Services.GetRequiredService>(); + mcpHttpLogger.LogInformation("Taskdeck MCP HTTP server starting on port {Port}", mcpPort); + + await mcpHttpApp.RunAsync(); + return; + } + + // ── 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) => { @@ -84,7 +171,7 @@ await mcpHost.RunAsync(); return; } -// ── End MCP stdio mode ─────────────────────────────────────────────────────── +// ── End MCP modes ─────────────────────────────────────────────────────────── var builder = WebApplication.CreateBuilder(args); From 8689ff103cf38577719c7ea744632b25f6184a10 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 00:59:15 +0100 Subject: [PATCH 02/11] Add api-key CLI commands for MCP key management Adds create, list, and revoke subcommands under the api-key group. Create generates a tdsk_ prefixed key and outputs the plaintext once. List shows all keys for the CLI actor. Revoke accepts --name or --id to deactivate a key. Follows existing CLI patterns with JSON output. --- .../Commands/ApiKeysCommandHandler.cs | 169 ++++++++++++++++++ .../Commands/CommandDispatcher.cs | 1 + .../Taskdeck.Cli/Commands/ConsoleOutput.cs | 4 + backend/src/Taskdeck.Cli/Program.cs | 2 + 4 files changed, 176 insertions(+) create mode 100644 backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs diff --git a/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs b/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs new file mode 100644 index 000000000..5b5ab96d0 --- /dev/null +++ b/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs @@ -0,0 +1,169 @@ +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 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 CreateAsync(string[] args) + { + var name = ArgParser.GetOption(args, "--name"); + if (string.IsNullOrWhiteSpace(name)) + { + return ConsoleOutput.PrintUsageError( + "Missing --name.", + "taskdeck api-key create --name [--expires ]"); + } + + 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 [--expires ]"); + } + expiresIn = TimeSpan.FromDays(days); + } + + 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 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 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 | --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 "); + } + + await _apiKeyService.RevokeKeyAsync(keyId, userId); + ConsoleOutput.WriteJson(new { revoked = keyId, status = "ok" }); + return ExitCodes.Success; + } + + // Revoke by name: find the first active key with this name + var keys = await _apiKeyService.ListKeysAsync(userId); + var target = keys.FirstOrDefault(k => k.Name == name && k.IsActive); + if (target is null) + { + return ConsoleOutput.PrintFailure( + ErrorCodes.NotFound, + $"No active API key found with name '{name}'."); + } + + 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); + } + } + + private async Task GetOrCreateCliActorIdAsync() + { + const string actorUsername = "taskdeck_cli_actor"; + const string actorEmail = "taskdeck-cli-actor@local.taskdeck"; + + var existingActor = await _unitOfWork.Users.GetByUsernameAsync(actorUsername); + if (existingActor is not null) + { + return existingActor.Id; + } + + var actor = new User(actorUsername, actorEmail, Guid.NewGuid().ToString("N")); + await _unitOfWork.Users.AddAsync(actor); + await _unitOfWork.SaveChangesAsync(); + return actor.Id; + } +} diff --git a/backend/src/Taskdeck.Cli/Commands/CommandDispatcher.cs b/backend/src/Taskdeck.Cli/Commands/CommandDispatcher.cs index 9be89f523..37a15fcf9 100644 --- a/backend/src/Taskdeck.Cli/Commands/CommandDispatcher.cs +++ b/backend/src/Taskdeck.Cli/Commands/CommandDispatcher.cs @@ -29,6 +29,7 @@ public async Task DispatchAsync(string[] args) "boards" => await scope.ServiceProvider.GetRequiredService().HandleAsync(command, remainingArgs), "columns" => await scope.ServiceProvider.GetRequiredService().HandleAsync(command, remainingArgs), "cards" => await scope.ServiceProvider.GetRequiredService().HandleAsync(command, remainingArgs), + "api-key" => await scope.ServiceProvider.GetRequiredService().HandleAsync(command, remainingArgs), "help" => ReturnHelp(), _ => ReturnUnknownCommand(group) }; diff --git a/backend/src/Taskdeck.Cli/Commands/ConsoleOutput.cs b/backend/src/Taskdeck.Cli/Commands/ConsoleOutput.cs index 9ff6ddfdd..4c0b9b4b0 100644 --- a/backend/src/Taskdeck.Cli/Commands/ConsoleOutput.cs +++ b/backend/src/Taskdeck.Cli/Commands/ConsoleOutput.cs @@ -46,6 +46,10 @@ taskdeck cards add --board --column --title [--de taskdeck cards move --card <card-id> --target-column <column-id> [--position <position>] taskdeck cards list --board <board-id> [--search <text>] [--column <column-id>] [--label <label-id>] + taskdeck api-key create --name <name> [--expires <days>] + taskdeck api-key list + taskdeck api-key revoke --name <name> | --id <key-id> + Exit codes: 0 success 1 command failed diff --git a/backend/src/Taskdeck.Cli/Program.cs b/backend/src/Taskdeck.Cli/Program.cs index d33ae6b2f..3cdff9ae5 100644 --- a/backend/src/Taskdeck.Cli/Program.cs +++ b/backend/src/Taskdeck.Cli/Program.cs @@ -29,9 +29,11 @@ builder.Services.AddScoped<BoardService>(); builder.Services.AddScoped<ColumnService>(); builder.Services.AddScoped<CardService>(); +builder.Services.AddScoped<ApiKeyService>(); builder.Services.AddScoped<BoardsCommandHandler>(); builder.Services.AddScoped<ColumnsCommandHandler>(); builder.Services.AddScoped<CardsCommandHandler>(); +builder.Services.AddScoped<ApiKeysCommandHandler>(); using var host = builder.Build(); From e21a33f04e7f372cd062919f019b8857776b4f13 Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Fri, 10 Apr 2026 01:02:09 +0100 Subject: [PATCH 03/11] Add CLI api-key command integration tests 12 tests covering create (with/without expiration), list, revoke (by name and by id), error handling for missing args, invalid inputs, nonexistent keys, and unknown subcommands. Tests use the existing CliHarness pattern running against ephemeral SQLite. --- .../Taskdeck.Cli.Tests/ApiKeyCommandTests.cs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 backend/tests/Taskdeck.Cli.Tests/ApiKeyCommandTests.cs diff --git a/backend/tests/Taskdeck.Cli.Tests/ApiKeyCommandTests.cs b/backend/tests/Taskdeck.Cli.Tests/ApiKeyCommandTests.cs new file mode 100644 index 000000000..d4d8afd03 --- /dev/null +++ b/backend/tests/Taskdeck.Cli.Tests/ApiKeyCommandTests.cs @@ -0,0 +1,269 @@ +using System.Diagnostics; +using System.Text.Json; +using FluentAssertions; +using Xunit; + +namespace Taskdeck.Cli.Tests; + +public class ApiKeyCommandTests +{ + [Fact] + public async Task ApiKeyCreate_ReturnsJsonWithTdskPrefix() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create --name \"Test Key\""); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + doc.RootElement.GetProperty("key").GetString().Should().StartWith("tdsk_"); + doc.RootElement.GetProperty("name").GetString().Should().Be("Test Key"); + doc.RootElement.GetProperty("message").GetString().Should().Contain("cannot be retrieved"); + } + + [Fact] + public async Task ApiKeyCreate_WithExpires_SetsExpiresAt() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create --name \"Expiring\" --expires 90d"); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + doc.RootElement.GetProperty("expiresAt").GetDateTimeOffset() + .Should().BeCloseTo(DateTimeOffset.UtcNow.AddDays(90), TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task ApiKeyCreate_WithNumericExpires_AcceptsDaysWithoutSuffix() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create --name \"NumExpiry\" --expires 30"); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + doc.RootElement.GetProperty("expiresAt").GetDateTimeOffset() + .Should().BeCloseTo(DateTimeOffset.UtcNow.AddDays(30), TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task ApiKeyCreate_MissingName_ReturnsUsageError() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create"); + + result.ExitCode.Should().Be(2); + result.StdErr.Should().Contain("--name"); + } + + [Fact] + public async Task ApiKeyCreate_InvalidExpires_ReturnsUsageError() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create --name \"Bad\" --expires abc"); + + result.ExitCode.Should().Be(2); + result.StdErr.Should().Contain("Invalid --expires"); + } + + [Fact] + public async Task ApiKeyList_ReturnsCreatedKeys() + { + await using var harness = new CliHarness(); + + await harness.RunAsync("api-key create --name \"List Key A\""); + await harness.RunAsync("api-key create --name \"List Key B\""); + + var result = await harness.RunAsync("api-key list"); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + + var names = doc.RootElement.EnumerateArray() + .Select(e => e.GetProperty("name").GetString()) + .ToList(); + names.Should().Contain("List Key A"); + names.Should().Contain("List Key B"); + } + + [Fact] + public async Task ApiKeyList_ShowsKeyPrefixNotFullKey() + { + await using var harness = new CliHarness(); + + await harness.RunAsync("api-key create --name \"Prefix Key\""); + + var result = await harness.RunAsync("api-key list"); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + var firstKey = doc.RootElement.EnumerateArray().First(); + firstKey.GetProperty("keyPrefix").GetString()!.Length.Should().Be(8); + firstKey.GetProperty("keyPrefix").GetString().Should().StartWith("tdsk_"); + } + + [Fact] + public async Task ApiKeyRevoke_ByName_SetsRevokedStatus() + { + await using var harness = new CliHarness(); + + await harness.RunAsync("api-key create --name \"Revoke Me\""); + + var revokeResult = await harness.RunAsync("api-key revoke --name \"Revoke Me\""); + revokeResult.ExitCode.Should().Be(0, revokeResult.StdErr); + using var revokeDoc = JsonDocument.Parse(revokeResult.StdOut); + revokeDoc.RootElement.GetProperty("status").GetString().Should().Be("ok"); + + // Verify in list + var listResult = await harness.RunAsync("api-key list"); + using var listDoc = JsonDocument.Parse(listResult.StdOut); + var revokedKey = listDoc.RootElement.EnumerateArray() + .First(e => e.GetProperty("name").GetString() == "Revoke Me"); + revokedKey.GetProperty("isActive").GetBoolean().Should().BeFalse(); + } + + [Fact] + public async Task ApiKeyRevoke_ById_SetsRevokedStatus() + { + await using var harness = new CliHarness(); + + var createResult = await harness.RunAsync("api-key create --name \"Revoke By Id\""); + using var createDoc = JsonDocument.Parse(createResult.StdOut); + var keyId = createDoc.RootElement.GetProperty("id").GetGuid(); + + var revokeResult = await harness.RunAsync($"api-key revoke --id {keyId}"); + revokeResult.ExitCode.Should().Be(0, revokeResult.StdErr); + } + + [Fact] + public async Task ApiKeyRevoke_NonexistentName_ReturnsFailure() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key revoke --name \"Does Not Exist\""); + + result.ExitCode.Should().Be(1); + result.StdErr.Should().Contain("No active API key found"); + } + + [Fact] + public async Task ApiKeyRevoke_MissingIdentifier_ReturnsUsageError() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key revoke"); + + result.ExitCode.Should().Be(2); + result.StdErr.Should().Contain("--name"); + } + + [Fact] + public async Task ApiKey_UnknownSubcommand_ReturnsUsageError() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key unknown"); + + result.ExitCode.Should().Be(2); + result.StdErr.Should().Contain("Unknown api-key command"); + } + + /// <summary> + /// Shared harness that runs the CLI as a subprocess against an ephemeral database. + /// </summary> + private sealed class CliHarness : IAsyncDisposable + { + private readonly string _repoRoot; + private readonly string _databasePath; + private readonly string _connectionString; + + public CliHarness() + { + _repoRoot = FindRepoRoot(); + _databasePath = Path.Combine(Path.GetTempPath(), $"taskdeck-cli-apikey-tests-{Guid.NewGuid():N}.db"); + _connectionString = $"Data Source={_databasePath}"; + } + + public async Task<CliCommandResult> RunAsync(string arguments) + { + var cliDllPath = ResolveCliDllPath(_repoRoot); + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{cliDllPath}\" {arguments}", + WorkingDirectory = _repoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + startInfo.Environment["TASKDECK_CONNECTION_STRING"] = _connectionString; + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var stdOut = await process.StandardOutput.ReadToEndAsync(); + var stdErr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new CliCommandResult(process.ExitCode, stdOut.Trim(), stdErr.Trim()); + } + + public ValueTask DisposeAsync() + { + foreach (var path in new[] { _databasePath, $"{_databasePath}-wal", $"{_databasePath}-shm", $"{_databasePath}-journal" }) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (IOException) { } + } + + return ValueTask.CompletedTask; + } + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + + while (current != null) + { + var solutionPath = Path.Combine(current.FullName, "backend", "Taskdeck.sln"); + if (File.Exists(solutionPath)) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Could not locate repository root from test execution directory."); + } + + private static string ResolveCliDllPath(string repoRoot) + { + var cliProjectBin = Path.Combine(repoRoot, "backend", "src", "Taskdeck.Cli", "bin"); + var debugPath = Path.Combine(cliProjectBin, "Debug", "net8.0", "Taskdeck.Cli.dll"); + if (File.Exists(debugPath)) + { + return debugPath; + } + + var releasePath = Path.Combine(cliProjectBin, "Release", "net8.0", "Taskdeck.Cli.dll"); + if (File.Exists(releasePath)) + { + return releasePath; + } + + throw new FileNotFoundException("Taskdeck.Cli.dll was not found in Debug or Release output directories."); + } + } + + private sealed record CliCommandResult(int ExitCode, string StdOut, string StdErr); +} From 85a77a0ffa8364ff21d2a7aa75420f7e82501ff6 Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Fri, 10 Apr 2026 01:12:17 +0100 Subject: [PATCH 04/11] Update docs for MCP Phase 3 completion Mark #654 as fully delivered in STATUS.md and IMPLEMENTATION_MASTERPLAN.md. Adds --mcp --transport http --port startup mode and CLI api-key commands to the existing delivery notes. Updates dependency chain to show all three phases completed with only #655 (production hardening) remaining. --- docs/IMPLEMENTATION_MASTERPLAN.md | 10 +++++----- docs/STATUS.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 14374edb8..315290181 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -783,7 +783,7 @@ Focus: Current status: - tool registry, policy evaluator, and first bounded template are now delivered (`#337`): `ITaskdeckTool`/`ITaskdeckToolRegistry` domain interfaces, `AgentPolicyEvaluator` with allowlist + risk-level gating, and `InboxTriageAssistant` bounded template (proposal-only, review-first default) - LLM tool-calling architecture spike completed (`#618`); Phase 1 delivered (`#649`): read tools + orchestrator + provider tool-calling extension; `#674` delivered (OpenAI strict mode + loop detection with error-retry bypass, PR `#694`); `#677` delivered (card ID prefix resolution for chat-to-proposal continuity, PR `#695`); `#650` delivered (write tools + proposal integration, PR `#731`); `#672` delivered (double LLM call elimination, PR `#727`); `#651` delivered (Phase 3 refinements: cost tracking, `LlmToolCalling:Enabled` feature flag, `TruncateToolResult` byte budget with binary search — 17 new tests, PR `#773`); ~~`#673`~~ delivered (argument replay — `Arguments` field on `ToolCallResult`, OpenAI/Gemini replay uses real arguments, 6 new tests, PR `#770`) -- MCP server architecture spike completed (`#619`); Phase 1 delivered (`#652`/`#664`): minimal prototype with `taskdeck://boards` resource over stdio; ~~`#653`~~ delivered (full inventory — 9 resources + 11 tools, PR `#739`); remaining: `#654` (HTTP + auth), `#655` (production hardening, deferred) +- MCP server architecture spike completed (`#619`); Phase 1 delivered (`#652`/`#664`): minimal prototype with `taskdeck://boards` resource over stdio; ~~`#653`~~ delivered (full inventory — 9 resources + 11 tools, PR `#739`); ~~`#654`~~ delivered (HTTP transport + API key auth — `ApiKey` entity, `ApiKeyMiddleware`, `HttpUserContextProvider`, `ApiKeysController`, `--mcp --transport http --port` startup mode, CLI `api-key create/list/revoke`, rate limiting, 43 tests, PRs `#792`+`#654`); remaining: `#655` (production hardening, deferred) - remaining work: `AgentProfile`/`AgentRun`/`AgentRunEvent` runtime primitives (`#336`), agent mode surfaces (`#338`), inspectable run detail Exit Criteria: @@ -855,7 +855,7 @@ Master tracker: `#531`. - email notification delivery - activity feed per board - LLM tool-calling for chat (`#647`: ~~`#649`~~ delivered → ~~`#650`~~ delivered → ~~`#651`~~ delivered) - - MCP server for external agent integration (`#648`: ~~`#652`~~ delivered → `#653`→`#654`) + - MCP server for external agent integration (`#648`: ~~`#652`~~ delivered → ~~`#653`~~ delivered → ~~`#654`~~ delivered) - `v0.5.0` **Power Up** (target: Week 15-20): - platform installers (Inno Setup, DMG, AppImage) @@ -961,10 +961,10 @@ Master tracker: `#531`. - MCP server implementation wave (from completed spike `#619`): - `#648` tracker - ~~`#652` Phase 1: minimal prototype — one resource + stdio + Claude Code~~ (delivered 2026-04-01, PR `#664`) - - `#653` Phase 2: full resource + tool inventory (2-3 weeks) - - `#654` Phase 3: HTTP transport + API key auth (1-2 weeks) + - ~~`#653` Phase 2: full resource + tool inventory~~ (delivered 2026-04-04, PR `#739`) + - ~~`#654` Phase 3: HTTP transport + API key auth~~ (delivered 2026-04-08+, PRs `#792`+`#654`) - `#655` Phase 4: production hardening (deferred to v0.4.0+ demand, `Priority IV`) - - Dependency chain: ~~`#652`~~ → `#653` → `#654` → `#655` + - Dependency chain: ~~`#652`~~ → ~~`#653`~~ → ~~`#654`~~ → `#655` - Phase 2 mirrors LLM tool-calling tool abstractions; shared Application layer services ### Platform Expansion Wave (2026-03-29 — Priority II) diff --git a/docs/STATUS.md b/docs/STATUS.md index af3ee9c3f..8dcb5a77d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -110,9 +110,9 @@ Current constraints are mostly hardening and consistency: - Feature, analytics, MCP, chat, testing, and UX expansion wave (2026-04-08, PRs `#787`–`#793`, 7 issues, ~390+ new tests with two rounds of adversarial review per PR): - **Exportable analytics CSV** (`#78`/`#787`): `MetricsExportService` with schema-versioned CSV export, CSV injection protection (leading-char and embedded-newline sanitization), UTF-8 BOM for Excel compatibility; `GET /api/metrics/boards/{boardId}/export` endpoint with date range/label filters and `Content-Disposition` attachment header; frontend "Export CSV" button in MetricsView with error toast; `ADR-0022` defers PDF export; 29 tests (21 unit + 8 integration); adversarial review caught and fixed embedded-newline injection vector (HIGH), missing CancellationToken forwarding, and silent frontend error swallowing - **Forecasting and capacity-planning service** (`#79`/`#790`): `ForecastingService` with rolling-average throughput from audit log card-move events, standard-deviation confidence bands (optimistic/expected/pessimistic), average cycle time from creation-to-done; `GET /api/forecast/board/{boardId}` endpoint with documented assumptions and data-point count; frontend forecast section in MetricsView showing estimated completion, confidence range, and caveats; 32 tests; adversarial review caught and fixed throughput double-counting when cards bounce Done→InProgress→Done (HIGH), history-window calculation using wrong denominator, and regex compiled fresh on every call - - **MCP HTTP transport and API key authentication** (`#654`/`#792`): `ApiKey` domain entity with `tdsk_` prefix and SHA-256 hashing at rest; EF Core migration for `ApiKeys` table with unique `KeyHash` index; `ApiKeyMiddleware` for Bearer token validation on `/mcp` path; `HttpUserContextProvider` maps API key → user for claims-first identity; `ApiKeysController` REST endpoints (create/list/revoke) with JWT auth; `MapMcp()` HTTP transport alongside REST endpoints via `ModelContextProtocol.AspNetCore`; rate limiting per API key (60 req/60s); 31 tests (11 domain + 20 integration); adversarial review caught and fixed key-existence oracle via differentiated error messages (MEDIUM), modulo bias in key generation, and bare catch block + - **MCP HTTP transport and API key authentication** (`#654`/`#792`): `ApiKey` domain entity with `tdsk_` prefix and SHA-256 hashing at rest; EF Core migration for `ApiKeys` table with unique `KeyHash` index; `ApiKeyMiddleware` for Bearer token validation on `/mcp` path; `HttpUserContextProvider` maps API key → user for claims-first identity; `ApiKeysController` REST endpoints (create/list/revoke) with JWT auth; `MapMcp()` HTTP transport alongside REST endpoints via `ModelContextProtocol.AspNetCore`; rate limiting per API key (60 req/60s); `--mcp --transport http --port 5001` dedicated startup mode for standalone MCP HTTP server; CLI `api-key create/list/revoke` commands for key management; 43 tests (11 domain + 20 integration + 12 CLI); adversarial review caught and fixed key-existence oracle via differentiated error messages (MEDIUM), modulo bias in key generation, and bare catch block - **Conversational refinement loop** (`#576`/`#791`): `ClarificationDetector` with strong/weak signal pattern split for ambiguity detection, max 2 clarification rounds before best-effort, skip-phrase detection ("just do your best"); `ChatService` integration tracking clarification state and injecting system prompt guidance; Mock provider simulates clarification for deterministic testing; frontend clarification badge and "Skip, just do your best" button in AutomationChatView; 41 tests (22 detector + 7 service + 6 false-positive regression + domain); adversarial review caught and fixed false-positive heuristic classifying normal LLM responses as clarification (HIGH) - - **Concurrency and race condition stress tests** (`#705`/`#793`): 13 stress tests in `ConcurrencyRaceConditionStressTests.cs` covering queue claim races (double-triage, stale timestamp, batch concurrent), card update conflicts (concurrent moves, stale-write 409, last-writer-wins), column reorder race, proposal approval races (double-approve, approve+reject, double-execute), rate limiting under load (burst beyond limit, cross-user isolation), and multi-user board stress; uses `SemaphoreSlim` barriers with `WaitAsync` for true simultaneity and separate `HttpClient` per task; SQLite write serialization limitations documented; proposal decision losers now return `409 Conflict` via proposal `UpdatedAt` optimistic concurrency; adversarial review fixed misleading doc comments, tightened weak assertions, and replaced non-thread-safe variables with `ConcurrentDictionary` + - **Concurrency and race condition stress tests** (`#705`/`#793`): 13 stress tests in `ConcurrencyRaceConditionStressTests.cs` covering queue claim races (double-triage, stale timestamp, batch concurrent), card update conflicts (concurrent moves, stale-write 409, last-writer-wins), column reorder race, proposal approval races (double-approve, approve+reject, double-execute), rate limiting under load (burst beyond limit, cross-user isolation), and multi-user board stress; uses `SemaphoreSlim` barriers with `WaitAsync` for true simultaneity and separate `HttpClient` per task; SQLite write serialization limitations documented; proposal decision losers now return `409 Conflict` via proposal `UpdatedAt` optimistic concurrency; adversarial review fixed misleading doc comments, tightened weak assertions, and replaced non-thread-safe variables with `ConcurrentDictionary` - **Property-based and adversarial input tests** (`#717`/`#789`): 211 tests across 5 files — 77 FsCheck domain entity tests (adversarial strings: unicode, null bytes, BOM, ZWSP, RTL override, surrogate pairs, XSS, SQL injection; boundary lengths; GUID/position validation), 29 JSON serialization round-trip fuzz tests (GUID format variations, DateTime boundaries, malformed JSON, large payloads), 80 API adversarial integration tests (no 500s from any adversarial input across board/card/column/capture/auth/search endpoints, malformed JSON, wrong content types, concurrent adversarial requests), 16 fast-check frontend input sanitization property tests, 9 store resilience property tests; `fast-check` added as frontend dev dependency; adversarial review fixed capture payload round-trip testing wrong DTO and null handling inconsistency in FsCheck generators - **Inbox premium primitives** (`#249`/`#788`): `InboxView.vue` reworked to use shared UI primitive components — `TdSkeleton` for loading states, `TdInlineAlert` for errors, `TdEmptyState` for empty list, `TdBadge` for status chips, `TdSpinner` for detail refresh; ~65 lines of redundant CSS removed; 7 new vitest tests; adversarial review fixed skeleton screen reader announcements (added `role="status"` and sr-only labels) and redundant `role="alert"` nesting - Ephemeral integration databases via Testcontainers (`#91`): `Taskdeck.Integration.Tests` project with `Testcontainers.PostgreSql` and `Npgsql.EntityFrameworkCore.PostgreSQL` packages; `PostgresContainerFixture` manages a shared ephemeral PostgreSQL container per xUnit collection; each test method gets its own isolated database (no cross-test contamination); schema created via `EnsureCreated()` from the EF Core model for PostgreSQL provider parity; 20 integration tests across 7 test classes covering Board CRUD, Card operations, Proposal lifecycle, per-test isolation verification, and sequential operation validation; CI workflow at `reusable-container-integration.yml` in ci-extended lane (label: testing); guide at `docs/testing/TESTCONTAINERS_GUIDE.md` From a042d36078ba04af766615af601a5d1f483cb406 Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Fri, 10 Apr 2026 01:19:06 +0100 Subject: [PATCH 05/11] Validate --transport and --port arguments in MCP startup Reject unknown transport values with a clear error message instead of silently falling through to stdio. Validate that --port is in the valid range 1-65535. Found during adversarial self-review. --- backend/src/Taskdeck.Api/Program.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 49d613dda..3a2e6b1b9 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -24,6 +24,12 @@ transport = args[i + 1].ToLowerInvariant(); } + if (transport != "stdio" && transport != "http") + { + Console.Error.WriteLine($"Error: unknown transport '{transport}'. Supported values: stdio, http"); + return; + } + if (transport == "http") { // ── MCP HTTP mode ─────────────────────────────────────────────────── @@ -33,7 +39,8 @@ for (int i = 0; i < args.Length - 1; i++) { if (string.Equals(args[i], "--port", StringComparison.OrdinalIgnoreCase) - && int.TryParse(args[i + 1], out var parsedPort)) + && int.TryParse(args[i + 1], out var parsedPort) + && parsedPort is >= 1 and <= 65535) mcpPort = parsedPort; } From 994d1d78f498b12287e87b174ac6c22e7142589a Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Sat, 11 Apr 2026 23:50:18 +0100 Subject: [PATCH 06/11] Fix CLI actor identity hijacking via email-based lookup SECURITY-HIGH: The CLI used GetByUsernameAsync with a predictable username (taskdeck_cli_actor) to find/create the system actor. If public registration was enabled, an attacker could register with this username first, causing the CLI to create API keys under the attacker's account. Fix: Look up the CLI actor by email instead of username. The email uses the non-routable @system.taskdeck domain, which cannot be registered through the normal authentication flow (email uniqueness check blocks it). Extract shared identity constants to CliActorIdentity to keep both BoardsCommandHandler and ApiKeysCommandHandler in sync. --- .../Commands/ApiKeysCommandHandler.cs | 16 ++++++++++----- .../Commands/BoardsCommandHandler.cs | 15 +++++++++----- .../Taskdeck.Cli/Commands/CliActorIdentity.cs | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 backend/src/Taskdeck.Cli/Commands/CliActorIdentity.cs diff --git a/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs b/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs index 5b5ab96d0..2dca6e41a 100644 --- a/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs +++ b/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs @@ -150,18 +150,24 @@ private async Task<int> RevokeAsync(string[] args) } } + /// <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() { - const string actorUsername = "taskdeck_cli_actor"; - const string actorEmail = "taskdeck-cli-actor@local.taskdeck"; - - var existingActor = await _unitOfWork.Users.GetByUsernameAsync(actorUsername); + var existingActor = await _unitOfWork.Users.GetByEmailAsync(CliActorIdentity.ActorEmail); if (existingActor is not null) { return existingActor.Id; } - var actor = new User(actorUsername, actorEmail, Guid.NewGuid().ToString("N")); + var actor = new User( + CliActorIdentity.ActorUsername, + CliActorIdentity.ActorEmail, + Guid.NewGuid().ToString("N")); await _unitOfWork.Users.AddAsync(actor); await _unitOfWork.SaveChangesAsync(); return actor.Id; diff --git a/backend/src/Taskdeck.Cli/Commands/BoardsCommandHandler.cs b/backend/src/Taskdeck.Cli/Commands/BoardsCommandHandler.cs index b7ba9ebb2..4060598cf 100644 --- a/backend/src/Taskdeck.Cli/Commands/BoardsCommandHandler.cs +++ b/backend/src/Taskdeck.Cli/Commands/BoardsCommandHandler.cs @@ -152,18 +152,23 @@ private async Task<int> UpdateAsync(string[] args, bool outputJson) return ExitCodes.Success; } + /// <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 via the + /// non-routable <c>@system.taskdeck</c> domain. + /// </summary> private async Task<Guid> GetOrCreateCliActorIdAsync() { - const string actorUsername = "taskdeck_cli_actor"; - const string actorEmail = "taskdeck-cli-actor@local.taskdeck"; - - var existingActor = await _unitOfWork.Users.GetByUsernameAsync(actorUsername); + var existingActor = await _unitOfWork.Users.GetByEmailAsync(CliActorIdentity.ActorEmail); if (existingActor is not null) { return existingActor.Id; } - var actor = new User(actorUsername, actorEmail, Guid.NewGuid().ToString("N")); + var actor = new User( + CliActorIdentity.ActorUsername, + CliActorIdentity.ActorEmail, + Guid.NewGuid().ToString("N")); await _unitOfWork.Users.AddAsync(actor); await _unitOfWork.SaveChangesAsync(); return actor.Id; diff --git a/backend/src/Taskdeck.Cli/Commands/CliActorIdentity.cs b/backend/src/Taskdeck.Cli/Commands/CliActorIdentity.cs new file mode 100644 index 000000000..5e997cbc6 --- /dev/null +++ b/backend/src/Taskdeck.Cli/Commands/CliActorIdentity.cs @@ -0,0 +1,20 @@ +namespace Taskdeck.Cli.Commands; + +/// <summary> +/// Shared identity constants for the CLI system actor. +/// The email uses the non-routable <c>@system.taskdeck</c> domain, which cannot +/// be registered through the normal authentication flow (email uniqueness check +/// blocks it). This prevents identity hijacking where an attacker registers the +/// CLI username before the CLI creates its actor. +/// </summary> +internal static class CliActorIdentity +{ + /// <summary>Username for the CLI system actor.</summary> + public const string ActorUsername = "taskdeck_cli_actor"; + + /// <summary> + /// Email for the CLI system actor. Uses a non-routable domain to prevent + /// registration-based hijacking. Actor lookup uses email, not username. + /// </summary> + public const string ActorEmail = "cli-actor@system.taskdeck"; +} From cb93b7249bc9d58725acf8bc260685a29909dea7 Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Sat, 11 Apr 2026 23:51:37 +0100 Subject: [PATCH 07/11] Fix MCP HTTP transport security and correctness issues - Bind to 0.0.0.0 instead of localhost to support container and remote access; add --host argument for configurability - Register rate limiting services and apply McpPerApiKey policy to the standalone MCP HTTP server endpoint - Show error and exit for invalid --port values instead of silently falling back to the default - Load appsettings.local.json in HTTP mode (matches stdio mode) - Use GetRequiredService instead of GetService for IAuthorizationService to fail fast on misconfiguration --- backend/src/Taskdeck.Api/Program.cs | 61 ++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 3a2e6b1b9..2d912682d 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -36,16 +36,36 @@ // 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; + var mcpHost = "0.0.0.0"; + var portSpecified = false; for (int i = 0; i < args.Length - 1; i++) { - if (string.Equals(args[i], "--port", StringComparison.OrdinalIgnoreCase) - && int.TryParse(args[i + 1], out var parsedPort) - && parsedPort is >= 1 and <= 65535) - mcpPort = parsedPort; + if (string.Equals(args[i], "--port", StringComparison.OrdinalIgnoreCase)) + { + portSpecified = true; + 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; + } + } + else if (string.Equals(args[i], "--host", StringComparison.OrdinalIgnoreCase)) + { + mcpHost = args[i + 1]; + } } var mcpHttpBuilder = WebApplication.CreateBuilder(args); - mcpHttpBuilder.WebHost.UseUrls($"http://localhost:{mcpPort}"); + + // 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://{mcpHost}:{mcpPort}"); // Infrastructure (DbContext, Repositories, UoW) mcpHttpBuilder.Services.AddInfrastructure(mcpHttpBuilder.Configuration); @@ -57,7 +77,7 @@ mcpHttpBuilder.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>())); mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.ColumnService>(); mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.CardService>(); mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.LabelService>(); @@ -75,6 +95,17 @@ 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() @@ -97,11 +128,21 @@ // API key authentication for MCP requests. mcpHttpApp.UseMiddleware<Taskdeck.Api.Middleware.ApiKeyMiddleware>(); - // Map the MCP endpoint. - mcpHttpApp.MapMcp(); + // Apply rate limiting before endpoint routing. + 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 port {Port}", mcpPort); + mcpHttpLogger.LogInformation("Taskdeck MCP HTTP server starting on http://{Host}:{Port}", mcpHost, mcpPort); await mcpHttpApp.RunAsync(); return; @@ -139,7 +180,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>(); From 5ed5a25616dceed257da8dab732b78b07ccfcb2e Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Sat, 11 Apr 2026 23:52:09 +0100 Subject: [PATCH 08/11] Fix rate limiting partition key for MCP API key users ResolveUserOrClientIdentifier only checked JWT claims, so MCP HTTP requests authenticated via API key (stored in HttpContext.Items by ApiKeyMiddleware) fell through to IP-based partitioning. This meant multiple API keys from the same IP shared a single rate limit bucket. Now checks HttpContext.Items[McpApiKeyUserId] as a fallback before defaulting to IP, ensuring per-API-key rate limiting works in both the co-hosted and standalone MCP HTTP server modes. --- .../Extensions/RateLimitingRegistration.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs b/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs index 485c6d5b2..dd98ac370 100644 --- a/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs @@ -6,6 +6,7 @@ using Taskdeck.Api.RateLimiting; using Taskdeck.Application.Services; using Taskdeck.Domain.Exceptions; +using Taskdeck.Infrastructure.Mcp; namespace Taskdeck.Api.Extensions; @@ -100,11 +101,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) From 70801808b740d0b4bc0c087f43b539d180014127 Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Sat, 11 Apr 2026 23:52:31 +0100 Subject: [PATCH 09/11] Reject ambiguous revoke-by-name when multiple keys match When multiple active API keys share the same name, revoke --name now lists all matches with their IDs and asks the user to use --id instead of silently revoking an arbitrary key. --- .../Commands/ApiKeysCommandHandler.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs b/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs index 2dca6e41a..cc80c8d99 100644 --- a/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs +++ b/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs @@ -130,16 +130,28 @@ private async Task<int> RevokeAsync(string[] args) return ExitCodes.Success; } - // Revoke by name: find the first active key with this name + // Revoke by name: find active keys with this name var keys = await _apiKeyService.ListKeysAsync(userId); - var target = keys.FirstOrDefault(k => k.Name == name && k.IsActive); - if (target is null) + 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; From 65e77b7355d95a2b31332cf9c1fd4ae81c57865f Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Sat, 11 Apr 2026 23:53:20 +0100 Subject: [PATCH 10/11] Fix doc references mixing PR and issue numbers Change "PRs #792+#654" to "PR #792, issue #654" since #654 is an issue number, not a PR number. --- docs/IMPLEMENTATION_MASTERPLAN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 315290181..5408fe5f9 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -783,7 +783,7 @@ Focus: Current status: - tool registry, policy evaluator, and first bounded template are now delivered (`#337`): `ITaskdeckTool`/`ITaskdeckToolRegistry` domain interfaces, `AgentPolicyEvaluator` with allowlist + risk-level gating, and `InboxTriageAssistant` bounded template (proposal-only, review-first default) - LLM tool-calling architecture spike completed (`#618`); Phase 1 delivered (`#649`): read tools + orchestrator + provider tool-calling extension; `#674` delivered (OpenAI strict mode + loop detection with error-retry bypass, PR `#694`); `#677` delivered (card ID prefix resolution for chat-to-proposal continuity, PR `#695`); `#650` delivered (write tools + proposal integration, PR `#731`); `#672` delivered (double LLM call elimination, PR `#727`); `#651` delivered (Phase 3 refinements: cost tracking, `LlmToolCalling:Enabled` feature flag, `TruncateToolResult` byte budget with binary search — 17 new tests, PR `#773`); ~~`#673`~~ delivered (argument replay — `Arguments` field on `ToolCallResult`, OpenAI/Gemini replay uses real arguments, 6 new tests, PR `#770`) -- MCP server architecture spike completed (`#619`); Phase 1 delivered (`#652`/`#664`): minimal prototype with `taskdeck://boards` resource over stdio; ~~`#653`~~ delivered (full inventory — 9 resources + 11 tools, PR `#739`); ~~`#654`~~ delivered (HTTP transport + API key auth — `ApiKey` entity, `ApiKeyMiddleware`, `HttpUserContextProvider`, `ApiKeysController`, `--mcp --transport http --port` startup mode, CLI `api-key create/list/revoke`, rate limiting, 43 tests, PRs `#792`+`#654`); remaining: `#655` (production hardening, deferred) +- MCP server architecture spike completed (`#619`); Phase 1 delivered (`#652`/`#664`): minimal prototype with `taskdeck://boards` resource over stdio; ~~`#653`~~ delivered (full inventory — 9 resources + 11 tools, PR `#739`); ~~`#654`~~ delivered (HTTP transport + API key auth — `ApiKey` entity, `ApiKeyMiddleware`, `HttpUserContextProvider`, `ApiKeysController`, `--mcp --transport http --port` startup mode, CLI `api-key create/list/revoke`, rate limiting, 43 tests, PR `#792`, issue `#654`); remaining: `#655` (production hardening, deferred) - remaining work: `AgentProfile`/`AgentRun`/`AgentRunEvent` runtime primitives (`#336`), agent mode surfaces (`#338`), inspectable run detail Exit Criteria: @@ -962,7 +962,7 @@ Master tracker: `#531`. - `#648` tracker - ~~`#652` Phase 1: minimal prototype — one resource + stdio + Claude Code~~ (delivered 2026-04-01, PR `#664`) - ~~`#653` Phase 2: full resource + tool inventory~~ (delivered 2026-04-04, PR `#739`) - - ~~`#654` Phase 3: HTTP transport + API key auth~~ (delivered 2026-04-08+, PRs `#792`+`#654`) + - ~~`#654` Phase 3: HTTP transport + API key auth~~ (delivered 2026-04-08+, PR `#792`, issue `#654`) - `#655` Phase 4: production hardening (deferred to v0.4.0+ demand, `Priority IV`) - Dependency chain: ~~`#652`~~ → ~~`#653`~~ → ~~`#654`~~ → `#655` - Phase 2 mirrors LLM tool-calling tool abstractions; shared Application layer services From 5778aef3b7441f92dd26438800ab58b7cf5b6791 Mon Sep 17 00:00:00 2001 From: Chris0Jeky <jeky.tck@gmail.com> Date: Sat, 11 Apr 2026 23:54:46 +0100 Subject: [PATCH 11/11] Fix variable name collision in MCP HTTP mode Rename mcpHost to mcpBindHost to avoid CS0136 conflict with the mcpHost variable in the stdio mode block (top-level statement scoping treats both as same scope). Remove unused portSpecified variable. --- backend/src/Taskdeck.Api/Program.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 2d912682d..7356d8e28 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -36,13 +36,11 @@ // 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; - var mcpHost = "0.0.0.0"; - var portSpecified = false; + var mcpBindHost = "0.0.0.0"; for (int i = 0; i < args.Length - 1; i++) { if (string.Equals(args[i], "--port", StringComparison.OrdinalIgnoreCase)) { - portSpecified = true; if (int.TryParse(args[i + 1], out var parsedPort) && parsedPort is >= 1 and <= 65535) { @@ -56,7 +54,7 @@ } else if (string.Equals(args[i], "--host", StringComparison.OrdinalIgnoreCase)) { - mcpHost = args[i + 1]; + mcpBindHost = args[i + 1]; } } @@ -65,7 +63,7 @@ // 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://{mcpHost}:{mcpPort}"); + mcpHttpBuilder.WebHost.UseUrls($"http://{mcpBindHost}:{mcpPort}"); // Infrastructure (DbContext, Repositories, UoW) mcpHttpBuilder.Services.AddInfrastructure(mcpHttpBuilder.Configuration); @@ -142,7 +140,7 @@ } var mcpHttpLogger = mcpHttpApp.Services.GetRequiredService<ILogger<Program>>(); - mcpHttpLogger.LogInformation("Taskdeck MCP HTTP server starting on http://{Host}:{Port}", mcpHost, mcpPort); + mcpHttpLogger.LogInformation("Taskdeck MCP HTTP server starting on http://{Host}:{Port}", mcpBindHost, mcpPort); await mcpHttpApp.RunAsync(); return;