Nextcloud Task MCP Server
Manages tasks (VTODOs) in a self-hosted Nextcloud instance over CalDAV, allowing creation, listing, updating, completing, and deleting tasks.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Nextcloud Task MCP ServerCreate a task to buy groceries due tomorrow"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
nextcloud-task-mcp
An MCP server that manages tasks (VTODOs) and calendar events (VEVENTs) in a self-hosted Nextcloud instance over CalDAV. Connect it to Claude as a custom connector to create, list, update and complete Nextcloud tasks, manage calendars and events (including recurring ones), link tasks to events (timeboxing), and get combined day agendas using natural language.
Built with FastMCP on the Streamable HTTP transport, and the
caldav 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
How it works
One CalDAV connection is opened at startup and reused for every request (no reconnect-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. See Authentication below.The server binds to a local HTTP port only (e.g.
127.0.0.1:8000). It does not handle TLS itself - in the intended deployment,tailscale funnelterminates TLS in front of it and exposes it to the public internet (required so Claude's backend can reach it and complete the OAuth flow).CalDAV/network failures (auth errors, timeouts, missing task lists/UIDs, ...) are caught and turned into short, clean error messages - no raw stack traces are ever returned to the MCP client.
Related MCP server: Google Tasks MCP Server
Setup
Requires Python 3.10+ and uv.
uv sync
cp .env.example .env
# edit .env with your Nextcloud base URL, an app password, and PUBLIC_BASE_URLGenerate a Nextcloud app password under Settings → Security → Devices & sessions
(never use your account password). NEXTCLOUD_BASE_URL is required — your Nextcloud
instance's base URL with no path, typically:
https://<your-nextcloud-domain>Must be https:// — the server refuses to start with a http:// URL unless it
points at a local address (localhost/127.0.0.1/::1) or NEXTCLOUD_ALLOW_INSECURE_HTTP=1
is 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 only
needed when your DAV endpoint is not <base>/remote.php/dav/ (e.g. if CalDAV sits behind a
different 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 - see
Authentication below for why this has to match precisely.
Run the server:
set -a; source .env; set +a
uv run nextcloud-task-mcpIt listens on MCP_HOST:MCP_PORT (default 127.0.0.1:8000) at the /mcp path, using the
Streamable HTTP transport.
Authentication
The server authenticates MCP clients with OAuth 2.1 (Dynamic Client Registration +
PKCE), via PersonalAuthProvider -
vendored into src/nextcloud_task_mcp/personal_auth.py
since it ships as a single file to copy in, not an installable package. There is no
static bearer token to configure.
This exists because Claude's connector UI (web, mobile, Desktop, Cowork) only exposes OAuth fields for custom connectors - it has no field for a raw static token. OAuth is also what makes the server usable from Claude mobile at all, since mobile has no config file to hand-edit.
How it's secured, since anyone on the internet can reach the OAuth discovery and registration 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 script never 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 the authorization code comes back directly in that same HTTP response. Configurable viaMCP_OAUTH_ALLOWED_REDIRECT_DOMAINS; when unset andPUBLIC_BASE_URLisn't local, the server 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 server refuses 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 is enforced by an interactive consent page:/authorizeparks the request under a cryptographically random, single-use pending key (10-minute TTL) and redirects the browser to/consent, which asks for the password before any authorization code is minted. The comparison is constant-time (secrets.compare_digest), and the form is rate-limited (max 5 wrong attempts per pending key, max 10 failures per client IP per 15 minutes) since it is a publicly reachable password prompt. The placeholder value shipped (commented out) in.env.exampleis rejected outright if left in place.Access tokens are opaque random strings (not JWTs with inspectable claims) and are persisted 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 records full request paths including query strings, which for/consentcarry the single-use pending keys that gate authorization, so the access log stays off. The consent handlers themselves never log or echo submitted form data anywhere either.
Local security patches. The vendored PersonalAuthProvider carries five fixes for
upstream issues found while building this integration, all confirmed by live
reproduction against a running instance, not just by reading the code - see the "LOCAL
PATCHES" note at the top of personal_auth.py
for the full log. The most consequential: upstream's password check had a dead-code
fallback that accepted any password (or none) as long as the redirect domain matched
the allow-list, and its whole delivery mechanism - expecting the OAuth client to embed
the password in the state/scope parameters - turned out to be unworkable against
real 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 to
somehow send your password in the OAuth state parameter of the /authorize request.
A live test against production claude.ai (real "Add custom connector" flow, /authorize
request 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 no
field 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 consent page replaces it: you now type the password into a form served by this server during the OAuth 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 the
deployment 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 handles this automatically; there's nothing to copy from the server.
Save. Claude opens the OAuth authorization flow in a browser, which lands on this server's consent page - enter your
MCP_OAUTH_PASSWORDthere and the connector is authenticated (synced automatically to Claude mobile).
Claude Desktop (no native remote-connector UI yet) instead uses the
mcp-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 are
plain ASCII, since the Anthropic API only allows [a-zA-Z0-9_.-] in schema
property 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) are
excluded — 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 task
is 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 status
parameter), 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 via
create_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 one
entry 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 |
|
| ISO 8601 date or datetime |
|
| ISO 8601 date or datetime |
|
|
|
|
| 0-100 |
|
| |
|
| |
|
| list of strings |
|
| see below |
|
| |
|
|
|
|
| UID of an existing task; makes this task its subtask |
|
| raw RFC 5545 text, e.g. |
|
| ISO 8601 occurrences the series skips; each must match |
|
|
|
Status on creation (status): a task is created open unless you say otherwise. Passing
status creates it in that state instead, so importing an already-finished task is one call
rather than a create_task followed by a complete_task. The values mean exactly what they
mean in update_task: "completed" also sets PERCENT-COMPLETE=100 and a COMPLETED
timestamp — of now, since the real completion time is not recoverable from anywhere —
while "in-progress"/"cancelled" only set STATUS. An explicit progress_percent in the
same 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 before
due_date if set, otherwise before start_date; a relative reminder without either
date raises an error. Absolute reminders without a UTC offset are interpreted in the server's
default timezone (MCP_DEFAULT_TIMEZONE, default Europe/Berlin) and stored as UTC per RFC
5545; reading them back formats the same instant in the default timezone, so the string may
differ from what was written. Reading a reminder and writing it back is safe — the alarm is
recognized as already present and left alone — but the strings are normalized ("-P1W" reads
back as "-P7D", "...Z" as the default timezone's offset, and every spelling of a
zero-length trigger — "P0D", "PT0S", "-PT0M" — as "-PT0M"). That last one matters
because a reminder firing exactly at the due date is written as P0D by this server's
iCalendar library and as -PT0M by the Nextcloud Tasks UI, so the same reminder used to read
back differently depending on which client last wrote the alarm. Alarms whose trigger this format
cannot 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 absolute
reminders 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 UTC
offset) 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 written
straight back through update_event without the event losing that anchor — which is what keeps
a 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 fields
you pass are changed; everything else on the task is left untouched. Passing reminders
replaces 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 and
the COMPLETED timestamp); "open" is the reopen path for a task completed by
mistake (removes COMPLETED, resets PERCENT-COMPLETE to 0); "in-progress"/"cancelled"
only set STATUS. If the same call also passes progress_percent, that explicit value
wins over whatever percentage status would derive. An unknown value is a speaking error
naming the four accepted labels, and writes nothing. status is not accepted in
clear_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 this parameter, 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 the
optional clear_fields parameter instead of just omitting it — omitting a field
leaves 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 be
cleared). 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 the
same call; recurrence's anchor requirement is checked
against the task's final state, so clearing the task's only start_date/due_date
while a recurrence is set or remains is rejected too. See docs/tools.md
for details and examples.
complete_task(list_name, task_uid)
Sets STATUS:COMPLETED, PERCENT-COMPLETE:100, and a COMPLETED timestamp. This does
not roll a recurring task's series forward — the task's recurrence (RRULE) is
left untouched, so completing a recurring task ends it as far as this server is
concerned; advance due_date instead to keep a series going. This is this server's
own verified behaviour (see docs/tools.md's complete_task section) — how the
Nextcloud 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 the
subtask hierarchy (RELATED-TO;RELTYPE=PARENT) only within one task list. Moving one half
of a parent/child pair therefore breaks the nesting without producing an error anywhere: the
property survives the move and simply points at a UID its list no longer holds. move_task
reports exactly those links — the moved task's own link to a parent left behind, and the
links of any subtasks left behind pointing at it — as a list of
{"uid", "title", "list", "missing_parent_uid"} entries, where uid/list name
the task carrying the dangling link. [] means the call left the hierarchy intact; null
means 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 reports
what the call leaves behind: re-parenting in the same call is not then warned about, while
pointing a task at a parent in some third list is. The subtasks left behind are the half no
move argument can reach — separate objects in the source list — so repair those by moving
them 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 |
| Batch update up to 200 tasks with the same field patch; patch validated up front |
| Batch delete up to 200 tasks from a task 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 for
migrating a list. One call resolves the list once and returns
{"list_name", "succeeded", "failed", "results"} with a per-UID
status, so an unknown UID or a conflicting edit costs one entry rather than the
whole batch. Gateway failures (502/503/504) and dropped connections are retried
per item before anything is reported. A failure that says the call is broken
still stops the batch, but names how far it got — which UIDs were done, which
are still to do — since re-running with the rest is the way out. move_tasks
is 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 task
tools' conventions (same parameter naming, same ISO 8601 date semantics,
clear_fields for clearing fields). See docs/tools.md for
the full reference.
Tool | Purpose |
| All event calendars with |
| New VEVENT calendar via |
| Rename and/or recolor ( |
| Permanently delete a calendar and all its events |
| Time-range query across one/several/all calendars, full-text, tag and cleanup filters ( |
| Single event by UID |
| Full event creation: all-day or timed, |
| One call for the fixed birthday convention: title |
| Partial update, same fields; |
| Batch update up to 200 events with the same field patch; patch validated up front |
| Add/remove single exception dates on up to 200 recurring events without rewriting the whole list |
| Permanently delete an event |
| Batch delete up to 200 events from a calendar |
| 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 |
| Cross-component |
| Timeboxing: builds an event from a task (title/location/tags, due date as start; |
| One day's events (recurring ones expanded) and due open tasks together |
| Aggregated tags ( |
For all-day events end is the inclusive last day (RFC 5545's exclusive
DTEND is translated on the way in and out). Mixed calendars (VEVENT+VTODO in
one collection) are supported and show up in both list_task_lists and
list_calendars.
Notes tools
The Nextcloud Notes app, over its own JSON REST API - a separate code path
from the CalDAV tools above, with its own NEXTCLOUD_BASE_URL config (see
Setup). Useful as a per-project "living document" (current state,
decisions + rationale, open questions, next step) alongside the task/calendar
tools' "what's open" view. See docs/tools.md for the full
reference.
Tool | Purpose |
| All notes, title/category/favorite only (no content) |
| Single note by id, including full content |
| New note |
| Partial update; |
| Patch one passage: |
| Replace one Markdown section (ATX heading + body, up to the next same-or-higher-level heading) selected by a heading prefix like |
| Read-then-write append to existing content |
| Case-insensitive substring search over title/content (client-side - the API has no full-text search) |
| Permanently delete a note |
Testing
Unit tests mock the caldav library and the Notes REST API (via
httpx.MockTransport) entirely - no network access, no real Nextcloud instance
required:
uv sync # installs the dev group (pytest, ruff) by default
uv run pytest -qIntegration 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 by default. 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 manual
dispatch) against a disposable nextcloud Docker container, so this path is exercised
against 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
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.
No tool schema history has been recorded yet.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Manage tasks, Focus Zone, notes, projects, and task history from compatible AI assistants.
Create and manage MeisterTask projects, tasks, and notes from your AI assistant.
Manage Superlist tasks and lists in plain language from any MCP-compatible AI agent.
Local-first task manager: create, edit, and complete tasks, projects, and checklists via MCP.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables task management through natural language with full CRUD operations including add, list, update, complete, and delete tasks with JSON persistence.-
- AlicenseNot gradedqualityAmaintenanceEnables AI assistants to manage Google Tasks through natural language interactions. Supports creating, updating, deleting, searching, and listing tasks with secure OAuth2 authentication.12610MIT
- AlicenseBqualityDmaintenanceEnables interaction with Vikunja task management instances through natural language. Supports comprehensive project and task operations including CRUD, assignments, labels, comments, relations, and attachments.33581MIT
- FlicenseCqualityDmaintenanceEnables managing todo lists and tasks through natural language, supporting creation, status changes, and deletion.7-
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Vando-sketch/NextCloudMCP'
If you have feedback or need assistance with the MCP directory API, please join our Discord server