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.
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.
experimental_toolApprovalSecret is not aToolLoopAgent settingIt 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.
toolNameObserved, not assumed:
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 asyncWithout 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 istoUIMessageStreamResponseNot toUIMessageStream.
--experimental-strip-typesStrip-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.
oidc_provider_bearer_validation issystem config, compared with=== trueuser_oidc/lib/User/Backend.php reads getSystemValue('user_oidc'). Setting it with
occ config:app:set does nothing at all — silently. It needs:
This was the main cause of a 401 that looked like a token problem.
iss must match the discoveryissuer exactlySelfEncodedValidator::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.
nextcloud intrusted_domainsOtherwise Nextcloud returns its "untrusted domain" HTML page instead of the discovery JSON, and validation fails with no useful message.
RFC 6749 refresh is unavailable. Hence D5.
/authorize needs no consent screenOnce 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.
SEARCH scope is relative to the DAV rootPassing /remote.php/dav/files/uid makes Sabre look for a child literally named
remote.php:
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/%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 filefast-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.
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:
file_conversions is emptyNo server-side text extraction, so a .xlsx is genuinely unreadable. See
Tools.
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.
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.
event.origin uselessWith 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.
@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.
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 pageFor 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%.
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.
"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.
/apps/mail/api/accounts with a valid OIDC token answers 412 {"message":"CSRF check failed"}. Those routes are built for a browser session.
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, notTraversableOCA\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 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.
toolChoice: "none" is a request, not a constraintObserved 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 runSo "the last step" is maxSteps - 2, not - 1: the final preparation can belong to a
step stopWhen then cancels.
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 drainedIt 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.
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.
${VAR:-} in compose passes an empty stringSo ?? 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.
A changed @NoCSRFRequired or constructor signature needs
docker compose restart nextcloud. The bind mount updates the file; opcache serves
the old compiled version.
localhost by defaultlocalhostHostValidation() 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@NoCSRFRequiredIt 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.
google/gemini-2.0-flash no longer exists on the gateway
(GatewayModelNotFoundError). Ask it rather than guessing:
useChat does not exposeaddToolApprovalResponseIts 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.
sendAutomaticallyWhenaddToolApprovalResponse 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.
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.
Captured from a real round trip, because the first implementation was written against a guessed shape:
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.
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:
tools/list needs no Nextcloud
call. Now an absent credential exposes zero workspace tools.<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.