Repository navigation
v2.9.0
✨ 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 newestupdatedTimeand 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_uncompletedhonorsdueDateandpriority: these were on the sharedmotion_tasksschema 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)listgainscompletedAfter/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-implyincludeAllStatuses; a very large window stays bounded byMAX_PAGESand surfaces in truncation metadata rather than truncating silently. (#149)- Schedule responses print each day's working hours:
formatScheduleListnow 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_statusesmoved into theessentialtier (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.tomlnow 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_SECRETwas unset, the path-secret check comparedundefined !== undefinedand 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 toMcpAgentwith no credential check. The agents SDK instantiates a Durable Object for anysessionIdwithout 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.timingSafeEqualrather 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
ZodObjectviaregisterToolinstead of the raw shape viatool(), which had discardedadditionalProperties: falseand 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_SECRETin anmcpSecretquery 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 withMOTION_MCP_SECRET, verified statelessly) at stream open and threads it through anmcpSessionparameter 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
mcpSessioncredential 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: Bearerremains the recommended mode.
🐛 Bug Fixes
dueDatefilter dropped same-day tasks for accounts west of UTC:listcompared 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'sdueDateis now reduced to a calendar date in the account timezone before comparing (newcalendarDateInZonehelper), falling back to the UTC date portion when no zone is resolvable. ThedueDatefilter is also now documented as an inclusive on-or-before-day bound that includes overdue tasks. (#148)- Assignee names resolved against the wrong workspace:
moveandupdateresolved 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 onmoveand to the task's own workspace onupdate. (#132) assigneename and themeshortcut were dropped: both were silently discarded or forwarded literally on task create/update/move, andmewas unresolved on recurring task creation. (#132)sanitizedeleted ordinary text: the tag-stripping regex removed any<...>span, so text likex < 5 and y > 3lost content. It is now anchored to real HTML tag names. (#132)- Oversized pages dropped items: pagination sliced a page exceeding
MAX_PAGE_SIZEand then advanced the cursor, silently discarding the remainder. It now stops and reports the count actually returned instead of0. (#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.UTCnormalizes overflow, so2026-02-31became March 3 and was sent to the API as a date the caller never requested.parseFilterDatenow 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
weekOfMonthcombinations 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
coerceTypesrewrote 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
optionswere accepted when creating a custom field, and recurring taskdurationwas unvalidated. Both are now rejected, with matching schema constraints (minItems: 1,minimum: 0). (#132) structuredContentomitted fields the text output shows:taskToStructuredContentnow projectsduration,deadlineType, andlabels, so machine consumers see the same task fields human readers do.durationis projected directly rather than summed fromchunks, which read0for 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 answeredOPTIONSwith 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/mcpare now answered before the gate with the same CORS headers the agents SDK emits; every non-OPTIONSrequest 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/mcpalone. 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_statusesacceptsoperationandworkspaceName(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 undocumentedentityTypesproperty onmotion_searchan error; usesearchScope. (#132) - Empty assignee identifiers are rejected:
assigneeIdandassigneeonmotion_tasks, andassigneeIdonmotion_recurring_tasks, now carryminLength: 1. An empty string previously reached the handler and was silently dropped, soupdateappeared to succeed while changing nothing. Callers now get a validation error; use theunassignoperation 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-staticon Windows). Lockfile-only; the package is a transitive dependency of@modelcontextprotocol/sdkand 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-server2.x declaresengines.node: ">=20", so installs on Node 18 emittedEBADENGINEwarnings. Development targets Node 24, pinned inmise.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.tsauth is now covered under workerd: aworkerVitest project runs the auth path inside@cloudflare/vitest-pool-workers, socrypto.subtle.timingSafeEqual, the realRequest/URLrewrite 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:
jsonSchemaToZodObjectstrictness (rejects unknown keys; enforcesminLength,minimum: 0,minItems: 1),fetchAllPagestruncation (oversized-page stop without cursor advance, accuratereturnedCount,max_itemsprecedence,max_pagesceiling), theworkspaceResolverambiguous case-insensitive-match warning, andendOfDayInZoneacross DST transitions in both hemispheres plus a fixed-offset control. (#136) npm run test:typeswas failing:tsconfig.tests.jsontype-checkedsrc/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, matchingtsconfig.json; the file stays type-checked under Workers types viatests/worker/tsconfig.json.- Integration tests silently skipped even with a key set:
vitest.integration.config.tsnever loaded.env, so thedescribe.skipguard saw noMOTION_API_KEYand skipped all 26 tests while exiting 0. The config now loads.envvia adotenv/configsetup file. - Added a CI workflow (
.github/workflows/ci.yml) that runs the type-checks, build, and the node + worker test suites on every push tomainand 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
essentialas the default, but the stdio server defaults tocomplete(10 tools). Corrected the tier list to match the code, README, and CLAUDE.md, with the tool counts each tier adds.