Every tool the agent can call, what it talks to, and the annotations that decide whether the user is asked first.
The reasoning behind this surface is in Designing the tool surface.
The approval policy is derived from the annotations and fails closed:
| Annotations | Category | Needs approval |
|---|---|---|
readOnlyHint: true | data | no |
destructiveHint: true | action | yes |
idempotentHint: true, not destructive | action | no |
| absent, or partial | action | yes |
mcp-nextcloud| Tool | Talks to | Annotations | Notes |
|---|---|---|---|
nextcloud_whoami | OCS | readOnly | Cheap, and grounds the model in who it is acting as |
files_search | WebDAV SEARCH | readOnly, idempotent | Names, MIME and mtime only — the name, never the contents |
files_grep | FTS5 index, then WebDAV SEARCH | readOnly, idempotent | The text inside documents → how |
files_read | PROPFIND then ranged GET | readOnly, idempotent | PROPFIND first, so a 15 MB PDF is refused without downloading it |
files_write | PUT with If-Match | destructive | Refuses binary extensions; a stale etag returns a re-read instruction |
files_share_internal | OCS shares v1 | non-readOnly | Internal only. Public links do not exist here |
| Tool | Talks to | Annotations | Notes |
|---|---|---|---|
calendar_list_events | PROPFIND + REPORT | readOnly, idempotent | Discovery folded in; one unreadable calendar does not fail the call. Deck's per-board calendars are excluded by default — one board is one calendar, so a handful of projects turned every agenda question into a dozen REPORTs. includeAppCalendars brings them back, and they are still named in the result so a missing deadline is answerable |
calendar_create_event | PUT .ics | non-readOnly | Times are UTC. attendees makes it a meeting, and Nextcloud sends the invitations itself |
calendar_update_event | REPORT by UID, then PUT | non-readOnly | Patches lines, never rebuilds: a repeat rule or an alarm this stack cannot express survives a "move it to 4pm". Bumps SEQUENCE, or an invited client ignores the update |
calendar_delete_event | REPORT by UID, then DELETE | destructive | The whole series, and a cancellation to every attendee |
An ORGANIZER is read from the account's CalDAV address set, never assembled. An
account with no email address has none, and the tool refuses with that explanation
rather than creating an event nobody is invited to.
Limits, surfaced to the model as warnings: no recurrence expansion — a weekly
meeting reports its first occurrence only — no all-day events, no alarms, and every
timestamp is UTC.
Nextcloud Talk. This replaced Jitsi, and the reason is architectural rather than practical: Jitsi has no room API — a room exists the moment somebody opens its URL — so there was no participant list and the only thing between a stranger and the meeting was the secrecy of the link. That made the visio the one place where access was decided here, by minting random bits, instead of by Nextcloud.
A Talk room is a conversation with members, so a room is created with the right
people in it, resolved through the same contacts_search every other tool uses.
| Tool | Talks to | Annotations | Notes |
|---|---|---|---|
meet_create_room | Talk OCS v4 | non-readOnly | Creates a conversation and adds members by Nextcloud account id. Not read-only — it writes something the user sees and notifies the people added, so it is approved like any other effect |
meet_schedule | Talk OCS v4 + PUT .ics | non-readOnly | The room first, then the event: a calendar entry pointing at a room that failed to be created is an invitation to nowhere |
The model may choose the name. Under Jitsi it could not — the name was the password. Here "Weekly" is a title, and the participant list is the boundary.
Two lists, because they are two things. participants are Nextcloud account
ids added to the room; attendees are email addresses Nextcloud sends a calendar
invitation to. The same person can be in both, and an external guest can be in
only the second.
Guest access is never the default. Passing any attendees opens the room to
anyone with the link, because an invitation whose link leads to a door the guest
cannot open is worse than no invitation — and the result carries a warning
saying so, which the model must pass on. Without email attendees the room stays
members-only.
There is deliberately no meet_list and no meet_cancel. calendar_list_events
already returns the room URL in location, and calendar_update_event /
calendar_delete_event already move and cancel the meeting — with the invitation
mails. A second tool family over the same object would be two sources of truth.
Without NEXTCLOUD_PUBLIC_URL both tools refuse: the compose-internal hostname is
correct for talking to Nextcloud and useless in an invitation, so a link built
from it would resolve for one container and nobody else.
| Tool | Talks to | Annotations | Notes |
|---|---|---|---|
contacts_search | CardDAV REPORT + OCS sharees | readOnly, idempotent | Two sources merged: vCards give humans, sharees gives the account id sharing accepts |
| Tool | Talks to | Annotations | Notes |
|---|---|---|---|
deck_list_cards | Deck API v1.0 | readOnly, idempotent | Naming one board also returns its labels and members; the lean listing does not. Archived cards come from their own route |
deck_create_board | Deck API v1.0 | non-readOnly | A whole project in one call: board, columns, labels, shares. A share to an unknown participant is refused before the write — Deck answers 200 and stores it otherwise |
deck_create_card | Deck API v1.0 | non-readOnly | Labels and assignees are applied through their own routes: passing them inline on create returns 400 cardId must be provided |
deck_update_card | GET, then PUT + assign routes | non-readOnly | Read, merge, write. Deck's update has no defaults for title/type/owner, and omitting done unticks the card. labels/assignees replace; only the diff is sent |
deck_move_card | Deck internal route | non-readOnly, idempotent | The public API answers 200 and does nothing → why |
There is no deck_delete_card. Archiving is reversible; losing a task is not.
mcp-mailFive tools, split down the middle by visibility:
| Tool | The model sees it | Effect |
|---|---|---|
mail_contacts | yes | none — resolves a name to a real address |
mail_search | yes, with a list app | none |
mail_read | yes | none |
mail_compose | yes, with a preview app | none — returns the message it would send |
mail_send | no | sends |
mail_draft | no | saves a draft |
The last two are declared app-visible only, so they never enter the agent's tool
set. mail_compose refuses any recipient that is neither known to the mail space
nor typed by the user — an address assembled from a display name is rejected, after
one was invented convincingly and wrongly.
mail_search reads Mail's local copy, which syncs in the background, so the tool
states that "no result" is not proof a message does not exist.
files_read names no format, on purpose:
truncated plus the
offset to continue from;find does a substring search returning each passage with its offset, insensitive
to case, accents and whitespace — text extracted from a PDF is full of missing
spaces, so find "new app" has to match newapp or a two-word search never hits.
format: "outline" returns structure without content: markdown headings with line
numbers, a CSV header plus five rows, a JSON key skeleton. It is what lets the model
decide where to read before spending tokens.
One table. Never a stack, never an Authorization header, never a URL beyond the
path the user can already see.
| Source | What the model is told (abridged) |
|---|---|
| no credential | "…nothing was sent to Nextcloud. A configuration fault in the caller — report it, do not retry." |
| 401 | "…expired or wrong. Ask the user to re-authenticate. Do not retry." |
| 403 | "…a permission decision, not a transient error. If it needs an admin account, tell the user — do not retry." |
| 404 | "Confirm the path with files_search before reading or writing. Do not guess variants." |
| 409 on PUT | "The parent folder does not exist, and Nextcloud does not create intermediates." |
| 412 | "…changed since you read it. Call files_read again, merge, then write with the new etag." |
| 423 | "…locked, probably open in an editor." |
| 507 | "…out of storage. Nothing was written. Do not retry." |
| 404 on an app route | "That app is not installed here. The capability does not exist." |
| 5xx | "Transient — one retry is reasonable, then stop and report." |
The pattern: name the cause, name the next action, and say explicitly whether to retry. Tests assert the substring the model needs — "do not retry", "files_search" — not whole sentences, so wording stays editable.
Every result carries content[0].text as compact JSON — never indented — and
structuredContent. Fields that matter as much as the payload:
| Field | Meaning |
|---|---|
truncated, nextOffset | there is more, and where to resume |
hint | what to do next, especially when the result is empty |
warnings | what this answer cannot tell you |