Compare commits

..

1 Commits

Author SHA1 Message Date
smithery-ai[bot] e37a74d657 Update README 2025-11-23 03:27:20 +00:00
47 changed files with 720 additions and 3345 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
packages: write
steps:
- name: Check out
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # 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@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
-57
View File
@@ -1,57 +0,0 @@
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@6337623ebba10cf8c8214b507993f8062fd4ccfb # 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
@@ -1,50 +0,0 @@
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@6337623ebba10cf8c8214b507993f8062fd4ccfb # 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
fetch-depth: 0
+24 -16
View File
@@ -24,25 +24,39 @@ jobs:
models: read
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- 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 ######
- name: Run docker compose with vector sync
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
with:
compose-file: |
./docker-compose.yml
./docker-compose.ci.yml
compose-file: "./docker-compose.yml"
up-flags: "--build"
env:
# Environment variables passed to docker-compose.ci.yml
# Override MCP container environment for OpenAI + vector sync
VECTOR_SYNC_ENABLED: "true"
VECTOR_SYNC_SCAN_INTERVAL: "5"
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@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Wait for Nextcloud to be ready
run: |
@@ -87,17 +101,11 @@ jobs:
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
run: |
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
uv run pytest tests/integration/test_rag_openai.py -v --log-cli-level=INFO
- name: Upload test results
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@v4
with:
name: rag-evaluation-results
path: |
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Install uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
submodules: 'true'
@@ -35,7 +35,7 @@ jobs:
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
with:
php-version: 8.4
coverage: none
@@ -49,14 +49,14 @@ jobs:
- name: Run docker compose
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
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@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Install Playwright dependencies
run: |
-48
View File
@@ -1,51 +1,3 @@
## 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
+1 -1
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
COPY --from=ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+1 -1
View File
@@ -17,7 +17,7 @@ FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+3 -1
View File
@@ -1,11 +1,12 @@
```markdown
<p align="center">
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
</p>
# Nextcloud MCP Server
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
[![smithery badge](https://smithery.ai/badge/@cbcoutinho/nextcloud-mcp-server)](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
@@ -223,3 +224,4 @@ This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) fo
- [Model Context Protocol](https://github.com/modelcontextprotocol)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [Nextcloud](https://nextcloud.com/)
```
@@ -1,5 +0,0 @@
#!/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.2
version: 1.16.0
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.35.0
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
generated: "2025-12-05T11:11:27.999374001Z"
digest: sha256:da8db198b12ce0252df220fabb297cfe69186edb8e67952c52e05de778189b92
generated: "2025-11-21T11:09:07.997781541Z"
+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.49.1
appVersion: "0.49.1"
version: 0.48.2
appVersion: "0.48.2"
keywords:
- nextcloud
- mcp
@@ -27,7 +27,7 @@ annotations:
grafana_dashboard_folder: "Nextcloud MCP"
dependencies:
- name: qdrant
version: "1.16.2"
version: "1.16.0"
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
-25
View File
@@ -1,25 +0,0 @@
# 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
+4 -4
View File
@@ -21,7 +21,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.2@sha256:04cc19547e586ac75e08dd056c11330d4ce4c5c561c89405b326180a37c19afb
image: docker.io/library/nextcloud:32.0.2@sha256:ac08482d73ffd85d94069ba291bbd5fb39a70ff21502030a2e3e2d89a7246a48
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
@@ -158,7 +158,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
command:
- "start-dev"
- "--import-realm"
@@ -245,7 +245,7 @@ services:
- smithery
qdrant:
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
restart: always
ports:
- 127.0.0.1:6333:6333 # REST API
+1 -3
View File
@@ -60,7 +60,6 @@ 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,
@@ -515,7 +514,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 news:read news: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"
# Add offline_access scope if refresh tokens are enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
@@ -1047,7 +1046,6 @@ 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
@@ -303,13 +303,10 @@ 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=(client_id, client_secret),
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
)
if response.status_code == 200:
@@ -139,7 +139,6 @@ 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,7 +18,6 @@ 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
@@ -82,7 +81,6 @@ 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)
-385
View File
@@ -1,385 +0,0 @@
"""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.
Args:
item_id: Item ID
Returns:
Item data
Raises:
HTTPStatusError: 404 if item not found
"""
response = await self._make_request("GET", f"{self.API_BASE}/items/{item_id}")
return response.json()
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", "")
+2 -6
View File
@@ -1174,9 +1174,7 @@ 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 and tag_id_elem.text is not None
else None,
"id": int(tag_id_elem.text) if tag_id_elem is not None else None,
"name": display_name_elem.text,
"userVisible": user_visible_elem.text.lower() == "true"
if user_visible_elem is not None
@@ -1371,9 +1369,7 @@ class WebDAVClient(BaseNextcloudClient):
)
file_info = {
"id": int(fileid_elem.text)
if fileid_elem is not None and fileid_elem.text is not None
else None,
"id": int(fileid_elem.text) if fileid_elem is not None else None,
"path": path,
"name": displayname_elem.text
if displayname_elem is not None
-170
View File
@@ -1,170 +0,0 @@
"""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_data: dict[str, Any],
log_record: 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_data: Dictionary to be serialized as JSON
log_record: 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_data, record, message_dict)
super().add_fields(log_record, record, message_dict)
# Add trace context if available
trace_context = get_trace_context()
if trace_context:
log_data["trace_id"] = trace_context.get("trace_id")
log_data["span_id"] = trace_context.get("span_id")
log_record["trace_id"] = trace_context.get("trace_id")
log_record["span_id"] = trace_context.get("span_id")
# Add standard fields with consistent naming
log_data["timestamp"] = self.formatTime(record)
log_data["level"] = record.levelname
log_data["logger"] = record.name
log_data["message"] = record.getMessage()
log_record["timestamp"] = self.formatTime(record)
log_record["level"] = record.levelname
log_record["logger"] = record.name
log_record["message"] = record.getMessage()
# Include exception info if present
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
log_record["exception"] = self.formatException(record.exc_info)
class TraceContextTextFormatter(logging.Formatter):
+4 -6
View File
@@ -17,20 +17,18 @@ class AnthropicProvider(Provider):
Note: Anthropic doesn't provide embedding models, only text generation.
"""
def __init__(
self, api_key: str, generation_model: str = "claude-3-5-sonnet-20241022"
):
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
"""
Initialize Anthropic provider.
Args:
api_key: Anthropic API key
generation_model: Model name (e.g., "claude-3-5-sonnet-20241022")
model: Model name (e.g., "claude-3-5-sonnet-20241022")
"""
self.client = AsyncAnthropic(api_key=api_key)
self.model = generation_model
self.model = model
logger.info(f"Initialized Anthropic provider (model={self.model})")
logger.info(f"Initialized Anthropic provider (model={model})")
@property
def supports_embeddings(self) -> bool:
+9 -53
View File
@@ -7,48 +7,13 @@ Supports:
"""
import logging
from functools import wraps
import anyio
from openai import AsyncOpenAI, RateLimitError
from openai import AsyncOpenAI
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,
@@ -121,7 +86,6 @@ 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.
@@ -140,7 +104,6 @@ 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,
@@ -188,8 +151,14 @@ class OpenAIProvider(Provider):
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
# Use helper method with retry logic for each batch
batch_embeddings = await self._embed_batch_request(batch)
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]
all_embeddings.extend(batch_embeddings)
# Update dimension if not set
@@ -202,18 +171,6 @@ 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.
@@ -237,7 +194,6 @@ 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: set[str] = {
str(point.payload.get("doc_type"))
doc_types = {
point.payload.get("doc_type")
for point in scroll_results
if point.payload.get("doc_type")
}
@@ -204,8 +204,6 @@ 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")
-2
View File
@@ -136,8 +136,6 @@ 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")
-2
View File
@@ -2,7 +2,6 @@ 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
@@ -14,7 +13,6 @@ __all__ = [
"configure_contacts_tools",
"configure_cookbook_tools",
"configure_deck_tools",
"configure_news_tools",
"configure_notes_tools",
"configure_semantic_tools",
"configure_sharing_tools",
-360
View File
@@ -1,360 +0,0 @@
"""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
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()
@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()
@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()
@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()
@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()
@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()
@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()
@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()
@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}",
)
)
+5 -14
View File
@@ -499,11 +499,9 @@ 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(sampling_timeout_seconds):
with anyio.fail_after(30):
sampling_result = await ctx.session.create_message(
messages=[
SamplingMessage(
@@ -550,14 +548,14 @@ def configure_semantic_tools(mcp: FastMCP):
except TimeoutError:
logger.warning(
f"Sampling request timed out after {sampling_timeout_seconds} seconds for query: '{query}', "
f"Sampling request timed out after 30 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 (>{sampling_timeout_seconds}s). "
f"The answer generation took too long (>30s). "
f"Found {len(accessible_results)} relevant documents. "
f"Please review the sources below or try a simpler query."
),
@@ -677,22 +675,15 @@ 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, excluding placeholders
# Placeholders are zero-vector points used to track processing state
# Count documents in collection
count_result = await qdrant_client.count(
collection_name=settings.get_collection_name(),
count_filter=Filter(must=[get_placeholder_filter()]),
collection_name=settings.get_collection_name()
)
indexed_count = count_result.count
@@ -1,49 +0,0 @@
"""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
+5 -62
View File
@@ -272,45 +272,6 @@ 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 == "file":
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
if not doc_task.file_path:
@@ -397,16 +358,15 @@ async def _index_document(
chunks = await chunker.chunk_text(content)
# Assign page numbers to chunks if page boundaries are available (PDFs)
page_boundaries = file_metadata.get("page_boundaries")
if doc_task.doc_type == "file" and page_boundaries is not None:
if doc_task.doc_type == "file" and "page_boundaries" in file_metadata:
with trace_operation(
"vector_sync.assign_page_numbers",
attributes={
"vector_sync.chunk_count": len(chunks),
"vector_sync.page_count": len(page_boundaries),
"vector_sync.page_count": len(file_metadata["page_boundaries"]),
},
):
assign_page_numbers(chunks, page_boundaries)
assign_page_numbers(chunks, file_metadata["page_boundaries"])
# Diagnostic: Verify page number assignment
assigned_count = sum(1 for c in chunks if c.page_number is not None)
@@ -429,8 +389,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(page_boundaries)} pages, "
f"First boundary: {page_boundaries[0] if page_boundaries else 'None'}"
f"Page boundaries: {len(file_metadata['page_boundaries'])} pages, "
f"First boundary: {file_metadata['page_boundaries'][0] if file_metadata['page_boundaries'] else 'None'}"
)
# Extract chunk texts for embedding
@@ -606,23 +566,6 @@ 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 {}
),
# Highlighted page image (PDF only)
**(
{
+3 -206
View File
@@ -206,11 +206,7 @@ async def scan_user_documents(
limit=10000,
)
indexed_doc_ids = {
point.payload["doc_id"]
for point in (scroll_result[0] or [])
if point.payload is not None
}
indexed_doc_ids = {point.payload["doc_id"] for point in scroll_result[0]}
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
@@ -380,9 +376,7 @@ async def scan_user_documents(
)
indexed_file_ids = {
point.payload["doc_id"]
for point in (file_scroll_result[0] or [])
if point.payload is not None
point.payload["doc_id"] for point in file_scroll_result[0]
}
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
@@ -550,206 +544,9 @@ 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}")
if queued > 0:
logger.info(
f"Sent {queued} documents ({file_queued} files, {news_queued} news items) for incremental sync: {user_id}"
f"Sent {queued} documents ({file_queued} files) 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
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.49.1"
version = "0.48.2"
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"}
@@ -36,7 +36,6 @@ 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",
+1 -7
View File
@@ -4,11 +4,5 @@
"config:best-practices",
"mergeConfidence:all-badges"
],
"dependencyDashboard": true,
"packageRules": [
{
"matchPackageNames": ["pillow"],
"allowedVersions": "<12.0.0"
}
]
"dependencyDashboard": true
}
-219
View File
@@ -480,222 +480,3 @@ 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
-561
View File
@@ -1,561 +0,0 @@
"""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 a single item by ID."""
item = create_mock_news_item(item_id=123, title="Single Item")
mock_response = create_mock_response(status_code=200, json_data=item)
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_item(item_id=123)
assert result["id"] == 123
assert result["title"] == "Single Item"
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/items/123")
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
@@ -1,26 +0,0 @@
"""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
@@ -1,264 +0,0 @@
"""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()
+23 -49
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 any provider that supports text generation (OpenAI, Ollama, Anthropic, Bedrock).
using OpenAI or GitHub Models API.
"""
import logging
@@ -10,58 +10,46 @@ from typing import Any
from mcp import types
from mcp.client.session import ClientSession, RequestContext
from nextcloud_mcp_server.providers.base import Provider
from nextcloud_mcp_server.providers.openai import OpenAIProvider
logger = logging.getLogger(__name__)
def create_sampling_callback(provider: Provider):
"""Factory to create a sampling callback using any generation-capable provider.
def create_openai_sampling_callback(provider: OpenAIProvider):
"""Factory to create a sampling callback using OpenAI provider.
The callback conforms to MCP's SamplingFnT protocol and can be passed
to ClientSession for handling sampling requests from the server.
Args:
provider: Any Provider instance that supports generation
(supports_generation=True)
provider: OpenAIProvider instance configured with a generation model
Returns:
Async callback function for MCP sampling
Raises:
ValueError: If provider doesn't support generation
Example:
```python
from nextcloud_mcp_server.providers import get_provider
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)
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
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 the configured provider."""
"""Handle sampling requests using OpenAI provider."""
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
# Extract messages and build prompt
@@ -80,13 +68,14 @@ def create_sampling_callback(provider: Provider):
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
try:
# Generate response using provider
# Note: temperature is typically hardcoded in providers at 0.7
# Generate response using OpenAI provider
# Note: temperature is hardcoded in the provider 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(
@@ -96,25 +85,10 @@ def create_sampling_callback(provider: Provider):
stopReason="endTurn",
)
except Exception as e:
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
logger.error(f"OpenAI generation failed: {e}")
return types.ErrorData(
code=types.INTERNAL_ERROR,
message=f"Generation failed: {e!s}",
message=f"OpenAI 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)
@@ -1,33 +1,26 @@
"""Integration tests for RAG pipeline with multiple LLM providers.
"""Integration tests for RAG pipeline with OpenAI/GitHub Models API.
These tests validate the complete semantic search and MCP sampling flow using:
1. MCP server's built-in semantic search (embeddings handled server-side)
2. MCP sampling for answer generation (any generation-capable provider)
1. OpenAI embeddings for semantic search
2. MCP sampling for answer generation
3. Pre-indexed Nextcloud User Manual as the knowledge base
Usage:
# Run with OpenAI (including GitHub Models API)
OPENAI_API_KEY=... pytest tests/integration/test_rag.py --provider=openai -v
# 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")
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")
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
Prerequisites:
- Nextcloud User Manual PDF uploaded to Nextcloud
- VECTOR_SYNC_ENABLED=true on the MCP server
- Provider-specific environment variables set
"""
import json
@@ -40,10 +33,9 @@ import anyio
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.providers.base import Provider
from nextcloud_mcp_server.providers.openai import OpenAIProvider
from tests.conftest import create_mcp_client_session
from tests.integration.provider_fixtures import create_generation_provider
from tests.integration.sampling_support import create_sampling_callback
from tests.integration.sampling_support import create_openai_sampling_callback
logger = logging.getLogger(__name__)
@@ -52,14 +44,14 @@ DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
async def llm_judge(
provider: Provider,
provider: "OpenAIProvider",
ground_truth: str,
system_output: str,
) -> bool:
"""Use LLM to judge if system output aligns with ground truth.
Args:
provider: Any provider with generation capability
provider: OpenAI provider with generation capability
ground_truth: The expected/reference answer
system_output: The system's actual output to evaluate
@@ -74,18 +66,17 @@ 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()
# Mark all tests as integration tests
# Skip all tests if OpenAI API key not configured
pytestmark = [
pytest.mark.integration,
pytest.mark.rag,
pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set - skipping OpenAI RAG tests",
),
]
# Ground truth fixture path
@@ -184,49 +175,78 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
@pytest.fixture(scope="module")
def provider_name(request) -> str:
"""Get the provider name from --provider flag.
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")
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
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
yield provider
await provider.close()
@pytest.fixture(scope="module")
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
"""Provider configured for text generation.
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,
)
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, generation_provider, provider_name
anyio_backend, openai_generation_provider
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client with sampling support using the specified provider.
"""MCP client with OpenAI-based sampling support.
This fixture creates an MCP client that can handle sampling requests
from the server using the configured generation provider.
from the server using OpenAI for text generation.
"""
sampling_callback = create_sampling_callback(generation_provider)
sampling_callback = create_openai_sampling_callback(openai_generation_provider)
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
client_name=f"Sampling MCP ({provider_name})",
client_name="OpenAI Sampling MCP",
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, generation_provider
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, openai_generation_provider
):
"""Test that semantic search retrieves relevant documents from the manual.
@@ -258,7 +278,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(
generation_provider,
openai_generation_provider,
test_case["ground_truth"],
all_excerpts,
)
@@ -269,16 +289,16 @@ async def test_semantic_search_answer_with_sampling(
nc_mcp_client_with_sampling,
ground_truth_qa,
indexed_manual_pdf,
generation_provider,
openai_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. Provider generates the answer via the sampling callback
3. OpenAI generates the answer via the sampling callback
Uses nc_mcp_client_with_sampling which has sampling enabled.
Uses nc_mcp_client_with_sampling which has OpenAI-based sampling enabled.
"""
# Use the 2FA question - has clear expected answer
test_case = ground_truth_qa[0]
@@ -328,7 +348,7 @@ async def test_semantic_search_answer_with_sampling(
# Use LLM judge to evaluate answer relevance
is_relevant = await llm_judge(
generation_provider,
openai_generation_provider,
test_case["ground_truth"],
data["generated_answer"],
)
+16 -7
View File
@@ -189,14 +189,25 @@ 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 WebDAV."""
"""Test that create_tag creates a system tag via OCS API."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock WebDAV response with Content-Location header
# Mock OCS response
mock_response = AsyncMock()
mock_response.status_code = 201
mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"}
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.raise_for_status = mocker.Mock()
mock_http_client.post = AsyncMock(return_value=mock_response)
@@ -213,10 +224,8 @@ 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] == "/remote.php/dav/systemtags/"
assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags"
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
+509 -588
View File
File diff suppressed because it is too large Load Diff