Five services. The interesting question is not what each one does — that is in Reference: services — but why they are not one program.
The answer is the same every time: each boundary exists because something must not cross it.
The model key and workspace access are never held by the same process.
The orchestrator has the model key and the conversation. It has no Nextcloud client, and adding one is the single change that would quietly end the guarantee this system exists to make. Workspace access rides on the user's own token, forwarded per request, and is spent by a different service.
The obvious simplification — let the orchestrator call Nextcloud directly and drop the tool server — would work on the first day and would turn "the agent can only see what the user can see" from a consequence of the structure into a claim somebody has to audit.
The split buys something practical too. Because MCP spec 2026-07-28 dropped
protocol-level sessions, one stateless process serves every user: the credential
arrives with the request instead of living in the environment. The stdio
predecessor needed one process per user, which is why it was retired.
It would be simpler for the browser to call the orchestrator directly. It cannot, for a reason that is about trust rather than routing: the only thing that knows who the user is, is Nextcloud.
The PHP app is the one component with a Nextcloud session. It reads the uid from
IUserSession — never from anything the client sent — and signs it, together with
a timestamp and a hash of the request body, before passing it on. Nothing
downstream re-derives an identity, and a uid arriving in a request body is rejected
with a 400 rather than ignored, because a body uid is an attempt to choose one.
Mail takes the same door from the other direction. mcp-mail calls back into
the PHP app rather than speaking IMAP, so no mail credential exists anywhere in
this stack — Nextcloud Mail owns the account, and swapping Gmail for Exchange is a
change in its settings and no code at all.
Two properties of this path break silently when disturbed.
The stream is proxied, not buffered. PHP's CURLOPT_RETURNTRANSFER collects
the whole body, which turns a token-by-token stream into one late blob. So the
response implements Nextcloud's ICallbackResponse and flushes each chunk through.
If a reply ever appears all at once in the browser while the orchestrator logs show
it streaming, PHP is buffering again.
Identity is decided once and signed. See G1.
This catches everyone, and it catches them twice because it appears in two unrelated places.
| Thing | Browser-facing | Compose-internal |
|---|---|---|
| OnlyOffice | DocumentServerUrl → localhost:8083 | DocumentServerInternalUrl → http://onlyoffice |
| Nextcloud, seen by the agent | overwritehost → localhost:8080 | NEXTCLOUD_URL → http://nextcloud |
Swapping either pair fails without an error worth reading. For OnlyOffice the
editor stops loading. For the agent, every OIDC token is rejected with a bare 401 —
user_oidc compares the token's iss against the discovery document's issuer
exactly, and those two values come from different hostnames unless
overwritehost forces them to agree.
Both were added after the agent worked, and both are shaped by a measured constraint rather than a preference.
A document becomes text. The OnlyOffice document server converts only a file it fetches itself, by URL, and it holds no credential of the user's. So the tool server downloads the file as the user — that is where RBAC applies, as always — and lends it back on a one-shot URL: 256-bit token, single use, 60 seconds, in memory, compose-internal. A temporary public share link would have been the easy answer, and it is forbidden here because it mutates the user's account.
A tool's result renders itself. The chat cannot inline an app's HTML: an iframe
on a local scheme inherits the embedder's CSP, and Nextcloud's
script-src-elem 'strict-dynamic' 'nonce-…' then blocks the app's own script. That
was measured in a browser, not deduced. So the PHP app re-serves the HTML from a
same-origin URL under a policy built from the document itself.
Two seams, and nothing else moves.
mcp-nextcloud/src/modules/
exporting register(server, ctx), plus one line in server.js.
→ Add a toolorchestrator/src/tools/registry.ts, and only there.agent/ imports nothing from tools/mcp/, db/ or context/sections/. It
receives a ToolSet and a ContextEngine as arguments. That import rule is what
makes the sentence above structural rather than aspirational.