15151 Commits
Author SHA1 Message Date
kolaente 179ed7f204 fix(mcp): validate meta-tool arguments, hydrate from the read_one spec, bound error text 2026-08-29 02:10:17 +02:00
kolaente 684b50a1b7 fix(handler): keep the v1 pagination error texts and drop the dead branch 2026-08-29 02:10:17 +02:00
kolaente dffb57c023 fix(mcp): reject 2^63 floats and accept integral floats on pointer fields 2026-08-29 02:10:17 +02:00
kolaente 81bdfb9629 fix(mcp): require every writable field on updates without a read_one
projects_users_update and projects_teams_update can't be hydrated, so an
omitted permission silently downgraded the share to 0.
2026-08-29 02:10:17 +02:00
kolaente 3111aad1df fix(models): skip validation of hydrated author fields and hide comment author emails
Whole-model validation recursed into Author/CreatedBy, so a user with an
unvalidated language setting could permanently break label, team and comment
updates for everyone touching their rows.
2026-08-29 02:10:17 +02:00
kolaente 0154597e4c fix(mcp): hydrate before update, validate the whole model, align the list envelope
Partial updates went to models that write a fixed Cols list, so
tasks_update {done:true} blanked description, priority and colour. Update
now reads the row first, like v2 AutoPatch. Also: integral floats accepted
for integer args, validation errors bounded to 200 runes per field, list
envelope renamed to match apiv2.Paginated, users_search shares it.
2026-08-29 01:42:20 +02:00
kolaente c66f116695 fix(routes): fail at startup if the MCP mount prefix drifts 2026-08-29 01:42:20 +02:00
kolaente 1165876191 refactor(validation): share StructFieldErrors and fail closed on undecodable errors 2026-08-29 01:42:20 +02:00
kolaente 8c02d8d377 refactor(handler): extract NormalizePagination
page=0 previously reached the models unchanged and dropped the LIMIT clause.
2026-08-29 01:42:20 +02:00
kolaente a3930f6e68 fix(tasks): hide assignee emails in TaskAssginee.ReadAll
Every other user-bearing ReadAll obfuscates in the model; this one leaked
addresses over v1, v2 and MCP.
2026-08-29 01:42:20 +02:00
kolaente 00eb50b79c test(mcp): tighten catalog scope assertions and restore registry state after tests 2026-08-29 01:27:15 +02:00
kolaente f71fce7b99 refactor(mcp): drop the ToolSuffix alias and cut comment noise 2026-08-29 01:00:54 +02:00
kolaente 2d37477e5e test(mcp): pin assertions that accepted either outcome
The assignee tests passed vacuously: the claimed count-query bug doesn't
exist and team 1 never had access to project 1. Unknown-tool and
invalid-argument cases now assert the branch go-sdk actually takes.
2026-08-29 01:00:54 +02:00
kolaente 08ab38836b fix(mcp): apply the REST layer's guards in Dispatch
- page/per_page default and clamp like the REST handler; page < 1 dropped
  the LIMIT clause entirely
- config-gated resources are unreachable through do_action
- create/update run the model's valid: tag rules
- read_all returns {items, result_count, total_items, page, per_page} and
  strips emails from user rows
2026-08-29 00:48:55 +02:00
kolaente 1adbae211e refactor(validation): move govalidator rules and struct validation into models 2026-08-29 00:48:55 +02:00
kolaente 03c1f0fc7f fix(mcp): gate users_search on scopes a token can actually hold
The users and projects_users_search groups don't exist; the routes collect
as other:users and projects:users_search, so PermissionsAreValid rejected
any real token and the tool was unreachable. Also strip emails on the
project-scoped search path.
2026-08-29 00:36:12 +02:00
kolaente be37d944f1 refactor(api-tokens): share one HasPermission lookup across caldav, feeds and mcp 2026-08-29 00:36:12 +02:00
kolaente ee85b245da test(models): restore api token route maps after each test 2026-08-29 00:34:27 +02:00
kolaente 95bc56de8a refactor(mcp): drop unused RequiredCreate and duplicate op tests 2026-08-29 00:34:27 +02:00
kolaente 1f0ed6d3dc fix(mcp): require the parent id on parent-scoped listings
projects_views_read_all had no way to name a project, so it always ran
against project 0 and failed CanRead.
2026-08-29 00:34:27 +02:00
kolaente 3324448881 fix(mcp): key the route-check exemption on the matched route 2026-08-29 00:33:56 +02:00
kolaente d914a561d6 fix(mcp): build the server per request instead of per session
Stateful streamable-HTTP cached the initialize request's context, so every
later call on a session ran as whoever opened it — any mcp:access token plus
a leaked Mcp-Session-Id gave full impersonation, and revoked tokens kept
working through their session. Stateless mode rebuilds the tool set from the
token on each request; resources register at startup instead of lazily.
2026-08-29 00:33:56 +02:00
kolaente 71634ba8c8 feat(mcp): expose expand on tasks_read_all 2026-08-29 00:05:16 +02:00
kolaente 2cba279af5 feat(mcp): add users_search tool for assignee and share discovery 2026-08-29 00:02:46 +02:00
kolaente 17d52933d7 fix(mcp): appease lint and pin user1's fixture token set
TestHumaAPIToken pinned user1's owned tokens to {1,2}, broken since the
MCP fixture tokens 9-11 landed; the exact set now includes them.
2026-08-28 23:56:04 +02:00
kolaente 09bf29acd5 feat(mcp): add find_action/do_action catalog for long-tail resources
Catalog-tier resources stay out of tools/list; agents discover them via
find_action (scope-filtered, schemas on demand) and invoke them through
do_action, which funnels into the same Dispatch path — schema validation
and the per-call scope re-check apply identically.

