Verified facts

Things that are true of the installed versions and contradict what the documentation, the type signatures, or plain intuition suggest. Every one of these cost debugging time, and several were silent — the wrong behaviour produced no error at all.

Read this page before "simplifying" anything in the auth, tool or approval paths.

Two guardrails that disabled themselves silently

client.tools() throws away MCP annotations

@ai-sdk/mcp's client.tools() reads annotations only to resolve a title, then returns { ...toolWithExecute, _meta } — annotations gone (node_modules/@ai-sdk/mcp/dist/index.js, in toolsFromDefinitions).

G3 derives needsApproval from destructiveHint, so using tools() means every destructive tool runs without asking, with no error anywhere.

The fix costs nothing: listTools() keeps the raw definitions, toolsFromDefinitions(defs) builds the same ToolSet from them. Both are public, and it is the same round-trip.

const defs = await client.listTools();                  // keeps annotations
const ann  = new Map(defs.tools.map(t => [t.name, t.annotations ?? {}]));
let   set  = client.toolsFromDefinitions(defs);         // same data, no extra call

experimental_toolApprovalSecret is not aToolLoopAgent setting

It is a streamText/generateText option. ToolLoopAgentSettings does accept prepareStep, stopWhen, onToolExecutionStart/End and toolApproval — so putting the approval secret there looks right, is accepted, and is ignored.

For a signature check that is the worst possible failure mode: approvals appear to work while nothing is verified. It belongs on the stream() call.

Both of these would pass a test that only asserts "the tool ran". The test that catches them asserts that an action tool stops and writes zero audit rows.

API shapes that differ from the obvious guess

The tool-execution event has notoolName

Observed, not assumed:

start → { callId, toolCall: { toolCallId, toolName, input }, messages, toolContext } end → the same, plus { toolOutput, toolExecutionMs }

There is no top-level toolName. Reading one yields undefined, which then violated tool TEXT NOT NULL — and the SDK swallows a throw inside a hook, so the result was an entirely empty audit table with no error line. The hook now logs its own failures.

convertToModelMessages is async

Without await it returns a Promise, which reaches standardizePrompt and produces messages.some is not a function, surfaced to the user as "No output generated".

agent.stream() returns a Promise, and the method istoUIMessageStreamResponse

Not toUIMessageStream.

TypeScript parameter properties break--experimental-strip-types

constructor(public status: 400 | 401 | 403, message: string) {}   // ✗ needs codegen

Strip-only mode erases types; a parameter property is a runtime transform. Assign the field explicitly. This is the only TS feature the no-build-step setup forbids.

Nextcloud behaviours

oidc_provider_bearer_validation issystem config, compared with=== true

user_oidc/lib/User/Backend.php reads getSystemValue('user_oidc'). Setting it with occ config:app:set does nothing at all — silently. It needs:

occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean

This was the main cause of a 401 that looked like a token problem.

iss must match the discoveryissuer exactly

SelfEncodedValidator::isValidBearerToken compares them with !==. The oidc app derives issuer from the request Host, so a token minted via localhost:8080 never matches a discovery document fetched internally via Host: nextcloud. overwritehost + overwriteprotocol force both to agree.

The internal discovery fetch needsnextcloud intrusted_domains

Otherwise Nextcloud returns its "untrusted domain" HTML page instead of the discovery JSON, and validation fails with no useful message.

15-minute tokens, and no refresh token at all

token response keys: ['access_token', 'expires_in', 'id_token', 'token_type'] expires_in = 900 grant_types_supported = ['authorization_code', 'implicit']

RFC 6749 refresh is unavailable. Hence D5.

Once consent is on record and the session is live, the authorize endpoint answers with a redirect carrying a code, immediately. This is what makes silent renewal possible.

WebDAVSEARCH scope is relative to the DAV root

Passing /remote.php/dav/files/uid makes Sabre look for a child literally named remote.php:

Sabre\DAV\Exception\NotFound — File not found: remote.php in 'root'

The scope href must be /files/uid. Every other DAV call wants the absolute form, which is why paths.js exports both filesRoot() and filesScope().

SEARCH accepts onlymime/type ormime/%

InvalidArgumentException: Unsupported query value for mimetype: application/pdf%, only values in the format "mime/type" or "mime/%" are supported

So image/% works and application/pdf% is a 500, while application/pdf (exact) is fine. Appending % to everything was wrong; normalizeMimeQuery() handles both shapes.

Also verified: getcontentlength > n is not searchable (400), and there is no full-text index — files_search matches names, MIME and mtime only.

resourcetype is"" for a file

fast-xml-parser yields an empty string for <d:resourcetype/>, so "collection" in props.resourcetype throws a TypeError on every plain file — which the error table then translated into "Nextcloud is unreachable". The same trap applies to calendar and addressbook; hasResourceType() handles all three, with its own test.

