-
Notifications
You must be signed in to change notification settings - Fork 0
MCP-03: HTTP transport and API key authentication #819
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2d27738
8689ff1
e21a33f
85a77a0
a042d36
994d1d7
cb93b72
5ed5a25
7080180
65e77b7
5778aef
c3e78eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This branch creates a fresh 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Manually instantiating mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.BoardService>(); |
||
| mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.AuthorizationService>(); | ||
|
Comment on lines
+61
to
+72
|
||
| 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
|
||
|
|
||
| // API key authentication for MCP requests. | ||
| mcpHttpApp.UseMiddleware<Taskdeck.Api.Middleware.ApiKeyMiddleware>(); | ||
|
|
||
| // Apply rate limiting before endpoint routing. | ||
|
Comment on lines
+127
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the new 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) => | ||
| { | ||
|
|
@@ -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>(); | ||
|
|
@@ -82,9 +215,9 @@ | |
| } | ||
|
|
||
| await mcpHost.RunAsync(); | ||
| return; | ||
| return 0; | ||
| } | ||
| // ── End MCP stdio mode ─────────────────────────────────────────────────────── | ||
| // ── End MCP modes ─────────────────────────────────────────────────────────── | ||
|
|
||
| var builder = WebApplication.CreateBuilder(args); | ||
|
|
||
|
|
@@ -237,5 +370,6 @@ | |
| }); | ||
|
|
||
| app.Run(); | ||
| return 0; | ||
|
|
||
| public partial class Program { } | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--portparsing accepts anyint(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.