Wave 1: task labels, task relations (subtasks), team members, project
user/team shares, project views. Deliberately absent: api tokens,
webhooks, link shares, buckets and positions (v1 token scopes don't map
onto (group, op) permissions), saved filters (nested filter object).

Adds IdentityFields for records not addressed by their id (team members
go by team + username) and treats readOnly+param fields as arguments
(REST reads them from the URL; MCP has no URL).
2026-08-28 23:54:56 +02:00
kolaente 28ee6a2a58 feat(mcp): expose task listing through the TaskCollection filter engine
tasks_read_all accepts filter/sort_by/order_by (from TaskCollection's
query-tagged fields) plus the generic search/page/per_page arguments;
project_id is optional so agents can list across projects. Fixture token
11 gains the tasks.read_all scope it now exercises.
2026-08-28 23:54:56 +02:00
kolaente c9f56c97cb feat(mcp): derive tool schemas and dispatch from model struct tags
Replaces the hand-written per-resource plumbing (input wrapper structs,
per-resource install functions, per-op Inputs maps) with a generic layer:

- schema.go reflects each op's input schema from the model's existing
  json/doc/readOnly/valid/minLength/param/query tags — the same contract
  the Huma-backed /api/v2 reads. URL-bound param fields become required
  JSON arguments; models without an exposed id (task assignees) are
  identified by their param fields.
- apply.go applies arguments presence-based: only keys the caller sent
  are written, so an explicit zero clears a field and an omitted key
  leaves it untouched — replacing the pointer tri-state wrappers.
- Registration is now a pure declaration (name, model, ops); the SDK's
  low-level AddTool with runtime-built schemas replaces the generic
  typed handlers, collapsing the six install functions into one loop.
- tasks_read_all now exists, backed by models.TaskCollection, exposing
  the filter/sort_by/order_by query surface.

inputs.go and its wrapper structs are deleted; behavior is pinned by the
unchanged mcp webtests.
2026-08-28 23:53:52 +02:00
kolaente 2904f0d4aa fix(mcp): allow update wrappers to clear booleans and numerics
copyByJSONTag previously skipped any IsZero value, which made it
impossible for tasks_update / projects_update to flip done from true
to false, reset priority/percent_done to 0, or unarchive a project.

A non-nil pointer src is now the unambiguous "caller supplied this"
signal: dereferenced values are written through even when zero, while
value-typed src fields keep the partial-update semantics. The
affected wrapper fields (Done, IsArchived, IsFavorite, Priority,
PercentDone, RepeatAfter, RepeatMode, BucketID,
CoverImageAttachmentID, ParentProjectID, Position) move to pointer
types so the JSON Schema still marks them optional.
2026-08-28 23:53:52 +02:00
kolaente 38d5ce1698 feat(mcp): expose remaining v1 resources via mcp tools
Registers tasks, labels, teams, task_comments and task_assignees through
the MCP tool surface, completing the v1 resource list from the plan:

  * tasks    : create / read_one / update / delete (read_all omitted;
               models.Task.ReadAll is a stub — TaskCollection is OOS)
  * labels   : full CRUD
  * teams    : full CRUD
  * tasks_comments  : full CRUD, install-time gated on
                      config.ServiceEnableTaskComments
  * tasks_assignees : create / read_all / delete only (REST exposes no
                      read_one or update)

Per-resource input wrappers carry the path-param fields (task_id,
user_id) explicitly so MCP callers can provide them as JSON args.
installToolsForToken fans out to one installer per resource; the
generics-bound addTool keeps per-(resource, op) call sites at compile
time. The api_tokens.yml fixture extends token 11 to cover the new
scopes; token count stays at 5 for user 1 so existing token-listing
tests are unaffected.