Deck cannot move a card through its public API

PUT /api/v1.0/boards/{b}/stacks/{s}/cards/{c} with a new stackId answers 200 and silently keeps the card where it was. The v1.1 reorder paths answer 405.

The only endpoint that moves a card is the web UI's own internal route, confirmed by reading CardController::reorder:

PUT /index.php/apps/deck/cards/{cardId}/reorder { stackId, order }

file_conversions is empty

No server-side text extraction, so a .xlsx is genuinely unreadable. See Tools.

Another account's DAV root answers 207, not 403

PROPFIND /remote.php/dav/files/otheruser/ returns 207 — the DAV node exists — but Depth: 1 lists no children and every file returns 404. Identical with Basic auth, so it is not Bearer-specific.

The RBAC test therefore asserts on the absence of children, not on the status code. A test expecting 403 would fail while giving the impression of a hole.

Browsers, CSP and frames

A local-scheme iframe inherits the embedder's CSP

An srcdoc, data: or blob: iframe runs under the parent's policy. Nextcloud's is script-src-elem 'strict-dynamic' 'nonce-…', so an MCP App's inline script is blocked — the frame loads and the script never runs. Measured in the real page: the console said "Executing inline script violates the following Content Security Policy directive". A document fetched over the network carries its own policy instead, and frame-src 'self' already allows a same-origin one, so nothing about the host page has to be loosened.

An opaque origin makesevent.origin useless

With sandbox="allow-scripts" and no allow-same-origin, the frame's origin is the string "null". Identify it by event.source === frame.contentWindow, never by origin.

//evil.example passesstartsWith("/")

A protocol-relative URL. The path validator for host/open looked correct and let it through; the test caught it, not the review. Backslashes go too — some parsers normalise them to slashes.

Tailwind v4@theme emits--color-<name>

Not --<name>. Reading --surface-dark off the island returns empty, and an empty custom property makes every color-mix() using it invalid, so the whole declaration is dropped. The symptom was an app frame rendering unreadably rather than an error anywhere.

Documents and conversion

The OnlyOffice document server converts PDF, Word, PowerPoint and spreadsheets

Measured on this stack from onlyoffice-docs-formats.json: pdf → txt, md, html, docx, png; docx → txt, md, html, png, pdf; xlsx → csv, png, pdf; md/txt/html → docx, pdf. mcp-nextcloud reaches it at http://onlyoffice (healthcheck 200).

pdf → md rasterises every page

For a 976 kB brochure the markdown was 7.3 MB, each page an embedded base64 PNG. txt is the clean target: the same brochure is 7.6 kB of real text, and Nextcloud Manual.pdf is 140 kB from 15.1 MB — 0.9%.

The converter caches on the key we pass

Cold conversion of that 15 MB PDF: 5.0 s. Same key again: 10 ms, same fileUrl. So the key is uid:path:etag and an edit invalidates it for free.

PDF text extraction loses spaces

"Easierway toselectanewapp" and "NEXTCLOUDLATEST" are real lines out of the manual. Any search inside extracted text must ignore whitespace, or every multi-word query misses.

Nextcloud Mail

Mail's REST API refuses a bearer with 412, not 401

/apps/mail/api/accounts with a valid OIDC token answers 412 {"message":"CSRF check failed"}. Those routes are built for a browser session.

…which proves a bearer authenticates an app route

The 412 is the tell: a bogus bearer on the same kind of route answers 401. So the OIDC token authenticates app routes, and @NoCSRFRequired on our own routes is what makes them reachable server-to-server. That single observation is why mcp-mail needs no mail credential.

AddressList isCountable, notTraversable

OCA\Mail\AddressList implements Countable and JsonSerializable. Iterating it yields nothing, so from and to came back as empty arrays on real messages. Call jsonSerialize().

A cross-app service must not be type-hinted in a constructor

A constructor parameter typed OCA\Mail\... is resolved at instantiation, so on a server without the Mail app every route in the controller fails — including the one whose job is to answer "Mail is not available". Resolve lazily from the container.

The AI SDK, again

toolChoice: "none" is a request, not a constraint

Observed with gemini-3-flash: on the step where it was set, the model called a tool anyway, and the turn ended on finish_reason: tool-calls with nothing said. activeTools: [] is enforced on our side, but a stubborn model then emits a call into the void and the SDK throws NoSuchToolError. What actually works is appending a plain user message ("answer now, you cannot call any more tools") and keeping activeTools: [] as the guarantee.

Also: { type: "none" } type-checks and the Gateway rejects it — "expected string, received undefined". The string form is the one.

prepareStep is called for a step that may never run

