Compare commits

...

117 Commits

Author SHA1 Message Date
github-actions[bot] 1a079a41e7 bump: version 0.52.0 → 0.52.1 2025-12-13 23:24:55 +00:00
Chris Coutinho ebbd3bcc61 Merge pull request #396 from cbcoutinho/feat/deck-vector-search
perf(deck): Optimize card lookup with O(1) metadata-based retrieval
2025-12-14 00:24:25 +01:00
Chris Coutinho 54fdc8addc Merge remote-tracking branch 'origin/master' into feat/deck-vector-search 2025-12-14 00:23:16 +01:00
Chris Coutinho e0320e761c perf(deck): optimize card lookup by storing board_id/stack_id in metadata
Addresses reviewer feedback on PR #395 about O(n²) performance issue.

Changes:
- scanner.py: Add metadata field to DocumentTask with board_id/stack_id
- scanner.py: Populate metadata during deck card scanning (both initial and incremental sync)
- processor.py: Use metadata for O(1) card lookup via get_card() API when available
- processor.py: Fallback to iteration for legacy data without metadata
- context.py: Add _get_deck_metadata_from_qdrant() helper to retrieve metadata from Qdrant
- context.py: Use metadata for fast path lookup in chunk context expansion
- context.py: Add user_id parameter to _fetch_document_text() for metadata retrieval

Performance Impact:
- Before: O(boards × stacks × cards) iteration for each card lookup
- After: O(1) direct API call using stored board_id/stack_id
- Graceful degradation: Falls back to iteration for legacy data

Testing:
- All existing integration tests pass (test_deck_vector_search.py)
- Type checking passes with no new errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 00:23:12 +01:00
github-actions[bot] 2b7c308188 bump: version 0.51.0 → 0.52.0 2025-12-13 22:56:03 +00:00
Chris Coutinho 40ac52654f Merge pull request #395 from cbcoutinho/feat/deck-vector-search
feat(vector): Add Deck card vector search with visualization support
2025-12-13 23:55:31 +01:00
Chris Coutinho 034e405824 build: Add qdrant-client until upstream issue is merged 2025-12-13 23:51:43 +01:00
Chris Coutinho 20404cf3f2 feat(vector): add Deck card vector search with visualization support
Adds comprehensive vector search support for Nextcloud Deck cards,
including semantic search indexing, chunk preview in the vector viz UI,
and proper deep linking to cards.

**Vector Search Indexing**
- Add deck_card scanning in scanner.py (scan_deck_cards function)
- Index cards from non-archived, non-deleted boards
- Store metadata: board_id, board_title, stack_id, stack_title, card_type, duedate, owner
- Content structure: title + "\n\n" + description (matches indexing format)
- Incremental sync based on lastModified timestamp
- Deletion tracking with grace period

**Vector Visualization Support**
- Add deck_card handler in context.py for chunk preview expansion
- Include board_id in search result metadata (bm25_hybrid.py, semantic.py)
- Expose metadata in viz_routes.py JSON responses
- Update vector-viz.js to construct proper Deck URLs: /apps/deck/board/{board_id}/card/{card_id}
- Update vector_viz.html filter label from "Deck" to "Deck Cards"

**Bug Fixes**
- Skip soft-deleted boards (deletedAt > 0) to prevent 403 Forbidden errors
- Applies to scanner, processor, and context expansion code paths
- Deck API returns deleted boards but rejects stack access with 403

**Testing**
- Add integration tests in test_deck_vector_search.py:
  - test_deck_card_semantic_search: Filtered search with doc_type="deck_card"
  - test_deck_card_appears_in_cross_app_search: Cross-app search includes deck cards
  - test_deck_card_chunk_context: Chunk context fetching for viz preview

**Documentation**
- Update README.md: Add Deck cards to semantic search feature list
- Update semantic-search-architecture.md: Document deck_card support
- Update nc_semantic_search tool documentation

**Type Safety**
- Fix type narrowing for page_boundaries (could be None) using cast()
- Fix scanner.py payload None check for type safety

Resolves vector search for Deck cards across indexing, search, and visualization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 23:51:18 +01:00
github-actions[bot] 264bb5475c bump: version 0.50.2 → 0.51.0 2025-12-13 21:24:19 +00:00
Chris Coutinho 6e3f9f6e79 Merge pull request #394 from cbcoutinho/news-link
feat(vector-viz): add news_item support for links and chunk expansion
2025-12-13 22:23:48 +01:00
Chris Coutinho 9d0a993c2a feat(vector-viz): add news_item support for links and chunk expansion
Add support for news_item document type in the vector visualization page:

- Add "News" checkbox to document type filter options
- Add URL handler to link news items to /apps/news/item/{id}
- Add content fetching for news items in chunk context expansion

This enables users to search and view news articles in the vector
visualization, with clickable links back to Nextcloud News and the
ability to expand chunks to see full article context.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 21:34:47 +01:00
github-actions[bot] cd3e60ba4f bump: version 0.50.1 → 0.50.2 2025-12-13 14:53:42 +00:00
Chris Coutinho 360299f5f6 Merge pull request #393 from cbcoutinho/fix/news-api-get-item-405-error
fix(news): revert get_item() to use get_items() + filter
2025-12-13 15:53:11 +01:00
Chris Coutinho d61e33113c fix(news): revert get_item() to use get_items() + filter
Reverts the "perf(news): use direct API endpoint for get_item()" change
from commit 92c4bf3 which incorrectly assumed GET /items/{itemId} exists.

The News API (v1-2, v1-3, v2) does not provide a direct endpoint to
retrieve individual items. The only /items/{itemId} routes are POST
operations for marking items read/unread/starred.

Changes:
- Restore original get_item() implementation that fetches all items
  and filters in Python
- Update exception from HTTPStatusError to ValueError
- Restore documentation explaining API limitation
- Update unit tests to mock get_items() instead of _make_request()
- Add test for ValueError when item not found

Fixes vector processor 405 errors when indexing news items.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 15:47:27 +01:00
Chris Coutinho 5faf7cf45f Merge pull request #391 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to fa48eef
2025-12-13 12:56:55 +01:00
renovate-bot-cbcoutinho[bot] cd922fa750 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to fa48eef 2025-12-13 11:07:41 +00:00
github-actions[bot] a4d4c386f7 bump: version 0.50.0 → 0.50.1 2025-12-12 17:00:34 +00:00
Chris Coutinho c8da826ef7 Merge pull request #382 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.23,<1.24
2025-12-12 18:00:04 +01:00
Chris Coutinho 5166c2c4d7 test: Add verification test for DNS rebinding protection fix
This test verifies that the MCP 1.23.x DNS rebinding protection fix works
correctly by sending requests with various Host headers that would be
rejected if the protection were enabled.

Test cases:
- Kubernetes service DNS (nextcloud-mcp-server.default.svc.cluster.local:8000)
- Custom domain (mcp.example.com:8000)
- Proxied hostname (proxy.internal:8000)
- Default localhost (localhost:8000)
- Malicious hostname (evil.attacker.com:8000)

Without the fix (enable_dns_rebinding_protection=False), these would fail with:
- 421 Misdirected Request (Host header not in allowed list)
- 403 Forbidden (Origin header not in allowed list)

With the fix, all requests succeed with 200 OK (SSE format).

Test results: All 2 tests passed
- test_accepts_various_host_headers: PASSED
- test_dns_rebinding_protection_is_disabled: PASSED
2025-12-12 17:56:16 +01:00
Chris Coutinho ec70e70a5d fix: Disable DNS rebinding protection for containerized deployments
MCP Python SDK 1.23.0 introduced automatic DNS rebinding protection that
auto-enables when host="127.0.0.1" (the default). This breaks containerized
deployments (Kubernetes, Docker) because the protection rejects requests
with Host headers like "nextcloud-mcp-server.default.svc.cluster.local:8000".

Root cause:
- FastMCP defaults to host="127.0.0.1"
- SDK auto-enables DNS rebinding protection with allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]
- K8s/Docker requests use service DNS names or proxied hostnames
- Protection middleware rejects these requests (421 Misdirected Request)

Solution:
- Explicitly pass transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
- Applied to all three FastMCP initializations (OAuth, Smithery, BasicAuth)
- DNS rebinding attacks mitigated by OAuth authentication and network isolation

This fixes issue #373 and enables MCP 1.23.x upgrade in PR #382.

For detailed analysis, see docs/MCP-1.23-DNS-REBINDING-FIX.md
2025-12-12 17:30:22 +01:00
Chris Coutinho 4a79b37714 Merge pull request #389 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.3
2025-12-12 12:23:44 +01:00
renovate-bot-cbcoutinho[bot] 76ae1c3603 chore(deps): update docker.io/library/nextcloud docker tag to v32.0.3 2025-12-12 11:09:06 +00:00
github-actions[bot] a60b88b80e bump: version 0.49.2 → 0.50.0 2025-12-11 12:58:08 +00:00
Chris Coutinho e31b4433a1 Merge pull request #387 from cbcoutinho/feat/add-mcp-tool-annotations
feat: add MCP tool annotations for enhanced UX
2025-12-11 13:57:35 +01:00
Chris Coutinho 19183ad14a fix: address PR review feedback
Address all reviewer comments from PR #387:

1.  Add unit tests for annotations (tests/server/test_annotations.py)
   - 10 comprehensive test functions validating all annotation patterns
   - Tests for titles, read-only, destructive, idempotent operations
   - Validates specific ADR-017 decisions (webdav write, semantic search)
   - Cross-category consistency checks

2.  Fix nc_webdav_write_file idempotency classification
   - Changed from idempotentHint=False to idempotentHint=True
   - Rationale: Uses HTTP PUT without version control
   - Writing same content to same path = same end state (idempotent)

3.  Fix semantic search openWorldHint inconsistency
   - Changed from openWorldHint=False to openWorldHint=True
   - Rationale: Consistent with other Nextcloud tools
   - Nextcloud is external to MCP server (indexed data is implementation detail)

4.  Update ADR-017 with resolved decisions
   - Converted Open Questions to Resolved Questions
   - Added detailed rationale for webdav write and semantic search
   - Updated status from Proposed to Implemented
   - Added decision timeline with dates

5.  Add MCP Tool Annotations guidelines to CLAUDE.md
   - Comprehensive section with code examples for all patterns
   - Key principles documented (idempotency, destructive, open world)
   - References ADR-017 for detailed rationale

All OAuth tools verified to have proper annotations (oauth_tools.py lines 686-751).
2025-12-11 13:50:55 +01:00
Chris Coutinho e1412320a7 feat: add MCP tool annotations for enhanced UX
Add ToolAnnotations to all 105+ MCP tools across 13 modules to enable
better client-side UX with human-readable titles and behavioral hints.

Changes:
- Add title and ToolAnnotations to all @mcp.tool() decorators
- Apply correct idempotency classification per ADR-017
- Add destructiveHint for delete operations
- Set openWorldHint=False for semantic search (internal data only)

Modules updated:
- OAuth (4 tools): Authentication and provisioning
- Notes (7 tools): Note management
- WebDAV (11 tools): File operations
- Semantic (3 tools): Semantic search and RAG
- Calendar (16 tools): Events and todos
- Contacts (7 tools): Address book management
- Sharing (5 tools): File/folder sharing
- Tables (6 tools): Structured data
- Deck (25 tools): Kanban board management
- Cookbook (13 tools): Recipe management
- News (8 tools): RSS feed reader

Annotation patterns:
- Read operations: readOnlyHint=True, openWorldHint=True
- Create operations: idempotentHint=False, openWorldHint=True
- Update operations: idempotentHint=False, openWorldHint=True
- Delete operations: destructiveHint=True, idempotentHint=True, openWorldHint=True

See docs/ADR-017-mcp-tool-annotations.md for rationale and implementation details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 12:45:02 +01:00
Chris Coutinho b9c94dfab0 Merge pull request #385 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 289deca
2025-12-10 12:56:34 +01:00
Chris Coutinho 6f43c09bd0 Merge pull request #386 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.17
2025-12-10 12:55:50 +01:00
Chris Coutinho 9e15e95c2b Merge pull request #384 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to f0c8eb2
2025-12-10 12:54:21 +01:00
renovate-bot-cbcoutinho[bot] 1306c4cc9c chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.17 2025-12-10 11:10:37 +00:00
renovate-bot-cbcoutinho[bot] f1247817d3 chore(deps): update docker.io/library/nginx:alpine docker digest to 289deca 2025-12-10 11:10:31 +00:00
renovate-bot-cbcoutinho[bot] fdad5b85c9 chore(deps): update anthropics/claude-code-action digest to f0c8eb2 2025-12-10 11:10:26 +00:00
Chris Coutinho 39ee0b5973 Merge pull request #381 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 590cad7
2025-12-09 18:44:34 +01:00
github-actions[bot] 33675c8ae8 bump: version 0.49.1 → 0.49.2 2025-12-09 17:43:25 +00:00
Chris Coutinho 90d5e9887a Merge pull request #383 from cbcoutinho/fix/bump
fix: Update lockfile
2025-12-09 18:42:53 +01:00
Chris Coutinho c3af591810 fix: Update lockfile 2025-12-09 18:42:03 +01:00
renovate-bot-cbcoutinho[bot] bb8a6200aa fix(deps): update dependency mcp to >=1.23,<1.24 2025-12-09 14:54:22 +00:00
Chris Coutinho 44573366eb build: Update lockfile 2025-12-09 15:49:25 +01:00
github-actions[bot] edb0af2bda bump: version 0.49.0 → 0.49.1 2025-12-09 14:46:43 +00:00
Chris Coutinho 7d5bb54b64 fix: Revert mcp version <1.23 2025-12-09 15:46:00 +01:00
Chris Coutinho a18c63792a Merge pull request #380 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.2
chore(deps): update docker.io/library/nextcloud:32.0.2 docker digest to 04cc195
2025-12-09 15:36:18 +01:00
renovate-bot-cbcoutinho[bot] 0b58707a49 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 590cad7 2025-12-09 11:07:35 +00:00
renovate-bot-cbcoutinho[bot] 0561b55af5 chore(deps): update docker.io/library/nextcloud:32.0.2 docker digest to 04cc195 2025-12-09 11:07:29 +00:00
Chris Coutinho d785ed9054 Merge pull request #379 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.5
2025-12-08 15:38:49 +01:00
renovate-bot-cbcoutinho[bot] 88fb8417fd chore(deps): update astral-sh/setup-uv action to v7.1.5 2025-12-08 11:07:22 +00:00
github-actions[bot] f70d743c8b bump: version 0.48.6 → 0.49.0 2025-12-08 06:23:14 +00:00
Chris Coutinho 251b8a10c0 Merge pull request #363 from cbcoutinho/feature/news-app-integration
feat(news): add Nextcloud News app integration
2025-12-08 07:22:42 +01:00
Chris Coutinho 3f06e2ee77 fix: resolve all type checking errors (8 errors fixed)
Fixed 8 type checker errors across the codebase:

- vector/scanner.py: Handle None scroll results with null-safe iteration
- search/{bm25_hybrid,semantic}.py: Add None checks for result.payload
- auth/{unified_verifier,webhook_routes}.py: Assert non-None auth credentials
- client/webdav.py: Add None checks before int() conversions
- providers/openai.py: Assert embedding_model is not None
- search/algorithms.py: Explicitly type doc_types set and cast values
- observability/logging_config.py: Match parent class signature (log_data)

Also fixed test_create_tag_creates_system_tag to match WebDAV implementation
(was testing OCS API endpoint, now tests correct WebDAV endpoint with
Content-Location header).

Type checker: 0 errors (down from 8), 20 warnings (ignored)
Tests: All 192 unit tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-08 01:09:02 +01:00
Chris Coutinho 7f11c793ef Merge remote-tracking branch 'origin/master' into feature/news-app-integration 2025-12-07 22:36:48 +01:00
Chris Coutinho e28dcbff9a Merge pull request #378 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.16
2025-12-07 13:28:38 +01:00
renovate-bot-cbcoutinho[bot] 89ec0186a4 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.16 2025-12-07 11:06:50 +00:00
Chris Coutinho 6e1efde8c6 Merge pull request #375 from cbcoutinho/renovate/qdrant-qdrant-v1.16.2
chore(deps): update qdrant/qdrant:v1.16.2 docker digest to dab6de3
2025-12-05 20:19:08 +01:00
Chris Coutinho 6aa80d4210 Merge pull request #377 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.4.2
2025-12-05 20:18:56 +01:00
Chris Coutinho 4e86006b3f Merge pull request #376 from cbcoutinho/renovate/qdrant-1.x
chore(deps): update helm release qdrant to v1.16.2
2025-12-05 20:18:32 +01:00
renovate-bot-cbcoutinho[bot] 679e22a7c2 chore(deps): update hoverkraft-tech/compose-action action to v2.4.2 2025-12-05 11:11:41 +00:00
renovate-bot-cbcoutinho[bot] 4d3228a4a8 chore(deps): update helm release qdrant to v1.16.2 2025-12-05 11:11:34 +00:00
renovate-bot-cbcoutinho[bot] 0aa307f0b6 chore(deps): update qdrant/qdrant:v1.16.2 docker digest to dab6de3 2025-12-05 11:11:18 +00:00
Chris Coutinho 6a69ecefb1 Merge pull request #372 from cbcoutinho/renovate/qdrant-qdrant-1.x
chore(deps): update qdrant/qdrant docker tag to v1.16.2
2025-12-04 13:56:27 +01:00
renovate-bot-cbcoutinho[bot] c05beb66e9 chore(deps): update qdrant/qdrant docker tag to v1.16.2 2025-12-04 11:09:16 +00:00
Chris Coutinho 34ddb24014 Merge pull request #368 from cbcoutinho/renovate/actions-checkout-digest
chore(deps): update actions/checkout digest to 8e8c483
2025-12-03 13:09:39 +01:00
Chris Coutinho 9d69613df7 Merge pull request #369 from cbcoutinho/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6.0.1
2025-12-03 13:09:26 +01:00
github-actions[bot] 630f818538 bump: version 0.48.5 → 0.48.6 2025-12-03 12:09:01 +00:00
Chris Coutinho b280a720ff Merge pull request #370 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.15
2025-12-03 13:08:59 +01:00
Chris Coutinho 48bac9c212 Merge pull request #371 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.23,<1.24
2025-12-03 13:08:30 +01:00
renovate-bot-cbcoutinho[bot] e88c49fb50 fix(deps): update dependency mcp to >=1.23,<1.24 2025-12-03 11:13:29 +00:00
renovate-bot-cbcoutinho[bot] 9e10a5a400 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.15 2025-12-03 11:12:56 +00:00
renovate-bot-cbcoutinho[bot] 1dbea24fa2 chore(deps): update actions/checkout action to v6.0.1 2025-12-03 11:12:49 +00:00
renovate-bot-cbcoutinho[bot] 0606228b40 chore(deps): update actions/checkout digest to 8e8c483 2025-12-03 11:12:44 +00:00
Chris Coutinho f35b9f0988 Merge pull request #366 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to 6337623
2025-12-02 13:17:39 +01:00
Chris Coutinho c400c46672 Merge pull request #367 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.14
2025-12-02 13:15:58 +01:00
renovate-bot-cbcoutinho[bot] fbdeb2161d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.14 2025-12-02 11:08:38 +00:00
renovate-bot-cbcoutinho[bot] 8c7d03dd29 chore(deps): update anthropics/claude-code-action digest to 6337623 2025-12-02 11:08:33 +00:00
Chris Coutinho 135ce7b2df Merge pull request #364 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.4.7
2025-12-02 07:07:36 +01:00
Chris Coutinho 0e47ae051b Merge pull request #365 from cbcoutinho/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2.5.0
2025-12-01 15:43:03 +01:00
renovate-bot-cbcoutinho[bot] 04255473d2 chore(deps): update softprops/action-gh-release action to v2.5.0 2025-12-01 11:07:53 +00:00
renovate-bot-cbcoutinho[bot] ce6bbff389 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.4.7 2025-12-01 11:07:45 +00:00
Chris Coutinho 92c4bf36f6 perf(news): use direct API endpoint for get_item()
Replace O(n) fetch-all-and-filter approach with O(1) direct API call.
The News API v1-3 supports GET /items/{id} for single-item retrieval.

- Update get_item() to use direct endpoint
- Add unit test for get_item() method
- Fixes critical performance issue identified in code review

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 17:22:51 +01:00
Chris Coutinho 0bedbf1877 Merge remote-tracking branch 'origin/master' into feature/news-app-integration 2025-11-29 17:19:16 +01:00
Chris Coutinho a5cb6e1242 refactor(news): simplify vector sync to fetch all items
Remove the complex starred+unread filtering logic in scan_news_items().
The News app's auto-purge feature (default: 200 items per feed) already
limits the total number of items, making explicit filtering unnecessary.

Changes:
- Replace two API calls (starred + unread) with single all-items call
- Remove deduplication logic that merged both lists
- Update docstring to explain the simpler approach

This reduces code complexity while maintaining the same effective coverage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 15:05:34 +01:00
Chris Coutinho a33f6a2f15 feat(news): add Nextcloud News app integration
Add full integration for the Nextcloud News (RSS/Atom reader) app:

- Add NewsClient with complete CRUD operations for folders, feeds, and items
- Add 8 read-only MCP tools for listing/getting folders, feeds, items
- Add Pydantic models for News entities with camelCase alias support
- Add vector sync support for starred + unread items
- Add HTML to Markdown converter using markdownify for better embeddings
- Add Docker post-install hook to enable News app
- Add 25 unit tests for NewsClient API methods

Vector sync indexes starred and unread items, providing a balanced approach
that captures important (starred) and current (unread) content without
indexing the entire article history.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 14:39:31 +01:00
Chris Coutinho d79e9090e6 Merge pull request #351 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin anthropics/claude-code-action action to a7e4c51
2025-11-29 12:39:10 +01:00
renovate-bot-cbcoutinho[bot] 97fd660e38 chore(deps): pin anthropics/claude-code-action action to a7e4c51 2025-11-29 11:05:15 +00:00
Chris Coutinho 96e168d035 Merge pull request #362 from cbcoutinho/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2025-11-29 00:07:55 +01:00
renovate-bot-cbcoutinho[bot] 4d2b77ecaf chore(deps): update actions/checkout action to v6 2025-11-28 23:06:18 +00:00
github-actions[bot] e48da80a4b bump: version 0.48.4 → 0.48.5 2025-11-28 23:03:07 +00:00
Chris Coutinho 6125312f61 Merge pull request #313 from cbcoutinho/renovate/pillow-12.x
fix(deps): update dependency pillow to v12
2025-11-29 00:02:36 +01:00
claude[bot] 007fd0c2e3 chore: add Renovate package rule to block Pillow >=12.0.0
Pillow 12.x is incompatible with fastembed which requires pillow<12.0.0.
Added package rule to prevent Renovate from updating Pillow to version 12+
and reverted pyproject.toml to use pillow<12.0.0.

