10 Commits

Author SHA1 Message Date
mAi
8370454b66 refactor(mcp): typed ValidationError surfaces via .error.data
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"`.
2026-05-22 11:50:57 +02:00
mAi
d7438ba89e refactor(mcp): widen ToolHandler signature to return *ToolError with .data support
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.
2026-05-22 11:46:19 +02:00
mAi
63efc23843 refactor(mcp): validate item writes via internal/itemwrite/
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
2026-05-22 00:37:24 +02:00
mAi
9e0e2a1d13 fix(mcp): expand empty kinds to all four before timeline_exclude filter
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
2026-05-22 00:17:32 +02:00
mAi
825894f511 refactor(mcp): wire aggregator directly, drop TimelineBuilder seam
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
2026-05-22 00:15:07 +02:00
mAi
0bea9c1ba4 feat(phase 4f): per-item timeline_exclude flag (hide noise from /timeline)
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)
2026-05-17 19:28:49 +02:00
mAi
f6cf050c3f feat(phase 4d): public-listing fields so projax becomes the portfolio source of truth
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)
2026-05-17 19:11:26 +02:00
mAi
8b51746183 feat(phase 4c-B slice 1): MCP timeline tool wrapping the chronological view
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).
2026-05-17 18:42:48 +02:00
mAi
e055e4607e feat(phase 3c per-events): event_date on item_links, Documents UI, PER URL resolver, MCP date-aware add_link
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).
2026-05-15 18:35:21 +02:00
mAi
dc50823860 feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax
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.
2026-05-15 17:59:03 +02:00