commit ec42e8e4bdde2daf1f5f10db0e8d209e34ad8e51 Author: mika kuns Date: Fri Apr 24 18:26:11 2026 +0200 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..247b559 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# .NET build output +bin/ +obj/ +[Bb]uild/ +[Bb]uildResults/ +[Oo]ut/ + +# Visual Studio / Rider / VS Code +.vs/ +.vscode/ +.idea/ +*.suo +*.user +*.userosscache +*.sln.docstates +*.userprefs + +# Test results +[Tt]est[Rr]esult*/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml +coverage*.info + +# Publish output +publish/ +*.[Pp]ublish.xml +PublishProfiles/ +artifacts/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +!packages/build/ +project.lock.json +project.fragment.lock.json + +# Project-specific — do not commit runtime data +*.db +*.db-shm +*.db-wal + +# OS +Thumbs.db +ehthumbs.db +Desktop.ini +.DS_Store + +# Misc +*.log +*.tmp +*.bak diff --git a/ClaudeMailbox.slnx b/ClaudeMailbox.slnx new file mode 100644 index 0000000..925baf8 --- /dev/null +++ b/ClaudeMailbox.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..3245073 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + v + + + + + diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..83111fc --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# ClaudeMailbox + +A standalone MCP mail server that lets parallel Claude sessions coordinate with each other. Any Claude session (plain terminal, ClaudeDo worktree, anything that consumes `.mcp.json`) can send messages to a peer session's inbox, check for pending messages, and discover other active mailboxes. + +Not a substitute for `run_in_background: true` — that handles single-session responsiveness. This handles **session-to-session** coordination. + +## Architecture + +One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` and a small REST API at `/v1/*`, and persists state in a single SQLite file. Sessions declare themselves via an `X-Mailbox` header in their `.mcp.json`. + +``` + session-backend session-frontend external sender + (X-Mailbox: backend) (X-Mailbox: frontend) (CLI / UI / hook) + | | | + | HTTP | | + +--------------+-----------------+--------------------------+ + v + claude-mailbox serve (ASP.NET Core + Kestrel) + /mcp MCP tools + /v1/* REST for non-MCP senders + /health + v + ~/.claude-mailbox/mailbox.db (SQLite WAL) +``` + +## Install + +```powershell +dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true +``` + +Put the resulting `claude-mailbox.exe` on your `PATH`. + +## Daemon lifecycle + +The daemon is a normal console process. Pick whichever level of automation you want: + +1. **Manual.** Open a terminal, run `claude-mailbox serve`, leave it open. Stops when you close the window. +2. **Startup shortcut.** Drop a shortcut to `claude-mailbox serve` in `shell:startup` — starts on login. +3. **Windows Service.** `sc.exe create ClaudeMailbox binPath= "C:\path\to\claude-mailbox.exe serve" start= auto` — same pattern ClaudeDo uses. + +Defaults: port `47822`, bind `127.0.0.1`, database at `%USERPROFILE%\.claude-mailbox\mailbox.db`. All overridable: + +``` +claude-mailbox serve [--port 47822] [--bind 127.0.0.1] [--db-path ] +``` + +## Use from a Claude session + +Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values): + +```json +{ + "mcpServers": { + "mailbox": { + "type": "http", + "url": "http://127.0.0.1:47822/mcp", + "headers": { + "X-Mailbox": "backend" + } + } + } +} +``` + +Four MCP tools are exposed: + +| Tool | Purpose | +|---|---| +| `mcp__mailbox__send(to, body)` | Send a message to another mailbox | +| `mcp__mailbox__check_inbox()` | Pull all pending messages for this mailbox (marks delivered) | +| `mcp__mailbox__peek_inbox()` | Non-consuming check — returns `{ pending, oldestAt }` | +| `mcp__mailbox__list_mailboxes()` | Discover known mailboxes and who has mail for you | + +### Suggested CLAUDE.md snippet for poll discipline + +``` +When coordinating with a peer session, call mcp__mailbox__peek_inbox +after each subagent completes. If pending > 0, call mcp__mailbox__check_inbox +and treat the messages as input with priority over the current plan. +``` + +## CLI client mode + +Any external process (scripts, UIs, hooks) can talk to a running daemon without needing MCP: + +``` +claude-mailbox send --to --from --body [--url http://127.0.0.1:47822] +claude-mailbox peek --name [--url ...] +claude-mailbox check --name [--url ...] +claude-mailbox list [--url ...] +``` + +The CLI subcommands are thin HTTP clients against the `/v1/*` endpoints. + +## REST surface + +| Method | Path | Requires `X-Mailbox` | Purpose | +|---|---|---|---| +| `GET` | `/health` | no | `{ status, version, dbPath }` | +| `POST` | `/v1/send` | yes (sender) | `{ to, body }` | +| `GET` | `/v1/peek?name=` | no | read-only status | +| `POST` | `/v1/check-inbox?name=` | yes (must match `name`) | consume inbox | +| `GET` | `/v1/list` | no | list all mailboxes | + +## Development + +``` +dotnet build +dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj +dotnet run --project src/ClaudeMailbox -- serve +``` + +Test suite covers end-to-end coordination, concurrent `check_inbox` race safety, and schema idempotency. + +## Scope + +- Loopback bind only (v1). Cross-machine coordination is a future extension — swap the middleware for token auth and change the bind address. +- No auth on loopback. Local filesystem permissions are the trust boundary. +- No message expiry or cleanup. Delivered messages stay as a timeline/audit log. diff --git a/global.json b/global.json new file mode 100644 index 0000000..1781139 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.418", + "rollForward": "latestFeature" + } +} diff --git a/src/ClaudeMailbox/ClaudeMailbox.csproj b/src/ClaudeMailbox/ClaudeMailbox.csproj new file mode 100644 index 0000000..2b10826 --- /dev/null +++ b/src/ClaudeMailbox/ClaudeMailbox.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + claude-mailbox + ClaudeMailbox + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/ClaudeMailbox/Cli/ClientCommands.cs b/src/ClaudeMailbox/Cli/ClientCommands.cs new file mode 100644 index 0000000..d59d1b7 --- /dev/null +++ b/src/ClaudeMailbox/Cli/ClientCommands.cs @@ -0,0 +1,104 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace ClaudeMailbox.Cli; + +public static class ClientCommands +{ + private const string DefaultUrl = "http://127.0.0.1:47822"; + + public static async Task RunAsync(string[] args) + { + var command = args[0]; + var url = GetOption(args, "--url") ?? DefaultUrl; + + using var client = new HttpClient { BaseAddress = new Uri(url) }; + + try + { + return command switch + { + "send" => await Send(args, client), + "peek" => await Peek(args, client), + "check" => await Check(args, client), + "list" => await List(client), + _ => PrintError($"Unknown command: {command}"), + }; + } + catch (HttpRequestException ex) + { + Console.Error.WriteLine($"Could not reach daemon at {url}: {ex.Message}"); + Console.Error.WriteLine("Is 'claude-mailbox serve' running?"); + return 2; + } + } + + private static async Task Send(string[] args, HttpClient client) + { + var to = Required(args, "--to"); + var from = Required(args, "--from"); + var body = Required(args, "--body"); + + var req = new HttpRequestMessage(HttpMethod.Post, "/v1/send") + { + Content = JsonContent.Create(new { to, body }), + }; + req.Headers.Add("X-Mailbox", from); + + var res = await client.SendAsync(req); + res.EnsureSuccessStatusCode(); + Console.WriteLine(await res.Content.ReadAsStringAsync()); + return 0; + } + + private static async Task Peek(string[] args, HttpClient client) + { + var name = Required(args, "--name"); + var res = await client.GetAsync($"/v1/peek?name={Uri.EscapeDataString(name)}"); + res.EnsureSuccessStatusCode(); + Console.WriteLine(await res.Content.ReadAsStringAsync()); + return 0; + } + + private static async Task Check(string[] args, HttpClient client) + { + var name = Required(args, "--name"); + + var req = new HttpRequestMessage(HttpMethod.Post, $"/v1/check-inbox?name={Uri.EscapeDataString(name)}"); + req.Headers.Add("X-Mailbox", name); + + var res = await client.SendAsync(req); + res.EnsureSuccessStatusCode(); + Console.WriteLine(await res.Content.ReadAsStringAsync()); + return 0; + } + + private static async Task List(HttpClient client) + { + var res = await client.GetAsync("/v1/list"); + res.EnsureSuccessStatusCode(); + Console.WriteLine(await res.Content.ReadAsStringAsync()); + return 0; + } + + public static string? GetOption(string[] args, string name) + { + for (var i = 0; i < args.Length - 1; i++) + if (args[i] == name) return args[i + 1]; + return null; + } + + private static string Required(string[] args, string name) + { + var v = GetOption(args, name); + if (string.IsNullOrWhiteSpace(v)) + throw new ArgumentException($"Missing required option {name}"); + return v; + } + + private static int PrintError(string msg) + { + Console.Error.WriteLine(msg); + return 1; + } +} diff --git a/src/ClaudeMailbox/Config/DaemonConfig.cs b/src/ClaudeMailbox/Config/DaemonConfig.cs new file mode 100644 index 0000000..9df833c --- /dev/null +++ b/src/ClaudeMailbox/Config/DaemonConfig.cs @@ -0,0 +1,13 @@ +namespace ClaudeMailbox.Config; + +public sealed class DaemonConfig +{ + public const int DefaultPort = 47822; + public const string DefaultBindAddress = "127.0.0.1"; + + public int Port { get; init; } = DefaultPort; + public string BindAddress { get; init; } = DefaultBindAddress; + public string DbPath { get; init; } = Paths.DefaultDbPath(); + + public string BaseUrl => $"http://{BindAddress}:{Port}"; +} diff --git a/src/ClaudeMailbox/Config/Paths.cs b/src/ClaudeMailbox/Config/Paths.cs new file mode 100644 index 0000000..9640ffd --- /dev/null +++ b/src/ClaudeMailbox/Config/Paths.cs @@ -0,0 +1,23 @@ +namespace ClaudeMailbox.Config; + +public static class Paths +{ + public static string Expand(string path) + { + if (string.IsNullOrWhiteSpace(path)) return path; + + var expanded = Environment.ExpandEnvironmentVariables(path); + if (expanded.StartsWith("~")) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + expanded = home + expanded[1..]; + } + return Path.GetFullPath(expanded); + } + + public static string DefaultDbPath() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".claude-mailbox", "mailbox.db"); + } +} diff --git a/src/ClaudeMailbox/Data/Configuration/MailboxConfiguration.cs b/src/ClaudeMailbox/Data/Configuration/MailboxConfiguration.cs new file mode 100644 index 0000000..0e108cb --- /dev/null +++ b/src/ClaudeMailbox/Data/Configuration/MailboxConfiguration.cs @@ -0,0 +1,18 @@ +using ClaudeMailbox.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeMailbox.Data.Configuration; + +public sealed class MailboxConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("mailboxes"); + + builder.HasKey(m => m.Name); + builder.Property(m => m.Name).HasColumnName("name").IsRequired(); + builder.Property(m => m.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(m => m.LastSeenAt).HasColumnName("last_seen_at").IsRequired(); + } +} diff --git a/src/ClaudeMailbox/Data/Configuration/MessageConfiguration.cs b/src/ClaudeMailbox/Data/Configuration/MessageConfiguration.cs new file mode 100644 index 0000000..6f68463 --- /dev/null +++ b/src/ClaudeMailbox/Data/Configuration/MessageConfiguration.cs @@ -0,0 +1,34 @@ +using ClaudeMailbox.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeMailbox.Data.Configuration; + +public sealed class MessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("messages"); + + builder.HasKey(m => m.Id); + builder.Property(m => m.Id).HasColumnName("id").ValueGeneratedOnAdd(); + builder.Property(m => m.ToMailbox).HasColumnName("to_mailbox").IsRequired(); + builder.Property(m => m.FromMailbox).HasColumnName("from_mailbox").IsRequired(); + builder.Property(m => m.Body).HasColumnName("body").IsRequired(); + builder.Property(m => m.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(m => m.DeliveredAt).HasColumnName("delivered_at"); + + builder.HasOne() + .WithMany() + .HasForeignKey(m => m.ToMailbox) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne() + .WithMany() + .HasForeignKey(m => m.FromMailbox) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(m => new { m.ToMailbox, m.DeliveredAt }) + .HasDatabaseName("ix_messages_to_delivered"); + } +} diff --git a/src/ClaudeMailbox/Data/MailboxDbContext.cs b/src/ClaudeMailbox/Data/MailboxDbContext.cs new file mode 100644 index 0000000..69c982b --- /dev/null +++ b/src/ClaudeMailbox/Data/MailboxDbContext.cs @@ -0,0 +1,34 @@ +using ClaudeMailbox.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeMailbox.Data; + +public class MailboxDbContext : DbContext +{ + public MailboxDbContext(DbContextOptions options) : base(options) { } + + public DbSet Mailboxes => Set(); + public DbSet Messages => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(MailboxDbContext).Assembly); + } + + public static void EnsureReady(MailboxDbContext db) + { + var dir = Path.GetDirectoryName(db.Database.GetDbConnection().DataSource); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + var conn = db.Database.GetDbConnection(); + conn.Open(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "PRAGMA journal_mode=WAL;"; + cmd.ExecuteNonQuery(); + } + + db.Database.EnsureCreated(); + } +} diff --git a/src/ClaudeMailbox/Data/Models/Mailbox.cs b/src/ClaudeMailbox/Data/Models/Mailbox.cs new file mode 100644 index 0000000..e70a38a --- /dev/null +++ b/src/ClaudeMailbox/Data/Models/Mailbox.cs @@ -0,0 +1,8 @@ +namespace ClaudeMailbox.Data.Models; + +public sealed class Mailbox +{ + public required string Name { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime LastSeenAt { get; set; } +} diff --git a/src/ClaudeMailbox/Data/Models/Message.cs b/src/ClaudeMailbox/Data/Models/Message.cs new file mode 100644 index 0000000..020dcc8 --- /dev/null +++ b/src/ClaudeMailbox/Data/Models/Message.cs @@ -0,0 +1,11 @@ +namespace ClaudeMailbox.Data.Models; + +public sealed class Message +{ + public long Id { get; set; } + public required string ToMailbox { get; set; } + public required string FromMailbox { get; set; } + public required string Body { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? DeliveredAt { get; set; } +} diff --git a/src/ClaudeMailbox/Data/Repositories/MailboxRepository.cs b/src/ClaudeMailbox/Data/Repositories/MailboxRepository.cs new file mode 100644 index 0000000..510acd9 --- /dev/null +++ b/src/ClaudeMailbox/Data/Repositories/MailboxRepository.cs @@ -0,0 +1,33 @@ +using ClaudeMailbox.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeMailbox.Data.Repositories; + +public sealed class MailboxRepository +{ + private readonly MailboxDbContext _db; + + public MailboxRepository(MailboxDbContext db) => _db = db; + + public async Task UpsertAsync(string name, CancellationToken ct = default) + { + var now = DateTime.UtcNow; + var row = await _db.Mailboxes.FirstOrDefaultAsync(m => m.Name == name, ct); + if (row is null) + { + row = new Mailbox { Name = name, CreatedAt = now, LastSeenAt = now }; + _db.Mailboxes.Add(row); + } + else + { + row.LastSeenAt = now; + } + await _db.SaveChangesAsync(ct); + return row; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + return await _db.Mailboxes.AsNoTracking().OrderBy(m => m.Name).ToListAsync(ct); + } +} diff --git a/src/ClaudeMailbox/Data/Repositories/MessageRepository.cs b/src/ClaudeMailbox/Data/Repositories/MessageRepository.cs new file mode 100644 index 0000000..05a0a1a --- /dev/null +++ b/src/ClaudeMailbox/Data/Repositories/MessageRepository.cs @@ -0,0 +1,77 @@ +using ClaudeMailbox.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeMailbox.Data.Repositories; + +public sealed class MessageRepository +{ + private readonly MailboxDbContext _db; + private readonly MailboxRepository _mailboxes; + + public MessageRepository(MailboxDbContext db, MailboxRepository mailboxes) + { + _db = db; + _mailboxes = mailboxes; + } + + public async Task SendAsync(string from, string to, string body, CancellationToken ct = default) + { + await _mailboxes.UpsertAsync(from, ct); + await _mailboxes.UpsertAsync(to, ct); + + var message = new Message + { + FromMailbox = from, + ToMailbox = to, + Body = body, + CreatedAt = DateTime.UtcNow, + DeliveredAt = null, + }; + _db.Messages.Add(message); + await _db.SaveChangesAsync(ct); + return message; + } + + public async Task PeekAsync(string name, CancellationToken ct = default) + { + var pending = await _db.Messages.AsNoTracking() + .Where(m => m.ToMailbox == name && m.DeliveredAt == null) + .OrderBy(m => m.Id) + .Select(m => m.CreatedAt) + .ToListAsync(ct); + + return new InboxStatus(pending.Count, pending.FirstOrDefault() == default ? null : pending.First()); + } + + public async Task> CheckInboxAsync(string name, CancellationToken ct = default) + { + // Atomic pull-and-mark: a transaction guarantees that two concurrent calls + // don't deliver the same message twice. + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + var pending = await _db.Messages + .Where(m => m.ToMailbox == name && m.DeliveredAt == null) + .OrderBy(m => m.Id) + .ToListAsync(ct); + + var now = DateTime.UtcNow; + foreach (var m in pending) + m.DeliveredAt = now; + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + return pending; + } + + public async Task PendingCountForAsync(string recipient, string sender, CancellationToken ct = default) + { + return await _db.Messages.AsNoTracking() + .CountAsync(m => + m.ToMailbox == recipient && + m.FromMailbox == sender && + m.DeliveredAt == null, ct); + } +} + +public sealed record InboxStatus(int Pending, DateTime? OldestAt); diff --git a/src/ClaudeMailbox/Http/MailboxContextAccessor.cs b/src/ClaudeMailbox/Http/MailboxContextAccessor.cs new file mode 100644 index 0000000..c152d9d --- /dev/null +++ b/src/ClaudeMailbox/Http/MailboxContextAccessor.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; + +namespace ClaudeMailbox.Http; + +public sealed class MailboxContextAccessor +{ + private readonly IHttpContextAccessor _http; + + public MailboxContextAccessor(IHttpContextAccessor http) => _http = http; + + public string Current + { + get + { + var name = _http.HttpContext?.Items[MailboxHeaderMiddleware.ItemsKey] as string; + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException( + "No mailbox name on request. Set the X-Mailbox header in your .mcp.json."); + return name; + } + } +} diff --git a/src/ClaudeMailbox/Http/MailboxHeaderMiddleware.cs b/src/ClaudeMailbox/Http/MailboxHeaderMiddleware.cs new file mode 100644 index 0000000..afa71e6 --- /dev/null +++ b/src/ClaudeMailbox/Http/MailboxHeaderMiddleware.cs @@ -0,0 +1,48 @@ +using ClaudeMailbox.Data.Repositories; +using Microsoft.AspNetCore.Http; + +namespace ClaudeMailbox.Http; + +public sealed class MailboxHeaderMiddleware +{ + public const string HeaderName = "X-Mailbox"; + public const string ItemsKey = "Mailbox"; + + private readonly RequestDelegate _next; + + public MailboxHeaderMiddleware(RequestDelegate next) => _next = next; + + public async Task InvokeAsync(HttpContext ctx, MailboxRepository mailboxes) + { + // Health is always anonymous. + if (ctx.Request.Path.StartsWithSegments("/health")) + { + await _next(ctx); + return; + } + + var name = ctx.Request.Headers[HeaderName].ToString().Trim(); + + // These endpoints work without identity (discovery / read-only status). + var path = ctx.Request.Path; + var isAnonymous = + path.Equals("/v1/list", StringComparison.OrdinalIgnoreCase) || + path.Equals("/v1/peek", StringComparison.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(name)) + { + if (isAnonymous) + { + await _next(ctx); + return; + } + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsync($"Missing {HeaderName} header."); + return; + } + + ctx.Items[ItemsKey] = name; + await mailboxes.UpsertAsync(name, ctx.RequestAborted); + await _next(ctx); + } +} diff --git a/src/ClaudeMailbox/Http/RestEndpoints.cs b/src/ClaudeMailbox/Http/RestEndpoints.cs new file mode 100644 index 0000000..80509ea --- /dev/null +++ b/src/ClaudeMailbox/Http/RestEndpoints.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using ClaudeMailbox.Config; +using ClaudeMailbox.Data.Repositories; + +namespace ClaudeMailbox.Http; + +public static class RestEndpoints +{ + public static void MapMailboxEndpoints(this WebApplication app) + { + app.MapGet("/health", (DaemonConfig cfg) => Results.Ok(new + { + status = "ok", + version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown", + dbPath = cfg.DbPath, + })); + + var group = app.MapGroup("/v1"); + + group.MapPost("/send", async ( + SendRequest body, + MailboxContextAccessor accessor, + MessageRepository messages, + CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(body.To) || string.IsNullOrWhiteSpace(body.Body)) + return Results.BadRequest(new { error = "to and body are required" }); + + var from = accessor.Current; + var msg = await messages.SendAsync(from, body.To, body.Body, ct); + return Results.Ok(new { id = msg.Id, queuedAt = msg.CreatedAt }); + }); + + group.MapGet("/peek", async ( + string name, + MessageRepository messages, + CancellationToken ct) => + { + var status = await messages.PeekAsync(name, ct); + return Results.Ok(new { pending = status.Pending, oldestAt = status.OldestAt }); + }); + + group.MapPost("/check-inbox", async ( + string name, + MailboxContextAccessor accessor, + MessageRepository messages, + CancellationToken ct) => + { + // Require the caller to be consuming their own inbox. + if (!string.Equals(name, accessor.Current, StringComparison.Ordinal)) + return Results.StatusCode(403); + + var pulled = await messages.CheckInboxAsync(name, ct); + return Results.Ok(pulled.Select(m => new + { + id = m.Id, + from = m.FromMailbox, + body = m.Body, + sentAt = m.CreatedAt, + })); + }); + + group.MapGet("/list", async ( + MailboxRepository mailboxes, + CancellationToken ct) => + { + var all = await mailboxes.ListAsync(ct); + return Results.Ok(all.Select(m => new + { + name = m.Name, + createdAt = m.CreatedAt, + lastSeenAt = m.LastSeenAt, + })); + }); + } + + public sealed record SendRequest(string To, string Body); +} diff --git a/src/ClaudeMailbox/Mcp/MailboxTools.cs b/src/ClaudeMailbox/Mcp/MailboxTools.cs new file mode 100644 index 0000000..443deb0 --- /dev/null +++ b/src/ClaudeMailbox/Mcp/MailboxTools.cs @@ -0,0 +1,70 @@ +using System.ComponentModel; +using ClaudeMailbox.Data.Repositories; +using ClaudeMailbox.Http; +using ModelContextProtocol.Server; + +namespace ClaudeMailbox.Mcp; + +public sealed record SendResult(long Id, DateTime QueuedAt); +public sealed record InboxMessage(long Id, string From, string Body, DateTime SentAt); +public sealed record InboxStatusDto(int Pending, DateTime? OldestAt); +public sealed record MailboxInfo(string Name, DateTime LastSeenAt, int PendingForYou); + +[McpServerToolType] +public sealed class MailboxTools +{ + private readonly MailboxContextAccessor _accessor; + private readonly MailboxRepository _mailboxes; + private readonly MessageRepository _messages; + + public MailboxTools( + MailboxContextAccessor accessor, + MailboxRepository mailboxes, + MessageRepository messages) + { + _accessor = accessor; + _mailboxes = mailboxes; + _messages = messages; + } + + [McpServerTool, Description("Send a message to another mailbox. The sender is the current session's X-Mailbox name.")] + public async Task Send( + [Description("Name of the recipient mailbox.")] string to, + [Description("Message body (plain text or markdown).")] string body, + CancellationToken ct) + { + var from = _accessor.Current; + var msg = await _messages.SendAsync(from, to, body, ct); + return new SendResult(msg.Id, msg.CreatedAt); + } + + [McpServerTool, Description("Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.")] + public async Task> CheckInbox(CancellationToken ct) + { + var name = _accessor.Current; + var pulled = await _messages.CheckInboxAsync(name, ct); + return pulled.Select(m => new InboxMessage(m.Id, m.FromMailbox, m.Body, m.CreatedAt)).ToList(); + } + + [McpServerTool, Description("Check whether the current mailbox has undelivered messages, without consuming them. Cheap; safe to call often.")] + public async Task PeekInbox(CancellationToken ct) + { + var name = _accessor.Current; + var status = await _messages.PeekAsync(name, ct); + return new InboxStatusDto(status.Pending, status.OldestAt); + } + + [McpServerTool, Description("List all known mailboxes with their last-seen timestamp and how many messages each has queued for the current mailbox.")] + public async Task> ListMailboxes(CancellationToken ct) + { + var me = _accessor.Current; + var all = await _mailboxes.ListAsync(ct); + var result = new List(all.Count); + foreach (var m in all) + { + var pending = await _messages.PendingCountForAsync(me, m.Name, ct); + result.Add(new MailboxInfo(m.Name, m.LastSeenAt, pending)); + } + return result; + } +} diff --git a/src/ClaudeMailbox/Program.cs b/src/ClaudeMailbox/Program.cs new file mode 100644 index 0000000..9d213e3 --- /dev/null +++ b/src/ClaudeMailbox/Program.cs @@ -0,0 +1,47 @@ +using ClaudeMailbox; +using ClaudeMailbox.Cli; +using ClaudeMailbox.Config; + +if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list") +{ + return await ClientCommands.RunAsync(args); +} + +// Strip the optional leading "serve" verb so WebApplication.CreateBuilder +// doesn't try to treat it as an unknown flag. +var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args; + +var cfg = new DaemonConfig +{ + Port = ParseInt(serveArgs, "--port", DaemonConfig.DefaultPort), + BindAddress = ClientCommands.GetOption(serveArgs, "--bind") ?? DaemonConfig.DefaultBindAddress, + DbPath = Paths.Expand(ClientCommands.GetOption(serveArgs, "--db-path") ?? Paths.DefaultDbPath()), +}; + +var builder = ServerHost.CreateBuilder(cfg, serveArgs); +builder.WebHost.UseUrls(cfg.BaseUrl); + +var app = builder.Build(); +ServerHost.ConfigurePipeline(app); + +app.Logger.LogInformation("ClaudeMailbox listening on {Url} (db: {Db})", cfg.BaseUrl, cfg.DbPath); + +try +{ + await app.RunAsync(); + return 0; +} +catch (IOException ex) when (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("Only one usage", StringComparison.OrdinalIgnoreCase)) +{ + Console.Error.WriteLine($"Port {cfg.Port} is already in use. Another claude-mailbox instance may be running."); + return 3; +} + +static int ParseInt(string[] args, string name, int fallback) +{ + var raw = ClientCommands.GetOption(args, name); + return int.TryParse(raw, out var v) ? v : fallback; +} + +public partial class Program { } diff --git a/src/ClaudeMailbox/ServerHost.cs b/src/ClaudeMailbox/ServerHost.cs new file mode 100644 index 0000000..a9755e3 --- /dev/null +++ b/src/ClaudeMailbox/ServerHost.cs @@ -0,0 +1,46 @@ +using ClaudeMailbox.Config; +using ClaudeMailbox.Data; +using ClaudeMailbox.Data.Repositories; +using ClaudeMailbox.Http; +using ClaudeMailbox.Mcp; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeMailbox; + +public static class ServerHost +{ + public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null) + { + var builder = WebApplication.CreateBuilder(args ?? Array.Empty()); + + builder.Services.AddSingleton(cfg); + builder.Services.AddHttpContextAccessor(); + + builder.Services.AddDbContext(opt => + opt.UseSqlite($"Data Source={cfg.DbPath}")); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + + return builder; + } + + public static void ConfigurePipeline(WebApplication app) + { + using (var scope = app.Services.CreateScope()) + { + MailboxDbContext.EnsureReady( + scope.ServiceProvider.GetRequiredService()); + } + + app.UseMiddleware(); + app.MapMailboxEndpoints(); + app.MapMcp("/mcp"); + } +} diff --git a/tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj b/tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj new file mode 100644 index 0000000..e1c613f --- /dev/null +++ b/tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ClaudeMailbox.Tests/MailboxEndToEndTests.cs b/tests/ClaudeMailbox.Tests/MailboxEndToEndTests.cs new file mode 100644 index 0000000..264e318 --- /dev/null +++ b/tests/ClaudeMailbox.Tests/MailboxEndToEndTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace ClaudeMailbox.Tests; + +public sealed class MailboxEndToEndTests +{ + [Fact] + public async Task Health_Returns_Ok() + { + await using var host = await TestHost.StartAsync(); + + var res = await host.Client.GetAsync("/health"); + res.EnsureSuccessStatusCode(); + + var body = await res.Content.ReadFromJsonAsync(); + Assert.Equal("ok", body.GetProperty("status").GetString()); + } + + [Fact] + public async Task Send_Without_Header_Is_BadRequest() + { + await using var host = await TestHost.StartAsync(); + + var res = await host.Client.PostAsJsonAsync("/v1/send", new { to = "anyone", body = "hi" }); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + } + + [Fact] + public async Task Two_Mailboxes_Coordinate() + { + await using var host = await TestHost.StartAsync(); + using var backend = host.NewClientFor("backend"); + using var frontend = host.NewClientFor("frontend"); + + // backend sends to frontend + var send = await backend.PostAsJsonAsync("/v1/send", new { to = "frontend", body = "API shape changed" }); + send.EnsureSuccessStatusCode(); + + // frontend peeks — expects 1 + var peek1 = await frontend.GetFromJsonAsync("/v1/peek?name=frontend"); + Assert.Equal(1, peek1.GetProperty("pending").GetInt32()); + + // frontend consumes + var check = await frontend.PostAsync("/v1/check-inbox?name=frontend", null); + check.EnsureSuccessStatusCode(); + var messages = await check.Content.ReadFromJsonAsync(); + Assert.Equal(1, messages.GetArrayLength()); + var msg = messages[0]; + Assert.Equal("backend", msg.GetProperty("from").GetString()); + Assert.Equal("API shape changed", msg.GetProperty("body").GetString()); + + // peek again — expects 0 + var peek2 = await frontend.GetFromJsonAsync("/v1/peek?name=frontend"); + Assert.Equal(0, peek2.GetProperty("pending").GetInt32()); + } + + [Fact] + public async Task Check_Inbox_Rejects_Mismatched_Identity() + { + await using var host = await TestHost.StartAsync(); + using var backend = host.NewClientFor("backend"); + using var frontend = host.NewClientFor("frontend"); + + await backend.PostAsJsonAsync("/v1/send", new { to = "frontend", body = "hello" }); + + // backend tries to consume frontend's inbox — must be rejected + var bad = await backend.PostAsync("/v1/check-inbox?name=frontend", null); + Assert.Equal(HttpStatusCode.Forbidden, bad.StatusCode); + } + + [Fact] + public async Task List_Returns_Known_Mailboxes() + { + await using var host = await TestHost.StartAsync(); + using var a = host.NewClientFor("alpha"); + using var b = host.NewClientFor("beta"); + + // Touch both mailboxes by having each peek its own inbox + await a.GetAsync("/v1/peek?name=alpha"); + await b.GetAsync("/v1/peek?name=beta"); + + // /v1/list is the only endpoint that works without X-Mailbox + var list = await host.Client.GetFromJsonAsync("/v1/list"); + var names = new List(); + foreach (var elem in list.EnumerateArray()) + names.Add(elem.GetProperty("name").GetString()!); + + Assert.Contains("alpha", names); + Assert.Contains("beta", names); + } +} diff --git a/tests/ClaudeMailbox.Tests/MigrationTests.cs b/tests/ClaudeMailbox.Tests/MigrationTests.cs new file mode 100644 index 0000000..58fb7fa --- /dev/null +++ b/tests/ClaudeMailbox.Tests/MigrationTests.cs @@ -0,0 +1,67 @@ +using ClaudeMailbox.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeMailbox.Tests; + +public sealed class MigrationTests +{ + [Fact] + public async Task EnsureReady_Creates_Schema_And_Is_Idempotent() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"claude-mailbox-migtest-{Guid.NewGuid():N}.db"); + try + { + using (var ctx = NewCtx(dbPath)) + MailboxDbContext.EnsureReady(ctx); + + // Second call must not throw. + using (var ctx = NewCtx(dbPath)) + MailboxDbContext.EnsureReady(ctx); + + // Verify tables exist. + await using var conn = new SqliteConnection($"Data Source={dbPath}"); + await conn.OpenAsync(); + + var tables = new List(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + tables.Add(reader.GetString(0)); + } + Assert.Contains("mailboxes", tables); + Assert.Contains("messages", tables); + + // Verify the expected index exists. + string? index; + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='index' AND name='ix_messages_to_delivered';"; + index = await cmd.ExecuteScalarAsync() as string; + } + Assert.Equal("ix_messages_to_delivered", index); + } + finally + { + SqliteConnection.ClearAllPools(); + foreach (var ext in new[] { "", "-wal", "-shm" }) + { + var p = dbPath + ext; + if (File.Exists(p)) + { + try { File.Delete(p); } catch { } + } + } + } + } + + private static MailboxDbContext NewCtx(string path) + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={path}") + .Options; + return new MailboxDbContext(opts); + } +} diff --git a/tests/ClaudeMailbox.Tests/RaceTests.cs b/tests/ClaudeMailbox.Tests/RaceTests.cs new file mode 100644 index 0000000..b1455d2 --- /dev/null +++ b/tests/ClaudeMailbox.Tests/RaceTests.cs @@ -0,0 +1,40 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace ClaudeMailbox.Tests; + +public sealed class RaceTests +{ + [Fact] + public async Task Parallel_CheckInbox_Delivers_Each_Message_Exactly_Once() + { + await using var host = await TestHost.StartAsync(); + using var sender = host.NewClientFor("sender"); + using var recipient = host.NewClientFor("recipient"); + + const int messageCount = 50; + for (var i = 0; i < messageCount; i++) + { + var res = await sender.PostAsJsonAsync("/v1/send", new { to = "recipient", body = $"msg-{i}" }); + res.EnsureSuccessStatusCode(); + } + + // Fire multiple concurrent checks. Each message must appear in exactly one result set. + var tasks = Enumerable.Range(0, 8).Select(async _ => + { + var res = await recipient.PostAsync("/v1/check-inbox?name=recipient", null); + res.EnsureSuccessStatusCode(); + return await res.Content.ReadFromJsonAsync(); + }); + + var results = await Task.WhenAll(tasks); + + var ids = new List(); + foreach (var arr in results) + foreach (var m in arr.EnumerateArray()) + ids.Add(m.GetProperty("id").GetInt64()); + + Assert.Equal(messageCount, ids.Count); + Assert.Equal(messageCount, ids.Distinct().Count()); + } +} diff --git a/tests/ClaudeMailbox.Tests/TestHost.cs b/tests/ClaudeMailbox.Tests/TestHost.cs new file mode 100644 index 0000000..d90a45e --- /dev/null +++ b/tests/ClaudeMailbox.Tests/TestHost.cs @@ -0,0 +1,84 @@ +using ClaudeMailbox; +using ClaudeMailbox.Config; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ClaudeMailbox.Tests; + +/// +/// Spins up a full ClaudeMailbox WebApplication on an ephemeral port against a temp SQLite file. +/// Disposable — removes the DB and stops the host on dispose. +/// +public sealed class TestHost : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly string _dbPath; + + public HttpClient Client { get; } + public string BaseUrl { get; } + public string DbPath => _dbPath; + + private TestHost(WebApplication app, string dbPath, string baseUrl) + { + _app = app; + _dbPath = dbPath; + BaseUrl = baseUrl; + Client = new HttpClient { BaseAddress = new Uri(baseUrl) }; + } + + public static async Task StartAsync() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"claude-mailbox-test-{Guid.NewGuid():N}.db"); + var cfg = new DaemonConfig + { + Port = 0, + BindAddress = "127.0.0.1", + DbPath = dbPath, + }; + + var builder = ServerHost.CreateBuilder(cfg); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + var app = builder.Build(); + ServerHost.ConfigurePipeline(app); + + await app.StartAsync(); + + // Discover the port Kestrel picked. + var server = app.Services.GetRequiredService(); + var feature = server.Features.Get(); + var url = feature?.Addresses.FirstOrDefault() + ?? throw new InvalidOperationException("No bound URL after start."); + + return new TestHost(app, dbPath, url); + } + + public HttpClient NewClientFor(string mailboxName) + { + var c = new HttpClient { BaseAddress = new Uri(BaseUrl) }; + c.DefaultRequestHeaders.Add("X-Mailbox", mailboxName); + return c; + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + + // Allow SQLite handle to release before deleting. + GC.Collect(); + GC.WaitForPendingFinalizers(); + + foreach (var ext in new[] { "", "-wal", "-shm" }) + { + var path = _dbPath + ext; + if (File.Exists(path)) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } + } +}