Add scratchpad_delete MCP tool

Mirrors the existing scratchpad_* tools end-to-end: catalog schema,
dispatch, ToolHost.ScratchpadDelete, and a host method that delegates to
scratchpad.Store.Delete and fires scratchpadsChanged() on success so the
sidebar refreshes. Missing-pad errors surface rather than being masked.

Resolves the [MCP SCRATCHPAD DELETE] TODO item.
This commit is contained in:
2026-05-25 12:23:58 +01:00
parent f61788eff2
commit 96f7c66d5f
6 changed files with 146 additions and 5 deletions

View File

@@ -832,6 +832,14 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
return err
}
func (h *toolHost) ScratchpadDelete(name string) error {
err := h.pads.Delete(name)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return err
}
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
w := mcp.WhoAmI{
ProcessID: callerID,
@@ -1091,7 +1099,7 @@ func availableToolsForRole(role mcp.CallerRole) []string {
"send_input", "send_message", "request_human_attention",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete",
"whoami", "help",
}
if role == mcp.RoleOrchestrator {
@@ -1146,8 +1154,8 @@ func helpFor(topic string) mcp.HelpResponse {
case "scratchpads":
return mcp.HelpResponse{
Topic: "scratchpads",
Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"},
Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional; delete removes a pad by name.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete"},
}
case "timers":
return mcp.HelpResponse{

View File

@@ -1,10 +1,12 @@
package app
import (
"errors"
"io"
"os"
"testing"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
)
@@ -95,3 +97,41 @@ func TestDeletingLastFocusedScratchpadFocusesRunningChild(t *testing.T) {
t.Fatalf("focusedID = %q, want pid", st.focusedID)
}
}
type scratchpadChangeRecorder struct {
count int
}
func (r *scratchpadChangeRecorder) scratchpadsChanged() {
r.count++
}
func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
pads, err := scratchpad.Open("scratchpad-delete-host-test")
if err != nil {
t.Fatalf("scratchpad.Open: %v", err)
}
if _, err := pads.Write("doomed.md", "content", ""); err != nil {
t.Fatalf("write doomed.md: %v", err)
}
recorder := &scratchpadChangeRecorder{}
host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40)
host.scratch = recorder
if err := host.ScratchpadDelete("doomed.md"); err != nil {
t.Fatalf("ScratchpadDelete: %v", err)
}
if recorder.count != 1 {
t.Fatalf("scratchpadsChanged calls = %d, want 1", recorder.count)
}
if _, _, err := pads.Read("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read deleted pad error = %v, want os.ErrNotExist", err)
}
if err := host.ScratchpadDelete("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("delete missing error = %v, want os.ErrNotExist", err)
}
if recorder.count != 1 {
t.Fatalf("scratchpadsChanged calls after failed delete = %d, want 1", recorder.count)
}
}

View File

@@ -358,6 +358,13 @@ func toolCatalog() []toolDescriptor {
"content": stringProp("Text to append."),
}, []string{"name", "content"}),
},
{
Name: "scratchpad_delete",
Description: "Delete a scratchpad entry.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
}, []string{"name"}),
},
{
Name: "whoami",
Description: "Return the caller's identity, role, parent, project metadata, and available tools.",

View File

@@ -101,6 +101,7 @@ type ToolHost interface {
ScratchpadRead(name string) (content string, revision string, err error)
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error
ScratchpadDelete(name string) error
// Meta.
WhoAmI(callerID string) WhoAmI
@@ -244,8 +245,8 @@ type TimerInfo struct {
ID string `json:"timer_id"`
Label string `json:"label,omitempty"`
Body string `json:"body,omitempty"`
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused"
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused"
OwnerID string `json:"owner_process_id"`
WatchedIDs []string `json:"watched,omitempty"`
FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"`
@@ -776,6 +777,18 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
}
return map[string]any{"ok": true}, 0, "", nil
case "scratchpad_delete":
var p struct {
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ScratchpadDelete(p.Name); err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"ok": true}, 0, "", nil
case "whoami":
return h.WhoAmI(callerID), 0, "", nil