feat(notes): add_note MCP tool with a per-day agent cap

Agents can write into the user's daily notes, but only within limits:
max 10 agent notes a day, max 500 characters, add only — no edit, no
delete. A new from_agent column separates their lines from the user's
so the cap counts the right ones and the UI can mark them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
CubeGameLP
2026-08-28 21:02:37 +02:00
co-authored by Claude Opus 5
parent cf093e3178
commit ae0c7d3771
13 changed files with 1187 additions and 6 deletions
+59
View File
@@ -0,0 +1,59 @@
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record AddNoteResult(bool Saved, string Id, int AgentNotesToday, int RemainingToday);
[McpServerToolType]
public sealed class NotesMcpTools
{
/// <summary>Agent-written notes allowed per day. The user's own notes are never counted or
/// capped — this only stops an agent from burying the day in its own bullets.</summary>
public const int DailyAgentCap = 10;
/// <summary>A note is a line, not a report. Longer output belongs in a task or a finding.</summary>
public const int MaxLength = 500;
private readonly DailyNoteRepository _notes;
private readonly HubBroadcaster _broadcaster;
public NotesMcpTools(DailyNoteRepository notes, HubBroadcaster broadcaster)
{
_notes = notes;
_broadcaster = broadcaster;
}
[McpServerTool, Description(
"Add one short line to the user's notes for today — the day log they read in ClaudeDo, " +
"not a log for you. Write only what the user would want to reread later and would " +
"otherwise lose: a decision they made, something you found that changes their plans, " +
"something they asked you to remember for the day. Task status, your progress and " +
"anything already visible in the task list are noise here. When unsure, don't. " +
"Capped at 10 agent notes per day; you cannot edit or delete notes, so a bad one stays " +
"until the user removes it.")]
public async Task<AddNoteResult> AddNote(
[Description("The note itself: one plain sentence, no markdown, no prefix, max 500 characters.")] string text,
CancellationToken cancellationToken = default)
{
var trimmed = (text ?? "").Trim();
if (trimmed.Length == 0)
throw new InvalidOperationException("text is required.");
if (trimmed.Length > MaxLength)
throw new InvalidOperationException(
$"Note is {trimmed.Length} characters, the limit is {MaxLength}. Shorten it to one line.");
var today = DateOnly.FromDateTime(DateTime.Today);
var used = await _notes.CountAgentNotesAsync(today, cancellationToken);
if (used >= DailyAgentCap)
throw new InvalidOperationException(
$"Daily cap reached: {DailyAgentCap} agent notes already written today. " +
"Nothing was saved — tell the user instead of retrying.");
var note = await _notes.AddAsync(today, trimmed, fromAgent: true, cancellationToken);
await _broadcaster.NotesUpdated();
return new AddNoteResult(true, note.Id, used + 1, DailyAgentCap - used - 1);
}
}
@@ -37,6 +37,10 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
/// <summary>An agent wrote a note through MCP — the open notes editor reloads.</summary>
public Task NotesUpdated() =>
_hub.Clients.All.SendAsync("NotesUpdated");
public Task ListUpdated(string listId) =>
_hub.Clients.All.SendAsync("ListUpdated", listId);
+13 -2
View File
@@ -970,14 +970,25 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
using var ctx = _dbFactory.CreateDbContext();
var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso));
return notes.Select(n => new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder)).ToList();
return notes.Select(Dto).ToList();
}
/// <summary>Both ends inclusive — the editor loads today plus its recent history in one call.</summary>
public async Task<List<DailyNoteDto>> GetDailyNotesBetween(string startIso, string endIso)
{
using var ctx = _dbFactory.CreateDbContext();
var notes = await new DailyNoteRepository(ctx).ListBetweenAsync(Day(startIso), Day(endIso));
return notes.Select(Dto).ToList();
}
private static DailyNoteDto Dto(Data.Models.DailyNoteEntity n) =>
new(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder, n.FromAgent);
public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text)
{
using var ctx = _dbFactory.CreateDbContext();
var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text);
return new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder);
return Dto(n);
}
public async Task UpdateDailyNote(string id, string text)
+4 -1
View File
@@ -355,7 +355,9 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
externalBuilder.Services.AddScoped<QueueStateMcpTools>();
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
externalBuilder.Services.AddScoped<DailyNoteRepository>();
externalBuilder.Services.AddScoped<FindingsMcpTools>();
externalBuilder.Services.AddScoped<NotesMcpTools>();
externalBuilder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true)
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
@@ -371,7 +373,8 @@ if (cfg.ExternalMcpPort > 0)
.WithTools<TaskWaitMcpTools>()
.WithTools<QueueStateMcpTools>()
.WithTools<AttachmentMcpTools>()
.WithTools<FindingsMcpTools>();
.WithTools<FindingsMcpTools>()
.WithTools<NotesMcpTools>();
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
externalApp = externalBuilder.Build();
+1 -1
View File
@@ -1,3 +1,3 @@
namespace ClaudeDo.Worker.Report;
public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder);
public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder, bool FromAgent);