Integration tests per resource cover tools/list visibility, at least
one successful create or read_all, and a permission denial scenario.
2026-08-28 23:53:52 +02:00
kolaente 532b0665ad feat(mcp): enforce per-tool api token scopes
Filter MCP tool visibility and invocation by the requesting API token's
(group, permission) scopes. tools/list now returns only the tools the
token's APIPermissions authorise; tools/call additionally re-checks the
scope in the dispatcher as defence-in-depth, so a session created with
one token cannot be reused to invoke tools that token never had access to.

The per-session filter runs at session-init via the StreamableHTTPHandler
getServer factory (which the SDK calls once per session, before caching
the *mcp.Server). The dispatcher check runs on every tools/call and
returns ErrScopeDenied, which the AddTool wrapper renders as an IsError
tool result.
2026-08-28 23:53:52 +02:00
kolaente bf2a644a63 feat(mcp): expose projects via mcp tools
Wires the projects resource into the MCP server end-to-end. The five
project tools (create, read_one, read_all, update, delete) are now
visible in tools/list and dispatch through handler.Do* like the REST
layer.

- Add ProjectCreateInput / ProjectUpdateInput in inputs.go with
  jsonschema tags covering only the writable fields the model honours
  (title, description, identifier, hex_color, parent_project_id,
  position, is_archived, is_favorite); computed fields like Owner and
  MaxPermission are intentionally absent so the SDK-reflected schema
  stays narrow.
- Add resources.go with a sync.Once-guarded RegisterResources(), and an
  installTools helper that registers tools per (resource, op) on the
  *mcp.Server via a generic addTool[In inputAdapter] helper. The
  handler maps domain failures (permission denials, missing rows,
  validation) to IsError tool results per the SDK convention.
- Add DispatchTyped in dispatcher.go so the AddTool handler can hand a
  pre-unmarshalled wrapper to the dispatcher without a JSON
  round-trip. The existing Dispatch (raw JSON path) delegates to a
  shared dispatchPrepared.
- Wire RegisterResources() + installTools() into newServer() so each
  new MCP session inherits the static tool set.
- Add fixture token 11 (mcp:access + projects:*) for the full-scope
  integration tests; bump TestAPIToken_ReadAll's expected count.
- Refresh TestMCP_ToolsListEmpty into
  TestMCP_ToolsListReturnsRegisteredResources, asserting the five
  projects_* tools are present (Task 6 will introduce scope-based
  filtering of this list).
- Add pkg/webtests/mcp_projects_test.go covering tools/list,
  create/read_one/read_all/update/delete happy paths, schema-validation
  failure on missing required title, permission denial on a forbidden
  project, and nonexistent-id lookup.
2026-08-28 23:53:52 +02:00
kolaente 675ecc8d5a feat(mcp): add per-tool input wrappers 2026-08-28 23:53:52 +02:00
kolaente d998d3b6c9 feat(mcp): add resource registry and dispatcher
Define the Op bitmask, the Resource struct, the package-level Register
function, and the Dispatch entry point that future tasks will use to
expose CRUD resources over MCP. No resources are registered yet.

Op carries the CRUD-op identity, knows its api-token permission string
(matching apiTokenRoutes exactly), and knows its tool-name suffix.
Resource.Inputs maps each enabled op to a pointer-to-zero of the wrapper
type the dispatcher will allocate and unmarshal into. Register validates
the resource shape and populates a tool-name lookup table so the
dispatcher never has to string-parse names like task_comments_read_all.

