Skip to content

v2.9.0

Choose a tag to compare

@devondragon devondragon released this 24 Aug 22:13
· 4 commits to main since this release

✨ Added

  • Account timezone and today's date on list responses: task-list responses (list, list_all_uncompleted) and schedule responses now begin with a one-line header, e.g. Account timezone: America/Denver | Today: 2026-08-24 (Monday). Date-relative reasoning the model does itself (weekday, "now") previously had nothing in the payload naming the account's zone, so fresh agents inferred "now" from the newest updatedTime and sometimes reported the wrong day. The zone is already resolved for rendering, so this adds no API calls, and it lands on both the stdio and Worker entry points. (#151, closes #150)
  • list_all_uncompleted honors dueDate and priority: these were on the shared motion_tasks schema but silently dropped, so "due this week across all workspaces" returned everything. Both filters are now threaded (dueDate validated in the account zone) through the per-workspace queries. (#149)
  • list gains completedAfter / completedBefore: Motion has no server-side completion-date filter, so "what did I get done this week?" meant dumping all statuses and filtering by hand. These client-side day-granularity bounds (account zone, both inclusive) auto-imply includeAllStatuses; a very large window stays bounded by MAX_PAGES and surfaces in truncation metadata rather than truncating silently. (#149)
  • Schedule responses print each day's working hours: formatScheduleList now shows the start-end times per day instead of only a working-days count, so "when am I free?" can anchor to real hours. The times were already in the payload. The tool description notes these are recurring working-hour templates only and do not expose calendar events or meetings. (#149)
  • motion_statuses moved into the essential tier (now 8 tools), so marking a task done can look up the workspace's resolved status name instead of guessing it. (#149)
  • Cloudflare Worker observability enabled: wrangler.toml now turns on Workers logs, so invocations are traceable in the Cloudflare dashboard for debugging.

🔒 Security

  • Fixed fail-open authentication in the Cloudflare Worker (critical): when MOTION_MCP_SECRET was unset, the path-secret check compared undefined !== undefined and authorized every request, exposing unauthenticated MCP access to the Motion account. The Worker now fails closed with a 500. (#132)
  • Closed an authentication bypass on the SSE message endpoint (critical): POST /mcp/message?sessionId=... was forwarded to McpAgent with no credential check. The agents SDK instantiates a Durable Object for any sessionId without verifying the id was issued on a secret-authenticated stream, so this route could invoke tools without the secret. It is now authenticated like every other route. (#132)
  • Constant-time secret comparison: secrets are compared as SHA-256 digests via crypto.subtle.timingSafeEqual rather than with !==. (#132)
  • Authorization: Bearer <secret> support: the recommended way to authenticate, keeping the secret out of the URL. The existing /mcp/SECRET/... path secret remains supported as a backward-compatible fallback, and the scheme name is matched case-insensitively per RFC 7235. (#132)
  • Worker tool schemas are now strict: tools are registered with the full ZodObject via registerTool instead of the raw shape via tool(), which had discarded additionalProperties: false and silently stripped unknown properties. (#132)
  • The raw secret no longer travels in the legacy-SSE message query string: path-secret clients previously echoed MOTION_MCP_SECRET in an mcpSecret query parameter on every message POST, landing the long-lived secret in access and proxy logs. The Worker now mints an expiring HMAC session credential (signed over its issue time with MOTION_MCP_SECRET, verified statelessly) at stream open and threads it through an mcpSession parameter instead. A leaked credential expires after 24 hours where a leaked shared secret never would; the raw secret in the parameter is now rejected, closing the downgrade. The TTL must outlast a session (the credential is minted once per stream), so the gain is eventual expiry, not a short window. (#135)

Note for path-secret + legacy SSE users: the message endpoint is authenticated by an expiring mcpSession credential the server mints and the client echoes back, so the raw secret no longer appears in query strings. Bearer clients authenticate with the header and receive no credential parameter. Authorization: Bearer remains the recommended mode.

🐛 Bug Fixes

  • dueDate filter dropped same-day tasks for accounts west of UTC: list compared each task's raw UTC date against a local calendar filter value. A task due end-of-day local time is stored as a UTC instant on the next calendar day, so it sorted after "today" and was silently dropped from "what's due today?" even though its display (rendered in the account zone) showed the correct day. Each task's dueDate is now reduced to a calendar date in the account timezone before comparing (new calendarDateInZone helper), falling back to the UTC date portion when no zone is resolvable. The dueDate filter is also now documented as an inclusive on-or-before-day bound that includes overdue tasks. (#148)
  • Assignee names resolved against the wrong workspace: move and update resolved an assignee name through a cross-workspace search that returned the first match found, so a name held by users in two workspaces could reassign a task to the wrong person. Resolution is now scoped to the destination workspace on move and to the task's own workspace on update. (#132)
  • assignee name and the me shortcut were dropped: both were silently discarded or forwarded literally on task create/update/move, and me was unresolved on recurring task creation. (#132)
  • sanitize deleted ordinary text: the tag-stripping regex removed any <...> span, so text like x < 5 and y > 3 lost content. It is now anchored to real HTML tag names. (#132)
  • Oversized pages dropped items: pagination sliced a page exceeding MAX_PAGE_SIZE and then advanced the cursor, silently discarding the remainder. It now stops and reports the count actually returned instead of 0. (#132)
  • Truncated result sets were cached as complete: projects and recurring tasks cached items without the truncation metadata, so a later hit returned a partial list as if whole. Truncated results are no longer cached, and the recurring-tasks cache key now includes limit/maxPages. (#132)
  • Search missed matches past the first page: client-side filtering only saw ~100 items per workspace. It now overfetches up to MAX_SEARCH_FETCH. (#132)
  • Invalid calendar dates were accepted: Date.UTC normalizes overflow, so 2026-02-31 became March 3 and was sent to the API as a date the caller never requested. parseFilterDate now rejects dates that do not round-trip. (#132)
  • Date-only due dates shifted a day east of UTC: they are now stored at local end-of-day in the resolved time zone. (#132)
  • Multi-day monthly/quarterly recurrence silently discarded days: unsupported weekOfMonth combinations are now rejected with an explanatory error; an all-seven-days set maps losslessly to the any-day pattern. (#132)
  • Validator mutated caller arguments: AJV's coerceTypes rewrote the input object in place. Validation now runs against a clone, and handlers receive the coerced values. (#132)
  • A duplicate name in MOTION_MCP_TOOLS=custom:... crashed Worker startup: the custom tool list is de-duplicated. (#132)
  • Empty select options were accepted when creating a custom field, and recurring task duration was unvalidated. Both are now rejected, with matching schema constraints (minItems: 1, minimum: 0). (#132)
  • structuredContent omitted fields the text output shows: taskToStructuredContent now projects duration, deadlineType, and labels, so machine consumers see the same task fields human readers do. duration is projected directly rather than summed from chunks, which read 0 for exactly the unschedulable tasks (schedulingIssue: true, chunks: []) an at-risk report exists to surface. (#140)
  • CORS preflight was rejected on /mcp (Bearer mode) and /mcp/message: the auth gate answered OPTIONS with a 404, but browsers send preflights without credentials, so browser-origin MCP clients could not use Bearer mode or the legacy SSE message endpoint at all. Preflights under /mcp are now answered before the gate with the same CORS headers the agents SDK emits; every non-OPTIONS request stays fully gated. (#138)
  • Path-secret SSE sub-paths dead-ended in a confusing SDK 404: /mcp/<secret>/sse (and any path-secret sub-path other than /mcp/<secret>/message) was rewritten and forwarded only to 404 inside the agents SDK, which matches the legacy stream on /mcp alone. The Worker now rejects these with a clean 404 before attaching the secret, so the long-lived secret no longer lands on a dead-end URL where a Durable Object exception could surface it in Workers trace events. The supported path-secret address is /mcp/<SECRET>; the server advertises its own sub-paths during a session. (#141)

🛠️ API Changes

  • motion_statuses accepts operation and workspaceName (with name resolution), matching the other tools. (#132)
  • All 10 tool schemas set additionalProperties: false, so unknown properties are rejected rather than ignored. Note this makes the previously undocumented entityTypes property on motion_search an error; use searchScope. (#132)
  • Empty assignee identifiers are rejected: assigneeId and assignee on motion_tasks, and assigneeId on motion_recurring_tasks, now carry minLength: 1. An empty string previously reached the handler and was silently dropped, so update appeared to succeed while changing nothing. Callers now get a validation error; use the unassign operation to clear a task's assignee. (#132)

📦 Dependencies

  • @hono/node-server: 1.19.13 → 2.1.1, resolving Dependabot alert #149 (GHSA-frvp-7c67-39w9, path traversal in serve-static on Windows). Lockfile-only; the package is a transitive dependency of @modelcontextprotocol/sdk and is never imported by either entry point. (#134)
  • Minimum Node.js is now 20 (was 18). Node 18 reached end of life in April 2025, and @hono/node-server 2.x declares engines.node: ">=20", so installs on Node 18 emitted EBADENGINE warnings. Development targets Node 24, pinned in mise.toml.

🧪 Testing

  • Added regression coverage for assignee resolution and workspace scoping, sanitize tag anchoring, frequency transforms, duration parsing, validator cloning/coercion, custom tool de-duplication, and status/search/custom-field/recurring handler paths. 539 tests across 30 files.
  • src/worker.ts auth is now covered under workerd: a worker Vitest project runs the auth path inside @cloudflare/vitest-pool-workers, so crypto.subtle.timingSafeEqual, the real Request/URL rewrite semantics, and the Durable Object bindings are exercised in the same runtime as production. (#139)
  • Covered four implemented-but-untested paths surfaced by the #132 review: jsonSchemaToZodObject strictness (rejects unknown keys; enforces minLength, minimum: 0, minItems: 1), fetchAllPages truncation (oversized-page stop without cursor advance, accurate returnedCount, max_items precedence, max_pages ceiling), the workspaceResolver ambiguous case-insensitive-match warning, and endOfDayInZone across DST transitions in both hemispheres plus a fixed-offset control. (#136)
  • npm run test:types was failing: tsconfig.tests.json type-checked src/utils/sessionCredential.ts, which uses workerd-only APIs (CryptoKey, crypto.subtle.timingSafeEqual) unavailable under the Node libs of that config. It is now excluded there, matching tsconfig.json; the file stays type-checked under Workers types via tests/worker/tsconfig.json.
  • Integration tests silently skipped even with a key set: vitest.integration.config.ts never loaded .env, so the describe.skip guard saw no MOTION_API_KEY and skipped all 26 tests while exiting 0. The config now loads .env via a dotenv/config setup file.
  • Added a CI workflow (.github/workflows/ci.yml) that runs the type-checks, build, and the node + worker test suites on every push to main and every pull request. It runs on Node 24 (the workerd test project requires Node >= 22); integration tests are excluded as they need a live API key.

📖 Documentation

  • DEVELOPER.md listed the wrong default tool tier: it labeled essential as the default, but the stdio server defaults to complete (10 tools). Corrected the tier list to match the code, README, and CLAUDE.md, with the tool counts each tier adds.