Per review:
- Hoist `import httpx` out of the two test function bodies and into
the module imports at the top of
test_astrolabe_chunk_context.py.
- Simplify the regression guard in
test_management_chunk_context_endpoint.py to use
`mock.assert_awaited_once_with(...)` instead of manually unpacking
call_args. This is stricter — it fails loudly on signature change —
and matches the canonical pattern for asserting mock calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename test_chunk_context_endpoint_handles_missing_app_password to
test_chunk_context_endpoint_rejects_invalid_bearer so it reflects
what is actually exercised: an invalid bearer is rejected upfront at
validate_token_and_get_user, not at the NotProvisionedError branch.
The NotProvisionedError path is covered by the corresponding unit
test in test_management_chunk_context_endpoint.py.
- Hoist `import base64` to module level per PEP 8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Astrolabe's ApiController endpoints (search, chunk-context) require a
CSRF `requesttoken` header — axios picks it up from OC.requestToken
automatically in the SPA, but page.request.get() does not.
The first CI run failed on the search step with 412 CSRF check failed
before reaching the chunk-context assertion that was supposed to
surface the handler bug. Load the Astrolabe page, read OC.requestToken,
and pass it on both calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /api/v1/chunk-context and /api/v1/pdf-preview handlers in
api/visualization.py forwarded the incoming OAuth bearer directly to
Nextcloud via NextcloudClient.from_token. In multi-user BasicAuth mode
Nextcloud has no validator for those bearers on Notes/WebDAV, so it
treats the request as anonymous and returns 401 — surfaced to the user
as a 500 from /apps/astrolabe/api/chunk-context. Search worked because
it only hits Qdrant.
Architecturally, OAuth is only for Astrolabe→MCP server; MCP server→
Nextcloud always uses the per-user app password stored during provision
(background sync already does this via vector.oauth_sync).
- Resolve the Nextcloud client through get_user_client_basic_auth in
both get_chunk_context and get_pdf_preview, surfacing
NotProvisionedError as a clean 401 instead of opaque 500.
- Apply the same fix to the session-cookie variant in
auth/viz_routes.chunk_context_endpoint for the internal viz UI.
Tests:
- New unit file test_management_chunk_context_endpoint.py, including a
regression guard that asserts get_user_client_basic_auth is awaited
(so reverting to from_token fails without needing a live Nextcloud).
- Updated test_management_pdf_preview_endpoint.py to mock the new auth
path (drops extract_bearer_token / NextcloudClient.from_token patches).
- New integration test test_astrolabe_chunk_context.py drives the full
chain (browser → Astrolabe → MCP → Nextcloud) in multi-user BasicAuth
mode, plus bare-bones 401 checks on the MCP endpoint.
Full unit suite: 546 passed.
Companion PR on astrolabe (cbcoutinho/astrolabe#66) sends the Nextcloud
UID as loginName in the app-password POST body so the stored record is
complete. Submodule bump to that branch will follow once CI reproduces
the failure on the old submodule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up astrolabe/astrolabe#61 which fixes app password provisioning
failure caused by loginName mismatch in ITokenProvider::generateToken().
This was the root cause of vector sync never indexing in multi-user
BasicAuth mode, which caused the plotly visualization test to fail.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The multi-user-basic integration job was consistently failing with
`CancelledError: Cancelled via cancel scope ... by <async_generator_athrow>`
followed by a cascade of `anyio.ClosedResourceError` in every subsequent
test. Root cause: `create_mcp_client_session` was declared as an async
generator driven by `async for session in ...:`, so Python's generator
finalizer (`aclose`) ran under pytest-asyncio's cleanup task instead of
the task that owned the nested `streamablehttp_client` cancel scope.
anyio then raised when the inner task group saw its scope being exited
from a foreign task, leaving the memory object streams half-closed and
poisoning the rest of the session.
Switching to `@asynccontextmanager` + `async with ... as session:` makes
`__aenter__`/`__aexit__` run in the frame that owns the context manager,
satisfying anyio's structured concurrency requirements.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- _resolve_settings_files() now raises FileNotFoundError when
NEXTCLOUD_MCP_SETTINGS_FILE points to a missing file, instead of
silently falling back to defaults (footgun on typos).
- .secrets.toml is now looked for alongside the explicit settings file
when NEXTCLOUD_MCP_SETTINGS_FILE is set, matching user expectation for
/etc-style deployments. Unset behaviour (cwd lookup) is unchanged.
- get_token_db_path() drops the redundant os.environ.get() short-circuit;
TOKEN_STORAGE_DB is already bound through dynaconf because the key is
declared in _DEFAULTS.
- is_ephemeral_token_db() docstring documents the "must call
get_token_db_path() first" precondition.
- alembic.ini comment clarifies the ./tokens.db placeholder is cwd-relative
by design and points readers at the -x database_url escape hatch.
- New tests/unit/test_config_paths.py (12 tests) covering the ephemeral
tempfile lifecycle, the TOKEN_STORAGE_DB override path, and all six
_resolve_settings_files() cases including the two new behaviours.
Full unit suite now at 476 passed (464 + 12 new). Ruff + ty clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs made `uvx --from . nextcloud-mcp-server run` (and any pip install)
unusable outside Docker:
1. Dynaconf was configured with ignore_unknown_envvars=True and relied on
settings.toml to declare the key schema. With no settings.toml in a wheel
install, every env var (NEXTCLOUD_HOST, MCP_DEPLOYMENT_MODE, ...) was
silently dropped. Moved the schema into a Python _DEFAULTS dict passed
directly to Dynaconf, kept settings.toml as an optional external override
(renamed to settings.toml.example, gitignored), and pointed docker-compose
at the example file.
2. Token SQLite DB defaulted to /app/data/tokens.db in multiple places
(auth/storage.py, migrations.py, alembic/env.py, cli.py db subcommands),
which blew up at uvicorn startup with FileNotFoundError on non-Docker
hosts. Replaced with a new config.get_token_db_path() helper that
resolves TOKEN_STORAGE_DB if explicitly set, otherwise allocates a
per-process tempfile cleaned up at interpreter exit via atexit — mirroring
the "ephemeral by default" pattern used for QDRANT_LOCATION=:memory:.
Containers are unaffected: docker-compose services now explicitly set
TOKEN_STORAGE_DB=/app/data/tokens.db (the fourth service that was missing
this pin has been brought in line with the other three).
Verified end-to-end in an isolated /tmp venv: env-var-only startup, Alembic
migrations run against the tempfile, Application startup complete, /health/live
returns 200, tempfile deleted on SIGTERM. Unit tests (464) + ruff + ty pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
External IdPs like AWS Cognito return scopes prefixed with the resource
server identifier (e.g. https://mcp.example.com/notes.read). MCP tools
use bare scope names (notes.read) in @require_scopes decorators. Without
stripping the prefix, scope matching fails and only identity-only tools
(openid/profile/email) are visible — resulting in 4/125 tools shown.
Strip the OIDC_RESOURCE_SERVER_ID prefix in both get_access_token_scopes()
(used by list_tools filtering) and the require_scopes decorator (used at
tool execution time).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add BasicAuthLifespanContext Protocol to make the contract between
StdioContext and get_client() explicit and type-safe. Document why
mcp.get_context() is required for non-template resources. Add News
and Collectives to README Supported Apps table, fix transport default.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AWS Cognito provides refresh tokens automatically with the authorization
code flow but does not list offline_access as a supported scope. Check
the IdP's scopes_supported discovery field before including it in
requests, and always accept refresh tokens from responses regardless.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove dead monkeypatch in test_stdio_calls_get_stdio_mcp
- Add _reload_config() teardown to single_user_env fixture
- Tighten AVAILABLE_APPS type to Callable[[FastMCP], None]
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Revert default transport to streamable-http (not a breaking change)
- Extract AVAILABLE_APPS constant to server/__init__.py (DRY)
- Wrap get_stdio_mcp ValueError in click.ClickException for clean errors
- Fix test_stdio.py: call _reload_config() so dynaconf sees env changes
- Use lazy %-style logging in stdio.py
- Add private API comments in test assertions
- Derive --enable-app CLI choices from AVAILABLE_APPS
- README: show explicit --transport stdio in uvx examples
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a lightweight stdio transport path so users can run the server
locally with MCP clients like Claude Code using `uvx nextcloud-mcp-server run`.
- New `nextcloud_mcp_server/stdio.py` with minimal FastMCP setup for
single-user BasicAuth (no OAuth, semantic search, or background sync)
- Default transport changed from streamable-http to stdio
- Dockerfile updated to explicitly use streamable-http for containers
- CLI `--enable-app` now includes news, collectives, and sharing
- README Quick Start section with uvx and MCP client config examples
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tags pushed with GITHUB_TOKEN don't trigger other workflows (GitHub's
anti-recursion protection), which is why a PAT was needed. Instead,
chain release and docker workflows directly via workflow_call from
bump-version, eliminating the need for a personal access token.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Flow 2 hardcoded offline_access in the scope string, but providers
like AWS Cognito don't support this scope (they handle refresh tokens
via client config). This caused invalid_scope errors on the Astrolabe
semantic search enablement flow.
Only include offline_access when enable_offline_access is explicitly
set, matching the behavior of DCR scope registration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AWS Cognito access tokens do not include an `aud` claim per RFC 7519 —
they use `client_id` instead. This causes `_has_mcp_audience` to reject
all Cognito-issued tokens with "Missing MCP audience. Got []".
When `aud` is empty, fall back to the `client_id` JWT claim for audience
validation. The MCP server's own client_id will be present there since
the AS proxy exchanges the authorization code using its credentials.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>