Compare commits

...

5 Commits

Author SHA1 Message Date
smithery-ai[bot] a0b5b0ada1 Update README 2025-11-23 00:55:16 +00:00
github-actions[bot] 626c4bf562 bump: version 0.47.0 → 0.48.0 2025-11-23 00:53:24 +00:00
Chris Coutinho a56b3f3d51 Merge pull request #347 from cbcoutinho/feature/openai-provider-support
feature/openai provider support
2025-11-23 01:52:55 +01:00
Chris Coutinho 2896fa1dc9 feat: Add tag management methods to WebDAV client
- Add get_file_info() to get file info including file ID via PROPFIND
- Add create_tag() to create system tags via OCS API
- Add get_or_create_tag() for idempotent tag creation
- Add assign_tag_to_file() to assign tags to files via WebDAV
- Add remove_tag_from_file() to remove tags from files

Also refactors RAG evaluation:
- Add indexed_manual_pdf fixture using existing nc_client/nc_mcp_client
- Remove manual tag creation steps from workflow (now handled by fixture)
- Add comprehensive unit tests for new WebDAV methods

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 01:51:42 +01:00
Chris Coutinho 04251401aa ci: Add permissions to github token 2025-11-23 01:26:22 +01:00
9 changed files with 586 additions and 177 deletions
+8 -166
View File
@@ -3,6 +3,10 @@ name: RAG Evaluation
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
manual_path:
description: 'Path to Nextcloud User Manual PDF in Nextcloud'
required: false
default: 'Nextcloud Manual.pdf'
embedding_model: embedding_model:
description: 'OpenAI embedding model' description: 'OpenAI embedding model'
required: false required: false
@@ -15,40 +19,15 @@ on:
jobs: jobs:
rag-evaluation: rag-evaluation:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 30
permissions:
models: read
steps: steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
submodules: 'true' submodules: 'true'
- name: Clone Nextcloud documentation
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
repository: 'nextcloud/documentation'
path: 'nextcloud-docs'
- name: Install Sphinx and LaTeX dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
python3-sphinx \
python3-pip \
latexmk \
texlive-latex-recommended \
texlive-latex-extra \
texlive-fonts-recommended \
texlive-fonts-extra
- name: Build User Manual PDF
run: |
cd nextcloud-docs/user_manual
pip3 install -r ../requirements.txt
make latexpdf
ls -la _build/latex/
cp _build/latex/NextcloudUserManual.pdf ../../Nextcloud_User_Manual.pdf
echo "PDF built successfully"
###### Required to build OIDC App ###### ###### Required to build OIDC App ######
- name: Set up php 8.4 - name: Set up php 8.4
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2 uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
@@ -111,149 +90,12 @@ jobs:
done done
echo "MCP server is ready." echo "MCP server is ready."
- name: Upload User Manual PDF to Nextcloud
run: |
echo "Uploading Nextcloud_User_Manual.pdf to Nextcloud..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u admin:admin \
-X PUT \
-T Nextcloud_User_Manual.pdf \
"http://localhost:8080/remote.php/dav/files/admin/Nextcloud_User_Manual.pdf")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "204" ]; then
echo "PDF uploaded successfully (HTTP $HTTP_CODE)"
else
echo "Failed to upload PDF (HTTP $HTTP_CODE)"
exit 1
fi
- name: Create vector-index tag
id: create_tag
run: |
# Create the tag using OCS API
echo "Creating vector-index tag..."
RESPONSE=$(curl -s -u admin:admin \
-X POST \
-H 'Content-Type: application/json' \
-H 'OCS-APIRequest: true' \
-d '{"name":"vector-index","userVisible":true,"userAssignable":true}' \
"http://localhost:8080/ocs/v2.php/apps/systemtags/api/v1/tags")
echo "Create tag response: $RESPONSE"
# Get tag ID from response or lookup
TAG_ID=$(echo "$RESPONSE" | grep -oP '(?<="id":)[0-9]+' | head -1 || echo "")
if [ -z "$TAG_ID" ]; then
echo "Tag may already exist, looking it up..."
TAG_ID=$(curl -s -u admin:admin \
-X PROPFIND \
-H 'Content-Type: application/xml' \
-d '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><oc:id/><oc:display-name/></d:prop></d:propfind>' \
http://localhost:8080/remote.php/dav/systemtags/ \
| grep -B2 "vector-index" | grep -oP '(?<=<oc:id>)[0-9]+(?=</oc:id>)' | head -1 || echo "")
fi
if [ -z "$TAG_ID" ]; then
echo "ERROR: Could not create or find vector-index tag"
exit 1
fi
echo "Tag ID: $TAG_ID"
echo "tag_id=$TAG_ID" >> $GITHUB_OUTPUT
- name: Get file ID of uploaded PDF
id: get_file_id
run: |
echo "Getting file ID for Nextcloud_User_Manual.pdf..."
# Get file ID using PROPFIND
FILE_ID=$(curl -s -u admin:admin \
-X PROPFIND \
-H 'Content-Type: application/xml' \
-d '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><oc:fileid/></d:prop></d:propfind>' \
"http://localhost:8080/remote.php/dav/files/admin/Nextcloud_User_Manual.pdf" \
| grep -oP '(?<=<oc:fileid>)[0-9]+(?=</oc:fileid>)' || echo "")
if [ -z "$FILE_ID" ]; then
echo "ERROR: Could not find file ID"
exit 1
fi
echo "Found file ID: $FILE_ID"
echo "file_id=$FILE_ID" >> $GITHUB_OUTPUT
- name: Tag file with vector-index
env:
FILE_ID: ${{ steps.get_file_id.outputs.file_id }}
TAG_ID: ${{ steps.create_tag.outputs.tag_id }}
run: |
echo "Tagging file $FILE_ID with tag $TAG_ID..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u admin:admin \
-X PUT \
-H 'Content-Type: application/json' \
-H 'Content-Length: 0' \
"http://localhost:8080/remote.php/dav/systemtags-relations/files/$FILE_ID/$TAG_ID")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "409" ]; then
echo "File tagged successfully (HTTP $HTTP_CODE)"
else
echo "Failed to tag file (HTTP $HTTP_CODE)"
exit 1
fi
- name: Wait for vector sync to complete indexing
env:
NEXTCLOUD_HOST: "http://localhost:8080"
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
echo "Waiting for vector sync to index the manual..."
max_attempts=60
attempt=0
# Wait for initial scan to pick up the file
sleep 10
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
# Check vector sync status via MCP
STATUS=$(curl -s http://localhost:8000/health || echo "{}")
echo "Attempt $attempt/$max_attempts: $STATUS"
# Also check indexed count via semantic search
# If we get results, indexing is done
RESULT=$(curl -s -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nc_get_vector_sync_status","arguments":{}}}' \
2>/dev/null || echo "{}")
echo "Vector sync status: $RESULT"
# Check if pending is 0 and indexed > 0
INDEXED=$(echo "$RESULT" | jq -r '.result.structuredContent.indexed // 0' 2>/dev/null || echo "0")
PENDING=$(echo "$RESULT" | jq -r '.result.structuredContent.pending // 1' 2>/dev/null || echo "1")
echo "Indexed: $INDEXED, Pending: $PENDING"
if [ "$INDEXED" -gt "0" ] && [ "$PENDING" -eq "0" ]; then
echo "Indexing complete! $INDEXED documents indexed."
break
fi
sleep 10
done
if [ $attempt -ge $max_attempts ]; then
echo "WARNING: Indexing may not be complete, proceeding anyway..."
fi
- name: Run RAG evaluation tests - name: Run RAG evaluation tests
env: env:
NEXTCLOUD_HOST: "http://localhost:8080" NEXTCLOUD_HOST: "http://localhost:8080"
NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin" NEXTCLOUD_PASSWORD: "admin"
RAG_MANUAL_PATH: ${{ inputs.manual_path }}
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }} OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: "https://models.github.ai/inference" OPENAI_BASE_URL: "https://models.github.ai/inference"
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }} OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
+6
View File
@@ -1,3 +1,9 @@
## v0.48.0 (2025-11-23)
### Feat
- Add tag management methods to WebDAV client
## v0.47.0 (2025-11-23) ## v0.47.0 (2025-11-23)
### Feat ### Feat
+1 -1
View File
@@ -4,8 +4,8 @@
# Nextcloud MCP Server # 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) [![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.** **A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application type: application
version: 0.47.0 version: 0.48.0
appVersion: "0.47.0" appVersion: "0.48.0"
keywords: keywords:
- nextcloud - nextcloud
- mcp - mcp
+230
View File
@@ -1295,3 +1295,233 @@ class WebDAVClient(BaseNextcloudClient):
logger.debug(f"Found {len(files)} files with tag ID {tag_id}") logger.debug(f"Found {len(files)} files with tag ID {tag_id}")
return files return files
async def get_file_info(self, path: str) -> dict[str, Any] | None:
"""Get file info including file ID via WebDAV PROPFIND.
Args:
path: Path to the file (relative to user's files directory)
Returns:
File info dictionary with id, name, size, content_type, etc.
Returns None if file not found.
"""
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
propfind_body = """<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:fileid/>
<d:displayname/>
<d:getcontentlength/>
<d:getcontenttype/>
<d:getlastmodified/>
<d:getetag/>
<d:resourcetype/>
</d:prop>
</d:propfind>"""
try:
response = await self._client.request(
"PROPFIND",
webdav_path,
headers={"Depth": "0"},
content=propfind_body,
)
response.raise_for_status()
except HTTPStatusError as e:
if e.response.status_code == 404:
logger.debug(f"File not found: {path}")
return None
raise
# Parse XML response
root = ET.fromstring(response.content)
ns = {
"d": "DAV:",
"oc": "http://owncloud.org/ns",
}
response_elem = root.find("d:response", ns)
if response_elem is None:
return None
propstat = response_elem.find("d:propstat", ns)
if propstat is None:
return None
prop = propstat.find("d:prop", ns)
if prop is None:
return None
# Extract properties
fileid_elem = prop.find("oc:fileid", ns)
displayname_elem = prop.find("d:displayname", ns)
contentlength_elem = prop.find("d:getcontentlength", ns)
contenttype_elem = prop.find("d:getcontenttype", ns)
lastmodified_elem = prop.find("d:getlastmodified", ns)
etag_elem = prop.find("d:getetag", ns)
resourcetype_elem = prop.find("d:resourcetype", ns)
is_directory = (
resourcetype_elem is not None
and resourcetype_elem.find("d:collection", ns) is not None
)
file_info = {
"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
else path.split("/")[-1],
"size": int(contentlength_elem.text)
if contentlength_elem is not None and contentlength_elem.text
else 0,
"content_type": contenttype_elem.text
if contenttype_elem is not None
else "",
"last_modified": lastmodified_elem.text
if lastmodified_elem is not None
else None,
"etag": etag_elem.text.strip('"')
if etag_elem is not None and etag_elem.text
else None,
"is_directory": is_directory,
}
logger.debug(f"Got file info for '{path}': id={file_info['id']}")
return file_info
async def create_tag(
self,
name: str,
user_visible: bool = True,
user_assignable: bool = True,
) -> dict[str, Any]:
"""Create a system tag via OCS API.
Args:
name: Name of the tag to create
user_visible: Whether the tag is visible to users
user_assignable: Whether users can assign this tag
Returns:
Tag dictionary with id, name, userVisible, userAssignable
Raises:
HTTPStatusError: If tag creation fails (409 if already exists)
"""
response = await self._client.post(
"/ocs/v2.php/apps/systemtags/api/v1/tags",
headers={
"OCS-APIRequest": "true",
"Content-Type": "application/json",
},
json={
"name": name,
"userVisible": user_visible,
"userAssignable": user_assignable,
},
)
response.raise_for_status()
# Parse OCS response
data = response.json()
ocs_data = data.get("ocs", {}).get("data", {})
tag_info = {
"id": ocs_data.get("id"),
"name": ocs_data.get("name", name),
"userVisible": ocs_data.get("userVisible", user_visible),
"userAssignable": ocs_data.get("userAssignable", user_assignable),
}
logger.info(f"Created tag '{name}' with ID {tag_info['id']}")
return tag_info
async def get_or_create_tag(
self,
name: str,
user_visible: bool = True,
user_assignable: bool = True,
) -> dict[str, Any]:
"""Get a tag by name, creating it if it doesn't exist.
Args:
name: Name of the tag
user_visible: Whether the tag is visible to users (for creation)
user_assignable: Whether users can assign this tag (for creation)
Returns:
Tag dictionary with id, name, userVisible, userAssignable
"""
# First try to get existing tag
existing_tag = await self.get_tag_by_name(name)
if existing_tag:
logger.debug(f"Tag '{name}' already exists with ID {existing_tag['id']}")
return existing_tag
# Create new tag
try:
return await self.create_tag(name, user_visible, user_assignable)
except HTTPStatusError as e:
if e.response.status_code == 409:
# Tag was created between our check and creation, fetch it
existing_tag = await self.get_tag_by_name(name)
if existing_tag:
return existing_tag
raise
async def assign_tag_to_file(self, file_id: int, tag_id: int) -> bool:
"""Assign a system tag to a file.
Args:
file_id: Numeric file ID
tag_id: Numeric tag ID
Returns:
True if tag was assigned successfully (or already assigned)
Raises:
HTTPStatusError: If tag assignment fails
"""
response = await self._client.request(
"PUT",
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
headers={"Content-Length": "0"},
content=b"",
)
# 201 = Created (new assignment), 409 = Conflict (already assigned)
if response.status_code in (201, 409):
logger.info(f"Tagged file {file_id} with tag {tag_id}")
return True
response.raise_for_status()
return True
async def remove_tag_from_file(self, file_id: int, tag_id: int) -> bool:
"""Remove a system tag from a file.
Args:
file_id: Numeric file ID
tag_id: Numeric tag ID
Returns:
True if tag was removed successfully (or wasn't assigned)
Raises:
HTTPStatusError: If tag removal fails
"""
response = await self._client.request(
"DELETE",
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
)
# 204 = No Content (removed), 404 = Not Found (wasn't assigned)
if response.status_code in (204, 404):
logger.info(f"Removed tag {tag_id} from file {file_id}")
return True
response.raise_for_status()
return True
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.47.0" version = "0.48.0"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data" description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [ authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"} {name = "Chris Coutinho", email = "chris@coutinho.io"}
+95 -5
View File
@@ -10,6 +10,7 @@ Environment Variables:
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference") OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small") OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
OPENAI_GENERATION_MODEL: Generation model for sampling (default: "gpt-4o-mini") 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: For GitHub CI, set:
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }} OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
@@ -18,15 +19,17 @@ For GitHub CI, set:
OPENAI_GENERATION_MODEL: openai/gpt-4o-mini OPENAI_GENERATION_MODEL: openai/gpt-4o-mini
Prerequisites: Prerequisites:
- Nextcloud User Manual indexed in Qdrant (via vector sync) - Nextcloud User Manual PDF uploaded to Nextcloud
- VECTOR_SYNC_ENABLED=true on the MCP server - VECTOR_SYNC_ENABLED=true on the MCP server
""" """
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, AsyncGenerator from typing import Any, AsyncGenerator
import anyio
import pytest import pytest
from mcp import ClientSession from mcp import ClientSession
@@ -34,6 +37,11 @@ from nextcloud_mcp_server.providers.openai import OpenAIProvider
from tests.conftest import create_mcp_client_session from tests.conftest import create_mcp_client_session
from tests.integration.sampling_support import create_openai_sampling_callback from tests.integration.sampling_support import create_openai_sampling_callback
logger = logging.getLogger(__name__)
# Default path to the Nextcloud User Manual PDF
DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
# Skip all tests if OpenAI API key not configured # Skip all tests if OpenAI API key not configured
pytestmark = [ pytestmark = [
pytest.mark.integration, pytest.mark.integration,
@@ -58,6 +66,86 @@ def ground_truth_qa():
return json.load(f) return json.load(f)
@pytest.fixture(scope="module")
async def indexed_manual_pdf(nc_client, nc_mcp_client):
"""Ensure the Nextcloud User Manual PDF is tagged and indexed for vector search.
This fixture:
1. Gets file info for the manual PDF
2. Creates/gets the 'vector-index' tag
3. Assigns the tag to the file
4. Waits for vector sync to complete indexing
Environment Variables:
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: Nextcloud Manual.pdf)
"""
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
logger.info(f"Setting up indexed manual PDF: {manual_path}")
# Get file info to verify file exists and get file ID
file_info = await nc_client.webdav.get_file_info(manual_path)
if not file_info:
pytest.skip(f"Manual PDF not found at '{manual_path}'")
file_id = file_info["id"]
logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})")
# Create or get the vector-index tag
tag = await nc_client.webdav.get_or_create_tag("vector-index")
tag_id = tag["id"]
logger.info(f"Using tag 'vector-index' (tag_id={tag_id})")
# Assign tag to file
await nc_client.webdav.assign_tag_to_file(file_id, tag_id)
logger.info(f"Tagged file {file_id} with vector-index tag")
# Wait for vector sync to complete indexing
max_attempts = 60
poll_interval = 10
logger.info("Waiting for vector sync to index the manual...")
for attempt in range(1, max_attempts + 1):
try:
# Call the MCP tool via the existing client session
result = await nc_mcp_client.call_tool(
"nc_get_vector_sync_status",
arguments={},
)
if not result.isError:
content = result.structuredContent or {}
indexed = content.get("indexed_count", 0)
pending = content.get("pending_count", 1)
logger.info(
f"Attempt {attempt}/{max_attempts}: "
f"indexed={indexed}, pending={pending}"
)
if indexed > 0 and pending == 0:
logger.info(
f"Vector indexing complete: {indexed} documents indexed"
)
break
except Exception as e:
logger.warning(f"Attempt {attempt}: Error checking status: {e}")
if attempt < max_attempts:
await anyio.sleep(poll_interval)
else:
logger.warning(
f"Vector indexing may not be complete after {max_attempts} attempts"
)
yield {
"path": manual_path,
"file_id": file_id,
"tag_id": tag_id,
}
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
async def openai_provider(): async def openai_provider():
"""OpenAI provider configured from environment (embeddings only).""" """OpenAI provider configured from environment (embeddings only)."""
@@ -129,7 +217,9 @@ async def test_openai_embeddings_work(openai_provider: OpenAIProvider):
assert len(embedding) in [1536, 3072] assert len(embedding) in [1536, 3072]
async def test_semantic_search_retrieval(nc_mcp_client, ground_truth_qa): async def test_semantic_search_retrieval(
nc_mcp_client, ground_truth_qa, indexed_manual_pdf
):
"""Test that semantic search retrieves relevant documents from the manual. """Test that semantic search retrieves relevant documents from the manual.
This tests the retrieval component of RAG - ensuring that queries This tests the retrieval component of RAG - ensuring that queries
@@ -167,7 +257,7 @@ async def test_semantic_search_retrieval(nc_mcp_client, ground_truth_qa):
async def test_semantic_search_answer_with_sampling( async def test_semantic_search_answer_with_sampling(
nc_mcp_client_with_sampling, ground_truth_qa nc_mcp_client_with_sampling, ground_truth_qa, indexed_manual_pdf
): ):
"""Test semantic search with MCP sampling for answer generation. """Test semantic search with MCP sampling for answer generation.
@@ -243,7 +333,7 @@ async def test_semantic_search_answer_with_sampling(
], ],
) )
async def test_retrieval_quality_all_queries( async def test_retrieval_quality_all_queries(
nc_mcp_client, ground_truth_qa, qa_index, min_expected_results nc_mcp_client, ground_truth_qa, indexed_manual_pdf, qa_index, min_expected_results
): ):
"""Test retrieval quality for all ground truth queries. """Test retrieval quality for all ground truth queries.
@@ -274,7 +364,7 @@ async def test_retrieval_quality_all_queries(
) )
async def test_no_results_for_unrelated_query(nc_mcp_client): async def test_no_results_for_unrelated_query(nc_mcp_client, indexed_manual_pdf):
"""Test that completely unrelated queries return low/no scores. """Test that completely unrelated queries return low/no scores.
The Nextcloud manual shouldn't have relevant content for The Nextcloud manual shouldn't have relevant content for
+241
View File
@@ -117,3 +117,244 @@ def test_parse_search_response_with_empty_tags(mocker):
assert len(results) == 1 assert len(results) == 1
assert "tags" in results[0] assert "tags" in results[0]
assert results[0]["tags"] == [] assert results[0]["tags"] == []
@pytest.mark.unit
async def test_get_file_info_returns_file_details(mocker):
"""Test that get_file_info returns file info including file ID."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock PROPFIND response
mock_response = AsyncMock()
mock_response.status_code = 207
mock_response.content = b"""<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/testuser/Documents/test.pdf</d:href>
<d:propstat>
<d:prop>
<oc:fileid>12345</oc:fileid>
<d:displayname>test.pdf</d:displayname>
<d:getcontentlength>1024</d:getcontentlength>
<d:getcontenttype>application/pdf</d:getcontenttype>
<d:getlastmodified>Sat, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
<d:getetag>"abc123"</d:getetag>
<d:resourcetype/>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"""
mock_response.raise_for_status = mocker.Mock()
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call get_file_info
result = await client.get_file_info("Documents/test.pdf")
# Verify result
assert result is not None
assert result["id"] == 12345
assert result["name"] == "test.pdf"
assert result["path"] == "Documents/test.pdf"
assert result["content_type"] == "application/pdf"
assert result["size"] == 1024
assert result["etag"] == "abc123"
assert result["is_directory"] is False
@pytest.mark.unit
async def test_get_file_info_returns_none_for_missing_file(mocker):
"""Test that get_file_info returns None for missing files."""
from httpx import HTTPStatusError, Response
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 404 response
mock_response = mocker.Mock(spec=Response)
mock_response.status_code = 404
mock_http_client.request = AsyncMock(
side_effect=HTTPStatusError(
"Not Found", request=mocker.Mock(), response=mock_response
)
)
# Call get_file_info
result = await client.get_file_info("nonexistent.pdf")
# Verify result is None
assert result is None
@pytest.mark.unit
async def test_create_tag_creates_system_tag(mocker):
"""Test that create_tag creates a system tag via OCS API."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock OCS response
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json = mocker.Mock(
return_value={
"ocs": {
"data": {
"id": 42,
"name": "vector-index",
"userVisible": True,
"userAssignable": True,
}
}
}
)
mock_response.raise_for_status = mocker.Mock()
mock_http_client.post = AsyncMock(return_value=mock_response)
# Call create_tag
result = await client.create_tag("vector-index")
# Verify result
assert result["id"] == 42
assert result["name"] == "vector-index"
assert result["userVisible"] is True
assert result["userAssignable"] is True
# Verify API call
mock_http_client.post.assert_called_once()
call_args = mock_http_client.post.call_args
assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags"
assert call_args[1]["json"]["name"] == "vector-index"
@pytest.mark.unit
async def test_get_or_create_tag_returns_existing_tag(mocker):
"""Test that get_or_create_tag returns existing tag without creating."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock existing tag
mocker.patch.object(
client,
"get_tag_by_name",
return_value={"id": 42, "name": "vector-index", "userVisible": True},
)
mock_create = mocker.patch.object(client, "create_tag")
# Call get_or_create_tag
result = await client.get_or_create_tag("vector-index")
# Verify existing tag returned without creating
assert result["id"] == 42
mock_create.assert_not_called()
@pytest.mark.unit
async def test_get_or_create_tag_creates_new_tag(mocker):
"""Test that get_or_create_tag creates tag when not found."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock no existing tag
mocker.patch.object(client, "get_tag_by_name", return_value=None)
mocker.patch.object(
client,
"create_tag",
return_value={"id": 42, "name": "vector-index", "userVisible": True},
)
# Call get_or_create_tag
result = await client.get_or_create_tag("vector-index")
# Verify tag was created
assert result["id"] == 42
client.create_tag.assert_called_once_with("vector-index", True, True)
@pytest.mark.unit
async def test_assign_tag_to_file_success(mocker):
"""Test that assign_tag_to_file assigns tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 201 Created response
mock_response = AsyncMock()
mock_response.status_code = 201
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call assign_tag_to_file
result = await client.assign_tag_to_file(12345, 42)
# Verify result
assert result is True
# Verify API call
mock_http_client.request.assert_called_once()
call_args = mock_http_client.request.call_args
assert call_args[0][0] == "PUT"
assert "/systemtags-relations/files/12345/42" in call_args[0][1]
@pytest.mark.unit
async def test_assign_tag_to_file_already_assigned(mocker):
"""Test that assign_tag_to_file handles already assigned (409) gracefully."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 409 Conflict response (already assigned)
mock_response = AsyncMock()
mock_response.status_code = 409
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call assign_tag_to_file
result = await client.assign_tag_to_file(12345, 42)
# Verify result (should succeed even with 409)
assert result is True
@pytest.mark.unit
async def test_remove_tag_from_file_success(mocker):
"""Test that remove_tag_from_file removes tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 204 No Content response
mock_response = AsyncMock()
mock_response.status_code = 204
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call remove_tag_from_file
result = await client.remove_tag_from_file(12345, 42)
# Verify result
assert result is True
# Verify API call
mock_http_client.request.assert_called_once()
call_args = mock_http_client.request.call_args
assert call_args[0][0] == "DELETE"
assert "/systemtags-relations/files/12345/42" in call_args[0][1]
@pytest.mark.unit
async def test_remove_tag_from_file_not_assigned(mocker):
"""Test that remove_tag_from_file handles not assigned (404) gracefully."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 404 Not Found response (tag wasn't assigned)
mock_response = AsyncMock()
mock_response.status_code = 404
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call remove_tag_from_file
result = await client.remove_tag_from_file(12345, 42)
# Verify result (should succeed even with 404)
assert result is True
Generated
+1 -1
View File
@@ -1936,7 +1936,7 @@ wheels = [
[[package]] [[package]]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.47.0" version = "0.48.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },