Tools

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.

Annotations and approval

The approval policy is derived from the annotations and fails closed:

AnnotationsCategoryNeeds approval
readOnlyHint: truedatano
destructiveHint: trueactionyes
idempotentHint: true, not destructiveactionno
absent, or partialactionyes

mcp-nextcloud

Files

ToolTalks toAnnotationsNotes
nextcloud_whoamiOCSreadOnlyCheap, and grounds the model in who it is acting as
files_searchWebDAV SEARCHreadOnly, idempotentNames, MIME and mtime only — the name, never the contents
files_grepFTS5 index, then WebDAV SEARCHreadOnly, idempotentThe text inside documents → how
files_readPROPFIND then ranged GETreadOnly, idempotentPROPFIND first, so a 15 MB PDF is refused without downloading it
files_writePUT with If-MatchdestructiveRefuses binary extensions; a stale etag returns a re-read instruction
files_share_internalOCS shares v1non-readOnlyInternal only. Public links do not exist here

Calendar

ToolTalks toAnnotationsNotes
calendar_list_eventsPROPFIND + REPORTreadOnly, idempotentDiscovery 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_eventPUT .icsnon-readOnlyTimes are UTC. attendees makes it a meeting, and Nextcloud sends the invitations itself
calendar_update_eventREPORT by UID, then PUTnon-readOnlyPatches 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_eventREPORT by UID, then DELETEdestructiveThe 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.

Visioconférence

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.

ToolTalks toAnnotationsNotes
meet_create_roomTalk OCS v4non-readOnlyCreates 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_scheduleTalk OCS v4 + PUT .icsnon-readOnlyThe 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.

Contacts

ToolTalks toAnnotationsNotes
contacts_searchCardDAV REPORT + OCS shareesreadOnly, idempotentTwo sources merged: vCards give humans, sharees gives the account id sharing accepts

Deck

ToolTalks toAnnotationsNotes
deck_list_cardsDeck API v1.0readOnly, idempotentNaming one board also returns its labels and members; the lean listing does not. Archived cards come from their own route
deck_create_boardDeck API v1.0non-readOnlyA 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_cardDeck API v1.0non-readOnlyLabels and assignees are applied through their own routes: passing them inline on create returns 400 cardId must be provided
deck_update_cardGET, then PUT + assign routesnon-readOnlyRead, 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_cardDeck internal routenon-readOnly, idempotentThe public API answers 200 and does nothing → why

There is no deck_delete_card. Archiving is reversible; losing a task is not.

mcp-mail

Five tools, split down the middle by visibility:

ToolThe model sees itEffect
mail_contactsyesnone — resolves a name to a real address
mail_searchyes, with a list appnone
mail_readyesnone
mail_composeyes, with a preview appnone — returns the message it would send
mail_sendnosends
mail_draftnosaves 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.

Reading a document

files_read names no format, on purpose:

  • text is sliced with an offset and a limit, and reports truncated plus the offset to continue from;
  • an image is handed over as bytes for the model to look at itself;
  • a document — PDF, Word, PowerPoint, spreadsheet — is converted to text by the document server and then sliced exactly like text. Measured: a 15.1 MB PDF becomes 140 kB of text, 0.9% of the source, in 5.0 s cold and 10 ms warm, because the converter caches on the file's identity and an edit invalidates it.

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.

Errors are instructions

One table. Never a stack, never an Authorization header, never a URL beyond the path the user can already see.

SourceWhat 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.

Result conventions

Every result carries content[0].text as compact JSON — never indented — and structuredContent. Fields that matter as much as the payload:

FieldMeaning
truncated, nextOffsetthere is more, and where to resume
hintwhat to do next, especially when the result is empty
warningswhat this answer cannot tell you