CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# AIquila — Claude Code Configuration
## Project Overview
Monorepo with two independently versioned components:
- **`mcp-server/`** — TypeScript/Node.js MCP server (npm package: `aiquila-mcp`, admin account: `aiquila` on npmjs.com; versioned in `package.json`)
- **`nextcloud-app/`** — PHP Nextcloud app (Composer, versioned in `appinfo/info.xml`). Developed and tested on PHP 8.5; the declared floor is 8.4
Docs live in `docs/`. Docker environments in `docker/` (dev: `installation/`, prod: `standalone/`).
## Key Commands
All MCP server commands run from `mcp-server/`:
```bash
npm run build # compile TypeScript → dist/
npm run dev # hot reload via tsx (stdio transport by default)
npm test # vitest unit tests
npm run lint # eslint (warnings ok, errors fail CI; `any` is an error outside src/__tests__)
npm run typecheck # tsc --noEmit (build skips type checking; CI runs this)
npm run format # prettier --write src/ (auto-fix formatting)
npx prettier --check src/ # check only — run before committing
```
**CI requires `npm run lint`, `npm run typecheck` and `npx prettier --check src/` to pass.**
Always run `npm run format` after editing TypeScript files.
HTTP transport locally:
```bash
MCP_TRANSPORT=http npm run dev # serves at http://localhost:3339/mcp
```
Docker (standalone — external Nextcloud):
```bash
cd docker/standalone && make up
make logs-follow
make test
```
## Nextcloud app — static analysis
Psalm runs over `lib/` at `errorLevel=3` and **CI fails on any error**:
```bash
cd nextcloud-app && composer psalm
```
`OCP\*` resolves through `vendor/nextcloud/ocp` (parsed via `<extraFiles>`); the private
`OC\` classes the app touches are stubbed in `tests/stubs/`. There is no baseline — fix
findings rather than adding one.
## Nextcloud app — OpenAPI spec
The specs are **OpenAPI 3.1.0** (JSON Schema 2020-12). `composer openapi` runs
`generate-spec --openapi-version 3.1.0`, then `tools/openapi-3.1.php`. The extractor only
relabels the version, so that script rewrites its 3.0 `nullable` into `type: [T, "null"]`.
Never call `vendor/bin/generate-spec` alone: it writes 3.0.3, and CI then reports a mismatch.
After editing a controller annotated with `#[OpenAPI]`, regenerate and commit the spec
(CI fails on a mismatch):
```bash
cd nextcloud-app && composer openapi # updates openapi.json, openapi-administration.json, openapi-full.json
```
## Nextcloud app — local dev testing
To test working-tree changes in the dev stack (`docker/installation/`) and check for
runtime errors:
```bash
cd docker/installation
make build-tarball # repackage nextcloud-app/ from the working tree
make up # IMPORTANT: rebuilds the image with the new tarball
# The post-install hook only runs on first install, so re-extract manually after a recreate:
docker compose exec -T nextcloud bash /docker-entrypoint-hooks.d/post-installation/aiquila-install.sh
docker compose exec -T nextcloud su -p www-data -s /bin/sh -c "php /var/www/html/occ upgrade" # if migrations changed
```
Gotchas:
- `make build-tarball` alone does **not** update the container — the install hook extracts
`/tmp/aiquila.tar.gz` which is baked into the image, so `make up` (image rebuild) is required.
- `make build-tarball` builds in a scratch dir inside the container from a read-only mount of
`nextcloud-app/`; it never touches your working tree (`vendor/`, `node_modules/`, `js/dist/`
and `composer.lock` are left alone).
Check logs / status codes (level 3 = ERROR, 4 = FATAL; `serverDI` level-0 lines are noise).
The dev stack runs with `debug` on, so the News app logs an ERROR
`App class OCA\News\AppInfo\Application is not setup via query()` on every `user:delete`.
It is upstream and not ours, and the check below filters it out:
```bash
make nc-log # tail data/nextcloud.log
docker compose exec -T nextcloud sh -c "grep '\"level\":[34]' /var/www/html/data/nextcloud.log | grep -v 'News.*Application is not setup via query' | tail"
# Exercise dashboard widgets (server-rendered get_items):
curl -s -u testuser:'AIquila2026!dev' -H 'OCS-APIRequest: true' \
"http://localhost:8080/ocs/v2.php/apps/dashboard/api/v2/widget-items?widgets[]=aiquila_usage"
```
## Workflow
**Always work on a branch — never commit directly to main.**
```bash
git checkout -b feat/<short-description> # or fix/, chore/, docs/
# ... make changes ...
git add <specific files>
git commit -m "feat(mcp): ..."
gh pr create
gh pr checks <N> --watch # a PR is not delivered until its checks are green
```
Commit messages follow **Conventional Commits** with scope:
- `feat(mcp):` / `fix(mcp):` / `chore(mcp):` — MCP server changes
- `feat(nextcloud):` / `fix(nextcloud):` — Nextcloud app changes
- `docs:` / `chore:` — cross-cutting changes
To auto-close the issue a PR resolves, put a **closing keyword** in the PR description —
`Closes #184` (or `Fixes #184` / `Resolves #184`). A bare mention like `(#184)` or
`Implements GH #184` links the issue but does **not** close it on merge.
### A PR is not done until its checks are green
**Opening a PR is not the end of the task — watch it to a conclusion.** `gh pr create`
returns a URL, not a result. Run `gh pr checks <N> --watch` and report the actual
outcome; never describe a PR as finished, ready, or passing on the strength of a local
run alone. CI tests things a working tree does not: a clean checkout, `npm ci` against
the committed lockfile, and a different Node, PHP and Go than yours.
If a check goes red, fix it in the same PR. The exception is `claude-review` on a fork
PR, which always fails with `Could not fetch an OIDC token` — see the fork section below
for why that one is infrastructure rather than a code signal.
Required checks are `ESLint & Prettier`, `MCP Server Tests` and `Nextcloud App Tests`.
A check stuck on `Expected — Waiting for status to be reported` is usually a workflow
that never ran; see the `paths:` filter warning under **Merging**.
### Documentation is part of the change
**Checking the docs is mandatory on every change; updating them is mandatory when
behaviour changed.** A PR that alters a command, flag, env var, endpoint, workflow or
default and leaves `docs/` describing the old behaviour is incomplete. Do the doc edit
in the same PR — not a follow-up.
- **Docs exist for the area you touched:** check them against the code as it is now,
not only the lines your change affects. Fix stale statements in the same PR.
- **No docs exist for it:** say so and offer to write them. Don't skip it silently.
### Findings go to GitHub issues
Work turns up things outside the task: bugs, stale docs, missing tests, upstream
drift, follow-up ideas. Don't leave them in the chat transcript. For each one, offer to:
- **create** an issue, labelled per **Issue labels** below, with a milestone;
- **update** an existing issue that already covers it, with a comment that adds the
new evidence;
- **link** related issues and PRs (`Related to #N`, or a closing keyword when the PR
resolves the issue).
Search first (`gh issue list --state all --search "<terms>"`) so you don't open a
duplicate. Offer — don't file without the user's go-ahead.
Where things live:
| Changed | Update |
|---|---|
| Workflows, CI behaviour | `docs/dev/ci-cd.md` |
| MCP tools | `docs/mcp/tools/**`, the tool table in `docs/mcp/README.md`, and the category's row in `docs/mcp/upstream-apis.md` |
| Env vars, transports, auth | `docs/mcp/setup.md`, the table in this file |
| Nextcloud endpoints | `docs/dev/openapi.md`, plus regenerating the spec |
| Install or deploy steps | `docs/installation/**`, `docs/hetzner/**` |
| How to propose a change | `CONTRIBUTING.md` |
**Keep the docs general.** They describe how the project works *now*, durably. They are
not a changelog or an incident log. Keep out:
- issue and PR numbers, and "fixed in #504" narrative
- dated workarounds, "currently broken" notes, migration chatter for a single release
- anything that stops being true once a ticket closes
That material belongs in the GitHub issue or discussion — link to it rather than
inlining it. If a fact needs a date or a ticket number to make sense, it is probably
not documentation. One fact, one home: link between pages instead of restating.
### Verify specs against the RFC
**Always check protocol and format behaviour against the RFC itself** — when writing,
reviewing or documenting it. Don't rely on memory, blog posts, library docs, or how
Nextcloud or a client happens to behave; read the normative text on
[rfc-editor.org](https://www.rfc-editor.org/) and check its errata and any RFC that
obsoletes or updates it.
Specs this repo leans on:
| Area | RFCs |
|---|---|
| OAuth / auth | 6749 (OAuth 2.0), 7636 (PKCE), 6750 (Bearer), 8414 (AS metadata), 7591 (dynamic client registration), 9728 (protected resource metadata), 8707 (resource indicators), 7519 (JWT), 7009 (revocation), 9207 (`iss` in the authorization response), 8252 (native apps), 9700 (security BCP) |
| HTTP / WebDAV | 9110 (HTTP semantics, incl. conditional requests — obsoletes 7232), 4918 (WebDAV) |
| Calendar / contacts | 5545 (iCalendar), 4791 (CalDAV), 6350 (vCard 4.0), 2426 (vCard 3.0, obsoleted by 6350 but still sent by clients), 6352 (CardDAV) |
| Other | 5646 (BCP 47 language tags), 2119 / 8174 (MUST / SHOULD keywords) |
| API description | OpenAPI 3.1.0 (not an RFC: [spec.openapis.org/oas/v3.1.0](https://spec.openapis.org/oas/v3.1.0)), JSON Schema 2020-12 |
**OAuth 2.1.** The MCP server's authorization server implements **OAuth 2.1**, currently
[`draft-ietf-oauth-v2-1-16`](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/). It
consolidates 6749, 7636, 6750, 8252 and 9700, and where it changes 6749 the draft wins. In
particular: PKCE S256 on every code, no implicit or password grant, exact redirect matching
(loopback any port), one-time-use refresh tokens with reuse detection, and an optional
`redirect_uri` at the token endpoint (§10.2: still enforced when an OAuth 2.0 client sends it).
Before relying on it, check the datatracker for a newer draft or the published RFC and update
the revision here.
- Read MUST / SHOULD / MAY per RFC 2119 — a SHOULD is not optional without a reason.
- Cite the RFC and section in the code comment or PR description where behaviour
depends on it (e.g. `RFC 6749 §5.2`), so reviewers can check the same text.
- Where the MCP specification or Nextcloud deviates from or profiles an RFC, verify
against that spec revision too and note the deviation.
**Upstream app APIs.** `docs/mcp/upstream-apis.md` maps every `MCP_TOOLS` category to the
Nextcloud app and API it calls, the upstream reference, when it shipped, and when it was
last verified. Before working on a tool category, read its row:
- Check the implementation against the linked reference for the upstream version you
target, not against the existing code. A `Last verified` that is `—` or older than the
current Nextcloud major means the code may follow an API that has since moved.
- In the same PR, update `Last verified` (app version from `occ app:list`, Nextcloud
version from `occ status`, date) for every category whose behaviour you exercised,
and fix the reference link if it moved.
- A new category gets a new row, with `Introduced` filled in and its registry entry in
`src/tool-registry.ts` matching the `Upstream` column.
**MCP protocol.** The MCP specification is not an RFC, but the same rule applies: verify
against the published revision, not memory. The latest revision is **`2026-07-28`**
(<https://modelcontextprotocol.io/specification/2026-07-28>). It drops the `initialize`
handshake: each request carries its version in `_meta`
(`io.modelcontextprotocol/protocolVersion`, mirrored in the `MCP-Protocol-Version`
header), and `server/discover` lists supported versions. Revisions up to `2025-11-25`
use the handshake.
- Before relying on a revision, check
<https://modelcontextprotocol.io/specification/versioning> for a newer **Current** one,
and update the revision above when it changes.
- Check which revisions the installed SDK actually negotiates
(`grep -h "PROTOCOL_VERSION.* = " mcp-server/node_modules/@modelcontextprotocol/core/dist/*.mjs`).
That can lag the latest spec, and the behaviour you implement must match the revision
being negotiated.
- Auth is the MCP authorization spec on top of the OAuth RFCs above. Check both.
## Reviewing pull requests
Contributor-facing rules live in `CONTRIBUTING.md`. This section is the maintainer
side: how to get a PR verified and merged, and which red checks are real.
### Verify claims, don't trust the description
A PR that says "tests pass" and "these tests are load-bearing" is a claim, not
evidence. How you check it depends on who wrote it.
**Never run an outside contributor's code on your workstation.** `npm ci` alone
executes arbitrary lifecycle scripts with your SSH keys, npm tokens and cloud
credentials in reach — a far worse exposure than an ephemeral runner. Do not check
out a fork branch and build or test it locally.
For a fork PR, verify at arm's length instead:
1. Read the diff as text — `gh pr diff <N>`.
2. Let CI run it: approve the parked workflow runs (below) and read the results.
3. Trigger **Manual Code Review** (`.github/workflows/manual-code-review.yml`) for
the automated review, which the `pull_request` workflow cannot do on forks:
```bash
gh workflow run manual-code-review.yml -f pr=<N>
gh run watch "$(gh run list --workflow=manual-code-review.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
```
If you genuinely must execute fork code, do it in a throwaway container with no
mounted credentials — not in your checkout.
For an **internal** PR from a trusted author, a local worktree is fine:
```bash
git fetch origin pull/<N>/head:pr<N>-check
git worktree add /tmp/pr<N> pr<N>-check # isolated; never dirty the main tree
cd /tmp/pr<N>/mcp-server && npm ci && npx vitest run
```
To confirm a new test actually covers the fix, revert only the source files and
re-run — the new tests must go red:
```bash
git checkout origin/main -- mcp-server/src/<changed files>
npx vitest run src/__tests__/<new test file>
```
Clean up with `git worktree remove --force /tmp/pr<N>` and `git branch -D pr<N>-check`.
Also confirm the premise against upstream when a fix depends on third-party
behaviour (e.g. Nextcloud/spreed docs for a Talk status code) rather than taking
the PR's reading of it.
### Fork PRs
Workflow runs from a fork park at `action_required` until a maintainer approves
them, so `gh pr checks` reports nothing at first:
```bash
gh api repos/elgorro/aiquila/actions/runs --paginate \
--jq '.workflow_runs[] | select(.head_branch=="<branch>" and .conclusion=="action_required") | "\(.id) \(.name)"'
gh api --method POST repos/elgorro/aiquila/actions/runs/<run_id>/approve
```
Approving releases the compute, **not** the secrets. GitHub does not pass repository
secrets or mint an OIDC token for `pull_request` runs from a fork, so fork builds
never see `CLAUDE_CODE_OAUTH_TOKEN` or anything else. Two consequences:
- Approving a fork run is safe. The runner is ephemeral, `GITHUB_TOKEN` is read-only,
and no `pull_request_target` trigger exists in this repo — that is the trigger that
*would* expose secrets to fork-controlled code, so keep it that way.
- **`claude-review` always fails on fork PRs** with `Could not fetch an OIDC token`,
even though `id-token: write` is set. This is infrastructure, not a code signal, so
do not let the red check block the merge. Run **Manual Code Review** instead
(`gh workflow run manual-code-review.yml -f pr=<N>`) — `workflow_dispatch` is
restricted to accounts with write access, so no outside contributor can trigger a
review of their own PR, and it runs in the base-repo context where credentials
exist. It checks out this repo's default branch rather than the PR head and grants
no shell tool, so fork code is read as data and never executed.
The residual risk is ordinary: `npm ci` and the test suite execute fork-authored code
**on the runner** — which is the right place for it. Read the diff before approving,
and treat any change to `package.json`, lock files or `.github/**` as a reason to look
much harder.
### Bot PRs
`claude-code-action` rejects non-human actors unless they appear in `allowed_bots`
(`.github/workflows/claude-code-review.yml`). `dependabot[bot]` is allow-listed; a new
bot needs adding there or it fails with `Workflow initiated by non-human actor`.
### Issue labels
Three independent axes, all applied as labels:
| Axis | Labels |
|---|---|
| Type (exactly one) | `type:bug`, `type:feature`, `type:docs`, `type:chore` |
| Component (one or more) | `component:mcp`, `component:nextcloud`, `component:infrastructure` |
| Priority (exactly one) | `priority:critical`, `priority:high`, `priority:medium`, `priority:low` |
`security` and `question` cut across all three — `question` means the approach is
still being decided, not that the issue is a support request. `dependencies`,
`javascript`, `go`, `docker` and `github_actions` come from Dependabot; do not
rename them, `.github/dependabot.yml` sets no `labels:` key so they rely on
Dependabot's defaults. Renaming `good first issue` or `help wanted` drops the repo
out of GitHub's contributor-discovery listings.
GitHub's native **Issue Types** are an organization-only feature and `elgorro` is a
personal account (`gh api orgs/elgorro/issue-types` → 404), so labels are the
mechanism here. Don't re-propose issue types without also proposing an org transfer.
Every open issue carries a type and a priority. Each of the four templates in
`.github/ISSUE_TEMPLATE/` applies one type plus a `priority:medium` baseline, so
every creation path is covered. To find issues missing either axis after a bulk
import or a manual creation:
```bash
gh issue list --state open --limit 200 --json number,title,labels \
--jq '.[] | select(([.labels[].name] | any(startswith("type:")) | not)
or ([.labels[].name] | any(startswith("priority:")) | not))
| "\(.number) \(.title)"'
```
To rename a label, use `gh label edit <old> --name <new>` — it preserves every
existing assignment. Creating a new label and deleting the old one loses them all.
`.github/labels.yml` is the checked-in registry. No workflow syncs it, so after
changing labels through the API, regenerate it (the command is in its header) and
commit — otherwise `gh label import` will later restore a stale set.
### Assigning an issue to an external contributor
GitHub only accepts assignees who are repo collaborators **or** who have commented on
that specific issue. A `Fixes #N` on the PR does not count. `gh issue edit --add-assignee`
exits 0 and silently does nothing when this fails — verify instead of trusting it:
```bash
gh api repos/elgorro/aiquila/assignees/<user> # 204 = assignable, 404 = not
gh issue view <N> --json assignees --jq '.assignees[].login'
```
Ask them to comment on the issue, or grant **Triage** (assignable, no write access).
### Merging
```bash
gh pr view <N> --json mergeable,mergeStateStatus,reviewDecision
gh api repos/elgorro/aiquila/compare/main...<owner>:<repo>:<branch> --jq '{ahead_by,behind_by}'
```
- `BLOCKED` + `REVIEW_REQUIRED` — needs an approving review; `UNSTABLE` means only a
non-required check is red (typically `claude-review` on a fork) and is mergeable.
- Squash-merge to match history (`fix(mcp): ... (#504)`); squash preserves the
contributor as author.
- Check `behind_by` is 0 before merging.
**Never put a `paths:` filter on the trigger of a workflow whose job is a required
status check, and never gate such a job with a job-level `if:`.** Either way the job
does not run, so it never reports, and the PR hangs on
`Expected — Waiting for status to be reported` with no way to clear it. Let the job
always run and gate its *steps* instead: `.github/workflows/test.yml` has a `changes`
job that diffs against the base commit and publishes per-component booleans, and each
test job gates its steps on those. A workflow change sets every component to true, so
CI revalidates itself. `.github/workflows/lint.yml` does the same inline.
Required checks are currently `ESLint & Prettier`, `MCP Server Tests` and
`Nextcloud App Tests`:
```bash
gh api repos/elgorro/aiquila/branches/main/protection --jq '.required_status_checks.contexts'
```
## Version Bumps
All locations that must be updated on each release:
- **MCP server**: `mcp-server/package.json` → `"version"`
- **MCP registry**: `mcp-server/server.json` → `"version"` fields + `ghcr.io` image tag (auto-synced by `npm run build` except the image tag)
- **Nextcloud app**: `nextcloud-app/appinfo/info.xml` → `<version>`
- **Nextcloud frontend**: `nextcloud-app/package.json` → `"version"`
- **Lock file**: run `npm install` in `mcp-server/` to sync `package-lock.json`
Both components stay on the same version number.
## Architecture Notes
- **Transports**: stdio (Claude Desktop) or Streamable HTTP (Docker/Claude.ai)
- **Auth**: built-in OAuth 2.1 provider (`MCP_AUTH_ENABLED=true`), PKCE, HS256 JWT via `jose`. Users sign in on Nextcloud through Login Flow v2; every tool call acts as the signed-in user with the app password Nextcloud issued (`src/nc-context.ts`), stored encrypted per grant
- **Logging**: structured JSON to stderr via pino (`LOG_LEVEL` env var, default `info`)
- **Logging is always to stderr** — stdout is reserved for the MCP protocol (stdio transport)
See `docs/dev/mcp-server-architecture.md` for full architecture details.
## Environment Variables (MCP server)
| Variable | Required | Notes |
|----------|----------|-------|
| `NEXTCLOUD_URL` | Yes | trailing slash stripped automatically |
| `NEXTCLOUD_PUBLIC_URL` | No | Browser-facing Nextcloud URL for the Login Flow v2 sign-in link, when `NEXTCLOUD_URL` is internal. Default `NEXTCLOUD_URL` |
| `NEXTCLOUD_USER` | stdio / no auth | The account stdio and unauthenticated HTTP act as. With OAuth, tools act as the signed-in user; only `MCP_INTERNAL_TOKEN` (deprecated) uses it |
| `NEXTCLOUD_PASSWORD` | stdio / no auth | use app password |
| `MCP_TRANSPORT` | No | `stdio` (default) or `http` |
| `MCP_AUTH_ENABLED` | No | `true` to enable OAuth for Claude.ai |
| `MCP_AUTH_SECRET` | If auth | `openssl rand -hex 32` |
| `MCP_AUTH_ISSUER` | If auth | public HTTPS URL of this server |
| `MCP_CREDENTIAL_KEY` | If auth, unless OpenBao | Hex key (`openssl rand -hex 64`); HKDF-SHA-512 → AES-256-GCM over the stored per-grant app passwords |
| `MCP_OPENBAO_ADDR` / `MCP_OPENBAO_TOKEN` | No | Seal the stored app passwords with OpenBao (or Vault) Transit instead. `MCP_OPENBAO_TRANSIT_MOUNT` (default `transit`), `MCP_OPENBAO_TRANSIT_KEY` (default `aiquila-credentials`) |
| `MCP_AUTH_STATE_DIR` | No | Directory for persisted OAuth state (default `/app/state`) |
| `MCP_CIMD_ENABLED` | No | Accept Client ID Metadata Document URLs as `client_id`. Default on, off when `MCP_REGISTRATION_TOKEN` is set |
| `MCP_REDIRECT_ALLOWLIST` | No | Extra redirect hosts (or `scheme:` entries, or `*`) dynamically registered clients may use. Loopback, `claude.ai`, `claude.com` and the `NEXTCLOUD_URL` host are always allowed |
| `MCP_LAZY_AUTH` | No | `false` requires a bearer token on every JSON-RPC method. Default `true`: `server/discover`, `initialize`, `tools/list` and `get_local_time` are served without one |
| `MCP_REFRESH_TOKEN_TTL` | No | Refresh token lifetime in seconds, sliding on each rotation (default `2592000` = 30 days) |
| `MCP_INTERNAL_TOKEN` | No | Deprecated. Static service-to-service bearer token that bypasses OAuth and acts as `NEXTCLOUD_USER`. Accepted only on direct connections (no forwarding headers) from internal addresses |
| `MCP_INTERNAL_TOKEN_NETWORKS` | No | Comma-separated CIDRs / addresses `MCP_INTERNAL_TOKEN` is accepted from; replaces the loopback + private-range default |
| `MCP_LOCALE` | No | BCP 47 locale tag for `localTime` in `get_local_time` (e.g. `sv-SE`); defaults to system locale |
| `MCP_MAX_FILE_SIZE` | No | Max `write_file` content length in bytes (default `1073741824` = 1 GB) |
| `MCP_ALLOWED_HOSTS` | No | Comma-separated extra hostnames for DNS rebinding protection (e.g. `mcp` for Docker service name) |
| `MCP_CORS_ORIGINS` | No | Comma-separated extra origins allowed to make browser requests. The `MCP_AUTH_ISSUER` origin is always allowed when auth is on. Any other `Origin` on `/mcp` gets `403` |
| `MCP_OCC_ALLOWLIST` | No | Comma-separated allowlist entries for `run_occ` (`cmd` = no arguments, `cmd *` = any); replaces the read-only default |
| `LOG_LEVEL` | No | `trace`/`debug`/`info`/`warn`/`error`/`fatal` |
## Tests
```bash
cd mcp-server && npm test
```
Tests live in `mcp-server/src/__tests__/`. Auth tests cover provider, store, and token flows.
`src/__tests__/rfc/` is the RFC conformance suite: it runs the real app from
`createHttpApp()` against a fake Nextcloud and cites the RFC section each test checks. When
protocol or format behaviour changes, add or adjust the test there with its section.
Do not use `--no-verify` to skip hooks.
### OAuth end-to-end test
`docker/standalone/scripts/test-oauth.sh` acts as an OAuth 2.1 client against a live server:
discovery (incl. the RFC 9728 `resource`) → client registration → `/authorize` → Nextcloud
Login Flow v2 sign-in (`scripts/nc-login-flow.sh`; checks the RFC 9207 `iss`) → token exchange
with `resource` and without `redirect_uri` →
MCP initialize → tools/list → `2026-07-28` `server/discover` → `2026-07-28` tools/list →
refresh rotation → access-token revocation (RFC 7009) → refresh-token reuse ends the grant
(RFC 9700 §4.14.2).
```bash
# Requires: docker/standalone/.env with TEST_NC_USER + TEST_NC_PASSWORD (an app password, or the
# password plus TEST_NC_TOTP_SECRET for a 2FA account) + auth vars
cd docker/standalone
./scripts/test-oauth.sh # default: http://localhost:3339
./scripts/test-oauth.sh https://localhost:3340 # via Caddy (add -k to CURL in the script)
```
Reads credentials from `docker/standalone/.env`. The script restarts the MCP container first to ensure clean session state.
### Functional tool test
`docker/standalone/scripts/test-tools.sh` authenticates via OAuth PKCE (no restart) and exercises core MCP tools end-to-end: `system_status`, `list_files`, `create_folder`, `write_file`, `read_file`, `search_files`, `get_file_info`, `delete`. Cleans up after itself.
```bash
cd docker/standalone
make test-tools # default: http://localhost:3339
```
Requires a running standalone stack (`make up`) and auth enabled in `.env`.