feat(notes): note-værktøjer i MCP, og sidebjælken ryddet op

Claude kan nu skrive og søge i noter gennem MCP. Det er også svaret på Apple
Noter: der findes ingen eksport, der er værd at bruge, men en model, der kører på
maskinen, kan læse Notes.app og skrive det, den finder, ind herigennem — én note
ad gangen, med teksten som Markdown. En flytning, ingen skal bygge en parser til.

Værktøjet melder tilbage, hvilke links noten fik. En note, hvis #tag var stavet
forkert, ser ellers præcis ud som en, hvor det ramte, og i en chat ser ingen den
række, der blev skrevet.

Noter står nu for sig selv med en streg over. De er ikke en femte måde at se de
samme opgaver på, hvilket er hvad alt ovenfor er — de er den anden halvdel af
programmet, og i den række læste de som endnu et filter.

Færdige er ude af menuen og ligger under Indstillinger → Data ved siden af
papirkurven. Argumentet for at have den fremme var, at man ikke ved, hvilken
visning man lukkede noget i — det er sandt, og det er også et sjældent ærinde ved
siden af fire punkter, der læses hver dag. Den er der stadig, med en beskrivelse
af hvad den er til.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-19 13:33:54 +02:00
co-authored by Claude Opus 5
parent a2a39c4e17
commit 5f04fe56bc
7 changed files with 225 additions and 19 deletions
+174
View File
@@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
}, "task_id", "body"),
}, s.mcpAddComment)
s.registerNoteTools(m)
return m
}
@@ -496,3 +497,176 @@ func clamp(value, fallback, max int) int {
}
return value
}
// --- notes ---------------------------------------------------------------------
// registerNoteTools adds the note half of the MCP surface.
//
// Separate from the task tools for one reason worth stating: this is how a note
// gets in from somewhere else. Apple Notes has no export worth the name, but a
// model running on the machine can read Notes.app and write what it finds through
// these — one note at a time, with the text as Markdown. That is a migration
// nobody has to build a parser for.
func (s *Server) registerNoteTools(m *mcp.Server) {
m.Register(mcp.Tool{
Name: "search_notes",
Description: "Find notes by text. Matches title and body, and is generous about " +
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
"With no query it returns the most recent notes. This is the tool for " +
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("Words to look for in the title and body."),
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
}),
}, s.mcpSearchNotes)
m.Register(mcp.Tool{
Name: "create_note",
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
"also exactly what an export produces, so nothing is converted and nothing " +
"is lost. Two things in the text are read out of it and become links: " +
"`#Projekt` files the note against that project, so it appears on the " +
"project's page without anybody filing it there, and `[[Another note]]` " +
"links to a note by its title. Use this to bring notes in from elsewhere — " +
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
InputSchema: mcp.Schema(map[string]any{
"body": mcp.Str("The note as Markdown. #Projekt and [[note]] become links."),
"title": mcp.Str("A title. Left out, the first line of the body becomes it."),
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
"pinned": mcp.Bool("Keep it at the top of the list."),
}),
}, s.mcpCreateNote)
m.Register(mcp.Tool{
Name: "update_note",
Description: "Change a note. Only the fields given are changed. Sending `body` " +
"replaces the whole text, so read the note first if you mean to add to it — " +
"and the links are read again from the new text, so a #tag removed is a link " +
"removed.",
InputSchema: mcp.Schema(map[string]any{
"note_id": mcp.Str("Which note."),
"body": mcp.Str("The whole new text, as Markdown."),
"title": mcp.Str("A new title."),
"pinned": mcp.Bool("Pin it or unpin it."),
}),
}, s.mcpUpdateNote)
}
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Text string `json:"text"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(args, &params)
limit := params.Limit
if limit <= 0 || limit > 100 {
limit = 25
}
var notes []store.Note
var err error
if strings.TrimSpace(params.Text) == "" {
notes, err = s.db.LooseNotes(ctx, userID)
} else {
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
}
if err != nil {
return nil, err
}
// The same filter the HTTP surface applies, for the same reason: a search must
// not become a way to read what you cannot open.
out := make([]map[string]any, 0, len(notes))
for _, n := range notes {
if !s.mcpMayRead(ctx, &n, userID) {
continue
}
out = append(out, map[string]any{
"id": n.ID, "title": n.Title, "body": n.Body,
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
})
if len(out) >= limit {
break
}
}
return map[string]any{"notes": out, "count": len(out)}, nil
}
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
if n.ProjectID == "" {
return n.CreatedBy == userID
}
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
return err == nil
}
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Body string `json:"body"`
Title string `json:"title"`
ProjectID string `json:"project_id"`
Pinned bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n := &store.Note{
CreatedBy: userID,
Title: params.Title,
Body: params.Body,
Pinned: params.Pinned,
}
if params.ProjectID != "" {
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
return nil, err
}
n.ProjectID = params.ProjectID
}
if err := s.db.SaveNote(ctx, n); err != nil {
return nil, err
}
// The links are reported back rather than left silent. A note whose #tag was
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
// the row that was written.
return map[string]any{
"id": n.ID, "title": n.Title, "links": n.Links,
}, nil
}
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
NoteID string `json:"note_id"`
Body *string `json:"body"`
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n, err := s.db.Note(ctx, params.NoteID)
if err != nil {
return nil, err
}
if n == nil || !s.mcpMayRead(ctx, n, userID) {
return nil, fmt.Errorf("no such note")
}
if n.ProjectID != "" {
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
return nil, err
}
}
if params.Body != nil {
n.Body = *params.Body
}
if params.Title != nil {
n.Title = *params.Title
}
if params.Pinned != nil {
n.Pinned = *params.Pinned
}
if err := s.db.SaveNote(ctx, n); err != nil {
return nil, err
}
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
}
+4 -1
View File
@@ -1464,7 +1464,10 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Færdige' }).click();
// Færdige left the sidebar: it is a rare errand next to four entries read every
// day, and it now sits with the other "where did that go" answers.
await page.goto('/indstillinger/data');
await page.getByRole('link', { name: 'Åbn færdige opgaver' }).click();
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
+27 -18
View File
@@ -369,29 +369,27 @@
{t('nav.delegated')}
</a>
{/if}
<!-- After the three views of what is left, because it is the one that is not
about what is left. It earns a permanent place rather than a toggle in
each view: somebody looking for a task they closed by mistake does not
know which view they closed it in. -->
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('done.title')}
</a>
<!-- Færdige is not in this list any more. It used to sit here on the argument
that somebody hunting a task they closed by mistake does not know which
view they closed it in — which is true, and is also a rare errand next to
four entries that are read every day. It lives under Settings → Data, with
the trash, where the other "where did that go" questions are answered. -->
{#if app.inbox}
{@render projectRow(app.inbox, false)}
<!-- Notes sit with the fixed places rather than among the projects: it is a
place you go, not a thing filed under one. -->
<a href="/noter" class="fixed" onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('notes.title')}
</a>
{/if}
</div>
<!-- Notes stand apart, with a rule above them.
They are not a fourth way of looking at the same tasks, which is what
everything above is; they are the other half of the program. Sitting in that
run made them read as one more filter. -->
<div class="apart">
<a href="/noter" class:active={current === '/noter'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('notes.title')}
</a>
</div>
<!-- One row for every project the sidebar shows — your own, the Inbox and the
shared ones — because all three are somewhere a task can be dropped, and
three copies of that would be three chances to fix a bug in two of them.
@@ -873,6 +871,17 @@
gap: 1px;
}
/* A rule and a little air, which is the whole of "separate". Notes are the other
half of the program, not a fifth way of looking at the same tasks. */
.apart {
display: flex;
flex-direction: column;
gap: 1px;
margin: var(--s3) 0;
padding-top: var(--s3);
border-top: 1px solid var(--line);
}
.group-head {
display: flex;
align-items: center;
+2
View File
@@ -318,6 +318,8 @@ export const da = {
'data.csvFile': 'CSV-fil',
'data.importing': 'Importerer …',
'data.openProject': 'Åbn projektet',
'data.doneHint': 'Alt du har lukket, nyeste først. Lukkede du noget ved en fejl, ligger det her — og kan åbnes igen.',
'data.openDone': 'Åbn færdige opgaver',
'data.trash': 'Papirkurv',
'data.trashEmpty': 'Papirkurven er tom.',
'data.restore': 'Hent tilbage',
+2
View File
@@ -313,6 +313,8 @@ export const en = {
'data.csvFile': 'CSV file',
'data.importing': 'Importing …',
'data.openProject': 'Open the project',
'data.doneHint': 'Everything you have closed, newest first. Closed something by mistake and it is here — and can be opened again.',
'data.openDone': 'Open finished tasks',
'data.trash': 'Trash',
'data.trashEmpty': 'The trash is empty.',
'data.restore': 'Restore',
@@ -225,6 +225,19 @@
{/if}
</section>
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
to the four that are read every day, and it belongs with the other "where did
that go" questions rather than above them. -->
<section class="panel">
<header>
<h2>{t('done.title')}</h2>
<p class="hint">{t('data.doneHint')}</p>
</header>
<div class="row">
<a class="ghost" href="/faerdige">{t('data.openDone')}</a>
</div>
</section>
<section class="panel">
<header>
<h2>{t('data.trash')}</h2>
+3
View File
@@ -168,6 +168,8 @@
<style>
.notes {
/* Air above the heading, so it does not sit against the top bar. */
padding-top: var(--s3);
display: grid;
grid-template-columns: minmax(220px, 300px) 1fr;
gap: var(--s4);
@@ -192,6 +194,7 @@
h1 {
font-size: var(--text-xl);
line-height: 1.2;
}
.new {