Merge branch 'mai/knuth/phase-5c-itemwrite' (phase 5c slice C: MCP write tools validate)

This commit is contained in:
mAi
2026-05-22 00:37:42 +02:00

View File

@@ -10,9 +10,27 @@ import (
"time"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/internal/itemwrite"
"github.com/m/projax/store"
)
// itemWriteError serialises an *itemwrite.ValidationError into a Go error
// whose Error() string carries the JSON shape MCP clients can parse to
// extract kind/path/detail. The JSON-RPC error envelope wraps this in
// .error.data so a TypeScript client gets a typed object alongside the
// human-readable message.
func itemWriteError(ve *itemwrite.ValidationError) error {
body, err := json.Marshal(map[string]any{
"kind": ve.Kind,
"path": ve.Path,
"detail": ve.Detail,
})
if err != nil {
return ve
}
return fmt.Errorf("validation %s: %s [%s]", ve.Kind, ve.Detail, string(body))
}
// TimelineArgs is the MCP-facing input shape for the `timeline` tool — a
// JSON-friendly equivalent of the URL query string web/timeline.go consumes.
type TimelineArgs struct {
@@ -818,9 +836,6 @@ func createItemTool(st *store.Store) ToolHandler {
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
if in.Slug == "" || in.Title == "" {
return nil, errors.New("slug and title are required")
}
parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths)
if err != nil {
return nil, err
@@ -829,6 +844,18 @@ func createItemTool(st *store.Store) ToolHandler {
if len(kind) == 0 {
kind = []string{"project"}
}
// Phase 5c: validate up-front so MCP clients get typed
// {kind, path, detail} on rejection instead of an opaque pgErr.
if ve := itemwrite.ValidateFormat(itemwrite.Input{
Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs,
}); ve != nil {
return nil, itemWriteError(ve)
}
if ve := itemwrite.ValidateAgainstStore(ctx, st, itemwrite.Input{
Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs,
}); ve != nil {
return nil, itemWriteError(ve)
}
it, err := st.Create(ctx, store.CreateInput{
Kind: kind,
Title: in.Title,
@@ -975,6 +1002,22 @@ func updateItemTool(st *store.Store) ToolHandler {
}
patch.ParentIDs = pids
}
// Phase 5c: pre-validate the patched item so MCP clients see typed
// {kind, path, detail} rejection instead of an opaque pgErr.
validateIn := itemwrite.Input{
ID: it.ID,
Title: patch.Title,
Slug: patch.Slug,
Status: patch.Status,
ParentIDs: patch.ParentIDs,
Path: it.PrimaryPath(),
}
if ve := itemwrite.ValidateFormat(validateIn); ve != nil {
return nil, itemWriteError(ve)
}
if ve := itemwrite.ValidateAgainstStore(ctx, st, validateIn); ve != nil {
return nil, itemWriteError(ve)
}
updated, err := st.Update(ctx, it.ID, patch)
if err != nil {
return nil, err