So "the last step" is maxSteps - 2, not - 1: the final preparation can belong to a step stopWhen then cancels.

The assistant message arrives with an empty id

Without generateMessageId, onFinish hands over a message whose id is "". One row with that primary key then made ON CONFLICT(id) DO NOTHING swallow every later assistant message, silently — the conversation came back as the user's questions with no answers. Pass generateMessageId: generateId, and never trust an id you did not generate.

onFinish needs the stream to be drained

It runs when the stream completes, and the PHP curl proxy does not reliably drain a short turn. consumeSseStream: consumeStream makes the server pull its own copy, so persistence stops depending on the client.

An app-only tool exists in no executable ToolSet

toolsFromDefinitions(modelVisible) builds only what the model can see, so dispatching mail_draft answered "not available here" while every permission set said yes. Build a second map from appVisible and keep it apart from the model's.

Environment and tooling

${VAR:-} in compose passes an empty string

So ?? never fires. SUMMARY_MODEL="" broke the boot; CONTEXT_WINDOW_OVERRIDE="" would have produced Number("") === 0 — a zero-token window, hence compaction on every turn, silently. config.ts treats empty as unset and num() rejects ≤ 0.

docker compose up -d neither rebuilds nor recreates

--build for a source change, --force-recreate for an .env change. Three debugging dead-ends came from reading code that was not the code running.

PHP annotations are opcache'd

A changed @NoCSRFRequired or constructor signature needs docker compose restart nextcloud. The bind mount updates the file; opcache serves the old compiled version.

The MCP server binds loopback and allows onlylocalhost by default

localhostHostValidation() rejects Host: mcp-nextcloud:8091, so container-to-container calls fail. BIND_HOST and ALLOWED_HOSTS make it explicit, and the defaults stay closed.

/oauth/start needs@NoCSRFRequired

It is a browser navigation from a link, so there is no request token to send — a 412 otherwise. state provides the protection instead. disconnect is a POST and keeps CSRF.

AI Gateway model ids drift

google/gemini-2.0-flash no longer exists on the gateway (GatewayModelNotFoundError). Ask it rather than guessing:

const { models } = await gateway.getAvailableModels();   // 305 ids at last check

useChat does not exposeaddToolApprovalResponse

Its Pick covers sendMessage, regenerate, stop, addToolResult, addToolOutput, status, messages, clearError. Answering an approval needs a Chat instance passed in via useChat({ chat }).

@ai-sdk/react bundles its own copy ofai

@ai-sdk/react@2.0.225 depends on ai: 5.0.223 and installs it nested. So the island ran two SDKs at once: ai@7 for the imports, ai@5 for the code that actually processes the stream. ai@5 knows neither tool-approval-request nor addToolApprovalResponse — zero occurrences of each — so the server's approval chunk arrived and was silently discarded.

There is no @ai-sdk/react built for ai@7 (3.x targets ai@6), so the island is pinned to the pair that ships together: ai@6.0.238 + @ai-sdk/react@3.0.240. Check ls node_modules/@ai-sdk/react/node_modules — a nested ai/ there means the versions have drifted apart again.

Answering an approval sends nothing withoutsendAutomaticallyWhen

addToolApprovalResponse records the decision in local state. The prompt disappears, which looks like success, and no request is issued — verified by intercepting fetch: one call for the question, none for the approval.

new Chat({, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses })

That is the third mechanism in this area whose failure mode is silence, after the dropped annotations and the ignored approval secret. A test that asserts "the tool ran" passes in all three broken worlds; only one asserting the whole round trip — click, then a row in audit.db, then the event in CalDAV — catches them.

The approval part states, in order

Captured from a real round trip, because the first implementation was written against a guessed shape:

input-available → approval-requested → approval-responded → output-available { approval: { id, signature } } (approval retained)

approval-responded carries the decision but no tool result. Anything that prunes history must treat it as unanswered, or an approval that never executed poisons the next turn with MissingToolResultsError. lib/dangling-tools.ts uses an allow-list of answered states for exactly that reason.

The agent answered from the prompt while every tool failed

Worth its own section, because it is the most instructive failure here.

Asked "who am I on this Nextcloud?", the agent replied "You are signed in as mcpspike (MCP Spike)" — correctly. But nextcloud_whoami had failed with "no credential": the OIDC token had expired. The answer came from the <environment> section of the system prompt.

Two problems, both fixed:

  1. No credential still loaded 11 tools, because tools/list needs no Nextcloud call. Now an absent credential exposes zero workspace tools.
  2. <environment> said "capabilities: memory" without saying the workspace was unreachable — so the model believed it could read files. workspaceConnected is now distinct from "has tools".

The lesson generalises: a plausible answer is not evidence that the machinery worked. The audit row said ok=0 while the reply looked perfect.

On this page