Dispatch threads the user from ctx, allocates a fresh wrapper, unmarshals
arguments, asks the wrapper to copy itself onto a fresh model via the
inputAdapter seam (which Task 4 will populate with real implementations),
and forwards to the corresponding handler.Do* function. The Do* calls go
through a swappable crudFuncs struct so the unit tests can verify
dispatch routing without standing up the database.
2026-08-28 23:53:52 +02:00
kolaente a1d21dff6d feat(mcp): add streamable-http endpoint skeleton
Mount /api/v1/mcp (and /api/v1/mcp/*) inside the authenticated route
group. Reject JWT-authed requests with 401 (token-only policy), reject
API tokens without the mcp:access scope with 403, and propagate the
authed *user.User + *models.APIToken to r.Context() via typed keys so
downstream tool handlers can pull them out without depending on Echo.

The MCP protocol — JSON-RPC framing, Mcp-Session-Id management, SSE
streaming — is delegated to github.com/modelcontextprotocol/go-sdk
v1.6.1. tools/list returns {"tools": []} since no tools are registered
yet.
2026-08-28 23:53:52 +02:00
kolaente 21d7c79d5a feat(mcp): register mcp:access api token scope
Adds the mcp scope group with a single access permission so it shows up
in GET /api/v1/routes (and therefore in the frontend token form).
Adds APIToken.HasMCPAccess() mirroring the caldav/feeds helpers.

The MCP endpoint will use POST, GET, and DELETE on the same path for the
streamable-HTTP transport, which CanDoAPIRoute's exact (method, path)
match cannot gate. The token middleware therefore skips the route check
for /api/v1/mcp and any sub-path; the actual authorization is delegated
to an inline HasMCPAccess() call in the MCP handler (added in the next
task).

Fixtures gain two MCP tokens for user 1: one mcp-only and one with
mcp:access plus projects read scopes for the per-tool scope filter tests.
2026-08-28 23:53:24 +02:00
kolaente 78bb1213f7 test(projects): cover the parent owner on member-created subprojects
The scenario reported in #3574: a write member creates a subproject under a
project someone else owns. The parent's owner keeps admin on it, and CanRead,
IsAdmin and expand=permissions must all agree, for a direct share and a team
share alike. Both directions are covered — an unrelated user gets neither read
nor admin and does not see the subprojects listed, and a write member is not
reported as admin on the parent.
2026-08-28 23:49:27 +02:00
kolaente 9d1ac4c1d3 fix(permissions): decode a null permission as unknown, not read
MarshalJSON emits null for PermissionUnknown, but json.Unmarshal treats null as
a no-op for an int, so it decoded back as PermissionRead — the two did not round
trip.

Permission also backs the persisted permission on ProjectUser, TeamProject and
LinkSharing, where isValid now rejects an explicit "permission": null instead of
silently creating a read tier share. Omitting the key still defaults to read:
UnmarshalJSON is never called for an absent key.
2026-08-28 23:49:27 +02:00
kolaente aa5c5c7274 fix(projects): report read for the Favorites pseudo project
Favorites has no row, so checkPermissionsForProjects returns nothing for it and
expand=permissions left the field nil. checkReadPermissionsForProjects already
resolves it to read; the list route now agrees instead of reporting null.
2026-08-28 23:49:27 +02:00
kolaente dfb469741c fix(projects): report null for a max_permission nobody computed
Permission's zero value is PermissionRead, so Project.MaxPermission serialized
as 0 — a real permission meaning read — on every response path that never
resolved it. GET /api/v1/projects/:project claimed read-only access on a
project you own while the x-max-permission header correctly said 2.

Typing the field *Permission makes that unrepresentable: nil marshals to null,
so "not computed" is the default rather than something each call site has to
remember. That also fixes the paths which serialize a project outside the CRUD
pipeline and had the same lie — the admin project list, the background
handlers, duplicated_project on both API versions, and the admin owner-reassign
route — and retires v2's two explicit resets, which the field type now covers.

Webhook payloads for project.created and project.updated change from 0 to null
along with it.

Fixes #3574
2026-08-28 23:49:27 +02:00
kolaente d75ffcb3f2 fix(settings): show the current language in the settings select
The api returns an empty string for the language when the user never
explicitly saved one. `assignData` only falls back to the model defaults
for null and undefined, so that empty string overwrote the browser
language default. The language select then had no matching option and
rendered blank.

Fall back to the browser language for any falsy value returned by the api.
2026-08-28 23:36:15 +02:00
kergma-lwandGitHub fb26873f40 fix(editor): preserve subscript and superscript in pasted text (#3606) 2026-08-28 23:04:58 +02:00
kolaente bdfd70c3b8 fix(lightbox): fall back to a generic accessible name for empty alt 2026-08-28 22:53:45 +02:00
kolaente 6981878092 feat(lightbox): keyboard zoom and pan 2026-08-28 22:53:45 +02:00
kolaente 935f2753e4 refactor: mount the lightbox only while open, keyed by url 2026-08-28 22:53:45 +02:00
kolaente 80e485b143 fix(lightbox): harden the gesture lifecycle 2026-08-28 22:53:45 +02:00
kolaente 89ece20b8b fix(lightbox): correct pan clamping, zoom anchoring and wheel sensitivity 2026-08-28 22:53:45 +02:00
kolaente daf8087d3b refactor(lightbox): extract zoom math into helpers/imageZoom.ts with tests 2026-08-28 22:53:45 +02:00
kolaente d068b9f9c2 fix(editor): don't trust stored image ids, scope and dedupe the blob lookup 2026-08-28 22:53:45 +02:00
kolaente ad36c68e09 fix(editor): open the lightbox only for resolved attachment images 2026-08-28 22:53:45 +02:00