Phase 5d slice B. createItemTool / updateItemTool stop encoding rejections
as `validation <kind>: <detail> [{json-blob}]` glued into .error.message
and instead return ValidationToolError(ve), which the JSON-RPC envelope
marshals as:
{ code: -32602,
message: "<kind>: <detail>",
data: { kind, path, detail } }
Clients introspect `.error.data.kind` directly — no JSON suffix to parse
out of the message. -32602 is the JSON-RPC "Invalid params" code, the
right semantic level for an itemwrite rejection.
mcp/tools.go:
- Replace itemWriteError with ValidationToolError. The legacy helper is
gone; four call sites (create_item × 2, update_item × 2) switch over
one-for-one.
mcp/mcp_test.go:
- Add TestToolsCallValidationError. Pins the wire shape: code=-32602,
message=`<kind>: <detail>` with no JSON suffix, and data carrying
{kind, path, detail}. Also asserts the rejection does NOT route
through result.isError — the slice A guarantee remains intact for
validation errors specifically.
- Import internal/itemwrite for the ValidationError fixture.
No test source edits to existing assertions — the prior tests don't
inspect the legacy `validation X: Y [{...}]` Msg shape, so behaviour
preservation holds without touching them. The new test is additive.
Live probe (post-deploy): POST `create_item` against projax.msbls.de/mcp/rpc
with slug='BAD.SLUG' returns `error.data.kind = "invalid-slug-format"`.
Phase 5d slice A. ToolHandler was `func(ctx, params) (any, error)` and
errors surfaced through the MCP `result.isError=true` content envelope with
no place to put structured payloads. Widen to `(any, *ToolError)` so
handlers return a typed `{Code, Msg, Data}` that the server marshals into
the JSON-RPC `error` envelope (`{code, message, data}`) — `data` is omitted
when nil so today's untyped errors stay clean.
Handler.go:
- ToolError gains `Code int`; Msg+Data unchanged. Error() preserved.
- Drop `AsToolError` — `errors.As` indirection is no longer needed now that
handlers return *ToolError directly.
- Add `InternalError(err)` (-32603, wraps a plain error) and
`InvalidParamsError(msg)` (-32602, declared for slice B's validation
promotion — no callers in slice A).
- `handleToolsCall` switches from the `result.isError` envelope to the
JSON-RPC `error` envelope via new `writeToolErr` helper. Transport-level
errors (`writeErr`) are unchanged.
Tools.go:
- `itemWriteError` now returns `*ToolError` with the legacy
`validation <kind>: <detail> [{json-blob}]` Msg text and no Data. Slice B
replaces this with `ValidationToolError` (typed .data + -32602).
- All ten tool handlers migrated to the new signature. Non-validation
paths default to `Code: codeInternalError (-32603)` via `InternalError(err)`
for semantic preservation; "field is required" guards keep the same
message string under -32603.
- Helper functions (`resolveItem`, `resolveParentPaths`,
`resolveTimelineWindow`, `resolveTimelineItems`, `applyHasLinkFilters`,
`parseInput`) keep returning plain `error`; their tool-handler callers
wrap with `InternalError`.
Test source edits (per the 5c rule):
- `mcp_test.go` TestToolsCallSuccessAndError: error path now asserts on
the JSON-RPC `.error.code == -32603` and `.error.message == "kaboom"`
envelope instead of `result.isError=true` + content text. The success
path is unchanged (`isError:false` and content[].text stay). Also
refreshed two handler-literal signatures in the same test file from
`(any, error)` → `(any, *ToolError)` so the test compiles against the
widened signature.
All other MCP tests stay behaviour-preserving — they exercise success
paths through the unchanged result envelope, or hit error paths via
`Handler(...) (any, *ToolError)` directly (timeline_test.go) and still see
a non-nil error.
Phase 5c slice C. createItemTool and updateItemTool now pre-validate
through internal/itemwrite/ before the store.Create / Update call.
- itemWriteError wraps a *ValidationError into an error whose
message embeds the JSON shape {kind, path, detail} — the JSON-RPC
envelope carries that as .error.message, and clients can parse the
bracketed JSON suffix to extract a typed object. (A future Slice D
could promote this to .error.data with native typed-error support
in the mcp server; out of scope here.)
- createItemTool: ValidateFormat + ValidateAgainstStore on
(title, slug, status, parent_ids) before store.Create. The old
"slug and title are required" inline check is removed — the
validator's missing-required kind covers it with a structured
reject.
- updateItemTool: same pair on the patched-item shape (the item's
existing fields plus whatever the input overrides). Catches
cycle / self-parent / slug-collision before the txn opens.
No mcp test source touched — assertions are on observable behaviour
(tool result shape, error presence) and the validator preserves both
for valid AND invalid inputs the SQL trigger would have rejected.
Task: t-projax-5c-itemwrite
Slice D regression: when args.Kinds was empty (the default 'all' case),
the in-memory timeline_exclude pass iterated zero kinds and dropped
every item. Expand to the full kind set before filtering, and use the
expanded set when reporting timelineView.kinds — matches the web view's
activeKinds() behaviour.
Probed live: timeline tool now returns days/rows for the default
window again.
Task: t-projax-5a-aggregator
Phase 5a slice D. The MCP timeline tool no longer depends on
*web.Server — it talks to *aggregate.Aggregator directly. The wrong-way
mcp → web layering that necessitated the TimelineBuilder interface is
gone.
- mcp/tools.go: TimelineBuilder interface deleted.
RegisterProjaxTools(s, st, agg *aggregate.Aggregator) now takes the
aggregator directly; passing nil keeps the timeline tool unregistered
(kill-switch contract unchanged).
- mcp/tools.go: TimelineArgs moved from web/ to mcp/ since it is the
MCP-facing input shape. The timeline tool runs the full pipeline:
store.ListByFilters → in-mem timeline-exclude + has-link narrowing →
agg.All(...) → Result.ToTimelineRows() → aggregate.BuildTimelineDays
→ timelineView. No web/ import in the timeline path.
- internal/aggregate/rows.go: new Result.ToTimelineRows() helper that
projects the typed rows into the flat TimelineRow sum-type both
web/timeline.go and mcp/tools.go consume. Single source of truth for
the Date-anchor choice across kinds.
- internal/aggregate/timeline_days.go: FormatPERDate lifted from web/
so timeline-row builders outside web/ can render PER strings without
re-importing web/.
- web/timeline.go: BuildTimelinePayloadFromArgs + TimelineArgs deleted
(no remaining callers — slice D inlined the MCP path).
- cmd/projax/main.go: pass srv.Aggregator() into RegisterProjaxTools.
MCP tree-filter parity note: the move to store.ListByFilters narrows
status to a single value (first of args.Status) and AND-matches
management (vs the web TreeFilter's OR). m's documented MCP uses
(tag + default status) round-trip identically. Logged as a footnote in
docs/plans/aggregator-refactor.md.
All mcp + web + aggregate tests green.
Task: t-projax-5a-aggregator
m's stated use case: home VTODOs (shopping list) shouldn't pollute the
chronological /timeline by default, but they should stay visible on the
home detail page itself. This adds an item-level switch with four kinds
and a URL override to peek at everything when wanted.
## Schema (migration 0015)
- timeline_exclude text[] NOT NULL DEFAULT '{}'
- items_timeline_exclude_idx GIN
- items_unified view rebuilt to surface the new column
- Behaviour-neutral: empty array = unchanged from today. m flips the
toggle himself via /admin/bulk or the detail-page form.
## Aggregation
- web/timeline.go: pre-compute the per-kind keep-list via keepFor(kind)
before fanning out — items with the kind in their exclude array are
dropped entirely (no CalDAV call wasted on excluded sources). Doc and
creation rows check the per-item flag inline. `?include_excluded=1`
(URL) and `include_excluded:true` (MCP arg) override the filter.
- store.Item.ExcludesTimelineKind(kind) helper accepts either singular
("todo") or plural ("todos") to bridge the kind-constant / persisted-
value naming choice — see comment for the why.
## UI
- /i/{path} grows a "Timeline behaviour" collapsible section with four
checkboxes (todos / events / docs / creation) and helper text. Open by
default when any kind is excluded, so m can see at a glance what's
hidden for this item.
- /admin/bulk gains a "timeline todos" select with "Exclude from timeline"
and "Re-include on timeline" — the other three kinds stay editable
per-item only per the task brief (most common use case is just todos).
## MCP
- update_item accepts timeline_exclude as a partial-update field with an
enum-restricted whitelist; unknown values dropped silently.
- itemView always emits timeline_exclude (defaults to []) so consumers
can render the toggle state without a second round-trip.
## Tests
- Migration + GIN index landed
- Item with timeline_exclude=['todos'] hides the VTODO from /timeline
- ?include_excluded=1 brings it back
- Bulk action toggles the array idempotently in both directions
- Detail page renders all 4 checkbox affordances
## docs/design.md
§12 gains a "Per-item exclusion" subsection documenting semantics, the
URL override, the bulk action, and the "detail page still shows everything"
invariant.
## Out of scope (per task brief)
- Per-tag exclusion (per-item is clearer)
- Per-day exclusion (overkill)
- Dashboard exclusion (m only flagged timeline; dashboard's "today" view
should still show shopping today if it's due today)
- Auto-seeding home with timeline_exclude=['todos'] (m runs once himself
via /admin/bulk after the deploy — schema change stays behaviour-neutral)
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.
## Schema (migration 0014)
- public boolean default false (partial index when true)
- public_description text default ''
- public_live_url text default ''
- public_source_url text default ''
- public_screenshots text[] default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx PARTIAL INDEX WHERE public = true (5% of rows)
## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter
## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB
## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
inputs + screenshot list editor with add/remove rows + inline JS for
the editor. Values persist when public is off so toggling never
destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
round-trip, detail-page affordances, tree-filter narrowing
## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).
## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
Exposes projax's /timeline aggregation (Phase 4a) over MCP-RPC so the
PWA (mAi#228) can fetch it without a session cookie against
projax.msbls.de. Same tool surface m's other agents already use.
## Changes
- web/timeline.go: export TimelineQuery, TimelinePayload, add typed
TimelineArgs + BuildTimelinePayloadFromArgs entrypoint. The web cache
stays scoped to the HTTP handler; MCP path re-aggregates per call.
- mcp/tools.go: register `timeline` tool when a TimelineBuilder is
passed. Output mirrors the web template's shape but stringifies
timestamps to YYYY-MM-DD or ISO-8601 UTC so JSON-RPC consumers don't
need Go time semantics.
- mcp/tools_test.go: existing tests pass nil builder (no behaviour
change to the rest of the tool surface).
- mcp/timeline_test.go: 7 unit tests covering registration, arg
forwarding, error propagation, empty payload, and view serialisation.
- cmd/projax/main.go: pass the running *web.Server as the third arg so
the timeline tool registers on the live server (CalDAV-aware).
- docs/design.md §14: documents the tool, schema, output shape, cache
semantics.
## Out of scope
- Caching the MCP path (rejected — re-aggregation per call is cheap;
divergent cache keys aren't worth invalidation complexity).
- Wrapping CalDAV writes (S2 — separate slice once m greenlights).
- PWA backend bridge + frontend (S2/S3 — m/mAi side, after this deploys).
migration 0011_item_links_event_date.sql: ADD event_date date + partial
index (idempotent). Day granularity by design per the PER spec; the
column lands NULL on every existing row, no backfill.
store:
- ItemLink gains an EventDate *time.Time (every read path scans it).
- AddLinkDated(ctx, item, refType, refID, rel, note, date, metadata)
upserts with COALESCE(new, old) for note + event_date so partial
callers don't clobber prior state.
- DatedLinks(item) returns event_date IS NOT NULL ordered DESC.
web:
- per.go: parsePER strips a trailing .YYMMDD (rejects invalid dates like
Feb 30); collisionTag yields a/b/.../z/aa/ab/...; computePERs walks
DatedLinks output and assigns render-time collision tags inside each
date group. Tags are never stored.
- handleDetail: 404 retry with PER stripped — /i/mfin.house1.260515
resolves to the house1 item with HighlightDate=2026-05-15.
- documents_section.tmpl: add-form (ref_type/date/ref_id/note),
date-sorted rows with computed PER, ref-type badge, remove × with
anti-forgery item-id check, highlight row when HighlightDate matches.
- POST /i/{path}/links/add and /links/remove handlers; HTMX swap on the
fragment, redirect for non-HTMX callers.
mcp:
- add_link accepts event_date: "YYYY-MM-DD" (parsed strict, hands back
fmt.Errorf on bad form). linkView.event_date surfaces it on responses.
- Existing add_link callers without event_date keep working unchanged.
docs:
- docs/standards/per.md gains an Implementation section pointing at
item_links.event_date + ref_types + render-time collision policy.
- docs/design.md adds a Documents/dated artifacts section with the
schema delta, conflict policy, and URL routing rules.
tests:
- per_test.go: parsePER (valid/invalid dates, non-numeric, wrong
length); collisionTag (1..53); computePERs (bare-then-.a, skips
undated, multi-date grouping).
mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools
delegate to *store.Store (no business-logic duplication).
- handler.go: handleRPC routes initialize / tools/list / tools/call /
ping / notifications/initialized; Bearer-token middleware; results
flow through the standard MCP content[].text envelope; tool errors
surface as isError: true (transport errors stay JSON-RPC errors).
- tools.go: 10 tools — list_items / get_item / create_item /
update_item / delete_item / list_links / add_link / remove_link /
search / tree. Multi-parent in/out — parent_paths[] string array,
resolved per call. itemView/linkView keep the wire shape snake_case
and stable.
- mcp_test.go + tools_test.go: protocol primitives (no DB) plus a
full create → get → search → delete round-trip skipping cleanly
when the DB env is absent. Multi-parent assertion discovers the
test pair from the live DB rather than hard-coding a row.
store extensions:
- ListByFilters(SearchFilters) with parent_path/tags/management/kind/
status/q/has_repo/has_caldav predicates.
- Search(q, limit) ranked across title/slug/aliases/content_md.
- GetByPathOrSlug for callers that don't know the full path.
- SoftDeleteCascade refuses on live descendants unless cascade=true.
web:
- New optional Server.MCP http.Handler. main.go mounts an mcp.Server
when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses
the Supabase-cookie auth middleware (its own Bearer auth applies).
- Off cleanly when the token is unset.
ops:
- ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out,
Bearer header).
- .mcp.json adds an http-transport entry for clients that speak
HTTP+MCP natively.
- deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret.
- docs/design.md §7 added: tool list, multi-parent semantics, env
contract, transport + bridge.