nextcloud-organizer-mcp
Organizer MCP for Nextcloud - an MCP server that manages tasks (VTODOs) andcalendar events (VEVENTs) over CalDAV, plus notes via the Nextcloud Notes app,in a self-hosted Nextcloud instance. Connect it to Claude as a customconnector to create, list, update and complete Nextcloud tasks, managecalendars and events (including recurring ones), link tasks to events(timeboxing), and get combined day agendas using natural language.
This is a community project and is not affiliated with or endorsed byNextcloud GmbH. (Formerly nextcloud-task-mcp.)
Built with FastMCP on the Streamable HTTP transport, and thecaldav library for talking to Nextcloud.
Documentation:
- Deployment guide — Ubuntu LXC + Tailscale + systemd + Claude connector setup
- Tool reference — all tools with parameters, examples and error messages
- Architecture — module layout, request flow, design decisions
- Contributing — dev setup, checks to run, pre-commit, vendored-file rules
- Changelog — notable changes by work package
- Security policy — how to report vulnerabilities privately
How it works
- One CalDAV connection is opened at startup and reused for every request (noreconnect-per-call).
- The server authenticates MCP clients with OAuth 2.1 (Dynamic Client Registration +PKCE), via
PersonalAuthProvider.No tool or CalDAV logic runs until a request carries a valid access token. SeeAuthentication below. - The server binds to a local HTTP port only (e.g.
127.0.0.1:8000). It does not handleTLS itself - in the intended deployment,tailscale funnelterminates TLS in front ofit and exposes it to the public internet (required so Claude's backend can reach it andcomplete the OAuth flow). - CalDAV/network failures (auth errors, timeouts, missing task lists/UIDs, ...) are caughtand turned into short, clean error messages - no raw stack traces are ever returned tothe MCP client.
Setup
Requires Python 3.10+. Install the released package from PyPI:
uv tool install nextcloud-organizer-mcp # or: pipx install nextcloud-organizer-mcp
and provide the environment variables below (see .env.example). Or run from acheckout with uv:
uv sync
cp .env.example .env
# edit .env with your Nextcloud base URL, an app password, and PUBLIC_BASE_URL
Generate a Nextcloud app password under Settings → Security → Devices & sessions(never use your account password). NEXTCLOUD_BASE_URL is required — your Nextcloudinstance's base URL with no path, typically:
https://<your-nextcloud-domain>
Must be https:// — the server refuses to start with a http:// URL unless itpoints at a local address (localhost/127.0.0.1/::1) or NEXTCLOUD_ALLOW_INSECURE_HTTP=1is set, since http:// sends the app password above in cleartext Basic Auth.
NEXTCLOUD_CALDAV_URL is optional and defaults to <base>/remote.php/dav/. It is onlyneeded when your DAV endpoint is not <base>/remote.php/dav/ (e.g. if CalDAV sits behind adifferent host or proxy path). Both URLs must point at the same Nextcloud instance.
PUBLIC_BASE_URL is the exact URL clients will use to reach this server - seeAuthentication below for why this has to match precisely.
Run the server:
set -a; source .env; set +a
uv run nextcloud-organizer-mcp
It listens on MCP_HOST:MCP_PORT (default 127.0.0.1:8000) at the /mcp path, using theStreamable HTTP transport.
Authentication
The server authenticates MCP clients with OAuth 2.1 (Dynamic Client Registration +PKCE), via PersonalAuthProvider -vendored into src/nextcloud_organizer_mcp/personal_auth.pysince it ships as a single file to copy in, not an installable package. There is nostatic bearer token to configure.
This exists because Claude's connector UI (web, mobile, Desktop, Cowork) only exposesOAuth fields for custom connectors - it has no field for a raw static token. OAuth isalso what makes the server usable from Claude mobile at all, since mobile has no configfile to hand-edit.
How it's secured, since anyone on the internet can reach the OAuth discovery andregistration endpoints once the server is public:
- Dynamic Client Registration is intentionally open (
/registeraccepts any client) -this is required for Claude.ai's connector flow and is not itself a security boundary. - The redirect-domain allow-list is not, by itself, a security boundary. A scriptnever has to actually control a listed domain (e.g.
claude.ai) to pass this check -it only has to claim a matchingredirect_uriwhen calling/authorize, and theauthorization code comes back directly in that same HTTP response. Configurable viaMCP_OAUTH_ALLOWED_REDIRECT_DOMAINS; when unset andPUBLIC_BASE_URLisn't local, theserver also dropslocalhostfrom the vendored default allow-list (alocalhostentry can never be reached by a real OAuth redirect on a public deployment anyway) -but don't rely on this list alone either way. MCP_OAUTH_PASSWORDis the actual security gate, and is required (the serverrefuses to start without it) wheneverPUBLIC_BASE_URLisn'tlocalhost/127.0.0.1,orMCP_HOSTis bound to a non-local address (e.g.0.0.0.0- a stale localhostPUBLIC_BASE_URLwith a0.0.0.0bind is a common Docker misconfiguration).Without it, anyone who can reach the server can self-issue a valid access token. It isenforced by an interactive consent page:/authorizeparks the request under acryptographically random, single-use pending key (10-minute TTL) and redirects thebrowser to/consent, which asks for the password before any authorization code isminted. The comparison is constant-time (secrets.compare_digest), and the form israte-limited (max 5 wrong attempts per pending key, max 10 failures per client IP per15 minutes) since it is a publicly reachable password prompt. The placeholder valueshipped (commented out) in.env.exampleis rejected outright if left in place.- Access tokens are opaque random strings (not JWTs with inspectable claims) and arepersisted to
MCP_OAUTH_STATE_DIR(default.oauth-state/oauth_tokens.json, gitignored)so they survive server restarts. - The
/mcpendpoint itself rejects any request without a validAuthorization: Bearer <access-token>header before any tool or CalDAV logic runs. - The server disables Uvicorn's default HTTP access log (
uvicorn_config={"access_log": False}inserver.py). The password itself only ever travels in the POST body of the/consentform, which Uvicorn never logs - but the default access-log format recordsfull request paths including query strings, which for/consentcarry thesingle-use pending keys that gate authorization, so the access log stays off. Theconsent handlers themselves never log or echo submitted form data anywhere either.
Local security patches. The vendored PersonalAuthProvider carries five fixes forupstream issues found while building this integration, all confirmed by livereproduction against a running instance, not just by reading the code - see the "LOCALPATCHES" note at the top of personal_auth.pyfor the full log. The most consequential: upstream's password check had a dead-codefallback that accepted any password (or none) as long as the redirect domain matchedthe allow-list, and its whole delivery mechanism - expecting the OAuth client to embedthe password in the state/scope parameters - turned out to be unworkable againstreal Claude clients (see below), so it was replaced by the interactive consent page.
Why a consent page (confirmed 2026-07-10). Upstream's design expected Claude tosomehow send your password in the OAuth state parameter of the /authorize request.A live test against production claude.ai (real "Add custom connector" flow, /authorizerequest captured in the browser's DevTools network tab) confirmed that can never happen:state carries Claude's own randomly generated CSRF token, and the connector UI has nofield that could influence it. The gate therefore denied every legitimate authorization
- fail-closed, so no exposure, but the connector could not be set up at all. The consentpage replaces it: you now type the password into a form served by this server during theOAuth flow, which is what upstream's
statetrick was trying to approximate.
Registering the connector in Claude
Once the server is running and reachable at PUBLIC_BASE_URL (see thedeployment guide for exposing it via Tailscale Funnel):
- In Claude.ai (or Cowork/Desktop): Settings → Connectors → Add custom connector.
- URL:
<PUBLIC_BASE_URL>/mcp, e.g.https://your-host.your-tailnet.ts.net/mcp. - Leave any Client ID / Client Secret fields blank - Dynamic Client Registration handlesthis automatically; there's nothing to copy from the server.
- Save. Claude opens the OAuth authorization flow in a browser, which lands on thisserver's consent page - enter your
MCP_OAUTH_PASSWORDthere and the connector isauthenticated (synced automatically to Claude mobile).
Claude Desktop (no native remote-connector UI yet) instead uses themcp-remote bridge in claude_desktop_config.json
- see the deployment guide for the exact config.
Tools
All tool parameter names match the field names below exactly (e.g. priority,due_date) - this is the literal MCP tool schema Claude calls. Names areplain ASCII, since the Anthropic API only allows [a-zA-Z0-9_.-] in schemaproperty names.
list_task_lists()
Returns all available Nextcloud task lists (calendars supporting VTODO) as{"name": ..., "url": ...} dicts (display name and internal CalDAV URL/ID).Event-only calendars (e.g. Nextcloud's default "Personal" calendar) areexcluded — list_calendars is their counterpart.
list_tasks(list_names=None, only_open=True, due_before=None, due_after=None, limit=None, priority=None, tag=None, search_text=None, without_reminder=False, without_visibility=False, without_tags=False, uid_regex=None, fields=None, compact=False, list_name=None)
Returns tasks across one, several, or all task lists (list_names=None queries every list on the account, unbounded unless you narrow it; list_name is a deprecated alias). only_open=True (default) excludes completed and cancelled tasks - this is the underlying caldav library's own "pending" query (any STATUS of COMPLETED/CANCELLED, or a COMPLETED timestamp, counts as not-open), not a choice layered on top here. Each taskis a dict with: uid, title, start_date, due_date, priority,progress_percent, status ("open" / "in-progress" / "completed" / "cancelled" -breaking change: two more values than before, settable via update_task's statusparameter), location, url, tags,reminders, notes, parent_uid (parent task UID, or null if not a subtask),recurrence (raw RRULE text, or null if the task doesn't recur — settable viacreate_task/update_task), exception_dates (the occurrences the series skips, EXDATE; [] if none),recurrence_id and series_uid (both null unless the row is an expanded occurrence, see below),list (the task list's display name), and list_url (its unique URL). Nextcloud allows two lists to share a name: list cannot tell them apart, but list_url can. You still cannot address such a list by name (it is ambiguous), so it must be renamed in Nextcloud.
Recurring tasks: with due_before given, a recurring task is expanded into one row per occurrence due inside the window (capped at 100 per task) — otherwise "what is due next week" could never include a weekly task started in March. Without due_before the series is returned as the single stored row it is, recurrence intact. An expanded row is a read-only view of one date: recurrence_id names its occurrence, series_uid points at the stored task, and its own uid is rejected by update_task/complete_task/delete_task/get_task rather than silently acting on the whole series. See docs/tools.md.
Results are sorted by due_date ascending (tasks without a readable due date last), then by title. Filters: priority ("high"/"medium"/"low"), tag (exact match), search_text (substring over title and notes), due_before/due_after (due range bounds); tag and search_text ignore case and Unicode spelling, and "" means "no filter" for all five. Cleanup filters (shared with list_events): without_reminder/without_visibility/without_tags keep only items with no reminders / no visibility / no tags, and uid_regex keeps only items whose uid matches a regular expression (case-sensitive re.search) — together they shortlist hand-created phone entries (all-uppercase UUIDs, nothing else set) in one call, e.g. uid_regex="^[A-F0-9-]+$". limit (must be > 0 — null, not 0, is "no limit") caps the number of results, applied last after merging across lists. Payload slimming: fields=[...] whitelists result keys (unknown names error), compact=true drops null/[]/"" values plus list_url and truncates notes to 200 chars (marked; get_task has the full text). See docs/tools.md for details.
get_task(list_name, task_uid)
Fetches a single task by UID, without listing the whole task list. Returns what oneentry from list_tasks holds, minus its list key.
create_task(list_name, title, ...)
Creates a task. Required: list_name, title. Optional fields and their CalDAV mapping:
| Parameter | CalDAV property | Notes |
|---|---|---|
start_date |
DTSTART |
ISO 8601 date or datetime |
due_date |
DUE |
ISO 8601 date or datetime |
priority |
PRIORITY |
"high"→1, "medium"→5, "low"→9 |
progress_percent |
PERCENT-COMPLETE |
0-100 |
location |
LOCATION |
|
url |
URL |
|
tags |
CATEGORIES |
list of strings |
reminders |
VALARM |
see below |
notes |
DESCRIPTION |
|
visibility |
CLASS |
"public"→PUBLIC, "private"→PRIVATE, "confidential"→CONFIDENTIAL |
parent_task |
RELATED-TO;RELTYPE=PARENT |
UID of an existing task; makes this task its subtask |
recurrence |
RRULE |
raw RFC 5545 text, e.g. "FREQ=WEEKLY;BYDAY=MO"; requires the task to have a start_date or due_date (existing or set in the same call) to recur from |
exception_dates |
EXDATE |
ISO 8601 occurrences the series skips; each must match start_date's value kind and name a real occurrence |
status |
STATUS |
"open" (the default when omitted) / "in-progress" / "completed" / "cancelled"; see below |
Status on creation (status): a task is created open unless you say otherwise. Passingstatus creates it in that state instead, so importing an already-finished task is one callrather than a create_task followed by a complete_task. The values mean exactly what theymean in update_task: "completed" also sets PERCENT-COMPLETE=100 and a COMPLETEDtimestamp — of now, since the real completion time is not recoverable from anywhere —while "in-progress"/"cancelled" only set STATUS. An explicit progress_percent in thesame call wins over the percentage status would otherwise derive.
Reminders (reminders): each entry is either a relative RFC 5545 duration (e.g."-P1D", "-PT1H") or an absolute ISO 8601 datetime. Relative reminders trigger beforedue_date if set, otherwise before start_date; a relative reminder without eitherdate raises an error. Absolute reminders without a UTC offset are interpreted in the server'sdefault timezone (MCP_DEFAULT_TIMEZONE, default Europe/Berlin) and stored as UTC per RFC5545; reading them back formats the same instant in the default timezone, so the string maydiffer from what was written. Reading a reminder and writing it back is safe — the alarm isrecognized as already present and left alone — but the strings are normalized ("-P1W" readsback as "-P7D", "...Z" as the default timezone's offset, and every spelling of azero-length trigger — "P0D", "PT0S", "-PT0M" — as "-PT0M"). That last one mattersbecause a reminder firing exactly at the due date is written as P0D by this server'siCalendar library and as -PT0M by the Nextcloud Tasks UI, so the same reminder used to readback differently depending on which client last wrote the alarm. Alarms whose trigger this formatcannot express are not listed, and are never touched by a write; see docs/tools.md.
BREAKING CHANGE: Server timezone handling uses a single configurable default timezone (
MCP_DEFAULT_TIMEZONE, defaultEurope/Berlin). SettingMCP_DEFAULT_TIMEZONE=UTCrestores the previous UTC-hardcoded behavior.
Date/time semantics (applies to start_date, due_date, start, end, and absolutereminders entries): a value of exactly "YYYY-MM-DD" creates an all-day entry(VALUE=DATE); any other ISO 8601 value is a datetime, and a naive datetime (no UTCoffset) is interpreted in the server's default timezone (MCP_DEFAULT_TIMEZONE, default Europe/Berlin).Returned timestamps carry the default timezone's offset (e.g. +02:00).An event keeps the timezone it is anchored to, so a value read from get_event can be writtenstraight back through update_event without the event losing that anchor — which is what keepsa recurring event on its wall-clock time across daylight-saving changes.
update_task(list_name, task_uid, ...)
Same fields as create_task (status included), all optional except task_uid. Only fieldsyou pass are changed; everything else on the task is left untouched. Passing remindersreplaces the reminders list_tasks shows; clear_fields clears every alarm instead.
status ("open" / "in-progress" / "completed" / "cancelled") sets STATUS."completed" behaves exactly like complete_task (also sets PERCENT-COMPLETE=100 andthe COMPLETED timestamp); "open" is the reopen path for a task completed bymistake (removes COMPLETED, resets PERCENT-COMPLETE to 0); "in-progress"/"cancelled"only set STATUS. If the same call also passes progress_percent, that explicit valuewins over whatever percentage status would derive. An unknown value is a speaking errornaming the four accepted labels, and writes nothing. status is not accepted inclear_fields - use status="open" to reopen instead.
BREAKING CHANGE: task
statusnow has four values instead of two("open"/"in-progress"/"completed"/"cancelled") and is directly settable via thisparameter, not just an implicit read-only result ofcomplete_task.
To remove a property entirely (e.g. delete a due date), list its field name in theoptional clear_fields parameter instead of just omitting it — omitting a fieldleaves it unchanged. Accepted names: start_date, due_date, priority,progress_percent, location, url, tags, reminders, notes, visibility,parent_task, recurrence, exception_dates (title and status cannot becleared). Clearing recurrence also drops the task's exception_dates and any RDATE,which mean nothing without a recurrence rule. A field can't be both set and cleared in thesame call; recurrence's anchor requirement is checkedagainst the task's final state, so clearing the task's only start_date/due_datewhile a recurrence is set or remains is rejected too. See docs/tools.mdfor details and examples.
complete_task(list_name, task_uid)
Sets STATUS:COMPLETED, PERCENT-COMPLETE:100, and a COMPLETED timestamp. This doesnot roll a recurring task's series forward — the task's recurrence (RRULE) isleft untouched, so completing a recurring task ends it as far as this server isconcerned; advance due_date instead to keep a series going. This is this server'sown verified behaviour (see docs/tools.md's complete_task section) — how theNextcloud Tasks app itself displays a completed recurring task is not verified here.A task completed by mistake can be reopened with update_task(status="open").
delete_task(list_name, task_uid)
Permanently deletes the task.
move_task(list_name, task_uid, target_list, parent_task=None, clear_fields=None)
Moves a task to another task list. Uses CalDAV MOVE to preserve server URL identity, UID, ETags, and all properties; falls back to verified copy-then-delete if the server refuses MOVE (HTTP 403/405/409/501). The fallback never deletes the source before writing and verifying the target copy, and the verification compares every instance of a recurring series, not just the UID. A gateway status (502/503/504) is no refusal but no answer either — the move may already have happened — so it is retried instead, and a task found in the target rather than the source comes back as "already_there". If the target list rejects tasks, an error is raised before touching the source. Returns {"uid": ..., "from": ..., "to": ..., "method": "MOVE" | "copied" | "already_there", "orphaned_subtask_links": ...}.
Orphaned subtask links (orphaned_subtask_links): Nextcloud Tasks resolves thesubtask hierarchy (RELATED-TO;RELTYPE=PARENT) only within one task list. Moving one halfof a parent/child pair therefore breaks the nesting without producing an error anywhere: theproperty survives the move and simply points at a UID its list no longer holds. move_taskreports exactly those links — the moved task's own link to a parent left behind, and thelinks of any subtasks left behind pointing at it — as a list of{"uid", "title", "list", "missing_parent_uid"} entries, where uid/list namethe task carrying the dangling link. [] means the call left the hierarchy intact; nullmeans the check could not be run afterwards (the move itself still succeeded).
The scan runs after this call's own parent_task/clear_fields, so it reportswhat the call leaves behind: re-parenting in the same call is not then warned about, whilepointing a task at a parent in some third list is. The subtasks left behind are the half nomove argument can reach — separate objects in the source list — so repair those by movingthem along too, or with one update_task each.
A list change almost always changes the hierarchy too, since the old parent stays behind in the source list. parent_task sets a new parent, or clear_fields=["parent_task"] detaches the task, in the same call — the write lands on the copy in the target list after the move succeeded, and the result then also carries "hierarchy": "set" | "cleared". Only that one field is accepted here; everything else still goes through update_task.
Task batches: update_tasks, delete_tasks, move_tasks
| Tool | Purpose |
|---|---|
update_tasks(list_name, task_uids, ...) |
Batch update up to 200 tasks with the same field patch; patch validated up front |
delete_tasks(list_name, task_uids) |
Batch delete up to 200 tasks from a task list |
move_tasks(list_name, task_uids, target_list) |
Move up to 200 tasks to another list; both lists resolved once |
The task-side twins of update_events/delete_events, and the tools formigrating a list. One call resolves the list once and returns{"list_name", "succeeded", "failed", "results"} with a per-UIDstatus, so an unknown UID or a conflicting edit costs one entry rather than thewhole batch. Gateway failures (502/503/504) and dropped connections are retriedper item before anything is reported. A failure that says the call is brokenstill stops the batch, but names how far it got — which UIDs were done, whichare still to do — since re-running with the rest is the way out. move_tasksis safe to re-run in full: a task already in the target is reported as"already_there". See docs/tools.md.
Calendar & event tools (VEVENT)
The same CalDAV account also holds event calendars; these tools mirror the tasktools' conventions (same parameter naming, same ISO 8601 date semantics,clear_fields for clearing fields). See docs/tools.md forthe full reference.
| Tool | Purpose |
|---|---|
list_calendars() |
All event calendars with color (#RRGGBB) and supported components |
create_calendar(display_name, color=None) |
New VEVENT calendar via MKCALENDAR, optional color |
update_calendar(calendar_name, new_display_name=None, color=None) |
Rename and/or recolor (PROPPATCH); URL/id stays stable |
delete_calendar(calendar_name) |
Permanently delete a calendar and all its events |
list_events(calendar_names=None, start=None, end=None, search_text=None, tag=None, limit=None, expand_recurrences=False, without_reminder=False, without_visibility=False, without_tags=False, uid_regex=None, fields=None, compact=False) |
Time-range query across one/several/all calendars, full-text, tag and cleanup filters (without_*, uid_regex — see list_tasks); optionally expands recurring events into single occurrences. fields whitelists result keys, compact drops empty fields and truncates description. Without calendars and bounds, a default window of today ±90 days applies |
get_event(calendar_name, event_uid) |
Single event by UID |
create_event(calendar_name, title, start, ...) |
Full event creation: all-day or timed, location, description, tags, status ("confirmed"/"tentative"/"cancelled"), visibility, recurrence (recurrence = raw RRULE), exceptions (exception_dates → EXDATE), reminders (reminders → VALARM), url, task link (linked_task) |
create_birthday(name, date, year=None, calendar=None) |
One call for the fixed birthday convention: title "🎂 <name> (<birth year>)", all-day on the birth date (so the age is readable from each occurrence), FREQ=YEARLY, tag Birthday, visibility private, reminders on the day and the day before. date is "MM-DD" or "YYYY-MM-DD"; the birth year is optional. Without calendar it writes to Birthdays, or to a pre-existing legacy Birthdays calendar if that is the only one of the two |
update_event(calendar_name, event_uid, ...) |
Partial update, same fields; clear_fields clears properties |
update_events(calendar_name, event_uids, ...) |
Batch update up to 200 events with the same field patch; patch validated up front |
update_exdates(calendar_name, event_uids, add=None, remove=None, ...) |
Add/remove single exception dates on up to 200 recurring events without rewriting the whole list |
delete_event(calendar_name, event_uid) |
Permanently delete an event |
delete_events(calendar_name, event_uids) |
Batch delete up to 200 events from a calendar |
move_event(calendar_name, event_uid, target_calendar, linked_task=None, clear_fields=None) |
Move an event to another calendar via CalDAV MOVE, fallback to verified copy-then-delete, retry on a gateway status; optionally re-links (or unlinks) its task in the same call, mirroring move_task |
link_task_to_event(list_name, task_uid, calendar_name, event_uid, relation="time_block") |
Cross-component RELATED-TO link, written on the event: "time_block" (event reserves time for the task) or "prerequisite" (event must happen before the task) |
create_event_from_task(list_name, task_uid, calendar_name, start=None, duration_minutes=None, end=None, description=None, reminders=None, visibility=None) |
Timeboxing: builds an event from a task (title/location/tags, due date as start; description inherits notes unless overridden) and links both. end/duration_minutes are mutually exclusive; neither given = 60 minutes |
get_agenda(date, calendar_names=None, list_names=None) |
One day's events (recurring ones expanded) and due open tasks together |
list_tags(calendar_names=None, list_names=None) |
Aggregated tags (CATEGORIES) and usage counts across calendars and task lists (expensive: reads collections completely) |
For all-day events end is the inclusive last day (RFC 5545's exclusiveDTEND is translated on the way in and out). Mixed calendars (VEVENT+VTODO inone collection) are supported and show up in both list_task_lists andlist_calendars.
Notes tools
The Nextcloud Notes app, over its own JSON REST API - a separate code pathfrom the CalDAV tools above, with its own NEXTCLOUD_BASE_URL config (seeSetup). Useful as a per-project "living document" (current state,decisions + rationale, open questions, next step) alongside the task/calendartools' "what's open" view. See docs/tools.md for the fullreference.
| Tool | Purpose |
|---|---|
list_notes(category=None) |
All notes, title/category/favorite only (no content) |
get_note(note_id) |
Single note by id, including full content |
create_note(title, category=None, content=None, favorite=None) |
New note |
update_note(note_id, ...) |
Partial update; content replaces content wholesale |
replace_in_note(note_id, old_text, new_text) |
Patch one passage: old_text must match the content exactly once (0 or >1 matches is an error), then it is replaced by new_text |
update_note_section(note_id, section, content) |
Replace one Markdown section (ATX heading + body, up to the next same-or-higher-level heading) selected by a heading prefix like "## 7."; content includes the heading line |
append_to_note(note_id, text) |
Read-then-write append to existing content |
search_notes(search_text, category=None) |
Case-insensitive substring search over title/content (client-side - the API has no full-text search) |
delete_note(note_id) |
Permanently delete a note |
Testing
Unit tests mock the caldav library and the Notes REST API (viahttpx.MockTransport) entirely - no network access, no real Nextcloud instancerequired:
uv sync # installs the dev group (pytest, ruff) by default
uv run pytest -q
Integration tests exercise the full flow against your real Nextcloud instance (create,list, update, complete, delete a task in a disposable test list). They're skipped bydefault. To run them:
export RUN_INTEGRATION_TESTS=1
export NEXTCLOUD_CALDAV_URL=... NEXTCLOUD_USERNAME=... NEXTCLOUD_APP_PASSWORD=...
export INTEGRATION_TEST_LIST="Test" # an existing task list; tasks are created/deleted in it
uv run pytest -q
.github/workflows/integration.yml runs these on a weekly schedule (and on manualdispatch) against a disposable nextcloud Docker container, so this path is exercisedagainst a real server periodically even though it's excluded from per-PR CI.
See CONTRIBUTING.md for the full local dev setup (lint/type-check/coverage commands, pre-commit hooks, and the vendored-file rules for personal_auth.py).
License
MIT