Skip to content

Commit ee32a1b

Browse files
committed
feat: Switch to using async client
1 parent c918284 commit ee32a1b

File tree

12 files changed

+838
-441
lines changed

12 files changed

+838
-441
lines changed

.github/workflows/test.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,21 @@ on:
66
- master
77

88
jobs:
9-
build:
9+
linting:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
13+
- name: Install the latest version of uv
14+
uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6
15+
- name: Check format
16+
run: |
17+
uv run --frozen ruff format --diff
18+
- name: Linting
19+
run: |
20+
uv run --frozen ruff check
21+
22+
23+
integration-test:
1024
runs-on: ubuntu-latest
1125

1226
steps:

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ WORKDIR /app
44

55
COPY . .
66

7-
RUN uv sync --locked
7+
RUN uv sync --locked --no-dev
88

9-
CMD ["uv", "run", "--locked", "mcp", "run", "--transport", "sse", "nextcloud_mcp_server/server.py:mcp"]
9+
CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server.py:mcp"]

nextcloud_mcp_server/client.py

Lines changed: 211 additions & 104 deletions
Large diffs are not rendered by default.

nextcloud_mcp_server/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"loggers": {
1818
"": {
1919
"handlers": ["default"],
20-
"level": "DEBUG",
20+
"level": "INFO",
2121
},
2222
"httpx": {
2323
"handlers": ["default"],

nextcloud_mcp_server/server.py

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@
44
from contextlib import asynccontextmanager
55
from dataclasses import dataclass
66
from mcp.server.fastmcp import FastMCP, Context
7-
from mcp.server import Server
87
from collections.abc import AsyncIterator
98
from nextcloud_mcp_server.client import NextcloudClient
10-
import asyncio # Import asyncio
119

1210
setup_logging()
1311

@@ -28,7 +26,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
2826
yield AppContext(client=client)
2927
finally:
3028
# Cleanup on shutdown
31-
client._client.close()
29+
await client._client.aclose()
3230

3331

3432
# Create an MCP server
@@ -38,46 +36,46 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
3836

3937

4038
@mcp.resource("nc://capabilities")
41-
def nc_get_capabilities():
39+
async def nc_get_capabilities():
4240
"""Get the Nextcloud Host capabilities"""
4341
# client = NextcloudClient.from_env()
4442
ctx = (
4543
mcp.get_context()
4644
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
4745
client: NextcloudClient = ctx.request_context.lifespan_context.client
48-
return client.capabilities()
46+
return await client.capabilities()
4947

5048

5149
@mcp.resource("notes://settings")
52-
def notes_get_settings():
50+
async def notes_get_settings():
5351
"""Get the Notes App settings"""
5452
ctx = (
5553
mcp.get_context()
5654
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
5755
client: NextcloudClient = ctx.request_context.lifespan_context.client
58-
return client.notes_get_settings()
56+
return await client.notes_get_settings()
5957

6058

6159
@mcp.tool()
62-
def nc_get_note(note_id: int, ctx: Context):
60+
async def nc_get_note(note_id: int, ctx: Context):
6361
"""Get user note using note id"""
6462
client: NextcloudClient = ctx.request_context.lifespan_context.client
65-
return client.notes_get_note(note_id=note_id)
63+
return await client.notes_get_note(note_id=note_id)
6664

6765

6866
@mcp.tool()
69-
def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
67+
async def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
7068
"""Create a new note"""
7169
client: NextcloudClient = ctx.request_context.lifespan_context.client
72-
return client.notes_create_note(
70+
return await client.notes_create_note(
7371
title=title,
7472
content=content,
7573
category=category,
7674
)
7775

7876

7977
@mcp.tool()
80-
def nc_notes_update_note(
78+
async def nc_notes_update_note(
8179
note_id: int,
8280
etag: str,
8381
title: str | None,
@@ -87,7 +85,7 @@ def nc_notes_update_note(
8785
):
8886
logger.info("Updating note %s", note_id)
8987
client: NextcloudClient = ctx.request_context.lifespan_context.client
90-
return client.notes_update_note(
88+
return await client.notes_update_note(
9189
note_id=note_id,
9290
etag=etag,
9391
title=title,
@@ -97,35 +95,35 @@ def nc_notes_update_note(
9795

9896

9997
@mcp.tool()
100-
def nc_notes_append_content(note_id: int, content: str, ctx: Context):
98+
async def nc_notes_append_content(note_id: int, content: str, ctx: Context):
10199
"""Append content to an existing note with a clear separator"""
102100
logger.info("Appending content to note %s", note_id)
103101
client: NextcloudClient = ctx.request_context.lifespan_context.client
104-
return client.notes_append_content(note_id=note_id, content=content)
102+
return await client.notes_append_content(note_id=note_id, content=content)
105103

106104

107105
@mcp.tool()
108-
def nc_notes_search_notes(query: str, ctx: Context):
106+
async def nc_notes_search_notes(query: str, ctx: Context):
109107
"""Search notes by title or content, returning only id, title, and category."""
110108
client: NextcloudClient = ctx.request_context.lifespan_context.client
111-
return client.notes_search_notes(query=query)
109+
return await client.notes_search_notes(query=query)
112110

113111

114112
@mcp.tool()
115-
def nc_notes_delete_note(note_id: int, ctx: Context):
113+
async def nc_notes_delete_note(note_id: int, ctx: Context):
116114
logger.info("Deleting note %s", note_id)
117115
client: NextcloudClient = ctx.request_context.lifespan_context.client
118-
return client.notes_delete_note(note_id=note_id)
116+
return await client.notes_delete_note(note_id=note_id)
119117

120118

121119
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
122-
def nc_notes_get_attachment(note_id: int, attachment_filename: str):
120+
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
123121
"""Get a specific attachment from a note"""
124122
ctx = mcp.get_context()
125123
client: NextcloudClient = ctx.request_context.lifespan_context.client
126124
# Assuming a method get_note_attachment exists in the client
127125
# This method should return the raw content and determine the mime type
128-
content, mime_type = client.get_note_attachment(
126+
content, mime_type = await client.get_note_attachment(
129127
note_id=note_id, filename=attachment_filename
130128
)
131129
return {

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
nc-mcp-server = "nextcloud_mcp_server.server:run"
1818

1919
[tool.pytest.ini_options]
20+
asyncio_mode = "auto"
2021
log_cli = 1
2122
log_cli_level = "WARN"
2223
log_level = "WARN"
@@ -38,9 +39,10 @@ build-backend = "poetry.core.masonry.api"
3839

3940
[dependency-groups]
4041
dev = [
41-
"black>=25.1.0",
4242
"commitizen>=4.8.2",
4343
"ipython>=9.2.0",
4444
"pytest>=8.3.5",
45+
"pytest-asyncio>=1.0.0",
4546
"pytest-cov>=6.1.1",
47+
"ruff>=0.11.13",
4648
]

tests/conftest.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
import os
33
import logging
44
import uuid
5-
import time
65
from nextcloud_mcp_server.client import NextcloudClient, HTTPStatusError
76

87
logger = logging.getLogger(__name__)
98

9+
1010
@pytest.fixture(scope="session")
11-
def nc_client() -> NextcloudClient:
11+
async def nc_client() -> NextcloudClient:
1212
"""
1313
Fixture to create a NextcloudClient instance for integration tests.
1414
Uses environment variables for configuration.
@@ -20,15 +20,18 @@ def nc_client() -> NextcloudClient:
2020
client = NextcloudClient.from_env()
2121
# Optional: Perform a quick check like getting capabilities to ensure connection works
2222
try:
23-
client.capabilities()
24-
logger.info("NextcloudClient session fixture initialized and capabilities checked.")
23+
await client.capabilities()
24+
logger.info(
25+
"NextcloudClient session fixture initialized and capabilities checked."
26+
)
2527
except Exception as e:
2628
logger.error(f"Failed to initialize NextcloudClient session fixture: {e}")
2729
pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}")
2830
return client
2931

32+
3033
@pytest.fixture
31-
def temporary_note(nc_client: NextcloudClient):
34+
async def temporary_note(nc_client: NextcloudClient):
3235
"""
3336
Fixture to create a temporary note for a test and ensure its deletion afterward.
3437
Yields the created note dictionary.
@@ -42,21 +45,21 @@ def temporary_note(nc_client: NextcloudClient):
4245

4346
logger.info(f"Creating temporary note: {note_title}")
4447
try:
45-
created_note_data = nc_client.notes_create_note(
48+
created_note_data = await nc_client.notes_create_note(
4649
title=note_title, content=note_content, category=note_category
4750
)
4851
note_id = created_note_data.get("id")
4952
if not note_id:
5053
pytest.fail("Failed to get ID from created temporary note.")
51-
54+
5255
logger.info(f"Temporary note created with ID: {note_id}")
53-
yield created_note_data # Provide the created note data to the test
56+
yield created_note_data # Provide the created note data to the test
5457

5558
finally:
5659
if note_id:
5760
logger.info(f"Cleaning up temporary note ID: {note_id}")
5861
try:
59-
nc_client.notes_delete_note(note_id=note_id)
62+
await nc_client.notes_delete_note(note_id=note_id)
6063
logger.info(f"Successfully deleted temporary note ID: {note_id}")
6164
except HTTPStatusError as e:
6265
# Ignore 404 if note was already deleted by the test itself
@@ -67,36 +70,42 @@ def temporary_note(nc_client: NextcloudClient):
6770
except Exception as e:
6871
logger.error(f"Unexpected error deleting temporary note {note_id}: {e}")
6972

73+
7074
@pytest.fixture
71-
def temporary_note_with_attachment(nc_client: NextcloudClient, temporary_note: dict):
75+
async def temporary_note_with_attachment(nc_client: NextcloudClient, temporary_note: dict):
7276
"""
7377
Fixture that creates a temporary note, adds an attachment, and cleans up both.
7478
Yields a tuple: (note_data, attachment_filename, attachment_content).
7579
Depends on the temporary_note fixture.
7680
"""
7781
note_data = temporary_note
7882
note_id = note_data["id"]
79-
note_category = note_data.get("category") # Get category from the note data
83+
note_category = note_data.get("category") # Get category from the note data
8084
unique_suffix = uuid.uuid4().hex[:8]
8185
attachment_filename = f"temp_attach_{unique_suffix}.txt"
82-
attachment_content = f"Content for {attachment_filename}".encode('utf-8')
86+
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
8387
attachment_mime = "text/plain"
84-
85-
logger.info(f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')")
88+
89+
logger.info(
90+
f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')"
91+
)
8692
try:
8793
# Pass the category to add_note_attachment
88-
upload_response = nc_client.add_note_attachment(
94+
upload_response = await nc_client.add_note_attachment(
8995
note_id=note_id,
9096
filename=attachment_filename,
9197
content=attachment_content,
92-
category=note_category, # Pass the fetched category
93-
mime_type=attachment_mime
98+
category=note_category, # Pass the fetched category
99+
mime_type=attachment_mime,
94100
)
95-
assert upload_response.get("status_code") in [201, 204], f"Failed to upload attachment: {upload_response}"
101+
assert upload_response.get("status_code") in [
102+
201,
103+
204,
104+
], f"Failed to upload attachment: {upload_response}"
96105
logger.info(f"Attachment '{attachment_filename}' added successfully.")
97-
106+
98107
yield note_data, attachment_filename, attachment_content
99-
108+
100109
# Cleanup for the attachment is handled by the notes_delete_note call
101110
# in the temporary_note fixture's finally block (which deletes the .attachments dir)
102111

0 commit comments

Comments
 (0)