Co-authored-by: Chris Coutinho <cbcoutinho@users.noreply.github.com>
2025-11-28 23:01:46 +00:00
Chris Coutinho c4f90d6a57 Merge pull request #361 from cbcoutinho/add-claude-github-actions-1764370764331
Add Claude Code GitHub Workflow
2025-11-29 00:00:04 +01:00
Chris Coutinho 5dd62c9466 "Claude Code Review workflow" 2025-11-28 23:59:26 +01:00
Chris Coutinho 4d072d7217 "Claude PR Assistant workflow" 2025-11-28 23:59:25 +01:00
Chris Coutinho b4242b1394 Merge pull request #360 from cbcoutinho/renovate/docker-metadata-action-digest
chore(deps): update docker/metadata-action digest to c299e40
2025-11-28 00:07:01 +01:00
renovate-bot-cbcoutinho[bot] fa2343dff9 chore(deps): update docker/metadata-action digest to c299e40 2025-11-27 17:04:27 +00:00
Chris Coutinho 1b1667bc2b Merge pull request #357 from cbcoutinho/renovate/shivammathur-setup-php-digest
chore(deps): update shivammathur/setup-php digest to 44454db
2025-11-26 18:25:06 +01:00
Chris Coutinho c2b4bf9c67 Merge pull request #358 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.13
2025-11-26 18:24:46 +01:00
Chris Coutinho 0845fefe6c Merge pull request #359 from cbcoutinho/renovate/qdrant-1.x
chore(deps): update helm release qdrant to v1.16.1
2025-11-26 18:24:34 +01:00
renovate-bot-cbcoutinho[bot] d911556a84 chore(deps): update helm release qdrant to v1.16.1 2025-11-26 17:04:52 +00:00
renovate-bot-cbcoutinho[bot] 38be8d9401 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.13 2025-11-26 17:04:31 +00:00
renovate-bot-cbcoutinho[bot] 9f3190f62a chore(deps): update shivammathur/setup-php digest to 44454db 2025-11-26 17:04:26 +00:00
Chris Coutinho 41aeb7e0f2 Merge pull request #356 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.4.6
2025-11-26 00:50:25 +01:00
renovate-bot-cbcoutinho[bot] f8e67519e1 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.4.6 2025-11-25 23:06:05 +00:00
Chris Coutinho 4279dcba1e Merge pull request #354 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.12
2025-11-25 18:19:32 +01:00
Chris Coutinho be7e3d6b56 Merge pull request #355 from cbcoutinho/renovate/qdrant-qdrant-1.x
chore(deps): update qdrant/qdrant docker tag to v1.16.1
2025-11-25 18:19:07 +01:00
renovate-bot-cbcoutinho[bot] 41e128190b chore(deps): update qdrant/qdrant docker tag to v1.16.1 2025-11-25 17:06:22 +00:00
renovate-bot-cbcoutinho[bot] ba869ccde5 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.12 2025-11-25 17:06:11 +00:00
Chris Coutinho 27fe066b23 Merge pull request #353 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.2
chore(deps): update docker.io/library/nextcloud:32.0.2 docker digest to 8cb1dc8
2025-11-23 19:41:19 +01:00
renovate-bot-cbcoutinho[bot] e94b8ff714 chore(deps): update docker.io/library/nextcloud:32.0.2 docker digest to 8cb1dc8 2025-11-23 17:04:03 +00:00
github-actions[bot] e3a6894904 bump: version 0.48.3 → 0.48.4 2025-11-23 16:40:06 +00:00
Chris Coutinho 92b97bda00 fix: Add rate limit retry logic to OpenAI provider
Add exponential backoff retry handling for OpenAI API rate limits
(429 errors). This is needed for GitHub Models API which has stricter
rate limits than standard OpenAI API.

- Add retry_on_rate_limit decorator with exponential backoff
- Max 5 retries with delays: 2s → 4s → 8s → 16s → 32s
- Apply to embed(), _embed_batch_request(), and generate() methods

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 17:24:48 +01:00
Chris Coutinho d5c6039296 ci: Update rag pipeline 2025-11-23 16:33:39 +01:00
Chris Coutinho 3fa13c8bfd ci: Update rag pipeline 2025-11-23 16:12:37 +01:00
Chris Coutinho 9d306b71fa ci: Fix pytest path 2025-11-23 15:43:45 +01:00
Chris Coutinho 38a936c120 Merge pull request #352 from cbcoutinho/renovate/major-github-artifact-actions
chore(deps): update actions/upload-artifact action to v5
2025-11-23 12:43:43 +01:00
renovate-bot-cbcoutinho[bot] 86d13a7240 fix(deps): update dependency pillow to v12 2025-11-23 05:05:03 +00:00
renovate-bot-cbcoutinho[bot] 0b2d449ffa chore(deps): update actions/upload-artifact action to v5 2025-11-23 05:04:36 +00:00
Chris Coutinho d881373dce ci: Remove third_party from app mounts 2025-11-23 05:48:17 +01:00
github-actions[bot] 9ade4c65f3 bump: version 0.48.2 → 0.48.3 2025-11-23 04:44:17 +00:00
Chris Coutinho 5c73b85f65 fix: Increase MCP sampling timeout to 5 minutes for slower LLMs
- Increase sampling timeout from 30s to 300s in semantic.py to accommodate
  slower local LLMs like Ollama
- Refactor RAG integration tests to support multiple providers (ollama,
  openai, anthropic, bedrock)
- Remove unnecessary embedding_provider fixture since MCP server handles
  embeddings internally
- Add --provider flag via tests/integration/conftest.py
- Add provider_fixtures.py with factory functions for generation providers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 05:43:48 +01:00
67 changed files with 5772 additions and 853 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
packages: write
steps:
- name: Check out
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
@@ -25,7 +25,7 @@ jobs:
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
+57
View File
@@ -0,0 +1,57 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
+50
View File
@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+2 -2
View File
@@ -12,11 +12,11 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
# list of Docker images to use as base name for tags
images: |
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
+16 -24
View File
@@ -24,39 +24,25 @@ jobs:
models: read
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
submodules: 'true'
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
with:
php-version: 8.4
coverage: none
- name: Install OIDC app composer dependencies
run: |
cd third_party/oidc
composer install --no-dev
###### Required to build OIDC App ######
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Run docker compose with vector sync
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
with:
compose-file: "./docker-compose.yml"
compose-file: |
./docker-compose.yml
./docker-compose.ci.yml
up-flags: "--build"
env:
# Override MCP container environment for OpenAI + vector sync
VECTOR_SYNC_ENABLED: "true"
VECTOR_SYNC_SCAN_INTERVAL: "5"
# Environment variables passed to docker-compose.ci.yml
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: "https://models.github.ai/inference"
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
VECTOR_SYNC_SCAN_INTERVAL: "5"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
- name: Wait for Nextcloud to be ready
run: |
@@ -101,11 +87,17 @@ jobs:
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
run: |
uv run pytest tests/integration/test_rag_openai.py -v --log-cli-level=INFO
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
- name: Capture MCP container logs
if: always()
run: |
echo "=== MCP Container Logs ==="
docker compose logs mcp --tail=500
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: rag-evaluation-results
path: |
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+6 -6
View File
@@ -9,9 +9,9 @@ jobs:
linting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install the latest version of uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
submodules: 'true'
@@ -35,7 +35,7 @@ jobs:
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
with:
php-version: 8.4
coverage: none
@@ -49,14 +49,14 @@ jobs:
- name: Run docker compose
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
with:
compose-file: "./docker-compose.yml"
#compose-flags: "--profile qdrant"
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
- name: Install Playwright dependencies
run: |
+95
View File
@@ -1,3 +1,98 @@
## v0.52.1 (2025-12-13)
### Perf
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
## v0.52.0 (2025-12-13)
### Feat
- **vector**: add Deck card vector search with visualization support
## v0.51.0 (2025-12-13)
### Feat
- **vector-viz**: add news_item support for links and chunk expansion
## v0.50.2 (2025-12-13)
### Fix
- **news**: revert get_item() to use get_items() + filter
## v0.50.1 (2025-12-12)
### Fix
- Disable DNS rebinding protection for containerized deployments
- **deps**: update dependency mcp to >=1.23,<1.24
## v0.50.0 (2025-12-11)
### Feat
- add MCP tool annotations for enhanced UX
### Fix
- address PR review feedback
## v0.49.2 (2025-12-09)
### Fix
- Update lockfile
## v0.49.1 (2025-12-09)
### Fix
- Revert mcp version <1.23
## v0.49.0 (2025-12-08)
### Feat
- **news**: add Nextcloud News app integration
### Fix
- resolve all type checking errors (8 errors fixed)
### Refactor
- **news**: simplify vector sync to fetch all items
### Perf
- **news**: use direct API endpoint for get_item()
## v0.48.6 (2025-12-03)
### Fix
- **deps**: update dependency mcp to >=1.23,<1.24
## v0.48.5 (2025-11-28)
### Fix
- **deps**: update dependency pillow to v12
## v0.48.4 (2025-11-23)
### Fix
- Add rate limit retry logic to OpenAI provider
## v0.48.3 (2025-11-23)
### Fix
- Increase MCP sampling timeout to 5 minutes for slower LLMs
## v0.48.2 (2025-11-23)
### Fix
+62
View File
@@ -56,6 +56,68 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
### MCP Tool Annotations (ADR-017)
**All tools MUST include annotations** following these patterns:
```python
from mcp.types import ToolAnnotations
# Read-only tools (list, search, get)
@mcp.tool(
title="Human Readable Name",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True, # Nextcloud is external to MCP server
),
)
# Create operations
@mcp.tool(
title="Create Resource",
annotations=ToolAnnotations(
idempotentHint=False, # Creates new resources each time
openWorldHint=True,
),
)
# Update operations (with etag/version control)
@mcp.tool(
title="Update Resource",
annotations=ToolAnnotations(
idempotentHint=False, # ETag changes = different inputs
openWorldHint=True,
),
)
# Delete operations
@mcp.tool(
title="Delete Resource",
annotations=ToolAnnotations(
destructiveHint=True, # Permanently deletes data
idempotentHint=True, # Same end state if called repeatedly
openWorldHint=True,
),
)
# HTTP PUT without version control (special case)
@mcp.tool(
title="Write File",
annotations=ToolAnnotations(
idempotentHint=True, # Same content = same end state
openWorldHint=True,
),
)
```
**Key Principles**:
- **Idempotency**: Same inputs → same result. ETags change after updates, making them non-idempotent
- **Destructive**: Operations that permanently delete/overwrite data
- **Open World**: All Nextcloud tools access external service (openWorldHint=True)
- **Titles**: Use human-readable names, not snake_case function names
**See**: `docs/ADR-017-mcp-tool-annotations.md` for detailed rationale and examples
### Project Structure
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
+2 -2
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+2 -2
View File
@@ -12,12 +12,12 @@
# - Per-session app password authentication
# - Multi-user support via Smithery session config
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+3 -3
View File
@@ -63,7 +63,7 @@ http://127.0.0.1:8000/mcp
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama)
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
@@ -81,7 +81,7 @@ http://127.0.0.1:8000/mcp
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
| **Tables** | 5 | Row operations on Nextcloud Tables |
| **Sharing** | 10+ | Create and manage shares |
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) |
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
@@ -145,7 +145,7 @@ This enables natural language queries and helps discover related content across
### Features
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes, Files, News items, Deck cards; opt-in)
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
### Advanced Topics
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable news
+3 -3
View File
@@ -1,9 +1,9 @@
dependencies:
- name: qdrant
repository: https://qdrant.github.io/qdrant-helm
version: 1.16.0
version: 1.16.2
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.35.0
digest: sha256:da8db198b12ce0252df220fabb297cfe69186edb8e67952c52e05de778189b92
generated: "2025-11-21T11:09:07.997781541Z"
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
generated: "2025-12-05T11:11:27.999374001Z"
+3 -3
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.48.2
appVersion: "0.48.2"
version: 0.52.1
appVersion: "0.52.1"
keywords:
- nextcloud
- mcp
@@ -27,7 +27,7 @@ annotations:
grafana_dashboard_folder: "Nextcloud MCP"
dependencies:
- name: qdrant
version: "1.16.0"
version: "1.16.2"
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
+25
View File
@@ -0,0 +1,25 @@
# CI-specific overrides for RAG evaluation pipeline
# This file is used by the rag-evaluation.yml workflow to configure the MCP
# container with OpenAI/GitHub Models API for vector embeddings.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
#
# Environment variables (set in CI workflow):
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
services:
mcp:
environment:
# OpenAI provider configuration (required for CI vector sync)
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
# Faster sync for CI
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
# Enable document processing for PDF parsing
- ENABLE_DOCUMENT_PROCESSING=true
+5 -5
View File
@@ -21,7 +21,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.2@sha256:ac08482d73ffd85d94069ba291bbd5fb39a70ff21502030a2e3e2d89a7246a48
image: docker.io/library/nextcloud:32.0.3@sha256:54993ed39dc77f7a6ade142b1625972cb7a9393074325373402d47231314afbb
restart: always
ports:
- 0.0.0.0:8080:80
@@ -34,7 +34,7 @@ services:
- ./app-hooks:/docker-entrypoint-hooks.d:ro
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
- ./third_party:/opt/apps:ro
#- ./third_party:/opt/apps:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -51,7 +51,7 @@ services:
retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
image: docker.io/library/nginx:alpine@sha256:289decab414250121a93c3f1b8316b9c69906de3a4993757c424cb964169ad42
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
@@ -158,7 +158,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
command:
- "start-dev"
- "--import-realm"
@@ -245,7 +245,7 @@ services:
- smithery
qdrant:
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
restart: always
ports:
- 127.0.0.1:6333:6333 # REST API
+506
View File
@@ -0,0 +1,506 @@
# ADR-017: Add MCP Tool Annotations for Enhanced Client UX
## Status
Implemented
## Context
The MCP Python SDK supports tool annotations that provide behavioral hints and improved UX to MCP clients. Currently, our 101 tools across 10 modules lack these annotations, resulting in:
- Snake_case function names displayed to users (e.g., "nc_notes_create_note" instead of "Create Note")
- No behavioral hints for clients about read-only, destructive, or idempotent operations
- Missing parameter descriptions for better auto-completion and inline help
- Clients cannot optimize caching, warn before destructive operations, or retry safely
### Available MCP Annotations
The MCP SDK provides three types of annotations:
#### 1. Tool Decorator Parameters
```python
@mcp.tool(
title="Human-Readable Name",
description="Tool description", # Can also come from docstring
annotations=ToolAnnotations(...),
icons=[Icon(...)] # Optional visual icons
)
```
#### 2. ToolAnnotations Behavioral Hints
```python
from mcp.types import ToolAnnotations
ToolAnnotations(
title="Alternative Title", # Decorator title takes precedence
readOnlyHint=True, # Tool doesn't modify data
destructiveHint=True, # Tool may delete/overwrite data
idempotentHint=True, # Repeated calls with same args are safe
openWorldHint=True # Interacts with external entities
)
```
#### 3. Parameter Descriptions
```python
from pydantic import Field
async def tool(
param: str = Field(description="What this parameter does"),
ctx: Context
):
```
### Idempotency Analysis
**Important**: Idempotency means calling with **the same inputs** produces the same result.
**NOT Idempotent** (different inputs each call):
- **Updates with etag**: `update_note(id=1, title="X", etag="abc")` → etag changes to "def"
- Second call: `update_note(id=1, title="X", etag="abc")` → fails (etag mismatch)
- Different input (stale etag) → different result (error)
- **Creates**: `create_note(title="X")` → creates note 1
- Second call → creates note 2 (different result)
- **Append operations**: `append_content(id=1, text="X")` → adds X once
- Second call → adds X again (different result)
**Idempotent**:
- **Deletes**: `delete_note(id=1)` → note deleted
- Second call → 404 or success (same end state: note doesn't exist)
- Note: May return different status code, but end state is identical
- **Full resource PUT without version control**: `write_file(path="/test.txt", content="Hello")` → file has "Hello"
- Second call → file still has "Hello" (same end state)
- Example: `nc_webdav_write_file` uses HTTP PUT without etags/version control
- **Set operations**: `set_property(id=1, value="X")` → property = X
- Second call → property still = X (same result)
- Note: Nextcloud updates with etags use version control, so not idempotent
**Read-Only** (always idempotent, never destructive):
- All list, search, get operations
## Decision
Add annotations to all 101 tools in three phases:
### Phase 1: Titles (Quick Win)
Add human-readable titles to all tools:
```python
@mcp.tool(title="Create Note")
async def nc_notes_create_note(...):
```
**Effort**: 2-3 hours
**Impact**: Immediate UX improvement
### Phase 2: ToolAnnotations (Behavioral Hints)
Add annotations based on corrected categorization:
```python
# Read-only tools
@mcp.tool(
title="Search Notes",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True # Nextcloud is external to MCP server
)
)
# Delete tools (idempotent: same end state)
@mcp.tool(
title="Delete Note",
annotations=ToolAnnotations(
destructiveHint=True,
idempotentHint=True, # Deleting deleted item = same end state
openWorldHint=True
)
)
# Create tools (not idempotent: creates multiple items)
@mcp.tool(
title="Create Note",
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True
)
)
# Update tools with etag (not idempotent: etag changes)
@mcp.tool(
title="Update Note",
annotations=ToolAnnotations(
idempotentHint=False, # Etag required = different inputs each time
openWorldHint=True
)
)
# Append operations (not idempotent: adds content each time)
@mcp.tool(
title="Append to Note",
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True
)
)
```
**Effort**: 4-6 hours
**Impact**: Better client behavior (caching, warnings, retry logic)
### Phase 3: Parameter Descriptions
Add Field() descriptions to parameters:
```python
from pydantic import Field
@mcp.tool(title="Create Note", annotations=ToolAnnotations(idempotentHint=False))
async def nc_notes_create_note(
title: str = Field(description="The title of the note"),
content: str = Field(description="Markdown content of the note"),
category: str = Field(description="Category or folder name for organizing"),
ctx: Context
) -> CreateNoteResponse:
```
**Effort**: 6-8 hours
**Impact**: Better auto-completion and inline help
## Tool Categorization
### Read-Only Tools (~40 tools)
**Pattern**: List, search, get operations
**Annotations**: `readOnlyHint=True`, `openWorldHint=True`
Examples:
- `nc_notes_search_notes` → "Search Notes"
- `nc_webdav_list_directory` → "List Files and Directories"
- `nc_calendar_list_calendars` → "List Calendars"
- `nc_contacts_get_contact` → "Get Contact"
- `nc_semantic_search` → "Semantic Search"
- `check_logged_in` → "Check Server Login Status"
### Create Tools (~20 tools)
**Pattern**: Create new resources
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
Examples:
- `nc_notes_create_note` → "Create Note"
- `nc_calendar_create_event` → "Create Calendar Event"
- `nc_contacts_create_contact` → "Create Contact"
- `deck_create_card` → "Create Kanban Card"
- `nc_tables_create_row` → "Create Table Row"
### Update Tools (~25 tools)
**Pattern**: Modify existing resources with etag
**Annotations**: `idempotentHint=False` (etag changes), `openWorldHint=True`
Examples:
- `nc_notes_update_note` → "Update Note"
- `nc_calendar_update_event` → "Update Calendar Event"
- `nc_contacts_update_contact` → "Update Contact"
- `deck_update_card` → "Update Kanban Card"
**Rationale**: Updates require etag, which changes after each update. Same parameters on second call will fail due to stale etag = NOT idempotent.
### Append/Accumulate Tools (~5 tools)
**Pattern**: Add content without replacing
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
Examples:
- `nc_notes_append_content` → "Append to Note"
**Rationale**: Each call adds content, changing the result = NOT idempotent.
### Delete Tools (~10 tools)
**Pattern**: Remove resources
**Annotations**: `destructiveHint=True`, `idempotentHint=True`, `openWorldHint=True`
Examples:
- `nc_notes_delete_note` → "Delete Note"
- `nc_webdav_delete_resource` → "Delete File or Directory"
- `nc_calendar_delete_event` → "Delete Calendar Event"
- `nc_contacts_delete_contact` → "Delete Contact"
**Rationale**: Deleting already-deleted item results in same end state (item doesn't exist) = idempotent. Status code may differ, but outcome is identical.
### Special Cases
#### OAuth Provisioning Tools
```python
# Not read-only but requires user interaction
@mcp.tool(
title="Grant Server Access to Nextcloud",
annotations=ToolAnnotations(
readOnlyHint=False,
idempotentHint=False, # Creates new OAuth session each time
openWorldHint=True
)
)
async def provision_nextcloud_access(ctx: Context):
```
#### Semantic Search (Closed World)
```python
@mcp.tool(
title="Semantic Search",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=False # Searches only indexed Nextcloud data
)
)
async def nc_semantic_search(query: str, ctx: Context):
```
**Rationale**: Semantic search only queries pre-indexed Nextcloud content, not the "open world" like web search would.
## Tool Priority Matrix
### Critical Priority (~2 tools)
OAuth tools required for server functionality:
- `provision_nextcloud_access` → "Grant Server Access to Nextcloud"
- `check_logged_in` → "Check Server Login Status"
### High Priority (~50 tools)
Most commonly used modules:
- **Notes** (14 tools): Create, read, update, delete notes
- **WebDAV** (13 tools): File operations
- **Calendar** (15 tools): Events and todos
- **Semantic Search** (6 tools): AI-powered search
- **Contacts** (9 tools): Address book operations
### Medium Priority (~35 tools)
Secondary functionality:
- **Deck** (9 tools): Kanban boards
- **Tables** (7 tools): Structured data
- **Sharing** (5 tools): File sharing
### Low Priority (~14 tools)
Less frequently used:
- **Cookbook** (8 tools): Recipe management
- **News** (6 tools): RSS feeds
## Implementation Plan
### Week 1: Phase 1 - Titles
- Add human-readable titles to all 101 tools
- Update tool name mapping in documentation
- Manual test in MCP inspector
### Week 2: Phase 2 - ToolAnnotations (High Priority)
- Add annotations to Critical and High priority tools (~52 tools)
- Focus on Notes, WebDAV, Calendar, Semantic, OAuth
- Add unit tests validating annotation presence
### Week 3: Phase 2 - ToolAnnotations (Medium/Low Priority)
- Complete remaining tools (~49 tools)
- Deck, Tables, Contacts, Cookbook, News
- Update tool listings in README
### Week 4: Phase 3 - Parameter Descriptions
- Add Field() descriptions to Critical/High priority tools
- Start with OAuth, Notes, WebDAV modules
- Incremental completion over time
## Benefits
### For Users
- **Clearer UI**: "Create Note" vs "nc_notes_create_note"
- **Safety**: Warnings before destructive operations
- **Better help**: Parameter descriptions in auto-completion
- **Confidence**: Know which operations are safe to retry
### For MCP Clients
- **Caching**: Cache results from read-only tools
- **Safety prompts**: Warn before destructiveHint=true
- **Retry logic**: Safely retry idempotent operations
- **UI organization**: Group by behavior (reads vs writes vs deletes)
- **Performance**: Optimize based on hints
### For Developers
- **Self-documenting**: Behavior is explicit
- **Consistency**: Standard patterns across codebase
- **Testing**: Validate annotations match implementation
- **Maintenance**: Clear expectations for new tools
## Consequences
### Positive
- Immediate UX improvement with minimal effort
- Clients can make smarter decisions
- Self-documenting code
- Follows MCP best practices
### Negative
- Initial effort to add annotations (12-15 hours total)
- Must maintain annotations when adding new tools
- Risk of incorrect annotations misleading clients
### Neutral
- Annotations are hints, not guarantees
- Clients may ignore annotations
- Backward compatible (additive change)
### Mitigations
- **Incorrect annotations**: Add tests validating behavior matches hints
- **Maintenance burden**: Add to code review checklist and tool template
- **Documentation**: Update CLAUDE.md with annotation guidelines
## Examples
### Complete Annotated Tool (Delete)
```python
from mcp.types import ToolAnnotations
from pydantic import Field
@mcp.tool(
title="Delete Note",
annotations=ToolAnnotations(
destructiveHint=True, # Deletes data permanently
idempotentHint=True, # Same end state (note doesn't exist)
openWorldHint=True # Nextcloud is external
)
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_delete_note(
note_id: int = Field(description="The ID of the note to delete permanently"),
ctx: Context
) -> DeleteNoteResponse:
"""Delete a note permanently (requires notes:write scope)"""
client = await get_client(ctx)
# ... implementation ...
```
### Complete Annotated Tool (Update)
```python
@mcp.tool(
title="Update Note",
annotations=ToolAnnotations(
idempotentHint=False, # NOT idempotent: etag changes each update
openWorldHint=True
)
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_update_note(
note_id: int = Field(description="The ID of the note to update"),
title: str | None = Field(
default=None,
description="New title (omit to keep current)"
),
content: str | None = Field(
default=None,
description="New markdown content (omit to keep current)"
),
category: str | None = Field(
default=None,
description="New category/folder (omit to keep current)"
),
etag: str = Field(
description="ETag from get_note (prevents concurrent modification)"
),
ctx: Context
) -> UpdateNoteResponse:
"""Update an existing note's title, content, or category.
The etag parameter is required to prevent overwriting concurrent changes.
Get the current ETag by first calling nc_notes_get_note.
If the note has been modified since you retrieved it, the update will fail.
"""
client = await get_client(ctx)
# ... implementation ...
```
### Complete Annotated Tool (Read-Only)
```python
@mcp.tool(
title="Search Notes",
annotations=ToolAnnotations(
readOnlyHint=True, # Doesn't modify data
openWorldHint=True # Queries Nextcloud
)
)
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_search_notes(
query: str = Field(description="Search term to match in note titles or content"),
ctx: Context
) -> SearchNotesResponse:
"""Search notes by title or content, returning id, title, and category.
This is a read-only operation that searches across all user notes.
Use nc_notes_get_note to retrieve the full content of matching notes.
"""
client = await get_client(ctx)
# ... implementation ...
```
## Testing Strategy
### Unit Tests
Add tests validating annotation presence and correctness:
```python
def test_notes_tools_have_annotations():
"""Verify all notes tools have appropriate annotations."""
tools = get_registered_tools(mcp)
# Check create tool
create_tool = tools["nc_notes_create_note"]
assert create_tool.title == "Create Note"
assert create_tool.annotations.idempotentHint is False
# Check delete tool
delete_tool = tools["nc_notes_delete_note"]
assert delete_tool.title == "Delete Note"
assert delete_tool.annotations.destructiveHint is True
assert delete_tool.annotations.idempotentHint is True
# Check read-only tool
search_tool = tools["nc_notes_search_notes"]
assert search_tool.title == "Search Notes"
assert search_tool.annotations.readOnlyHint is True
```
### Integration Tests
- Verify existing tests pass with annotations
- Manual testing in MCP inspector/client
### Documentation Updates
- Update README tool listings with new titles
- Add annotation guidelines to CLAUDE.md
- Include examples in developer documentation
## Resolved Questions
1. **WebDAV write_file idempotency** (Resolved: 2025-12-11)
- **Decision**: Mark as `idempotentHint=True`
- **Rationale**: Uses HTTP PUT without version control. Writing same content to same path repeatedly produces identical end state, which is the definition of idempotency in HTTP semantics.
2. **Semantic search openWorldHint** (Resolved: 2025-12-11)
- **Decision**: Mark as `openWorldHint=True`
- **Rationale**: For consistency with other Nextcloud tools. While the data being searched is "indexed/internal", Nextcloud itself is external to the MCP server. The fact that data is indexed is an implementation detail, not a fundamental difference from other Nextcloud queries.
3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true?
- **Decision**: Yes. Logging/analytics are non-visible side effects that don't change user-observable state. Read-only refers to data modifications that affect the user's content.
## Future Considerations
1. **Icons**: Visual icons for tools (requires design work, deferred to future ADR)
2. **Parameter descriptions**: Add Pydantic `Field(description=...)` for better auto-completion (Phase 3, future work)
## References
- MCP Python SDK: `/home/chris/Software/python-sdk/`
- ToolAnnotations spec: `src/mcp/types.py:1247`
- FastMCP decorator: `src/mcp/server/fastmcp/server.py:444`
- Examples: `examples/fastmcp/parameter_descriptions.py`, `examples/fastmcp/icons_demo.py`
## Decision Timeline
- **Proposed**: 2025-12-11
- **Reviewed**: 2025-12-11 (Self-review during implementation)
- **Accepted**: 2025-12-11
- **Implemented**: 2025-12-11 (Phase 1 & 2 complete)
+104
View File
@@ -0,0 +1,104 @@
# MCP 1.23.x DNS Rebinding Protection Fix
## Problem
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
### Root Cause
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
```python
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
```
### What Was Happening
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
### Why `--host 0.0.0.0` Didn't Help
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
## Solution
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
### Changes Made
Modified `nextcloud_mcp_server/app.py`:
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
2. **Updated all three FastMCP initializations**:
- OAuth mode (line 1015)
- Smithery stateless mode (line 1030)
- BasicAuth mode (line 1040)
Each now includes:
```python
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
```
## Impact
### ✅ What This Fixes
- **Kubernetes deployments**: Requests with k8s service DNS names now work
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
- **Ingress controllers**: Requests via ingress hostnames now work
### 🔒 Security Considerations
DNS rebinding protection defends against attacks where:
1. Attacker controls a DNS domain (e.g., `evil.com`)
2. DNS initially resolves to attacker's IP
3. After victim's browser caches the origin, DNS changes to victim's localhost
4. Attacker's page can now make requests to victim's localhost services
**Why it's safe to disable for this deployment:**
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
4. **Host header validation inappropriate** for multi-tenant k8s environments
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
```python
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=[
"nextcloud-mcp-server.default.svc.cluster.local:*",
"mcp.example.com:*",
# Add all your expected Host header values
]
)
```
## Testing
- ✅ Ruff linting passes
- ✅ Type checking passes (pre-existing warnings unrelated)
- ✅ Module imports successfully
- ✅ Compatible with MCP 1.23.x
## References
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
- Issue #373 (original report of k8s breakage)
- PR #382 (MCP 1.23.x upgrade)
+4 -4
View File
@@ -5,7 +5,7 @@ This document explains the architecture of the semantic search feature in the Ne
> [!IMPORTANT]
> **Status: Experimental**
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
> - Currently supports **Notes, Files (PDFs), News items, and Deck cards**
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
> - RAG answer generation requires MCP client sampling support
@@ -39,9 +39,9 @@ Semantic search enables:
### Current Support
- **Supported Apps**: Notes (fully implemented)
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
- **Architecture**: Multi-app plugin system ready, awaiting implementation
- **Supported Apps**: Notes, Files (PDFs with text extraction), News items, Deck cards
- **Planned Apps**: Calendar events, Calendar tasks, Contacts
- **Architecture**: Multi-app plugin system ready for additional apps
## System Components
+26 -3
View File
@@ -19,6 +19,7 @@ import httpx
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -60,6 +61,7 @@ from nextcloud_mcp_server.server import (
configure_contacts_tools,
configure_cookbook_tools,
configure_deck_tools,
configure_news_tools,
configure_notes_tools,
configure_semantic_tools,
configure_sharing_tools,
@@ -514,7 +516,7 @@ async def load_oauth_client_credentials(
# and the authorization server will limit them to these allowed scopes.
#
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write"
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write"
# Add offline_access scope if refresh tokens are enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
@@ -1015,6 +1017,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
lifespan=oauth_lifespan,
token_verifier=token_verifier,
auth=auth_settings,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
@@ -1023,11 +1030,26 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# json_response=True returns plain JSON-RPC instead of SSE format,
# required for Smithery scanner compatibility
mcp = FastMCP(
"Nextcloud MCP", lifespan=app_lifespan_smithery, json_response=True
"Nextcloud MCP",
lifespan=app_lifespan_smithery,
json_response=True,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
logger.info("Configuring MCP server for BasicAuth mode")
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_basic,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
@mcp.resource("nc://capabilities")
async def nc_get_capabilities():
@@ -1046,6 +1068,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"contacts": configure_contacts_tools,
"cookbook": configure_cookbook_tools,
"deck": configure_deck_tools,
"news": configure_news_tools,
}
# If no specific apps are specified, enable all
@@ -201,8 +201,15 @@ function vizApp() {
return `${baseUrl}/apps/calendar`;
case 'contact':
return `${baseUrl}/apps/contacts`;
case 'deck':
case 'deck_card':
// URL pattern: /apps/deck/board/:boardId/card/:cardId
if (result.metadata && result.metadata.board_id) {
return `${baseUrl}/apps/deck/board/${result.metadata.board_id}/card/${result.id}`;
}
// Fallback if board_id not available
return `${baseUrl}/apps/deck`;
case 'news_item':
return `${baseUrl}/apps/news/item/${result.id}`;
default:
return `${baseUrl}`;
}
@@ -65,8 +65,12 @@
<span>Contacts</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="deck" style="margin-right: 4px;">
<span>Deck</span>
<input type="checkbox" x-model="docTypes" value="deck_card" style="margin-right: 4px;">
<span>Deck Cards</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="news_item" style="margin-right: 4px;">
<span>News</span>
</label>
</div>
</div>
@@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier):
try:
# Introspection requires client authentication
client_id = self.settings.oidc_client_id
client_secret = self.settings.oidc_client_secret
assert client_id is not None and client_secret is not None
response = await self.http_client.post(
self.introspection_uri,
data={"token": token},
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
auth=(client_id, client_secret),
)
if response.status_code == 200:
+2
View File
@@ -298,6 +298,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
"title": r.title,
"excerpt": r.excerpt,
"score": r.score,
"metadata": r.metadata,
}
for r in search_results
],
@@ -458,6 +459,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
), # Raw score from algorithm
"chunk_start_offset": r.chunk_start_offset,
"chunk_end_offset": r.chunk_end_offset,
"metadata": r.metadata, # Include metadata (e.g., board_id for deck_card)
}
for r in search_results
]
@@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
raise RuntimeError("BasicAuth credentials not configured")
assert nextcloud_host is not None # Type narrowing for type checker
assert username is not None and password is not None # Type narrowing
return httpx.AsyncClient(
base_url=nextcloud_host,
auth=(username, password),
+2
View File
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
from .cookbook import CookbookClient
from .deck import DeckClient
from .groups import GroupsClient
from .news import NewsClient
from .notes import NotesClient
from .sharing import SharingClient
from .tables import TablesClient
@@ -81,6 +82,7 @@ class NextcloudClient:
self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username)
self.deck = DeckClient(self._client, username)
self.news = NewsClient(self._client, username)
self.users = UsersClient(self._client, username)
self.groups = GroupsClient(self._client, username)
self.sharing = SharingClient(self._client, username)
+394
View File
@@ -0,0 +1,394 @@
"""Client for Nextcloud News app operations."""
import logging
from enum import IntEnum
from typing import Any
from .base import BaseNextcloudClient
logger = logging.getLogger(__name__)
class NewsItemType(IntEnum):
"""Type constants for News API item queries."""
FEED = 0 # Single feed
FOLDER = 1 # Folder and its feeds
STARRED = 2 # All starred items
ALL = 3 # All items
class NewsClient(BaseNextcloudClient):
"""Client for Nextcloud News app operations."""
app_name = "news"
API_BASE = "/apps/news/api/v1-3"
# --- Folders ---
async def get_folders(self) -> list[dict[str, Any]]:
"""Get all folders."""
response = await self._make_request("GET", f"{self.API_BASE}/folders")
return response.json().get("folders", [])
async def create_folder(self, name: str) -> dict[str, Any]:
"""Create a new folder.
Args:
name: Folder name
Returns:
Created folder data
Raises:
HTTPStatusError: 409 if folder name already exists,
422 if name is empty
"""
response = await self._make_request(
"POST", f"{self.API_BASE}/folders", json={"name": name}
)
folders = response.json().get("folders", [])
return folders[0] if folders else {}
async def rename_folder(self, folder_id: int, name: str) -> None:
"""Rename a folder.
Args:
folder_id: Folder ID
name: New folder name
Raises:
HTTPStatusError: 404 if folder not found, 409 if name exists
"""
await self._make_request(
"PUT", f"{self.API_BASE}/folders/{folder_id}", json={"name": name}
)
async def delete_folder(self, folder_id: int) -> None:
"""Delete a folder and all its feeds/items.
Args:
folder_id: Folder ID
Raises:
HTTPStatusError: 404 if folder not found
"""
await self._make_request("DELETE", f"{self.API_BASE}/folders/{folder_id}")
async def mark_folder_read(self, folder_id: int, newest_item_id: int) -> None:
"""Mark all items in a folder as read.
Args:
folder_id: Folder ID
newest_item_id: ID of newest item to mark read (prevents marking
items user hasn't seen yet)
Raises:
HTTPStatusError: 404 if folder not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/folders/{folder_id}/read",
json={"newestItemId": newest_item_id},
)
# --- Feeds ---
async def get_feeds(self) -> dict[str, Any]:
"""Get all feeds with metadata.
Returns:
Dict with keys:
- feeds: List of feed objects
- starredCount: Number of starred items
- newestItemId: ID of newest item (omitted if no items)
"""
response = await self._make_request("GET", f"{self.API_BASE}/feeds")
return response.json()
async def create_feed(
self, url: str, folder_id: int | None = None
) -> dict[str, Any]:
"""Subscribe to a new feed.
Args:
url: Feed URL
folder_id: Optional folder ID (None for root)
Returns:
Created feed data
Raises:
HTTPStatusError: 409 if feed already exists, 422 if URL is invalid
"""
body: dict[str, Any] = {"url": url}
if folder_id is not None:
body["folderId"] = folder_id
response = await self._make_request("POST", f"{self.API_BASE}/feeds", json=body)
data = response.json()
feeds = data.get("feeds", [])
return feeds[0] if feeds else {}
async def delete_feed(self, feed_id: int) -> None:
"""Unsubscribe from a feed (deletes all items).
Args:
feed_id: Feed ID
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request("DELETE", f"{self.API_BASE}/feeds/{feed_id}")
async def move_feed(self, feed_id: int, folder_id: int | None) -> None:
"""Move a feed to a different folder.
Args:
feed_id: Feed ID
folder_id: Target folder ID (None for root)
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/move",
json={"folderId": folder_id},
)
async def rename_feed(self, feed_id: int, title: str) -> None:
"""Rename a feed.
Args:
feed_id: Feed ID
title: New feed title
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/rename",
json={"feedTitle": title},
)
async def mark_feed_read(self, feed_id: int, newest_item_id: int) -> None:
"""Mark all items in a feed as read.
Args:
feed_id: Feed ID
newest_item_id: ID of newest item to mark read
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/read",
json={"newestItemId": newest_item_id},
)
# --- Items ---
async def get_items(
self,
batch_size: int = 50,
offset: int = 0,
type_: int = NewsItemType.ALL,
id_: int = 0,
get_read: bool = True,
oldest_first: bool = False,
) -> list[dict[str, Any]]:
"""Get items (articles) with filtering.
Args:
batch_size: Number of items to return (-1 for all)
offset: Item ID to start after (for pagination)
type_: Item type filter (NewsItemType)
id_: Feed/folder ID (ignored for STARRED/ALL types)
get_read: Include read items
oldest_first: Sort oldest first instead of newest
Returns:
List of item objects
"""
params: dict[str, Any] = {
"batchSize": batch_size,
"offset": offset,
"type": type_,
"id": id_,
"getRead": str(get_read).lower(),
"oldestFirst": str(oldest_first).lower(),
}
response = await self._make_request(
"GET", f"{self.API_BASE}/items", params=params
)
return response.json().get("items", [])
async def get_item(self, item_id: int) -> dict[str, Any]:
"""Get a specific item by ID.
Note: The News API doesn't have a direct single-item endpoint,
so we fetch all items and filter. For efficiency, consider
caching or using get_items with specific feed if known.
Args:
item_id: Item ID
Returns:
Item data
Raises:
ValueError: If item not found
"""
# Fetch all items and find the one we need
# This is inefficient but the API doesn't provide a direct endpoint
items = await self.get_items(batch_size=-1, get_read=True)
for item in items:
if item.get("id") == item_id:
return item
raise ValueError(f"Item {item_id} not found")
async def get_updated_items(
self,
last_modified: int,
type_: int = NewsItemType.ALL,
id_: int = 0,
) -> list[dict[str, Any]]:
"""Get items modified since a timestamp (for delta sync).
Args:
last_modified: Unix timestamp (seconds or microseconds)
type_: Item type filter
id_: Feed/folder ID
Returns:
List of modified items (includes deleted items)
"""
params: dict[str, Any] = {
"lastModified": last_modified,
"type": type_,
"id": id_,
}
response = await self._make_request(
"GET", f"{self.API_BASE}/items/updated", params=params
)
return response.json().get("items", [])
async def mark_item_read(self, item_id: int) -> None:
"""Mark a single item as read.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/read")
async def mark_item_unread(self, item_id: int) -> None:
"""Mark a single item as unread.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unread")
async def star_item(self, item_id: int) -> None:
"""Star (favorite) a single item.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/star")
async def unstar_item(self, item_id: int) -> None:
"""Unstar a single item.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unstar")
async def mark_items_read(self, item_ids: list[int]) -> None:
"""Mark multiple items as read.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/read/multiple", json={"itemIds": item_ids}
)
async def mark_items_unread(self, item_ids: list[int]) -> None:
"""Mark multiple items as unread.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST",
f"{self.API_BASE}/items/unread/multiple",
json={"itemIds": item_ids},
)
async def star_items(self, item_ids: list[int]) -> None:
"""Star multiple items.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/star/multiple", json={"itemIds": item_ids}
)
async def unstar_items(self, item_ids: list[int]) -> None:
"""Unstar multiple items.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST",
f"{self.API_BASE}/items/unstar/multiple",
json={"itemIds": item_ids},
)
async def mark_all_read(self, newest_item_id: int) -> None:
"""Mark all items as read.
Args:
newest_item_id: ID of newest item to mark read
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/read", json={"newestItemId": newest_item_id}
)
# --- Status ---
async def get_status(self) -> dict[str, Any]:
"""Get News app status and configuration.
Returns:
Dict with version and warnings
"""
response = await self._make_request("GET", f"{self.API_BASE}/status")
return response.json()
async def get_version(self) -> str:
"""Get News app version.
Returns:
Version string (e.g., "25.0.0")
"""
response = await self._make_request("GET", f"{self.API_BASE}/version")
return response.json().get("version", "")
+6 -2
View File
@@ -1174,7 +1174,9 @@ class WebDAVClient(BaseNextcloudClient):
if display_name_elem is not None and display_name_elem.text == tag_name:
tag_info = {
"id": int(tag_id_elem.text) if tag_id_elem is not None else None,
"id": int(tag_id_elem.text)
if tag_id_elem is not None and tag_id_elem.text is not None
else None,
"name": display_name_elem.text,
"userVisible": user_visible_elem.text.lower() == "true"
if user_visible_elem is not None
@@ -1369,7 +1371,9 @@ class WebDAVClient(BaseNextcloudClient):
)
file_info = {
"id": int(fileid_elem.text) if fileid_elem is not None else None,
"id": int(fileid_elem.text)
if fileid_elem is not None and fileid_elem.text is not None
else None,
"path": path,
"name": displayname_elem.text
if displayname_elem is not None
+170
View File
@@ -0,0 +1,170 @@
"""Pydantic models for Nextcloud News app responses."""
from typing import List
from pydantic import BaseModel, ConfigDict, Field
from .base import BaseResponse
class NewsFolder(BaseModel):
"""Model for a News folder."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Folder ID")
name: str = Field(description="Folder name")
class NewsFeed(BaseModel):
"""Model for a News feed (RSS/Atom subscription)."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Feed ID")
url: str = Field(description="Feed URL")
title: str = Field(description="Feed title")
favicon_link: str | None = Field(
None, alias="faviconLink", description="Favicon URL"
)
link: str | None = Field(None, description="Website link")
added: int = Field(description="Unix timestamp when feed was added")
folder_id: int | None = Field(
None, alias="folderId", description="Parent folder ID"
)
unread_count: int = Field(
0, alias="unreadCount", description="Number of unread items"
)
ordering: int = Field(
0, description="Feed ordering (0=default, 1=oldest, 2=newest)"
)
pinned: bool = Field(False, description="Whether feed is pinned to top")
update_error_count: int = Field(
0, alias="updateErrorCount", description="Consecutive update failures"
)
last_update_error: str | None = Field(
None, alias="lastUpdateError", description="Last update error message"
)
@property
def has_errors(self) -> bool:
"""Check if feed has update errors."""
return self.update_error_count > 0
class NewsItem(BaseModel):
"""Model for a News item (article) with full content."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Item ID")
guid: str = Field(description="Globally unique identifier")
guid_hash: str = Field(alias="guidHash", description="MD5 hash of GUID")
url: str | None = Field(None, description="Article URL")
title: str = Field(description="Article title")
author: str | None = Field(None, description="Article author")
pub_date: int | None = Field(
None, alias="pubDate", description="Publication timestamp"
)
body: str | None = Field(None, description="Article content (HTML)")
enclosure_mime: str | None = Field(
None, alias="enclosureMime", description="Enclosure MIME type"
)
enclosure_link: str | None = Field(
None, alias="enclosureLink", description="Enclosure URL"
)
media_thumbnail: str | None = Field(
None, alias="mediaThumbnail", description="Media thumbnail URL"
)
media_description: str | None = Field(
None, alias="mediaDescription", description="Media description"
)
feed_id: int = Field(alias="feedId", description="Parent feed ID")
unread: bool = Field(True, description="Whether item is unread")
starred: bool = Field(False, description="Whether item is starred")
rtl: bool = Field(False, description="Right-to-left text")
last_modified: int = Field(
alias="lastModified", description="Last modification timestamp"
)
fingerprint: str | None = Field(
None, description="Content fingerprint for deduplication"
)
content_hash: str | None = Field(
None, alias="contentHash", description="Content hash"
)
class NewsItemSummary(BaseModel):
"""Lightweight model for News item list responses."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Item ID")
title: str = Field(description="Article title")
feed_id: int = Field(alias="feedId", description="Parent feed ID")
unread: bool = Field(True, description="Whether item is unread")
starred: bool = Field(False, description="Whether item is starred")
pub_date: int | None = Field(
None, alias="pubDate", description="Publication timestamp"
)
url: str | None = Field(None, description="Article URL")
author: str | None = Field(None, description="Article author")
class NewsStatus(BaseModel):
"""Model for News app status."""
version: str = Field(description="News app version")
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
# --- Response Models ---
class ListFoldersResponse(BaseResponse):
"""Response model for listing folders."""
results: List[NewsFolder] = Field(description="List of folders")
total_count: int = Field(description="Total number of folders")
class ListFeedsResponse(BaseResponse):
"""Response model for listing feeds."""
results: List[NewsFeed] = Field(description="List of feeds")
starred_count: int = Field(0, description="Number of starred items")
newest_item_id: int | None = Field(None, description="ID of newest item")
total_count: int = Field(description="Total number of feeds")
class ListItemsResponse(BaseResponse):
"""Response model for listing items."""
results: List[NewsItemSummary] = Field(description="List of items")
total_count: int = Field(description="Number of items returned")
has_more: bool = Field(False, description="Whether more items exist")
oldest_id: int | None = Field(None, description="Oldest item ID (for pagination)")
class GetItemResponse(BaseResponse):
"""Response model for getting a single item."""
item: NewsItem = Field(description="Full item details")
class FeedHealthResponse(BaseResponse):
"""Response model for feed health status."""
feed_id: int = Field(description="Feed ID")
title: str = Field(description="Feed title")
url: str = Field(description="Feed URL")
has_errors: bool = Field(description="Whether feed has update errors")
error_count: int = Field(description="Number of consecutive errors")
last_error: str | None = Field(None, description="Last error message")
class GetStatusResponse(BaseResponse):
"""Response model for app status."""
version: str = Field(description="News app version")
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
@@ -60,7 +60,7 @@ class TraceContextFormatter(JsonFormatter):
def add_fields(
self,
log_record: dict[str, Any],
log_data: dict[str, Any],
record: logging.LogRecord,
message_dict: dict[str, Any],
) -> None:
@@ -68,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
Add custom fields to the log record, including trace context.
Args:
log_record: Dictionary to be serialized as JSON
log_data: Dictionary to be serialized as JSON
record: LogRecord instance
message_dict: Dictionary of extra fields from log call
"""
# Call parent to add standard fields
super().add_fields(log_record, record, message_dict)
super().add_fields(log_data, record, message_dict)
# Add trace context if available
trace_context = get_trace_context()
if trace_context:
log_record["trace_id"] = trace_context.get("trace_id")
log_record["span_id"] = trace_context.get("span_id")
log_data["trace_id"] = trace_context.get("trace_id")
log_data["span_id"] = trace_context.get("span_id")
# Add standard fields with consistent naming
log_record["timestamp"] = self.formatTime(record)
log_record["level"] = record.levelname
log_record["logger"] = record.name
log_record["message"] = record.getMessage()
log_data["timestamp"] = self.formatTime(record)
log_data["level"] = record.levelname
log_data["logger"] = record.name
log_data["message"] = record.getMessage()
# Include exception info if present
if record.exc_info:
log_record["exception"] = self.formatException(record.exc_info)
log_data["exception"] = self.formatException(record.exc_info)
class TraceContextTextFormatter(logging.Formatter):
+6 -4
View File
@@ -17,18 +17,20 @@ class AnthropicProvider(Provider):
Note: Anthropic doesn't provide embedding models, only text generation.
"""
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
def __init__(
self, api_key: str, generation_model: str = "claude-3-5-sonnet-20241022"
):
"""
Initialize Anthropic provider.
Args:
api_key: Anthropic API key
model: Model name (e.g., "claude-3-5-sonnet-20241022")
generation_model: Model name (e.g., "claude-3-5-sonnet-20241022")
"""
self.client = AsyncAnthropic(api_key=api_key)
self.model = model
self.model = generation_model
logger.info(f"Initialized Anthropic provider (model={model})")
logger.info(f"Initialized Anthropic provider (model={self.model})")
@property
def supports_embeddings(self) -> bool:
+53 -9
View File
@@ -7,13 +7,48 @@ Supports:
"""
import logging
from functools import wraps
from openai import AsyncOpenAI
import anyio
from openai import AsyncOpenAI, RateLimitError
from .base import Provider
logger = logging.getLogger(__name__)
# Rate limit retry configuration
MAX_RETRIES = 5
INITIAL_RETRY_DELAY = 2.0 # seconds
MAX_RETRY_DELAY = 60.0 # seconds
def retry_on_rate_limit(func):
"""Decorator to retry on OpenAI rate limit errors with exponential backoff."""
@wraps(func)
async def wrapper(*args, **kwargs):
retry_delay = INITIAL_RETRY_DELAY
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
return await func(*args, **kwargs)
except RateLimitError as e:
last_error = e
if attempt < MAX_RETRIES:
logger.warning(
f"Rate limit hit (attempt {attempt}/{MAX_RETRIES}), "
f"retrying in {retry_delay:.1f}s..."
)
await anyio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
logger.error(f"Rate limit exceeded after {MAX_RETRIES} attempts")
raise last_error # type: ignore[misc]
return wrapper
# Well-known embedding dimensions for OpenAI models
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
"text-embedding-3-small": 1536,
@@ -86,6 +121,7 @@ class OpenAIProvider(Provider):
"""Whether this provider supports text generation."""
return self.generation_model is not None
@retry_on_rate_limit
async def embed(self, text: str) -> list[float]:
"""
Generate embedding vector for text.
@@ -104,6 +140,7 @@ class OpenAIProvider(Provider):
"Embedding not supported - no embedding_model configured"
)
assert self.embedding_model is not None # Type narrowing
response = await self.client.embeddings.create(
input=text,
model=self.embedding_model,
@@ -151,14 +188,8 @@ class OpenAIProvider(Provider):
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
response = await self.client.embeddings.create(
input=batch,
model=self.embedding_model,
)
# Sort by index to maintain order
sorted_data = sorted(response.data, key=lambda x: x.index)
batch_embeddings = [item.embedding for item in sorted_data]
# Use helper method with retry logic for each batch
batch_embeddings = await self._embed_batch_request(batch)
all_embeddings.extend(batch_embeddings)
# Update dimension if not set
@@ -171,6 +202,18 @@ class OpenAIProvider(Provider):
return all_embeddings
@retry_on_rate_limit
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
"""Make a single batch embedding request with retry logic."""
assert self.embedding_model is not None # Type narrowing
response = await self.client.embeddings.create(
input=batch,
model=self.embedding_model,
)
# Sort by index to maintain order
sorted_data = sorted(response.data, key=lambda x: x.index)
return [item.embedding for item in sorted_data]
def get_dimension(self) -> int:
"""
Get embedding dimension.
@@ -194,6 +237,7 @@ class OpenAIProvider(Provider):
)
return self._dimension
@retry_on_rate_limit
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
"""
Generate text from a prompt.
+2 -2
View File
@@ -108,8 +108,8 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
with_vectors=False, # Don't need vectors for type discovery
)
doc_types = {
point.payload.get("doc_type")
doc_types: set[str] = {
str(point.payload.get("doc_type"))
for point in scroll_results
if point.payload.get("doc_type")
}
+15 -5
View File
@@ -204,6 +204,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
results = []
for result in search_response.points:
if result.payload is None:
continue
# doc_id can be int (notes) or str (files - file paths)
doc_id = result.payload["doc_id"]
doc_type = result.payload.get("doc_type", "note")
@@ -217,6 +219,18 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
seen_chunks.add(chunk_key)
# Build metadata dict with common fields
metadata = {
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
"search_method": f"bm25_hybrid_{self.fusion_name}",
}
# Add deck_card-specific metadata for frontend URL construction
if doc_type == "deck_card":
if board_id := result.payload.get("board_id"):
metadata["board_id"] = board_id
# Return unverified results (verification happens at output stage)
results.append(
SearchResult(
@@ -225,11 +239,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
title=result.payload.get("title", "Untitled"),
excerpt=result.payload.get("excerpt", ""),
score=result.score, # Fusion score (RRF or DBSF)
metadata={
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
"search_method": f"bm25_hybrid_{self.fusion_name}",
},
metadata=metadata,
chunk_start_offset=result.payload.get("chunk_start_offset"),
chunk_end_offset=result.payload.get("chunk_end_offset"),
page_number=result.payload.get("page_number"),
+149 -2
View File
@@ -209,6 +209,64 @@ async def _get_file_path_from_qdrant(
return None
async def _get_deck_metadata_from_qdrant(
user_id: str, card_id: int
) -> dict[str, int] | None:
"""Retrieve board_id and stack_id for a deck card from Qdrant payload.
Args:
user_id: User ID who owns the card
card_id: Card ID
Returns:
Dictionary with board_id and stack_id, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
# Query for any chunk of this card (we just need metadata)
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_id", match=MatchValue(value=card_id)),
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
]
),
limit=1,
with_payload=["board_id", "stack_id"],
with_vectors=False,
)
if scroll_result[0]:
point = scroll_result[0][0]
board_id = point.payload.get("board_id")
stack_id = point.payload.get("stack_id")
if board_id is not None and stack_id is not None:
logger.debug(
f"Retrieved deck metadata for card {card_id}: "
f"board_id={board_id}, stack_id={stack_id}"
)
return {"board_id": int(board_id), "stack_id": int(stack_id)}
logger.debug(
f"Could not find deck metadata in Qdrant for card {card_id} "
f"(might be legacy data without board_id/stack_id)"
)
return None
except Exception as e:
logger.debug(f"Error querying Qdrant for deck metadata: {e}")
return None
@dataclass
class ChunkContext:
"""Expanded chunk with surrounding context and position markers.
@@ -394,7 +452,9 @@ async def get_chunk_with_context(
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
# Fetch full document text
full_text = await _fetch_document_text(nc_client, resolved_doc_id, doc_type)
full_text = await _fetch_document_text(
nc_client, resolved_doc_id, doc_type, user_id
)
if full_text is None:
logger.warning(
f"Could not fetch document text for {doc_type} {doc_id}, "
@@ -453,7 +513,7 @@ async def get_chunk_with_context(
async def _fetch_document_text(
nc_client: NextcloudClient, doc_id: str | int, doc_type: str
nc_client: NextcloudClient, doc_id: str | int, doc_type: str, user_id: str
) -> str | None:
"""Fetch full text content of a document.
@@ -524,6 +584,93 @@ async def _fetch_document_text(
f"Error fetching file content for {doc_id}: {e}", exc_info=True
)
return None
elif doc_type == "news_item":
# Fetch news item by ID
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
item = await nc_client.news.get_item(int(doc_id))
# Reconstruct full content as indexed: title + source + URL + body
# This ensures chunk offsets align with indexed content structure
body_markdown = html_to_markdown(item.get("body", ""))
item_title = item.get("title", "")
item_url = item.get("url", "")
feed_title = item.get("feedTitle", "")
content_parts = [item_title]
if feed_title:
content_parts.append(f"Source: {feed_title}")
if item_url:
content_parts.append(f"URL: {item_url}")
content_parts.append("") # Blank line
content_parts.append(body_markdown)
return "\n".join(content_parts)
elif doc_type == "deck_card":
# Fetch card from Deck API
# Try to get board_id/stack_id from Qdrant metadata (O(1) lookup)
# Otherwise fall back to iteration (legacy data)
card = None
deck_metadata = await _get_deck_metadata_from_qdrant(user_id, int(doc_id))
if deck_metadata:
# Fast path: Direct lookup with known board_id/stack_id
board_id = deck_metadata["board_id"]
stack_id = deck_metadata["stack_id"]
try:
card = await nc_client.deck.get_card(
board_id=board_id, stack_id=stack_id, card_id=int(doc_id)
)
logger.debug(
f"Retrieved deck card {doc_id} using metadata "
f"(board_id={board_id}, stack_id={stack_id})"
)
except Exception as e:
logger.warning(
f"Failed to fetch card with metadata (board_id={board_id}, "
f"stack_id={stack_id}, card_id={doc_id}): {e}, falling back to iteration"
)
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
if card is None:
boards = await nc_client.deck.get_boards()
card_found = False
for board in boards:
if card_found:
break
# Skip deleted boards (soft delete: deletedAt > 0)
if board.deletedAt > 0:
logger.debug(
f"Skipping deleted board {board.id} while searching for card {doc_id}"
)
continue
stacks = await nc_client.deck.get_stacks(board.id)
for stack in stacks:
if card_found:
break
if stack.cards:
for c in stack.cards:
if c.id == int(doc_id):
card = c
card_found = True
logger.debug(
f"Found deck card {doc_id} in board {board.id}, "
f"stack {stack.id} (fallback iteration)"
)
break
if not card_found:
logger.warning(f"Deck card {doc_id} not found in any board/stack")
return None
# Reconstruct full content as indexed: title + "\n\n" + description
# This ensures chunk offsets align with indexed content structure
content_parts = [card.title]
if card.description:
content_parts.append(card.description)
return "\n\n".join(content_parts)
else:
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
return None
+14 -4
View File
@@ -136,6 +136,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
results = []
for result in search_response.points:
if result.payload is None:
continue
# doc_id can be int (notes) or str (files - file paths)
doc_id = result.payload["doc_id"]
doc_type = result.payload.get("doc_type", "note")
@@ -149,6 +151,17 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
seen_chunks.add(chunk_key)
# Build metadata dict with common fields
metadata = {
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
}
# Add deck_card-specific metadata for frontend URL construction
if doc_type == "deck_card":
if board_id := result.payload.get("board_id"):
metadata["board_id"] = board_id
# Return unverified results (verification happens at output stage)
results.append(
SearchResult(
@@ -157,10 +170,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
title=result.payload.get("title", "Untitled"),
excerpt=result.payload.get("excerpt", ""),
score=result.score,
metadata={
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
},
metadata=metadata,
chunk_start_offset=result.payload.get("chunk_start_offset"),
chunk_end_offset=result.payload.get("chunk_end_offset"),
page_number=result.payload.get("page_number"),
+2
View File
@@ -2,6 +2,7 @@ from .calendar import configure_calendar_tools
from .contacts import configure_contacts_tools
from .cookbook import configure_cookbook_tools
from .deck import configure_deck_tools
from .news import configure_news_tools
from .notes import configure_notes_tools
from .semantic import configure_semantic_tools
from .sharing import configure_sharing_tools
@@ -13,6 +14,7 @@ __all__ = [
"configure_contacts_tools",
"configure_cookbook_tools",
"configure_deck_tools",
"configure_news_tools",
"configure_notes_tools",
"configure_semantic_tools",
"configure_sharing_tools",
+69 -16
View File
@@ -3,6 +3,7 @@ import logging
from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -19,7 +20,10 @@ logger = logging.getLogger(__name__)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool()
@mcp.tool(
title="List Calendars",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
@@ -30,7 +34,10 @@ def configure_calendar_tools(mcp: FastMCP):
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
@mcp.tool()
@mcp.tool(
title="Create Calendar Event",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_create_event(
@@ -107,7 +114,10 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@mcp.tool(
title="List Calendar Events",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_list_events(
@@ -210,7 +220,10 @@ def configure_calendar_tools(mcp: FastMCP):
return events
@mcp.tool()
@mcp.tool(
title="Get Calendar Event",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_get_event(
@@ -223,7 +236,10 @@ def configure_calendar_tools(mcp: FastMCP):
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
return event_data
@mcp.tool()
@mcp.tool(
title="Update Calendar Event",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_update_event(
@@ -297,7 +313,12 @@ def configure_calendar_tools(mcp: FastMCP):
calendar_name, event_uid, event_data, etag
)
@mcp.tool()
@mcp.tool(
title="Delete Calendar Event",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_delete_event(
@@ -309,7 +330,10 @@ def configure_calendar_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.calendar.delete_event(calendar_name, event_uid)
@mcp.tool()
@mcp.tool(
title="Create Meeting",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_create_meeting(
@@ -376,7 +400,10 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@mcp.tool(
title="Get Upcoming Events",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_get_upcoming_events(
@@ -427,7 +454,10 @@ def configure_calendar_tools(mcp: FastMCP):
all_events.sort(key=lambda x: x.get("start_datetime", ""))
return all_events[:limit]
@mcp.tool()
@mcp.tool(
title="Find Availability",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_find_availability(
@@ -508,7 +538,10 @@ def configure_calendar_tools(mcp: FastMCP):
constraints=constraints,
)
@mcp.tool()
@mcp.tool(
title="Bulk Calendar Operations",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_bulk_operations(
@@ -758,7 +791,10 @@ def configure_calendar_tools(mcp: FastMCP):
"results": results,
}
@mcp.tool()
@mcp.tool(
title="Manage Calendar",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_manage_calendar(
@@ -828,7 +864,10 @@ def configure_calendar_tools(mcp: FastMCP):
# ============= Todo/Task Tools =============
@mcp.tool()
@mcp.tool(
title="List Todo Tasks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("todo:read", "calendar:read")
@instrument_tool
async def nc_calendar_list_todos(
@@ -874,7 +913,10 @@ def configure_calendar_tools(mcp: FastMCP):
todos=todos, calendar_name=calendar_name, total_count=len(todos)
)
@mcp.tool()
@mcp.tool(
title="Create Todo Task",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("todo:write", "calendar:read")
@instrument_tool
async def nc_calendar_create_todo(
@@ -918,7 +960,10 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_todo(calendar_name, todo_data)
@mcp.tool()
@mcp.tool(
title="Update Todo Task",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("todo:write", "calendar:read")
@instrument_tool
async def nc_calendar_update_todo(
@@ -979,7 +1024,12 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
@mcp.tool()
@mcp.tool(
title="Delete Todo Task",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("todo:write", "calendar:read")
@instrument_tool
async def nc_calendar_delete_todo(
@@ -1000,7 +1050,10 @@ def configure_calendar_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.calendar.delete_todo(calendar_name, todo_uid)
@mcp.tool()
@mcp.tool(
title="Search Todo Tasks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("todo:read", "calendar:read")
@instrument_tool
async def nc_calendar_search_todos(
+33 -7
View File
@@ -1,6 +1,7 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool()
@mcp.tool(
title="List Address Books",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_addressbooks(ctx: Context):
@@ -19,7 +23,10 @@ def configure_contacts_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.contacts.list_addressbooks()
@mcp.tool()
@mcp.tool(
title="List Contacts",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
@@ -27,7 +34,10 @@ def configure_contacts_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.contacts.list_contacts(addressbook=addressbook)
@mcp.tool()
@mcp.tool(
title="Create Address Book",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_create_addressbook(
@@ -44,7 +54,12 @@ def configure_contacts_tools(mcp: FastMCP):
name=name, display_name=display_name
)
@mcp.tool()
@mcp.tool(
title="Delete Address Book",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
@@ -52,7 +67,10 @@ def configure_contacts_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.contacts.delete_addressbook(name=name)
@mcp.tool()
@mcp.tool(
title="Create Contact",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_create_contact(
@@ -70,7 +88,12 @@ def configure_contacts_tools(mcp: FastMCP):
addressbook=addressbook, uid=uid, contact_data=contact_data
)
@mcp.tool()
@mcp.tool(
title="Delete Contact",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
@@ -78,7 +101,10 @@ def configure_contacts_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@mcp.tool()
@mcp.tool(
title="Update Contact",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_update_contact(
+55 -14
View File
@@ -3,7 +3,7 @@ import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from mcp.types import ErrorData, ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -71,7 +71,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Import Recipe from URL",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
@@ -129,7 +132,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="List Recipes",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
@@ -155,7 +161,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Get Recipe",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
@@ -181,7 +190,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Create Recipe",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_create_recipe(
@@ -261,7 +273,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Update Recipe",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_update_recipe(
@@ -351,7 +366,12 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Delete Recipe",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_delete_recipe(
@@ -387,7 +407,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Search Recipes",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_search_recipes(
@@ -424,7 +447,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="List Recipe Categories",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
@@ -452,7 +478,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Get Recipes in Category",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_get_recipes_in_category(
@@ -489,7 +518,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="List Recipe Keywords",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
@@ -515,7 +547,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Get Recipes with Keywords",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_get_recipes_with_keywords(
@@ -550,7 +585,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Set Cookbook Configuration",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_set_config(
@@ -594,7 +632,10 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Reindex Recipes",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
+107 -25
View File
@@ -2,6 +2,7 @@ import logging
from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -117,7 +118,10 @@ def configure_deck_tools(mcp: FastMCP):
# Read Tools (converted from resources)
@mcp.tool()
@mcp.tool(
title="List Deck Boards",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
@@ -126,7 +130,10 @@ def configure_deck_tools(mcp: FastMCP):
boards = await client.deck.get_boards()
return boards
@mcp.tool()
@mcp.tool(
title="Get Deck Board",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
@@ -135,7 +142,10 @@ def configure_deck_tools(mcp: FastMCP):
board = await client.deck.get_board(board_id)
return board
@mcp.tool()
@mcp.tool(
title="List Deck Stacks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
@@ -144,7 +154,10 @@ def configure_deck_tools(mcp: FastMCP):
stacks = await client.deck.get_stacks(board_id)
return stacks
@mcp.tool()
@mcp.tool(
title="Get Deck Stack",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
@@ -153,7 +166,10 @@ def configure_deck_tools(mcp: FastMCP):
stack = await client.deck.get_stack(board_id, stack_id)
return stack
@mcp.tool()
@mcp.tool(
title="List Deck Cards",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_cards(
@@ -166,7 +182,10 @@ def configure_deck_tools(mcp: FastMCP):
return stack.cards
return []
@mcp.tool()
@mcp.tool(
title="Get Deck Card",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_card(
@@ -177,7 +196,10 @@ def configure_deck_tools(mcp: FastMCP):
card = await client.deck.get_card(board_id, stack_id, card_id)
return card
@mcp.tool()
@mcp.tool(
title="List Deck Labels",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
@@ -186,7 +208,10 @@ def configure_deck_tools(mcp: FastMCP):
board = await client.deck.get_board(board_id)
return board.labels
@mcp.tool()
@mcp.tool(
title="Get Deck Label",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@instrument_tool
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
@@ -197,7 +222,10 @@ def configure_deck_tools(mcp: FastMCP):
# Create/Update/Delete Tools
@mcp.tool()
@mcp.tool(
title="Create Deck Board",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_create_board(
@@ -215,7 +243,10 @@ def configure_deck_tools(mcp: FastMCP):
# Stack Tools
@mcp.tool()
@mcp.tool(
title="Create Deck Stack",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_create_stack(
@@ -232,7 +263,10 @@ def configure_deck_tools(mcp: FastMCP):
stack = await client.deck.create_stack(board_id, title, order)
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
@mcp.tool()
@mcp.tool(
title="Update Deck Stack",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_update_stack(
@@ -259,7 +293,12 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool()
@mcp.tool(
title="Delete Deck Stack",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_delete_stack(
@@ -281,7 +320,10 @@ def configure_deck_tools(mcp: FastMCP):
)
# Card Tools
@mcp.tool()
@mcp.tool(
title="Create Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_create_card(
@@ -316,7 +358,10 @@ def configure_deck_tools(mcp: FastMCP):
stackId=card.stackId,
)
@mcp.tool()
@mcp.tool(
title="Update Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_update_card(
@@ -370,7 +415,12 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool()
@mcp.tool(
title="Delete Deck Card",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_delete_card(
@@ -393,7 +443,10 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool()
@mcp.tool(
title="Archive Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_archive_card(
@@ -416,7 +469,10 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool()
@mcp.tool(
title="Unarchive Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_unarchive_card(
@@ -439,7 +495,10 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool()
@mcp.tool(
title="Reorder/Move Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_reorder_card(
@@ -472,7 +531,10 @@ def configure_deck_tools(mcp: FastMCP):
)
# Label Tools
@mcp.tool()
@mcp.tool(
title="Create Deck Label",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_create_label(
@@ -489,7 +551,10 @@ def configure_deck_tools(mcp: FastMCP):
label = await client.deck.create_label(board_id, title, color)
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
@mcp.tool()
@mcp.tool(
title="Update Deck Label",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_update_label(
@@ -516,7 +581,12 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool()
@mcp.tool(
title="Delete Deck Label",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_delete_label(
@@ -538,7 +608,10 @@ def configure_deck_tools(mcp: FastMCP):
)
# Card-Label Assignment Tools
@mcp.tool()
@mcp.tool(
title="Assign Label to Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_assign_label_to_card(
@@ -562,7 +635,10 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool()
@mcp.tool(
title="Remove Label from Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_remove_label_from_card(
@@ -587,7 +663,10 @@ def configure_deck_tools(mcp: FastMCP):
)
# Card-User Assignment Tools
@mcp.tool()
@mcp.tool(
title="Assign User to Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_assign_user_to_card(
@@ -611,7 +690,10 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool()
@mcp.tool(
title="Unassign User from Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@instrument_tool
async def deck_unassign_user_from_card(
+384
View File
@@ -0,0 +1,384 @@
"""MCP tools for Nextcloud News app."""
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.client.news import NewsItemType
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.news import (
FeedHealthResponse,
GetItemResponse,
GetStatusResponse,
ListFeedsResponse,
ListFoldersResponse,
ListItemsResponse,
NewsFeed,
NewsFolder,
NewsItem,
NewsItemSummary,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def configure_news_tools(mcp: FastMCP):
"""Configure News app MCP tools."""
@mcp.tool(
title="List News Folders",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse:
"""List all News folders (requires news:read scope)."""
client = await get_client(ctx)
try:
folders_data = await client.news.get_folders()
folders = [NewsFolder(**f) for f in folders_data]
return ListFoldersResponse(results=folders, total_count=len(folders))
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing folders: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list folders: {e.response.status_code}",
)
)
@mcp.tool(
title="List News Feeds",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse:
"""List all News feeds with metadata (requires news:read scope).
Returns feeds with unread counts, error status, and overall starred count.
"""
client = await get_client(ctx)
try:
data = await client.news.get_feeds()
feeds = [NewsFeed(**f) for f in data.get("feeds", [])]
return ListFeedsResponse(
results=feeds,
starred_count=data.get("starredCount", 0),
newest_item_id=data.get("newestItemId"),
total_count=len(feeds),
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing feeds: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list feeds: {e.response.status_code}",
)
)
@mcp.tool(
title="List News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_items(
ctx: Context,
feed_id: int | None = None,
folder_id: int | None = None,
starred_only: bool = False,
unread_only: bool = False,
limit: int = 50,
offset: int = 0,
) -> ListItemsResponse:
"""List News items (articles) with optional filtering (requires news:read scope).
Args:
feed_id: Filter by specific feed ID
folder_id: Filter by specific folder ID
starred_only: Return only starred items
unread_only: Return only unread items
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with items, count, and pagination info
"""
client = await get_client(ctx)
# Determine item type filter
type_ = NewsItemType.ALL
id_ = 0
if starred_only:
type_ = NewsItemType.STARRED
elif feed_id is not None:
type_ = NewsItemType.FEED
id_ = feed_id
elif folder_id is not None:
type_ = NewsItemType.FOLDER
id_ = folder_id
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=type_,
id_=id_,
get_read=not unread_only,
)
items = [NewsItemSummary(**i) for i in items_data]
# Determine pagination info
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing items: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list items: {e.response.status_code}",
)
)
@mcp.tool(
title="Get News Item",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse:
"""Get a specific News item by ID with full content (requires news:read scope).
Args:
item_id: Item ID
Returns:
GetItemResponse with full item details including HTML body
"""
client = await get_client(ctx)
try:
item_data = await client.news.get_item(item_id)
item = NewsItem(**item_data)
return GetItemResponse(item=item)
except ValueError as e:
raise McpError(ErrorData(code=-1, message=str(e)))
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting item {item_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Item {item_id} not found"))
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get item {item_id}: {e.response.status_code}",
)
)
@mcp.tool(
title="Get Starred News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_starred_items(
ctx: Context, limit: int = 50, offset: int = 0
) -> ListItemsResponse:
"""Get starred (favorited) News items (requires news:read scope).
Convenience method for retrieving user's starred articles.
Args:
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with starred items
"""
client = await get_client(ctx)
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=NewsItemType.STARRED,
get_read=True, # Include read starred items
)
items = [NewsItemSummary(**i) for i in items_data]
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting starred items: {str(e)}"
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get starred items: {e.response.status_code}",
)
)
@mcp.tool(
title="Get Unread News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_unread_items(
ctx: Context, limit: int = 50, offset: int = 0
) -> ListItemsResponse:
"""Get unread News items (requires news:read scope).
Convenience method for retrieving unread articles across all feeds.
Args:
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with unread items
"""
client = await get_client(ctx)
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=NewsItemType.ALL,
get_read=False, # Only unread items
)
items = [NewsItemSummary(**i) for i in items_data]
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting unread items: {str(e)}"
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get unread items: {e.response.status_code}",
)
)
@mcp.tool(
title="Get News Feed Health",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse:
"""Get health status for a specific feed (requires news:read scope).
Returns error count and last error message if the feed has update issues.
Args:
feed_id: Feed ID to check
Returns:
FeedHealthResponse with error status
"""
client = await get_client(ctx)
try:
data = await client.news.get_feeds()
for feed_data in data.get("feeds", []):
if feed_data.get("id") == feed_id:
feed = NewsFeed(**feed_data)
return FeedHealthResponse(
feed_id=feed.id,
title=feed.title,
url=feed.url,
has_errors=feed.has_errors,
error_count=feed.update_error_count,
last_error=feed.last_update_error,
)
raise McpError(ErrorData(code=-1, message=f"Feed {feed_id} not found"))
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error getting feed health: {str(e)}",
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get feed health: {e.response.status_code}",
)
)
@mcp.tool(
title="Get News App Status",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_status(ctx: Context) -> GetStatusResponse:
"""Get News app status and version (requires news:read scope).
Returns version information and any configuration warnings.
"""
client = await get_client(ctx)
try:
status_data = await client.news.get_status()
return GetStatusResponse(
version=status_data.get("version", "unknown"),
warnings=status_data.get("warnings", {}),
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error getting status: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get status: {e.response.status_code}",
)
)
+51 -8
View File
@@ -3,7 +3,7 @@ import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from mcp.types import ErrorData, ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -85,7 +85,13 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Create Note",
annotations=ToolAnnotations(
idempotentHint=False, # Multiple calls create multiple notes
openWorldHint=True,
),
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_create_note(
@@ -132,7 +138,13 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Update Note",
annotations=ToolAnnotations(
idempotentHint=False, # Requires etag which changes = not idempotent
openWorldHint=True,
),
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_update_note(
@@ -198,7 +210,13 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Append to Note",
annotations=ToolAnnotations(
idempotentHint=False, # Each call adds content = not idempotent
openWorldHint=True,
),
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_append_content(
@@ -249,7 +267,13 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Search Notes",
annotations=ToolAnnotations(
readOnlyHint=True, # Search doesn't modify data
openWorldHint=True,
),
)
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
@@ -296,7 +320,13 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Get Note",
annotations=ToolAnnotations(
readOnlyHint=True, # Read operation only
openWorldHint=True,
),
)
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
@@ -326,7 +356,13 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Get Note Attachment",
annotations=ToolAnnotations(
readOnlyHint=True, # Read operation only
openWorldHint=True,
),
)
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_get_attachment(
@@ -373,7 +409,14 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool()
@mcp.tool(
title="Delete Note",
annotations=ToolAnnotations(
destructiveHint=True, # Permanently deletes data
idempotentHint=True, # Deleting deleted note = same end state
openWorldHint=True,
),
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
@@ -15,6 +15,7 @@ import httpx
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
from nextcloud_mcp_server.auth import require_scopes
@@ -684,11 +685,16 @@ def register_oauth_tools(mcp):
@mcp.tool(
name="provision_nextcloud_access",
title="Grant Server Access to Nextcloud",
description=(
"Provision offline access to Nextcloud resources. "
"This is required before using Nextcloud tools. "
"You'll need to complete an OAuth authorization in your browser."
),
annotations=ToolAnnotations(
idempotentHint=False, # Creates new OAuth session each time
openWorldHint=True,
),
)
@require_scopes("openid")
async def tool_provision_access(
@@ -699,7 +705,13 @@ def register_oauth_tools(mcp):
@mcp.tool(
name="revoke_nextcloud_access",
title="Revoke Server Access to Nextcloud",
description="Revoke offline access to Nextcloud resources.",
annotations=ToolAnnotations(
destructiveHint=True, # Removes stored access tokens
idempotentHint=True, # Revoking revoked access = same end state
openWorldHint=True,
),
)
@require_scopes("openid")
async def tool_revoke_access(
@@ -709,7 +721,12 @@ def register_oauth_tools(mcp):
@mcp.tool(
name="check_provisioning_status",
title="Check Provisioning Status",
description="Check whether Nextcloud access is provisioned.",
annotations=ToolAnnotations(
readOnlyHint=True, # Only checks status, doesn't modify
openWorldHint=True,
),
)
@require_scopes("openid")
async def tool_check_status(
@@ -719,10 +736,15 @@ def register_oauth_tools(mcp):
@mcp.tool(
name="check_logged_in",
title="Check Server Login Status",
description=(
"Check if you are logged in to Nextcloud. "
"If not logged in, this tool will prompt you to complete the login flow."
),
annotations=ToolAnnotations(
readOnlyHint=True, # Checking status doesn't modify state
openWorldHint=True,
),
)
@require_scopes("openid")
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
+39 -11
View File
@@ -12,6 +12,7 @@ from mcp.types import (
ModelPreferences,
SamplingMessage,
TextContent,
ToolAnnotations,
)
from nextcloud_mcp_server.auth import require_scopes
@@ -34,7 +35,13 @@ logger = logging.getLogger(__name__)
def configure_semantic_tools(mcp: FastMCP):
"""Configure semantic search tools for MCP server."""
@mcp.tool()
@mcp.tool(
title="Semantic Search",
annotations=ToolAnnotations(
readOnlyHint=True, # Search doesn't modify data
openWorldHint=True, # Queries external Nextcloud service
),
)
@require_scopes("semantic:read")
@instrument_tool
async def nc_semantic_search(
@@ -58,13 +65,13 @@ def configure_semantic_tools(mcp: FastMCP):
database for optimal relevance. This provides the best of both semantic
understanding and keyword precision.
Requires VECTOR_SYNC_ENABLED=true. Currently only "note" documents are
fully supported for indexing.
Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files,
news items, and deck cards.
Args:
query: Natural language or keyword search query
limit: Maximum number of results to return (default: 10)
doc_types: Document types to search (e.g., ["note", "file"]). None = search all indexed types (default)
doc_types: Document types to search (e.g., ["note", "file", "deck_card", "news_item"]). None = search all indexed types (default)
score_threshold: Minimum fusion score (0-1, default: 0.0)
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
RRF: Good general-purpose fusion using reciprocal ranks
@@ -285,7 +292,13 @@ def configure_semantic_tools(mcp: FastMCP):
logger.error(f"Search error: {e}", exc_info=True)
raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}"))
@mcp.tool()
@mcp.tool(
title="Search with AI-Generated Answer",
annotations=ToolAnnotations(
readOnlyHint=True, # Search doesn't modify data
openWorldHint=False, # Searches only indexed Nextcloud data
),
)
@require_scopes("semantic:read")
@instrument_tool
async def nc_semantic_search_answer(
@@ -499,9 +512,11 @@ def configure_semantic_tools(mcp: FastMCP):
)
# 6. Request LLM completion via MCP sampling with timeout
# Note: 5 minute timeout to accommodate slower local LLMs (e.g., Ollama)
sampling_timeout_seconds = 300
try:
with anyio.fail_after(30):
with anyio.fail_after(sampling_timeout_seconds):
sampling_result = await ctx.session.create_message(
messages=[
SamplingMessage(
@@ -548,14 +563,14 @@ def configure_semantic_tools(mcp: FastMCP):
except TimeoutError:
logger.warning(
f"Sampling request timed out after 30 seconds for query: '{query}', "
f"Sampling request timed out after {sampling_timeout_seconds} seconds for query: '{query}', "
f"returning search results only"
)
return SamplingSearchResponse(
query=query,
generated_answer=(
f"[Sampling request timed out]\n\n"
f"The answer generation took too long (>30s). "
f"The answer generation took too long (>{sampling_timeout_seconds}s). "
f"Found {len(accessible_results)} relevant documents. "
f"Please review the sources below or try a simpler query."
),
@@ -621,7 +636,13 @@ def configure_semantic_tools(mcp: FastMCP):
success=True,
)
@mcp.tool()
@mcp.tool(
title="Check Indexing Status",
annotations=ToolAnnotations(
readOnlyHint=True, # Only checks status
openWorldHint=True,
),
)
@require_scopes("semantic:read")
@instrument_tool
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
@@ -675,15 +696,22 @@ def configure_semantic_tools(mcp: FastMCP):
# Get Qdrant client and query indexed count
indexed_count = 0
try:
from qdrant_client.models import Filter
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.placeholder import (
get_placeholder_filter,
)
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Count documents in collection
# Count documents in collection, excluding placeholders
# Placeholders are zero-vector points used to track processing state
count_result = await qdrant_client.count(
collection_name=settings.get_collection_name()
collection_name=settings.get_collection_name(),
count_filter=Filter(must=[get_placeholder_filter()]),
)
indexed_count = count_result.count
+23 -5
View File
@@ -3,6 +3,7 @@
import json
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -16,7 +17,10 @@ def configure_sharing_tools(mcp: FastMCP):
mcp: FastMCP server instance
"""
@mcp.tool()
@mcp.tool(
title="Create Share",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_create(
@@ -56,7 +60,12 @@ def configure_sharing_tools(mcp: FastMCP):
)
return json.dumps(share_data, indent=2)
@mcp.tool()
@mcp.tool(
title="Delete Share",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_delete(share_id: int, ctx: Context) -> str:
@@ -76,7 +85,10 @@ def configure_sharing_tools(mcp: FastMCP):
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
)
@mcp.tool()
@mcp.tool(
title="Get Share Details",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_get(share_id: int, ctx: Context) -> str:
@@ -95,7 +107,10 @@ def configure_sharing_tools(mcp: FastMCP):
share_data = await client.sharing.get_share(share_id)
return json.dumps(share_data, indent=2)
@mcp.tool()
@mcp.tool(
title="List Shares",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_list(
@@ -117,7 +132,10 @@ def configure_sharing_tools(mcp: FastMCP):
)
return json.dumps(shares, indent=2)
@mcp.tool()
@mcp.tool(
title="Update Share",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
+27 -6
View File
@@ -1,6 +1,7 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
def configure_tables_tools(mcp: FastMCP):
# Tables tools
@mcp.tool()
@mcp.tool(
title="List Tables",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("tables:read")
@instrument_tool
async def nc_tables_list_tables(ctx: Context):
@@ -19,7 +23,10 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.list_tables()
@mcp.tool()
@mcp.tool(
title="Get Table Schema",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("tables:read")
@instrument_tool
async def nc_tables_get_schema(table_id: int, ctx: Context):
@@ -27,7 +34,10 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.get_table_schema(table_id)
@mcp.tool()
@mcp.tool(
title="Read Table Rows",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("tables:read")
@instrument_tool
async def nc_tables_read_table(
@@ -40,7 +50,10 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.get_table_rows(table_id, limit, offset)
@mcp.tool()
@mcp.tool(
title="Insert Table Row",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("tables:write")
@instrument_tool
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
@@ -51,7 +64,10 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.create_row(table_id, data)
@mcp.tool()
@mcp.tool(
title="Update Table Row",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("tables:write")
@instrument_tool
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
@@ -62,7 +78,12 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.update_row(row_id, data)
@mcp.tool()
@mcp.tool(
title="Delete Table Row",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("tables:write")
@instrument_tool
async def nc_tables_delete_row(row_id: int, ctx: Context):
+79 -11
View File
@@ -1,6 +1,7 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -16,7 +17,13 @@ logger = logging.getLogger(__name__)
def configure_webdav_tools(mcp: FastMCP):
# WebDAV file system tools
@mcp.tool()
@mcp.tool(
title="List Files and Directories",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_list_directory(
@@ -50,7 +57,13 @@ def configure_webdav_tools(mcp: FastMCP):
total_size=total_size,
)
@mcp.tool()
@mcp.tool(
title="Read File",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_read_file(path: str, ctx: Context):
@@ -117,7 +130,13 @@ def configure_webdav_tools(mcp: FastMCP):
"encoding": "base64",
}
@mcp.tool()
@mcp.tool(
title="Write File",
annotations=ToolAnnotations(
idempotentHint=True, # HTTP PUT without version control is idempotent
openWorldHint=True,
),
)
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_write_file(
@@ -146,7 +165,13 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.write_file(path, content_bytes, content_type)
@mcp.tool()
@mcp.tool(
title="Create Directory",
annotations=ToolAnnotations(
idempotentHint=True, # Creating existing dir returns 405 = same end state
openWorldHint=True,
),
)
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_create_directory(path: str, ctx: Context):
@@ -161,7 +186,14 @@ def configure_webdav_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.webdav.create_directory(path)
@mcp.tool()
@mcp.tool(
title="Delete File or Directory",
annotations=ToolAnnotations(
destructiveHint=True, # Permanently deletes data
idempotentHint=True, # Deleting deleted resource = same end state
openWorldHint=True,
),
)
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_delete_resource(path: str, ctx: Context):
@@ -176,7 +208,13 @@ def configure_webdav_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.webdav.delete_resource(path)
@mcp.tool()
@mcp.tool(
title="Move or Rename File",
annotations=ToolAnnotations(
idempotentHint=False, # Moving changes source and dest
openWorldHint=True,
),
)
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_move_resource(
@@ -197,7 +235,13 @@ def configure_webdav_tools(mcp: FastMCP):
source_path, destination_path, overwrite
)
@mcp.tool()
@mcp.tool(
title="Copy File or Directory",
annotations=ToolAnnotations(
idempotentHint=False, # Creates new resource each time
openWorldHint=True,
),
)
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_copy_resource(
@@ -218,7 +262,13 @@ def configure_webdav_tools(mcp: FastMCP):
source_path, destination_path, overwrite
)
@mcp.tool()
@mcp.tool(
title="Search Files",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_search_files(
@@ -335,7 +385,13 @@ def configure_webdav_tools(mcp: FastMCP):
filters_applied=filters if filters else None,
)
@mcp.tool()
@mcp.tool(
title="Find Files by Name",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_find_by_name(
@@ -363,7 +419,13 @@ def configure_webdav_tools(mcp: FastMCP):
filters_applied={"name_pattern": pattern},
)
@mcp.tool()
@mcp.tool(
title="Find Files by Type",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_find_by_type(
@@ -391,7 +453,13 @@ def configure_webdav_tools(mcp: FastMCP):
filters_applied={"mime_type": mime_type},
)
@mcp.tool()
@mcp.tool(
title="List Favorite Files",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_list_favorites(
@@ -0,0 +1,49 @@
"""HTML to Markdown conversion utilities for vector sync."""
import logging
from markdownify import markdownify as md
logger = logging.getLogger(__name__)
def html_to_markdown(html_content: str | None) -> str:
"""Convert HTML content to Markdown, preserving semantic structure.
This function converts HTML (typically from RSS/Atom feed items) to Markdown
for better text embedding. Markdown preserves:
- Heading hierarchy (important for document structure)
- Lists (bullet and numbered)
- Links (as [text](url))
- Bold/italic emphasis
- Paragraphs and line breaks
Args:
html_content: HTML string to convert (may be None or empty)
Returns:
Markdown string, or empty string if input is None/empty
Example:
>>> html_to_markdown("<h1>Title</h1><p>Content with <b>bold</b>.</p>")
'# Title\\n\\nContent with **bold**.\\n\\n'
"""
if not html_content:
return ""
try:
markdown = md(
html_content,
heading_style="ATX", # Use # style headings
strip=["script", "style", "iframe", "noscript"], # Remove unsafe elements
bullets="-", # Use - for unordered lists
code_language="", # Don't add language hints to code blocks
)
return markdown.strip()
except Exception as e:
logger.warning(f"Failed to convert HTML to Markdown: {e}")
# Fallback: strip all HTML tags as a last resort
import re
text = re.sub(r"<[^>]+>", " ", html_content)
return " ".join(text.split()) # Normalize whitespace
+174 -6
View File
@@ -6,6 +6,7 @@ Processes documents from stream: fetches content, generates embeddings, stores i
import logging
import time
import uuid
from typing import Any, cast
import anyio
from anyio.abc import TaskStatus
@@ -272,6 +273,136 @@ async def _index_document(
file_path = None # Notes don't have file paths
content_bytes = None # Notes don't have binary content
content_type = None
elif doc_task.doc_type == "news_item":
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
item = await nc_client.news.get_item(int(doc_task.doc_id))
# Convert HTML body to Markdown for better embedding
body_markdown = html_to_markdown(item.get("body", ""))
# Build content: title + URL + body
item_title = item.get("title", "")
item_url = item.get("url", "")
feed_title = item.get("feedTitle", "")
# Structure content for embedding
content_parts = [item_title]
if feed_title:
content_parts.append(f"Source: {feed_title}")
if item_url:
content_parts.append(f"URL: {item_url}")
content_parts.append("") # Blank line
content_parts.append(body_markdown)
content = "\n".join(content_parts)
title = item_title
etag = item.get("guidHash", "")
# Store news-specific metadata for later use in payload
file_metadata = {
"feed_id": item.get("feedId"),
"feed_title": feed_title,
"author": item.get("author"),
"pub_date": item.get("pubDate"),
"starred": item.get("starred", False),
"unread": item.get("unread", True),
"url": item_url,
"guid_hash": item.get("guidHash"),
"enclosure_link": item.get("enclosureLink"),
"enclosure_mime": item.get("enclosureMime"),
}
file_path = None
content_bytes = None
content_type = None
elif doc_task.doc_type == "deck_card":
# Fetch card from Deck API
# Use metadata from scanner if available (O(1) lookup)
# Otherwise fall back to iteration (legacy data)
card = None
board = None
stack = None
if (
doc_task.metadata
and "board_id" in doc_task.metadata
and "stack_id" in doc_task.metadata
):
# Fast path: Direct lookup with known board_id/stack_id
board_id = doc_task.metadata["board_id"]
stack_id = doc_task.metadata["stack_id"]
try:
card = await nc_client.deck.get_card(
board_id=int(board_id),
stack_id=int(stack_id),
card_id=int(doc_task.doc_id),
)
# Fetch board and stack info for metadata
boards = await nc_client.deck.get_boards()
for b in boards:
if b.id == int(board_id):
board = b
stacks = await nc_client.deck.get_stacks(b.id)
for s in stacks:
if s.id == int(stack_id):
stack = s
break
break
except Exception as e:
logger.warning(
f"Failed to fetch card with metadata (board_id={board_id}, stack_id={stack_id}, card_id={doc_task.doc_id}): {e}, falling back to iteration"
)
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
if card is None:
boards = await nc_client.deck.get_boards()
card_found = False
for b in boards:
if card_found:
break
# Skip deleted boards (soft delete: deletedAt > 0)
if b.deletedAt > 0:
continue
stacks = await nc_client.deck.get_stacks(b.id)
for s in stacks:
if card_found:
break
if s.cards:
for c in s.cards:
if c.id == int(doc_task.doc_id):
card = c
board = b
stack = s
card_found = True
break
if not card_found:
raise ValueError(
f"Deck card {doc_task.doc_id} not found in any board/stack"
)
# Build content from card title and description
content_parts = [card.title]
if card.description:
content_parts.append(card.description)
content = "\n\n".join(content_parts)
title = card.title
# Store deck-specific metadata
file_metadata = {
"board_id": board.id,
"board_title": board.title,
"stack_id": stack.id,
"stack_title": stack.title,
"card_type": card.type,
"duedate": (card.duedate.isoformat() if card.duedate else None),
"archived": card.archived,
"owner": (
card.owner.uid if hasattr(card.owner, "uid") else str(card.owner)
),
}
etag = card.etag or ""
file_path = None
content_bytes = None
content_type = None
elif doc_task.doc_type == "file":
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
if not doc_task.file_path:
@@ -358,15 +489,18 @@ async def _index_document(
chunks = await chunker.chunk_text(content)
# Assign page numbers to chunks if page boundaries are available (PDFs)
if doc_task.doc_type == "file" and "page_boundaries" in file_metadata:
page_boundaries = file_metadata.get("page_boundaries")
if doc_task.doc_type == "file" and page_boundaries is not None:
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
with trace_operation(
"vector_sync.assign_page_numbers",
attributes={
"vector_sync.chunk_count": len(chunks),
"vector_sync.page_count": len(file_metadata["page_boundaries"]),
"vector_sync.page_count": len(page_boundaries_list),
},
):
assign_page_numbers(chunks, file_metadata["page_boundaries"])
assign_page_numbers(chunks, page_boundaries_list)
# Diagnostic: Verify page number assignment
assigned_count = sum(1 for c in chunks if c.page_number is not None)
@@ -389,8 +523,8 @@ async def _index_document(
f"Text length: {len(content)}, "
f"Chunks: {len(chunks)}, "
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
f"Page boundaries: {len(file_metadata['page_boundaries'])} pages, "
f"First boundary: {file_metadata['page_boundaries'][0] if file_metadata['page_boundaries'] else 'None'}"
f"Page boundaries: {len(page_boundaries_list)} pages, "
f"First boundary: {page_boundaries_list[0] if page_boundaries_list else 'None'}"
)
# Extract chunk texts for embedding
@@ -464,6 +598,9 @@ async def _index_document(
logger.warning("No page boundaries available, skipping highlighting")
return
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
logger.info(
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
)
@@ -474,7 +611,7 @@ async def _index_document(
lambda: PDFHighlighter.highlight_chunks_batch(
pdf_bytes=content_bytes,
chunks=chunk_data,
page_boundaries=page_boundaries,
page_boundaries=page_boundaries_list,
full_text=content,
color="yellow",
zoom=2.0,
@@ -566,6 +703,37 @@ async def _index_document(
if doc_task.doc_type == "file"
else {}
),
# News item-specific metadata
**(
{
"feed_id": file_metadata.get("feed_id"),
"feed_title": file_metadata.get("feed_title"),
"author": file_metadata.get("author"),
"pub_date": file_metadata.get("pub_date"),
"starred": file_metadata.get("starred"),
"unread": file_metadata.get("unread"),
"url": file_metadata.get("url"),
"guid_hash": file_metadata.get("guid_hash"),
"enclosure_link": file_metadata.get("enclosure_link"),
"enclosure_mime": file_metadata.get("enclosure_mime"),
}
if doc_task.doc_type == "news_item"
else {}
),
# Deck card-specific metadata
**(
{
"board_id": file_metadata.get("board_id"),
"board_title": file_metadata.get("board_title"),
"stack_id": file_metadata.get("stack_id"),
"stack_title": file_metadata.get("stack_title"),
"card_type": file_metadata.get("card_type"),
"duedate": file_metadata.get("duedate"),
"owner": file_metadata.get("owner"),
}
if doc_task.doc_type == "deck_card"
else {}
),
# Highlighted page image (PDF only)
**(
{
+426 -5
View File
@@ -36,6 +36,9 @@ class DocumentTask:
operation: str # "index" or "delete"
modified_at: int
file_path: str | None = None # File path for files (when doc_id is file_id)
metadata: dict[str, int | str] | None = (
None # Additional metadata (e.g., board_id/stack_id for deck_card)
)
# Track documents potentially deleted (grace period before actual deletion)
@@ -79,9 +82,11 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
if scroll_result[0]:
timestamps = [
point.payload.get("indexed_at", 0) for point in scroll_result[0]
point.payload.get("indexed_at", 0)
for point in scroll_result[0]
if point.payload is not None
]
max_timestamp = max(timestamps)
max_timestamp = max(timestamps) if timestamps else 0
logger.info(
f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}"
)
@@ -206,7 +211,11 @@ async def scan_user_documents(
limit=10000,
)
indexed_doc_ids = {point.payload["doc_id"] for point in scroll_result[0]}
indexed_doc_ids = {
point.payload["doc_id"]
for point in (scroll_result[0] or [])
if point.payload is not None
}
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
@@ -376,7 +385,9 @@ async def scan_user_documents(
)
indexed_file_ids = {
point.payload["doc_id"] for point in file_scroll_result[0]
point.payload["doc_id"]
for point in (file_scroll_result[0] or [])
if point.payload is not None
}
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
@@ -544,9 +555,419 @@ async def scan_user_documents(
queued += file_queued
# Scan News items (starred + unread)
news_queued = 0
try:
news_queued = await scan_news_items(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
)
queued += news_queued
except Exception as e:
logger.warning(f"Failed to scan news items for {user_id}: {e}")
# Scan Deck cards
deck_queued = 0
try:
deck_queued = await scan_deck_cards(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
)
queued += deck_queued
except Exception as e:
logger.warning(f"Failed to scan deck cards for {user_id}: {e}")
if queued > 0:
logger.info(
f"Sent {queued} documents ({file_queued} files) for incremental sync: {user_id}"
f"Sent {queued} documents ({file_queued} files, {news_queued} news items, {deck_queued} deck cards) for incremental sync: {user_id}"
)
else:
logger.debug(f"No changes detected for {user_id}")
async def scan_news_items(
user_id: str,
send_stream: MemoryObjectSendStream[DocumentTask],
nc_client: NextcloudClient,
initial_sync: bool,
scan_id: int,
) -> int:
"""
Scan user's News items and queue changed items for indexing.
Indexes all items from the user's feeds. The News app's auto-purge
feature (default: 200 items per feed) naturally limits the total
number of items, making explicit filtering unnecessary.
Args:
user_id: User to scan
send_stream: Stream to send changed documents to processors
nc_client: Authenticated Nextcloud client
initial_sync: If True, send all documents (first-time sync)
scan_id: Scan identifier for logging
Returns:
Number of items queued for processing
"""
from nextcloud_mcp_server.client.news import NewsItemType
settings = get_settings()
queued = 0
# Get indexed news item IDs from Qdrant (for deletion tracking)
indexed_item_ids: set[str] = set()
if not initial_sync:
qdrant_client = await get_qdrant_client()
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_type", match=MatchValue(value="news_item")),
]
),
with_payload=["doc_id"],
with_vectors=False,
limit=10000,
)
indexed_item_ids = {
point.payload["doc_id"]
for point in (scroll_result[0] or [])
if point.payload is not None
}
logger.debug(f"Found {len(indexed_item_ids)} indexed news items in Qdrant")
# Fetch all items (News app caps at ~200 per feed via auto-purge)
all_items = await nc_client.news.get_items(
batch_size=-1,
type_=NewsItemType.ALL,
get_read=True,
)
logger.debug(f"[SCAN-{scan_id}] Found {len(all_items)} news items")
item_count = len(all_items)
nextcloud_item_ids: set[str] = set()
for item in all_items:
doc_id = str(item["id"])
nextcloud_item_ids.add(doc_id)
# Use lastModified timestamp (microseconds in News API)
modified_at = item.get("lastModified", 0)
# Convert to seconds if needed (News API uses microseconds)
if modified_at > 10000000000: # > year 2286 in seconds
modified_at = modified_at // 1000000
if initial_sync:
# Send everything on first sync - write placeholder first
await write_placeholder_point(
doc_id=doc_id,
doc_type="news_item",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="news_item",
operation="index",
modified_at=modified_at,
)
)
queued += 1
else:
# Incremental sync: check if item exists and compare modified_at
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
logger.debug(
f"News item {doc_id} reappeared, removing from deletion grace period"
)
del _potentially_deleted[doc_key]
# Query Qdrant for existing entry
existing_metadata = await query_document_metadata(
doc_id=doc_id, doc_type="news_item", user_id=user_id
)
needs_indexing = False
if existing_metadata is None:
needs_indexing = True
elif existing_metadata.get("modified_at", 0) < modified_at:
needs_indexing = True
elif existing_metadata.get("is_placeholder", False):
queued_at = existing_metadata.get("queued_at", 0)
placeholder_age = time.time() - queued_at
stale_threshold = settings.vector_sync_scan_interval * 5
if placeholder_age > stale_threshold:
logger.debug(
f"Found stale placeholder for news item {doc_id} "
f"(age={placeholder_age:.1f}s), requeuing"
)
needs_indexing = True
if needs_indexing:
await write_placeholder_point(
doc_id=doc_id,
doc_type="news_item",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="news_item",
operation="index",
modified_at=modified_at,
)
)
queued += 1
logger.info(
f"[SCAN-{scan_id}] Found {item_count} news items (starred+unread) for {user_id}"
)
record_vector_sync_scan(item_count)
# Check for deleted items (not initial sync)
# Items become "deleted" when they are no longer starred AND become read
if not initial_sync:
grace_period = settings.vector_sync_scan_interval * 1.5
current_time = time.time()
for doc_id in indexed_item_ids:
if doc_id not in nextcloud_item_ids:
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
first_missing_time = _potentially_deleted[doc_key]
time_missing = current_time - first_missing_time
if time_missing >= grace_period:
logger.info(
f"News item {doc_id} missing for {time_missing:.1f}s "
f"(>{grace_period:.1f}s grace period), sending deletion"
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="news_item",
operation="delete",
modified_at=0,
)
)
queued += 1
del _potentially_deleted[doc_key]
else:
logger.debug(
f"News item {doc_id} missing for first time, starting grace period"
)
_potentially_deleted[doc_key] = current_time
return queued
async def scan_deck_cards(
user_id: str,
send_stream: MemoryObjectSendStream[DocumentTask],
nc_client: NextcloudClient,
initial_sync: bool,
scan_id: int,
) -> int:
"""
Scan user's Deck cards and queue changed cards for indexing.
Indexes cards from all non-archived boards and stacks.
Args:
user_id: User to scan
send_stream: Stream to send changed documents to processors
nc_client: Authenticated Nextcloud client
initial_sync: If True, send all documents (first-time sync)
scan_id: Scan identifier for logging
Returns:
Number of cards queued for processing
"""
settings = get_settings()
queued = 0
# Get indexed deck card IDs from Qdrant (for deletion tracking)
indexed_card_ids: set[str] = set()
if not initial_sync:
qdrant_client = await get_qdrant_client()
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
]
),
with_payload=["doc_id"],
with_vectors=False,
limit=10000,
)
indexed_card_ids = {
point.payload["doc_id"]
for point in (scroll_result[0] or [])
if point.payload is not None
}
logger.debug(f"Found {len(indexed_card_ids)} indexed deck cards in Qdrant")
# Fetch all boards
boards = await nc_client.deck.get_boards()
logger.debug(f"[SCAN-{scan_id}] Found {len(boards)} deck boards")
card_count = 0
nextcloud_card_ids: set[str] = set()
# Iterate through boards
for board in boards:
# Skip archived boards
if board.archived:
continue
# Skip deleted boards (soft delete: deletedAt > 0)
if board.deletedAt > 0:
logger.debug(f"[SCAN-{scan_id}] Skipping deleted board {board.id}")
continue
# Get stacks for this board
stacks = await nc_client.deck.get_stacks(board.id)
# Iterate through stacks
for stack in stacks:
# Skip if stack has no cards
if not stack.cards:
continue
# Iterate through cards in stack
for card in stack.cards:
# Skip archived cards
if card.archived:
continue
card_count += 1
doc_id = str(card.id)
nextcloud_card_ids.add(doc_id)
# Use lastModified timestamp if available
modified_at = card.lastModified or 0
if initial_sync:
# Send everything on first sync - write placeholder first
await write_placeholder_point(
doc_id=doc_id,
doc_type="deck_card",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="index",
modified_at=modified_at,
metadata={"board_id": board.id, "stack_id": stack.id},
)
)
queued += 1
else:
# Incremental sync: check if card exists and compare modified_at
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
logger.debug(
f"Deck card {doc_id} reappeared, removing from deletion grace period"
)
del _potentially_deleted[doc_key]
# Query Qdrant for existing entry
existing_metadata = await query_document_metadata(
doc_id=doc_id, doc_type="deck_card", user_id=user_id
)
needs_indexing = False
if existing_metadata is None:
needs_indexing = True
elif existing_metadata.get("modified_at", 0) < modified_at:
needs_indexing = True
elif existing_metadata.get("is_placeholder", False):
queued_at = existing_metadata.get("queued_at", 0)
placeholder_age = time.time() - queued_at
stale_threshold = settings.vector_sync_scan_interval * 5
if placeholder_age > stale_threshold:
logger.debug(
f"Found stale placeholder for deck card {doc_id} "
f"(age={placeholder_age:.1f}s), requeuing"
)
needs_indexing = True
if needs_indexing:
await write_placeholder_point(
doc_id=doc_id,
doc_type="deck_card",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="index",
modified_at=modified_at,
metadata={"board_id": board.id, "stack_id": stack.id},
)
)
queued += 1
logger.info(
f"[SCAN-{scan_id}] Found {card_count} deck cards (non-archived) for {user_id}"
)
record_vector_sync_scan(card_count)
# Check for deleted cards (not initial sync)
if not initial_sync:
grace_period = settings.vector_sync_scan_interval * 1.5
current_time = time.time()
for doc_id in indexed_card_ids:
if doc_id not in nextcloud_card_ids:
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
first_missing_time = _potentially_deleted[doc_key]
time_missing = current_time - first_missing_time
if time_missing >= grace_period:
logger.info(
f"Deck card {doc_id} missing for {time_missing:.1f}s "
f"(>{grace_period:.1f}s grace period), sending deletion"
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="delete",
modified_at=0,
)
)
queued += 1
del _potentially_deleted[doc_key]
else:
logger.debug(
f"Deck card {doc_id} missing for first time, starting grace period"
)
_potentially_deleted[doc_key] = current_time
return queued
+4 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.48.2"
version = "0.52.1"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
requires-python = ">=3.11"
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
dependencies = [
"mcp[cli] (>=1.22,<1.23)",
"mcp[cli] (>=1.23,<1.24)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
"icalendar (>=6.0.0,<7.0.0)",
@@ -36,6 +36,7 @@ dependencies = [
"python-json-logger>=3.2.0", # Structured JSON logging
"jinja2>=3.1.6",
"langchain-text-splitters>=1.0.0",
"markdownify>=0.14.1", # HTML to Markdown conversion for News items
"pymupdf>=1.26.6",
"pymupdf4llm>=0.2.2",
"pymupdf-layout>=1.26.6",
@@ -100,6 +101,7 @@ extend-select = ["I"]
[tool.uv.sources]
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
[build-system]
requires = ["uv_build>=0.9.4,<0.10.0"]
+7 -1
View File
@@ -4,5 +4,11 @@
"config:best-practices",
"mergeConfidence:all-badges"
],
"dependencyDashboard": true
"dependencyDashboard": true,
"packageRules": [
{
"matchPackageNames": ["pillow"],
"allowedVersions": "<12.0.0"
}
]
}
+219
View File
@@ -480,3 +480,222 @@ def create_mock_table_row_ocs_response(
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
return create_mock_response(status_code=200, json_data=ocs_response)
# ============================================================================
# News Mock Response Helpers
# ============================================================================
def create_mock_news_folders_response(
folders: list[dict] | None = None,
) -> httpx.Response:
"""Create a mock response for News folders list.
Args:
folders: List of folder dictionaries. If None, returns empty list.
Returns:
Mock httpx.Response with folders data
"""
if folders is None:
folders = []
return create_mock_response(status_code=200, json_data={"folders": folders})
def create_mock_news_folder_response(
folder_id: int = 1,
name: str = "Test Folder",
**kwargs,
) -> httpx.Response:
"""Create a mock response for a News folder.
Args:
folder_id: Folder ID
name: Folder name
**kwargs: Additional folder fields
Returns:
Mock httpx.Response with folder data
"""
folder_data = {
"id": folder_id,
"name": name,
**kwargs,
}
return create_mock_response(status_code=200, json_data={"folders": [folder_data]})
def create_mock_news_feeds_response(
feeds: list[dict] | None = None,
starred_count: int = 0,
newest_item_id: int | None = None,
) -> httpx.Response:
"""Create a mock response for News feeds list.
Args:
feeds: List of feed dictionaries. If None, returns empty list.
starred_count: Number of starred items
newest_item_id: ID of newest item
Returns:
Mock httpx.Response with feeds data
"""
if feeds is None:
feeds = []
data = {
"feeds": feeds,
"starredCount": starred_count,
}
if newest_item_id is not None:
data["newestItemId"] = newest_item_id
return create_mock_response(status_code=200, json_data=data)
def create_mock_news_feed_response(
feed_id: int = 1,
url: str = "https://example.com/feed",
title: str = "Test Feed",
favicon_link: str | None = None,
folder_id: int | None = None,
unread_count: int = 0,
**kwargs,
) -> httpx.Response:
"""Create a mock response for a News feed.
Args:
feed_id: Feed ID
url: Feed URL
title: Feed title
favicon_link: Favicon URL
folder_id: Parent folder ID
unread_count: Number of unread items
**kwargs: Additional feed fields
Returns:
Mock httpx.Response with feed data
"""
feed_data = {
"id": feed_id,
"url": url,
"title": title,
"faviconLink": favicon_link,
"folderId": folder_id,
"unreadCount": unread_count,
"link": kwargs.get("link", "https://example.com"),
"added": kwargs.get("added", 1700000000),
"updateErrorCount": kwargs.get("updateErrorCount", 0),
"lastUpdateError": kwargs.get("lastUpdateError"),
**{
k: v
for k, v in kwargs.items()
if k not in ["link", "added", "updateErrorCount", "lastUpdateError"]
},
}
return create_mock_response(status_code=200, json_data={"feeds": [feed_data]})
def create_mock_news_items_response(
items: list[dict] | None = None,
) -> httpx.Response:
"""Create a mock response for News items list.
Args:
items: List of item dictionaries. If None, returns empty list.
Returns:
Mock httpx.Response with items data
"""
if items is None:
items = []
return create_mock_response(status_code=200, json_data={"items": items})
def create_mock_news_item(
item_id: int = 1,
feed_id: int = 1,
title: str = "Test Article",
body: str = "<p>Test content</p>",
url: str = "https://example.com/article",
author: str | None = "Test Author",
pub_date: int = 1700000000,
unread: bool = True,
starred: bool = False,
**kwargs,
) -> dict:
"""Create a mock News item dictionary.
Args:
item_id: Item ID
feed_id: Parent feed ID
title: Article title
body: Article body (HTML)
url: Article URL
author: Article author
pub_date: Publication timestamp (Unix)
unread: Whether item is unread
starred: Whether item is starred
**kwargs: Additional item fields
Returns:
Item dictionary
"""
return {
"id": item_id,
"feedId": feed_id,
"title": title,
"body": body,
"url": url,
"author": author,
"pubDate": pub_date,
"unread": unread,
"starred": starred,
"guid": kwargs.get("guid", f"guid-{item_id}"),
"guidHash": kwargs.get("guidHash", f"hash-{item_id}"),
"lastModified": kwargs.get("lastModified", pub_date * 1000000),
"enclosureLink": kwargs.get("enclosureLink"),
"enclosureMime": kwargs.get("enclosureMime"),
"fingerprint": kwargs.get("fingerprint", f"fp-{item_id}"),
"contentHash": kwargs.get("contentHash", f"ch-{item_id}"),
**{
k: v
for k, v in kwargs.items()
if k
not in [
"guid",
"guidHash",
"lastModified",
"enclosureLink",
"enclosureMime",
"fingerprint",
"contentHash",
]
},
}
def create_mock_news_status_response(
version: str = "25.0.0",
warnings: dict | None = None,
) -> httpx.Response:
"""Create a mock response for News status.
Args:
version: News app version
warnings: Warning messages
Returns:
Mock httpx.Response with status data
"""
data = {
"version": version,
"warnings": warnings or {},
}
return create_mock_response(status_code=200, json_data=data)
View File
+580
View File
@@ -0,0 +1,580 @@
"""Unit tests for NewsClient API methods."""
import logging
import httpx
import pytest
from nextcloud_mcp_server.client.news import NewsClient, NewsItemType
from tests.client.conftest import (
create_mock_error_response,
create_mock_news_feed_response,
create_mock_news_feeds_response,
create_mock_news_folder_response,
create_mock_news_folders_response,
create_mock_news_item,
create_mock_news_items_response,
create_mock_news_status_response,
create_mock_response,
)
logger = logging.getLogger(__name__)
# Mark all tests in this module as unit tests
pytestmark = pytest.mark.unit
# ============================================================================
# Folder Tests
# ============================================================================
async def test_news_api_get_folders(mocker):
"""Test that get_folders correctly parses the API response."""
mock_response = create_mock_news_folders_response(
folders=[
{"id": 1, "name": "Tech"},
{"id": 2, "name": "News"},
]
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
folders = await client.get_folders()
assert len(folders) == 2
assert folders[0]["id"] == 1
assert folders[0]["name"] == "Tech"
assert folders[1]["name"] == "News"
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/folders")
async def test_news_api_create_folder(mocker):
"""Test that create_folder correctly creates a folder."""
mock_response = create_mock_news_folder_response(folder_id=3, name="New Folder")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
folder = await client.create_folder(name="New Folder")
assert folder["id"] == 3
assert folder["name"] == "New Folder"
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/folders", json={"name": "New Folder"}
)
async def test_news_api_rename_folder(mocker):
"""Test that rename_folder makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.rename_folder(folder_id=1, name="Renamed")
mock_make_request.assert_called_once_with(
"PUT", "/apps/news/api/v1-3/folders/1", json={"name": "Renamed"}
)
async def test_news_api_delete_folder(mocker):
"""Test that delete_folder makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.delete_folder(folder_id=1)
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/folders/1")
# ============================================================================
# Feed Tests
# ============================================================================
async def test_news_api_get_feeds(mocker):
"""Test that get_feeds correctly parses the API response."""
mock_response = create_mock_news_feeds_response(
feeds=[
{"id": 1, "url": "https://example.com/feed1", "title": "Feed 1"},
{"id": 2, "url": "https://example.com/feed2", "title": "Feed 2"},
],
starred_count=5,
newest_item_id=100,
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_feeds()
assert len(result["feeds"]) == 2
assert result["starredCount"] == 5
assert result["newestItemId"] == 100
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/feeds")
async def test_news_api_create_feed(mocker):
"""Test that create_feed correctly creates a feed."""
mock_response = create_mock_news_feed_response(
feed_id=10, url="https://example.com/new-feed", title="New Feed"
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
feed = await client.create_feed(url="https://example.com/new-feed")
assert feed["id"] == 10
assert feed["url"] == "https://example.com/new-feed"
mock_make_request.assert_called_once_with(
"POST",
"/apps/news/api/v1-3/feeds",
json={"url": "https://example.com/new-feed"},
)
async def test_news_api_create_feed_with_folder(mocker):
"""Test that create_feed correctly creates a feed in a folder."""
mock_response = create_mock_news_feed_response(
feed_id=10, url="https://example.com/feed", folder_id=5
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
feed = await client.create_feed(url="https://example.com/feed", folder_id=5)
assert feed["folderId"] == 5
mock_make_request.assert_called_once_with(
"POST",
"/apps/news/api/v1-3/feeds",
json={"url": "https://example.com/feed", "folderId": 5},
)
async def test_news_api_delete_feed(mocker):
"""Test that delete_feed makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.delete_feed(feed_id=10)
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/feeds/10")
async def test_news_api_move_feed(mocker):
"""Test that move_feed makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.move_feed(feed_id=10, folder_id=5)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/feeds/10/move", json={"folderId": 5}
)
async def test_news_api_rename_feed(mocker):
"""Test that rename_feed makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.rename_feed(feed_id=10, title="Renamed Feed")
mock_make_request.assert_called_once_with(
"POST",
"/apps/news/api/v1-3/feeds/10/rename",
json={"feedTitle": "Renamed Feed"},
)
# ============================================================================
# Item Tests
# ============================================================================
async def test_news_api_get_items(mocker):
"""Test that get_items correctly parses the API response."""
items = [
create_mock_news_item(item_id=1, title="Article 1"),
create_mock_news_item(item_id=2, title="Article 2"),
]
mock_response = create_mock_news_items_response(items=items)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_items()
assert len(result) == 2
assert result[0]["title"] == "Article 1"
assert result[1]["title"] == "Article 2"
# Verify default parameters
call_args = mock_make_request.call_args
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items")
params = call_args[1]["params"]
assert params["batchSize"] == 50
assert params["type"] == NewsItemType.ALL
async def test_news_api_get_items_starred(mocker):
"""Test that get_items with STARRED type filters correctly."""
items = [create_mock_news_item(item_id=1, starred=True)]
mock_response = create_mock_news_items_response(items=items)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_items(type_=NewsItemType.STARRED)
assert len(result) == 1
assert result[0]["starred"] is True
call_args = mock_make_request.call_args
params = call_args[1]["params"]
assert params["type"] == NewsItemType.STARRED
async def test_news_api_get_items_unread_only(mocker):
"""Test that get_items with get_read=False filters correctly."""
items = [create_mock_news_item(item_id=1, unread=True)]
mock_response = create_mock_news_items_response(items=items)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_items(get_read=False)
assert len(result) == 1
call_args = mock_make_request.call_args
params = call_args[1]["params"]
assert params["getRead"] == "false"
async def test_news_api_get_item(mocker):
"""Test that get_item fetches all items and filters for the requested ID."""
# Create multiple items, only one should be returned
items = [
create_mock_news_item(item_id=100, title="Other Item 1"),
create_mock_news_item(item_id=123, title="Single Item"),
create_mock_news_item(item_id=200, title="Other Item 2"),
]
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_get_items = mocker.patch.object(NewsClient, "get_items", return_value=items)
client = NewsClient(mock_client, "testuser")
result = await client.get_item(item_id=123)
assert result["id"] == 123
assert result["title"] == "Single Item"
# Verify it fetched all items with correct params
mock_get_items.assert_called_once_with(batch_size=-1, get_read=True)
async def test_news_api_get_item_not_found(mocker):
"""Test that get_item raises ValueError when item not found."""
items = [
create_mock_news_item(item_id=100, title="Item 1"),
create_mock_news_item(item_id=200, title="Item 2"),
]
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mocker.patch.object(NewsClient, "get_items", return_value=items)
client = NewsClient(mock_client, "testuser")
with pytest.raises(ValueError, match="Item 999 not found"):
await client.get_item(item_id=999)
async def test_news_api_get_updated_items(mocker):
"""Test that get_updated_items correctly calls the updated endpoint."""
items = [create_mock_news_item(item_id=1)]
mock_response = create_mock_news_items_response(items=items)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_updated_items(last_modified=1700000000)
assert len(result) == 1
call_args = mock_make_request.call_args
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items/updated")
params = call_args[1]["params"]
assert params["lastModified"] == 1700000000
async def test_news_api_mark_item_read(mocker):
"""Test that mark_item_read makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.mark_item_read(item_id=123)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/123/read"
)
async def test_news_api_mark_item_unread(mocker):
"""Test that mark_item_unread makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.mark_item_unread(item_id=123)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/123/unread"
)
async def test_news_api_star_item(mocker):
"""Test that star_item makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.star_item(item_id=123)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/123/star"
)
async def test_news_api_unstar_item(mocker):
"""Test that unstar_item makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.unstar_item(item_id=123)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/123/unstar"
)
async def test_news_api_mark_items_read_multiple(mocker):
"""Test that mark_items_read makes the correct API call for multiple items."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.mark_items_read(item_ids=[1, 2, 3])
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/read/multiple", json={"itemIds": [1, 2, 3]}
)
async def test_news_api_star_items_multiple(mocker):
"""Test that star_items makes the correct API call for multiple items."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.star_items(item_ids=[1, 2, 3])
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/star/multiple", json={"itemIds": [1, 2, 3]}
)
# ============================================================================
# Status Tests
# ============================================================================
async def test_news_api_get_status(mocker):
"""Test that get_status correctly parses the API response."""
mock_response = create_mock_news_status_response(
version="25.0.0",
warnings={"improperlyConfiguredCron": False},
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
status = await client.get_status()
assert status["version"] == "25.0.0"
assert "warnings" in status
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/status")
async def test_news_api_get_version(mocker):
"""Test that get_version correctly parses the API response."""
mock_response = create_mock_response(
status_code=200, json_data={"version": "25.0.0"}
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
version = await client.get_version()
assert version == "25.0.0"
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/version")
# ============================================================================
# Error Handling Tests
# ============================================================================
async def test_news_api_create_folder_conflict(mocker):
"""Test that create_folder raises HTTPStatusError on 409 conflict."""
error_response = create_mock_error_response(409, "Folder name already exists")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"409 Conflict",
request=httpx.Request("POST", "http://test.local"),
response=error_response,
)
client = NewsClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.create_folder(name="Existing Folder")
assert excinfo.value.response.status_code == 409
async def test_news_api_delete_feed_not_found(mocker):
"""Test that delete_feed raises HTTPStatusError on 404."""
error_response = create_mock_error_response(404, "Feed not found")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("DELETE", "http://test.local"),
response=error_response,
)
client = NewsClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.delete_feed(feed_id=999999)
assert excinfo.value.response.status_code == 404
async def test_news_api_create_feed_invalid_url(mocker):
"""Test that create_feed raises HTTPStatusError on 422 for invalid URL."""
error_response = create_mock_error_response(422, "Invalid feed URL")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"422 Unprocessable Entity",
request=httpx.Request("POST", "http://test.local"),
response=error_response,
)
client = NewsClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.create_feed(url="not-a-valid-url")
assert excinfo.value.response.status_code == 422
+26
View File
@@ -0,0 +1,26 @@
"""Pytest configuration for integration tests.
This conftest.py provides hooks and fixtures specific to integration tests,
including the --provider flag for RAG tests.
"""
# Valid provider names
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
def pytest_addoption(parser):
"""Add --provider command line option for RAG tests."""
parser.addoption(
"--provider",
action="store",
default=None,
choices=VALID_PROVIDERS,
help="LLM provider for RAG tests: openai, ollama, anthropic, bedrock",
)
def pytest_configure(config):
"""Configure custom markers."""
config.addinivalue_line(
"markers", "rag: mark test as RAG integration test (requires --provider flag)"
)
+264
View File
@@ -0,0 +1,264 @@
"""Provider fixtures for integration tests.
This module provides pytest fixtures that configure LLM providers based on
an explicit --provider flag. Supports OpenAI, Ollama, Anthropic, and Bedrock.
Usage:
pytest tests/integration/test_rag.py --provider=openai
pytest tests/integration/test_rag.py --provider=ollama
pytest tests/integration/test_rag.py --provider=anthropic
pytest tests/integration/test_rag.py --provider=bedrock
Environment Variables by Provider:
OpenAI:
OPENAI_API_KEY: API key (required)
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
OPENAI_GENERATION_MODEL: Generation model (default: "gpt-4o-mini")
Ollama:
OLLAMA_BASE_URL: API URL (required, e.g., "http://localhost:11434")
OLLAMA_EMBEDDING_MODEL: Embedding model (default: "nomic-embed-text")
OLLAMA_GENERATION_MODEL: Generation model (default: "llama3.2:1b")
Anthropic:
ANTHROPIC_API_KEY: API key (required)
ANTHROPIC_GENERATION_MODEL: Model (default: "claude-3-haiku-20240307")
Bedrock:
AWS_REGION: AWS region (required)
BEDROCK_EMBEDDING_MODEL: Embedding model ID
BEDROCK_GENERATION_MODEL: Generation model ID
"""
import logging
import os
from typing import AsyncGenerator
import pytest
from nextcloud_mcp_server.providers.base import Provider
logger = logging.getLogger(__name__)
# Valid provider names (must match conftest.py)
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
async def create_generation_provider(provider_name: str) -> Provider:
"""Create a provider configured for text generation.
Args:
provider_name: One of "openai", "ollama", "anthropic", "bedrock"
Returns:
Provider instance configured for generation
Raises:
ValueError: If provider_name is invalid or required env vars missing
"""
if provider_name == "openai":
from nextcloud_mcp_server.providers.openai import OpenAIProvider
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY environment variable required")
base_url = os.getenv("OPENAI_BASE_URL")
generation_model = os.getenv("OPENAI_GENERATION_MODEL", "gpt-4o-mini")
# GitHub Models API requires model name prefix
if base_url and "models.github.ai" in base_url:
if not generation_model.startswith("openai/"):
generation_model = f"openai/{generation_model}"
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=None, # Generation only
generation_model=generation_model,
)
logger.info(f"Created OpenAI generation provider: model={generation_model}")
return provider
elif provider_name == "ollama":
from nextcloud_mcp_server.providers.ollama import OllamaProvider
base_url = os.getenv("OLLAMA_BASE_URL")
if not base_url:
raise ValueError("OLLAMA_BASE_URL environment variable required")
generation_model = os.getenv("OLLAMA_GENERATION_MODEL", "llama3.2:1b")
provider = OllamaProvider(
base_url=base_url,
embedding_model=None, # Generation only
generation_model=generation_model,
)
logger.info(f"Created Ollama generation provider: model={generation_model}")
return provider
elif provider_name == "anthropic":
from nextcloud_mcp_server.providers.anthropic import AnthropicProvider
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError("ANTHROPIC_API_KEY environment variable required")
generation_model = os.getenv(
"ANTHROPIC_GENERATION_MODEL", "claude-3-haiku-20240307"
)
provider = AnthropicProvider(
api_key=api_key,
generation_model=generation_model,
)
logger.info(f"Created Anthropic generation provider: model={generation_model}")
return provider
elif provider_name == "bedrock":
from nextcloud_mcp_server.providers.bedrock import BedrockProvider
region = os.getenv("AWS_REGION")
if not region:
raise ValueError("AWS_REGION environment variable required")
generation_model = os.getenv("BEDROCK_GENERATION_MODEL")
if not generation_model:
raise ValueError("BEDROCK_GENERATION_MODEL environment variable required")
provider = BedrockProvider(
region=region,
embedding_model=None, # Generation only
generation_model=generation_model,
)
logger.info(f"Created Bedrock generation provider: model={generation_model}")
return provider
else:
raise ValueError(f"Unknown provider: {provider_name}. Valid: {VALID_PROVIDERS}")
async def create_embedding_provider(provider_name: str) -> Provider:
"""Create a provider configured for embeddings.
Args:
provider_name: One of "openai", "ollama", "bedrock"
(Anthropic does not support embeddings)
Returns:
Provider instance configured for embeddings
Raises:
ValueError: If provider_name is invalid, doesn't support embeddings,
or required env vars missing
"""
if provider_name == "anthropic":
raise ValueError("Anthropic does not support embeddings")
if provider_name == "openai":
from nextcloud_mcp_server.providers.openai import OpenAIProvider
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY environment variable required")
base_url = os.getenv("OPENAI_BASE_URL")
embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
# GitHub Models API requires model name prefix
if base_url and "models.github.ai" in base_url:
if not embedding_model.startswith("openai/"):
embedding_model = f"openai/{embedding_model}"
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
logger.info(f"Created OpenAI embedding provider: model={embedding_model}")
return provider
elif provider_name == "ollama":
from nextcloud_mcp_server.providers.ollama import OllamaProvider
base_url = os.getenv("OLLAMA_BASE_URL")
if not base_url:
raise ValueError("OLLAMA_BASE_URL environment variable required")
embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
provider = OllamaProvider(
base_url=base_url,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
logger.info(f"Created Ollama embedding provider: model={embedding_model}")
return provider
elif provider_name == "bedrock":
from nextcloud_mcp_server.providers.bedrock import BedrockProvider
region = os.getenv("AWS_REGION")
if not region:
raise ValueError("AWS_REGION environment variable required")
embedding_model = os.getenv("BEDROCK_EMBEDDING_MODEL")
if not embedding_model:
raise ValueError("BEDROCK_EMBEDDING_MODEL environment variable required")
provider = BedrockProvider(
region=region,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
logger.info(f"Created Bedrock embedding provider: model={embedding_model}")
return provider
else:
raise ValueError(f"Unknown provider: {provider_name}. Valid: {VALID_PROVIDERS}")
# =============================================================================
# Pytest Fixtures
# =============================================================================
@pytest.fixture(scope="module")
def provider_name(request) -> str:
"""Get the provider name from --provider flag.
Raises pytest.skip if --provider not specified.
"""
name = request.config.getoption("--provider")
if not name:
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
return name
@pytest.fixture(scope="module")
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
"""Fixture providing a generation-capable provider.
Requires --provider flag to be set.
"""
provider = await create_generation_provider(provider_name)
yield provider
await provider.close()
@pytest.fixture(scope="module")
async def embedding_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
"""Fixture providing an embedding-capable provider.
Requires --provider flag to be set.
Note: Anthropic does not support embeddings - test will fail if used.
"""
if provider_name == "anthropic":
pytest.skip("Anthropic does not support embeddings")
provider = await create_embedding_provider(provider_name)
yield provider
await provider.close()
+49 -23
View File
@@ -1,7 +1,7 @@
"""MCP sampling support for integration tests.
This module provides utilities to enable real LLM-based sampling in integration tests
using OpenAI or GitHub Models API.
using any provider that supports text generation (OpenAI, Ollama, Anthropic, Bedrock).
"""
import logging
@@ -10,46 +10,58 @@ from typing import Any
from mcp import types
from mcp.client.session import ClientSession, RequestContext
from nextcloud_mcp_server.providers.openai import OpenAIProvider
from nextcloud_mcp_server.providers.base import Provider
logger = logging.getLogger(__name__)
def create_openai_sampling_callback(provider: OpenAIProvider):
"""Factory to create a sampling callback using OpenAI provider.
def create_sampling_callback(provider: Provider):
"""Factory to create a sampling callback using any generation-capable provider.
The callback conforms to MCP's SamplingFnT protocol and can be passed
to ClientSession for handling sampling requests from the server.
Args:
provider: OpenAIProvider instance configured with a generation model
provider: Any Provider instance that supports generation
(supports_generation=True)
Returns:
Async callback function for MCP sampling
Raises:
ValueError: If provider doesn't support generation
Example:
```python
provider = OpenAIProvider(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
generation_model="gpt-4o-mini",
)
callback = create_openai_sampling_callback(provider)
from nextcloud_mcp_server.providers import get_provider
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
sampling_callback=callback,
):
# Session now supports sampling
pass
provider = get_provider() # Auto-detect from environment
if provider.supports_generation:
callback = create_sampling_callback(provider)
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
sampling_callback=callback,
):
# Session now supports sampling
pass
```
"""
if not provider.supports_generation:
raise ValueError(
f"Provider {provider.__class__.__name__} does not support generation"
)
# Get model name for logging (provider-specific attribute)
model_name = (
getattr(provider, "generation_model", None) or provider.__class__.__name__
)
async def sampling_callback(
context: RequestContext[ClientSession, Any],
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
"""Handle sampling requests using OpenAI provider."""
"""Handle sampling requests using the configured provider."""
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
# Extract messages and build prompt
@@ -68,14 +80,13 @@ def create_openai_sampling_callback(provider: OpenAIProvider):
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
try:
# Generate response using OpenAI provider
# Note: temperature is hardcoded in the provider at 0.7
# Generate response using provider
# Note: temperature is typically hardcoded in providers at 0.7
response = await provider.generate(
prompt=prompt,
max_tokens=params.maxTokens,
)
model_name = provider.generation_model or "unknown"
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
return types.CreateMessageResult(
@@ -85,10 +96,25 @@ def create_openai_sampling_callback(provider: OpenAIProvider):
stopReason="endTurn",
)
except Exception as e:
logger.error(f"OpenAI generation failed: {e}")
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
return types.ErrorData(
code=types.INTERNAL_ERROR,
message=f"OpenAI generation failed: {e!s}",
message=f"Generation failed: {e!s}",
)
return sampling_callback
def create_openai_sampling_callback(provider: "Provider"):
"""Factory to create a sampling callback using OpenAI provider.
This is a backward-compatible wrapper around create_sampling_callback().
Prefer using create_sampling_callback() directly for new code.
Args:
provider: OpenAIProvider instance configured with a generation model
Returns:
Async callback function for MCP sampling
"""
return create_sampling_callback(provider)
@@ -0,0 +1,238 @@
"""Integration tests for Deck card vector search.
These tests validate that Deck cards are properly indexed and searchable
via semantic search.
"""
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.smoke]
async def test_deck_card_semantic_search(nc_mcp_client, nc_client, mocker):
"""Test that Deck cards can be indexed and searched via semantic search.
This test:
1. Creates a Deck board with a card
2. Manually triggers indexing (simulates vector sync)
3. Performs semantic search filtering by deck_card doc_type
4. Verifies the card is found in results
"""
# Skip if vector sync is not enabled
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
if settings_response.isError:
pytest.skip("Vector sync not enabled")
# Create a test board
board_title = "Test Board for Vector Search"
board = await nc_client.deck.create_board(title=board_title, color="ff0000")
try:
# Create a stack for the board
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
# Create a test card with searchable content
card_title = "Machine Learning Project Plan"
card_description = """
# ML Project Outline
## Phase 1: Data Collection
- Gather training data from multiple sources
- Clean and preprocess the dataset
## Phase 2: Model Training
- Experiment with different neural network architectures
- Use gradient descent optimization
## Phase 3: Deployment
- Deploy model to production environment
- Monitor performance metrics
"""
card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=card_title,
description=card_description,
)
# Note: In a real integration test with vector sync enabled,
# we would wait for the background scanner to index the card.
# For now, we'll test the scanning function directly if needed.
# TODO: Once vector sync is running in test environment,
# add actual semantic search test here
# For now, just verify the card was created successfully
assert card.id is not None
assert card.title == card_title
assert card.description == card_description
# Test semantic search with deck_card filter
# Note: This will only work if vector sync is actually running
# and the card has been indexed
try:
search_result = await nc_mcp_client.call_tool(
"nc_semantic_search",
{
"query": "machine learning neural networks",
"doc_types": ["deck_card"],
"limit": 10,
},
)
# If vector sync is working, we should find the card
if not search_result.isError:
data = search_result.structuredContent
results = data.get("results", [])
# Check if our card is in the results
found_card = any(
r.get("doc_type") == "deck_card" and r.get("title") == card_title
for r in results
)
# Log result for debugging
if found_card:
print("✓ Successfully found Deck card in vector search")
else:
print(
"⚠ Deck card not found in search (may need time for indexing)"
)
except Exception as e:
# If search fails, it might be because indexing hasn't happened yet
print(f"⚠ Semantic search failed (indexing may not be complete): {e}")
finally:
# Cleanup: delete the board
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
async def test_deck_card_appears_in_cross_app_search(nc_mcp_client, nc_client):
"""Test that Deck cards appear in cross-app semantic search (no doc_type filter).
This verifies that when searching without specifying doc_types,
Deck cards are included in the results alongside notes, files, etc.
"""
# Skip if vector sync is not enabled
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
if settings_response.isError:
pytest.skip("Vector sync not enabled")
# Create a test board with a distinctive card
board_title = "Cross-App Search Test Board"
board = await nc_client.deck.create_board(title=board_title, color="00ff00")
try:
# Create a stack for the board
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
# Use a very distinctive term to make it easy to find
unique_term = "xylophone_banana_unicorn_test"
_card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=f"Test Card with {unique_term}",
description=f"This card contains the unique search term: {unique_term}",
)
# Test cross-app search (no doc_type filter)
try:
search_result = await nc_mcp_client.call_tool(
"nc_semantic_search",
{
"query": unique_term,
"limit": 20,
},
)
if not search_result.isError:
data = search_result.structuredContent
results = data.get("results", [])
# Check if deck_card appears in cross-app results
deck_cards_found = [
r for r in results if r.get("doc_type") == "deck_card"
]
if deck_cards_found:
print(
f"✓ Found {len(deck_cards_found)} Deck card(s) in cross-app search"
)
else:
print(
"⚠ No Deck cards in cross-app search (may need time for indexing)"
)
except Exception as e:
print(f"⚠ Cross-app search failed: {e}")
finally:
# Cleanup
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
async def test_deck_card_chunk_context(nc_client):
"""Test that Deck card chunk context can be fetched for visualization.
This test validates that the vector viz UI can display Deck card previews
by fetching the chunk context via the context expansion module.
"""
from nextcloud_mcp_server.search.context import get_chunk_with_context
# Create board, stack, and card
board = await nc_client.deck.create_board(title="Test Board", color="ff0000")
try:
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
card_title = "Test Card for Context Expansion"
card_description = "This is a test description that should be fetched by the context expansion module when displaying chunk previews in the vector visualization UI."
card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=card_title,
description=card_description,
)
# Fetch chunk context (simulates viz UI request)
# The chunk spans the title, so start=0 and end=len(card_title)
context = await get_chunk_with_context(
nc_client=nc_client,
user_id=nc_client.username,
doc_id=card.id,
doc_type="deck_card",
chunk_start=0,
chunk_end=len(card_title),
context_chars=100,
)
# Verify context was fetched successfully
assert context is not None, "Chunk context should not be None"
assert card_title in context.chunk_text, (
f"Card title '{card_title}' should be in chunk_text"
)
# Verify context includes description
assert card_description[:50] in context.after_context, (
"Card description should be in after_context"
)
print(f"✓ Successfully fetched chunk context for Deck card {card.id}")
finally:
# Cleanup
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
@@ -1,26 +1,33 @@
"""Integration tests for RAG pipeline with OpenAI/GitHub Models API.
"""Integration tests for RAG pipeline with multiple LLM providers.
These tests validate the complete semantic search and MCP sampling flow using:
1. OpenAI embeddings for semantic search
2. MCP sampling for answer generation
1. MCP server's built-in semantic search (embeddings handled server-side)
2. MCP sampling for answer generation (any generation-capable provider)
3. Pre-indexed Nextcloud User Manual as the knowledge base
Environment Variables:
OPENAI_API_KEY: OpenAI API key or GitHub token for models.github.ai
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
OPENAI_GENERATION_MODEL: Generation model for sampling (default: "gpt-4o-mini")
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: "Nextcloud_User_Manual.pdf")
Usage:
# Run with OpenAI (including GitHub Models API)
OPENAI_API_KEY=... pytest tests/integration/test_rag.py --provider=openai -v
For GitHub CI, set:
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: https://models.github.ai/inference
OPENAI_EMBEDDING_MODEL: openai/text-embedding-3-small
OPENAI_GENERATION_MODEL: openai/gpt-4o-mini
# Run with Ollama
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_GENERATION_MODEL=llama3.2:1b \\
pytest tests/integration/test_rag.py --provider=ollama -v
# Run with Anthropic
ANTHROPIC_API_KEY=... pytest tests/integration/test_rag.py --provider=anthropic -v
# Run with AWS Bedrock
AWS_REGION=us-east-1 BEDROCK_GENERATION_MODEL=... \\
pytest tests/integration/test_rag.py --provider=bedrock -v
Environment Variables:
See tests/integration/provider_fixtures.py for provider-specific configuration.
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: "Nextcloud Manual.pdf")
Prerequisites:
- Nextcloud User Manual PDF uploaded to Nextcloud
- VECTOR_SYNC_ENABLED=true on the MCP server
- Provider-specific environment variables set
"""
import json
@@ -33,9 +40,10 @@ import anyio
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.providers.openai import OpenAIProvider
from nextcloud_mcp_server.providers.base import Provider
from tests.conftest import create_mcp_client_session
from tests.integration.sampling_support import create_openai_sampling_callback
from tests.integration.provider_fixtures import create_generation_provider
from tests.integration.sampling_support import create_sampling_callback
logger = logging.getLogger(__name__)
@@ -44,14 +52,14 @@ DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
async def llm_judge(
provider: "OpenAIProvider",
provider: Provider,
ground_truth: str,
system_output: str,
) -> bool:
"""Use LLM to judge if system output aligns with ground truth.
Args:
provider: OpenAI provider with generation capability
provider: Any provider with generation capability
ground_truth: The expected/reference answer
system_output: The system's actual output to evaluate
@@ -66,17 +74,18 @@ Does the system output contain the key facts from the ground truth?
Answer: TRUE or FALSE"""
logger.info("Received ground truth: %s", ground_truth)
logger.info("Received system output: %s", system_output)
response = await provider.generate(prompt, max_tokens=10)
logger.info("LLM Judge response: %s", response)
return "TRUE" in response.upper()
# Skip all tests if OpenAI API key not configured
# Mark all tests as integration tests
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set - skipping OpenAI RAG tests",
),
pytest.mark.rag,
]
# Ground truth fixture path
@@ -175,78 +184,49 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
@pytest.fixture(scope="module")
async def openai_provider():
"""OpenAI provider configured from environment (embeddings only)."""
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
def provider_name(request) -> str:
"""Get the provider name from --provider flag.
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
yield provider
await provider.close()
Raises pytest.skip if --provider not specified.
"""
name = request.config.getoption("--provider")
if not name:
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
return name
@pytest.fixture(scope="module")
async def openai_generation_provider():
"""OpenAI provider configured for text generation (for sampling callback)."""
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
generation_model = os.getenv("OPENAI_GENERATION_MODEL", "gpt-4o-mini")
# For GitHub Models API, use the prefixed model name
if base_url and "models.github.ai" in base_url:
if not generation_model.startswith("openai/"):
generation_model = f"openai/{generation_model}"
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=None, # Generation only
generation_model=generation_model,
)
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
"""Provider configured for text generation.
Requires --provider flag to be set.
"""
provider = await create_generation_provider(provider_name)
yield provider
await provider.close()
@pytest.fixture(scope="module")
async def nc_mcp_client_with_sampling(
anyio_backend, openai_generation_provider
anyio_backend, generation_provider, provider_name
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client with OpenAI-based sampling support.
"""MCP client with sampling support using the specified provider.
This fixture creates an MCP client that can handle sampling requests
from the server using OpenAI for text generation.
from the server using the configured generation provider.
"""
sampling_callback = create_openai_sampling_callback(openai_generation_provider)
sampling_callback = create_sampling_callback(generation_provider)
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
client_name="OpenAI Sampling MCP",
client_name=f"Sampling MCP ({provider_name})",
sampling_callback=sampling_callback,
):
yield session
async def test_openai_embeddings_work(openai_provider: OpenAIProvider):
"""Test that OpenAI embeddings can be generated."""
embedding = await openai_provider.embed("test query about Nextcloud")
assert isinstance(embedding, list)
assert len(embedding) > 0
assert all(isinstance(x, float) for x in embedding)
# OpenAI embedding dimensions: 1536 (small) or 3072 (large)
assert len(embedding) in [1536, 3072]
async def test_semantic_search_retrieval(
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, openai_generation_provider
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, generation_provider
):
"""Test that semantic search retrieves relevant documents from the manual.
@@ -278,7 +258,7 @@ async def test_semantic_search_retrieval(
# Use LLM judge to evaluate if excerpts are relevant to ground truth
all_excerpts = " ".join([r["excerpt"] for r in data["results"]])
is_relevant = await llm_judge(
openai_generation_provider,
generation_provider,
test_case["ground_truth"],
all_excerpts,
)
@@ -289,16 +269,16 @@ async def test_semantic_search_answer_with_sampling(
nc_mcp_client_with_sampling,
ground_truth_qa,
indexed_manual_pdf,
openai_generation_provider,
generation_provider,
):
"""Test semantic search with MCP sampling for answer generation.
This tests the full RAG pipeline:
1. Semantic search retrieves relevant documents
2. MCP sampling generates an answer from the retrieved context
3. OpenAI generates the answer via the sampling callback
3. Provider generates the answer via the sampling callback
Uses nc_mcp_client_with_sampling which has OpenAI-based sampling enabled.
Uses nc_mcp_client_with_sampling which has sampling enabled.
"""
# Use the 2FA question - has clear expected answer
test_case = ground_truth_qa[0]
@@ -348,7 +328,7 @@ async def test_semantic_search_answer_with_sampling(
# Use LLM judge to evaluate answer relevance
is_relevant = await llm_judge(
openai_generation_provider,
generation_provider,
test_case["ground_truth"],
data["generated_answer"],
)
+193
View File
@@ -0,0 +1,193 @@
"""Tests for MCP tool annotations (ADR-017)."""
import pytest
from mcp import ClientSession
pytestmark = pytest.mark.integration
async def test_all_tools_have_titles(nc_mcp_client: ClientSession):
"""Verify all tools have human-readable titles (Phase 1 of ADR-017)."""
tools = await nc_mcp_client.list_tools()
# Every tool should have a title (not None)
for tool in tools.tools:
assert tool.title is not None, f"Tool {tool.name} is missing a title"
# Title should not be empty
assert tool.title.strip() != "", f"Tool {tool.name} has an empty title"
# Title should be human-readable (not snake_case function name)
assert tool.title != tool.name, (
f"Tool {tool.name} title is same as function name"
)
async def test_all_tools_have_annotations(nc_mcp_client: ClientSession):
"""Verify all tools have ToolAnnotations (Phase 2 of ADR-017)."""
tools = await nc_mcp_client.list_tools()
for tool in tools.tools:
# Every tool should have annotations
assert tool.annotations is not None, f"Tool {tool.name} is missing annotations"
async def test_read_only_tools_have_correct_annotations(nc_mcp_client: ClientSession):
"""Verify read-only tools are marked correctly."""
tools = await nc_mcp_client.list_tools()
# Known read-only tools (list, search, get operations)
read_only_prefixes = ["list", "search", "get"]
read_only_patterns = ["_get_", "_list_", "_search_"]
for tool in tools.tools:
# Check if tool name suggests it's read-only
is_likely_readonly = tool.name.startswith(tuple(read_only_prefixes)) or any(
pattern in tool.name for pattern in read_only_patterns
)
if is_likely_readonly:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.readOnlyHint is True, (
f"Read-only tool {tool.name} should have readOnlyHint=True"
)
assert tool.annotations.destructiveHint is not True, (
f"Read-only tool {tool.name} should not have destructiveHint=True"
)
async def test_destructive_tools_have_correct_annotations(nc_mcp_client: ClientSession):
"""Verify destructive operations are marked correctly."""
tools = await nc_mcp_client.list_tools()
# Known destructive operations
destructive_keywords = ["delete", "remove", "revoke"]
for tool in tools.tools:
has_destructive_keyword = any(
keyword in tool.name.lower() for keyword in destructive_keywords
)
if has_destructive_keyword:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.destructiveHint is True, (
f"Destructive tool {tool.name} should have destructiveHint=True"
)
async def test_delete_operations_are_idempotent(nc_mcp_client: ClientSession):
"""Verify delete operations are marked as idempotent (ADR-017 decision)."""
tools = await nc_mcp_client.list_tools()
for tool in tools.tools:
if "delete" in tool.name.lower():
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.idempotentHint is True, (
f"Delete tool {tool.name} should be idempotent (same end state)"
)
async def test_create_operations_not_idempotent(nc_mcp_client: ClientSession):
"""Verify create operations are marked as non-idempotent."""
tools = await nc_mcp_client.list_tools()
for tool in tools.tools:
if "create" in tool.name.lower() and "calendar_create_meeting" not in tool.name:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.idempotentHint is not True, (
f"Create tool {tool.name} should not be idempotent (creates new resources)"
)
async def test_update_operations_not_idempotent(nc_mcp_client: ClientSession):
"""Verify update operations are marked as non-idempotent (due to etag requirements)."""
tools = await nc_mcp_client.list_tools()
for tool in tools.tools:
if "update" in tool.name.lower():
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
# Most updates use etags which change each time, making them non-idempotent
# Exception: calendar_update_event might be different
assert tool.annotations.idempotentHint is not True, (
f"Update tool {tool.name} should not be idempotent (etag changes)"
)
async def test_webdav_write_is_idempotent(nc_mcp_client: ClientSession):
"""Verify nc_webdav_write_file is marked as idempotent (ADR-017 decision).
WebDAV write uses HTTP PUT without version control, making it idempotent.
Writing same content to same path repeatedly produces same end state.
"""
tools = await nc_mcp_client.list_tools()
write_tool = next(
(tool for tool in tools.tools if tool.name == "nc_webdav_write_file"), None
)
assert write_tool is not None, "nc_webdav_write_file tool not found"
assert write_tool.annotations is not None, "write_file missing annotations"
assert write_tool.annotations.idempotentHint is True, (
"nc_webdav_write_file should be idempotent (HTTP PUT without version control)"
)
async def test_semantic_search_open_world(nc_mcp_client: ClientSession):
"""Verify semantic search has openWorldHint=True (ADR-017 decision).
Semantic search queries external Nextcloud service, consistent with other tools.
"""
tools = await nc_mcp_client.list_tools()
semantic_tool = next(
(tool for tool in tools.tools if tool.name == "nc_semantic_search"), None
)
if semantic_tool: # Only if semantic search is enabled
assert semantic_tool.annotations is not None, (
"semantic_search missing annotations"
)
assert semantic_tool.annotations.openWorldHint is True, (
"nc_semantic_search should have openWorldHint=True (queries external service)"
)
async def test_annotation_consistency(nc_mcp_client: ClientSession):
"""Verify annotation consistency across similar tools."""
tools = await nc_mcp_client.list_tools()
# Group tools by category
categories = {
"notes": [],
"calendar": [],
"contacts": [],
"webdav": [],
"tables": [],
"deck": [],
"cookbook": [],
"sharing": [],
}
for tool in tools.tools:
for category in categories:
if tool.name.startswith(f"nc_{category}_"):
categories[category].append(tool)
# Within each category, similar operations should have similar annotations
for category, category_tools in categories.items():
# All list/search/get operations should be read-only
read_ops = [
t
for t in category_tools
if any(op in t.name for op in ["list", "search", "get"])
]
for tool in read_ops:
assert tool.annotations.readOnlyHint is True, (
f"{tool.name} is a read operation but not marked read-only"
)
# All delete operations should be destructive and idempotent
delete_ops = [t for t in category_tools if "delete" in t.name]
for tool in delete_ops:
assert tool.annotations.destructiveHint is True, (
f"{tool.name} is a delete operation but not marked destructive"
)
assert tool.annotations.idempotentHint is True, (
f"{tool.name} is a delete operation but not marked idempotent"
)
+174
View File
@@ -0,0 +1,174 @@
"""
Test that DNS rebinding protection is properly disabled for containerized deployments.
This test verifies that the fix for MCP 1.23.x DNS rebinding protection works correctly.
Without the fix, requests with Host headers that don't match the default allowed list
(127.0.0.1:*, localhost:*, [::1]:*) would be rejected with a 421 Misdirected Request error.
"""
import httpx
import pytest
@pytest.mark.integration
async def test_accepts_various_host_headers():
"""Test that the MCP server accepts requests with various Host headers.
This test simulates what happens in containerized deployments where the Host
header might be a k8s service DNS name, a proxied hostname, or other values
that don't match the default allowed list.
Without the DNS rebinding protection fix, these requests would fail with:
- 421 Misdirected Request (for Host header mismatch)
- 403 Forbidden (for Origin header mismatch)
"""
mcp_url = "http://localhost:8000/mcp"
# Test various Host headers that would be rejected by DNS rebinding protection
test_cases = [
{
"name": "Kubernetes service DNS",
"headers": {
"Host": "nextcloud-mcp-server.default.svc.cluster.local:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Custom domain",
"headers": {
"Host": "mcp.example.com:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Proxied hostname",
"headers": {
"Host": "proxy.internal:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Default localhost (should always work)",
"headers": {
"Host": "localhost:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
]
# Create a simple initialize request payload
initialize_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
"id": 1,
}
async with httpx.AsyncClient() as client:
for test_case in test_cases:
print(f"\n🧪 Testing: {test_case['name']}")
print(f" Host header: {test_case['headers']['Host']}")
response = await client.post(
mcp_url,
json=initialize_request,
headers=test_case["headers"],
timeout=10.0,
)
# With DNS rebinding protection enabled (MCP 1.23 default), these would fail with:
# - 421 Misdirected Request (Host header not in allowed list)
# - 403 Forbidden (Origin header not in allowed list)
#
# With our fix (enable_dns_rebinding_protection=False), they should succeed
assert response.status_code in [200, 202], (
f"Request failed for {test_case['name']}: "
f"status={response.status_code}, "
f"headers={test_case['headers']}, "
f"body={response.text[:200]}"
)
print(f" ✅ Status: {response.status_code}")
# For SSE responses (status 200), verify we got SSE format
# For JSON responses (status 202), verify we got valid JSON
if response.status_code == 200:
# SSE response - should start with "event: message" or similar
response_text = response.text
assert "event:" in response_text or "data:" in response_text, (
f"Expected SSE format for {test_case['name']}, got: {response_text[:200]}"
)
print(" ✅ Received SSE stream response")
elif response.status_code == 202:
# JSON response for notifications
response_json = response.json()
assert "jsonrpc" in response_json or response_json is None, (
f"Invalid response for {test_case['name']}: {response_json}"
)
print(" ✅ Received JSON response")
@pytest.mark.integration
async def test_dns_rebinding_protection_is_disabled():
"""Verify that DNS rebinding protection is actually disabled in the configuration.
This test makes a request that would DEFINITELY fail if DNS rebinding protection
was enabled with default settings (only allowing 127.0.0.1:*, localhost:*, [::1]:*).
"""
mcp_url = "http://localhost:8000/mcp"
# Use a Host header that would NEVER be in the default allowed list
malicious_host = "evil.attacker.com:8000"
initialize_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
"id": 1,
}
async with httpx.AsyncClient() as client:
response = await client.post(
mcp_url,
json=initialize_request,
headers={
"Host": malicious_host,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
timeout=10.0,
)
# If DNS rebinding protection was enabled, this would return:
# - 421 Misdirected Request (Host header validation failed)
#
# Since we disabled it, this should succeed (status 200 or 202)
assert response.status_code in [200, 202], (
f"DNS rebinding protection may still be enabled! "
f"Request with Host='{malicious_host}' was rejected: "
f"status={response.status_code}, body={response.text[:500]}"
)
# Verify we got a valid response (SSE or JSON)
if response.status_code == 200:
response_text = response.text
assert "event:" in response_text or "data:" in response_text, (
f"Expected SSE format, got: {response_text[:200]}"
)
print("✅ DNS rebinding protection is properly disabled")
print(
f" Request with Host '{malicious_host}' succeeded: {response.status_code}"
)
+7 -16
View File
@@ -189,25 +189,14 @@ async def test_get_file_info_returns_none_for_missing_file(mocker):
@pytest.mark.unit
async def test_create_tag_creates_system_tag(mocker):
"""Test that create_tag creates a system tag via OCS API."""
"""Test that create_tag creates a system tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock OCS response
# Mock WebDAV response with Content-Location header
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json = mocker.Mock(
return_value={
"ocs": {
"data": {
"id": 42,
"name": "vector-index",
"userVisible": True,
"userAssignable": True,
}
}
}
)
mock_response.status_code = 201
mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"}
mock_response.raise_for_status = mocker.Mock()
mock_http_client.post = AsyncMock(return_value=mock_response)
@@ -224,8 +213,10 @@ async def test_create_tag_creates_system_tag(mocker):
# Verify API call
mock_http_client.post.assert_called_once()
call_args = mock_http_client.post.call_args
assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags"
assert call_args[0][0] == "/remote.php/dav/systemtags/"
assert call_args[1]["json"]["name"] == "vector-index"
assert call_args[1]["json"]["userVisible"] is True
assert call_args[1]["json"]["userAssignable"] is True
@pytest.mark.unit
Generated
+592 -517
View File
File diff suppressed because it is too large Load Diff