Compare commits

..

1 Commits

Author SHA1 Message Date
Chris Coutinho b047fce290 feat(server): Add /heartbeat endpoint 2025-09-12 10:45:18 +02:00
15 changed files with 349 additions and 715 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
*
!pyproject.toml
!poetry.lock
!README.md
!uv.lock
!nextcloud_mcp_server/**/*.py
!nextcloud_mcp_server/
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -27,11 +27,11 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Run docker compose
uses: hoverkraft-tech/compose-action@b716db5b717cb9b81e391fe638e5aceaa2299e43 # v2.4.0
uses: hoverkraft-tech/compose-action@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0
with:
compose-file: "./docker-compose.yml"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@eb1897b8dc4b5d5bfe39a428a8f2304605e0983c # v7.0.0
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
- name: Wait for service to be ready
run: |
-30
View File
@@ -1,33 +1,3 @@
## v0.12.6 (2025-10-11)
### Fix
- **deps**: update dependency mcp to >=1.17,<1.18
## v0.12.5 (2025-10-03)
### Fix
- **deps**: update dependency mcp to >=1.16,<1.17
## v0.12.4 (2025-09-25)
### Fix
- **deps**: update dependency mcp to >=1.15,<1.16
## v0.12.3 (2025-09-23)
### Refactor
- Add tools for all resources to enable tool-only workflows
## v0.12.2 (2025-09-20)
### Refactor
- Add `http` to --transport option
## v0.12.1 (2025-09-11)
### Fix
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208
FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-alpine@sha256:2a2cae80b7d3b3b3c7f94ec3ed91e9b3ca2524a7a429824fbbadd9954fa5d6b6
WORKDIR /app
@@ -6,4 +6,4 @@ COPY . .
RUN uv sync --locked --no-dev
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
ENTRYPOINT ["/app/.venv/bin/python", "-m", "nextcloud_mcp_server.app", "--host", "0.0.0.0"]
-4
View File
@@ -248,10 +248,6 @@ You can then connect to and interact with the server's tools and resources throu
Contributions are welcome! Please feel free to submit issues or pull requests on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server).
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=cbcoutinho/nextcloud-mcp-server&type=Date)](https://www.star-history.com/#cbcoutinho/nextcloud-mcp-server&Date)
## License
This project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details.
+3 -3
View File
@@ -3,7 +3,7 @@ services:
# https://hub.docker.com/_/mariadb
db:
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
image: docker.io/library/mariadb:lts@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71
image: mariadb:lts@sha256:ec5d50f32359ff020b93cce6834f9bf89147c34aea0e90c952ccf556c94a4fb8
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
@@ -17,11 +17,11 @@ services:
# Note: Redis is an external service. You can find more information about the configuration here:
# https://hub.docker.com/_/redis
redis:
image: docker.io/library/redis:alpine@sha256:59b6e694653476de2c992937ebe1c64182af4728e54bb49e9b7a6c26614d8933
image: redis:alpine@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232
restart: always
app:
image: docker.io/library/nextcloud:32.0.0@sha256:3e70e4dfe882ef44738fdc30d9896fb07c12febb27c4a1177e3d63dc0004a0b4
image: nextcloud:31.0.8@sha256:c3329db9d0d0d79b1fe6433b54b81c28acaefecfe96a400be202b7da80f6b8ca
#user: www-data:www-data
restart: always
#post_start:
+18 -5
View File
@@ -6,7 +6,8 @@ from contextlib import asynccontextmanager, AsyncExitStack
from dataclasses import dataclass
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.routing import Mount, Route
from starlette.responses import JSONResponse
from mcp.server.fastmcp import Context, FastMCP
@@ -44,7 +45,11 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
await client.close()
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
async def heartbeat(request):
return JSONResponse({"status": "ok"})
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
setup_logging()
# Create an MCP server
@@ -84,9 +89,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
)
if transport == "sse":
mcp.settings.sse_path = "/"
mcp_app = mcp.sse_app()
lifespan = None
elif transport in ("http", "streamable-http"):
else:
mcp.settings.streamable_http_path = "/"
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
@@ -95,7 +102,13 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
await stack.enter_async_context(mcp.session_manager.run())
yield
app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan)
app = Starlette(
routes=[
Route("/heartbeat", endpoint=heartbeat),
Mount("/sse" if transport == "sse" else "/mcp", app=mcp_app),
],
lifespan=lifespan,
)
return app
@@ -117,7 +130,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
"-t",
default="sse",
show_default=True,
type=click.Choice(["sse", "streamable-http", "http"]),
type=click.Choice(["sse", "streamable-http"]),
)
@click.option(
"--enable-app",
+1 -91
View File
@@ -5,10 +5,6 @@ from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.deck import (
DeckBoard,
DeckStack,
DeckCard,
DeckLabel,
CreateBoardResponse,
CreateStackResponse,
StackOperationResponse,
@@ -29,7 +25,6 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_boards_resource():
"""List all Nextcloud Deck boards"""
ctx: Context = mcp.get_context()
await ctx.warning("This message is deprecated, use the deck_get_board instead")
client: NextcloudClient = ctx.request_context.lifespan_context.client
boards = await client.deck.get_boards()
return [board.model_dump() for board in boards]
@@ -38,9 +33,6 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_board_resource(board_id: int):
"""Get details of a specific Nextcloud Deck board"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_board tool instead"
)
client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id)
return board.model_dump()
@@ -49,9 +41,6 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_stacks_resource(board_id: int):
"""List all stacks in a Nextcloud Deck board"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_stacks tool instead"
)
client: NextcloudClient = ctx.request_context.lifespan_context.client
stacks = await client.deck.get_stacks(board_id)
return [stack.model_dump() for stack in stacks]
@@ -60,9 +49,6 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_stack_resource(board_id: int, stack_id: int):
"""Get details of a specific Nextcloud Deck stack"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_stack tool instead"
)
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id)
return stack.model_dump()
@@ -71,9 +57,6 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_cards_resource(board_id: int, stack_id: int):
"""List all cards in a Nextcloud Deck stack"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_cards tool instead"
)
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
@@ -84,9 +67,6 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_card_resource(board_id: int, stack_id: int, card_id: int):
"""Get details of a specific Nextcloud Deck card"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_card tool instead"
)
client: NextcloudClient = ctx.request_context.lifespan_context.client
card = await client.deck.get_card(board_id, stack_id, card_id)
return card.model_dump()
@@ -95,9 +75,6 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_labels_resource(board_id: int):
"""List all labels in a Nextcloud Deck board"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_labels tool instead"
)
client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id)
return [label.model_dump() for label in board.labels]
@@ -106,78 +83,11 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_label_resource(board_id: int, label_id: int):
"""Get details of a specific Nextcloud Deck label"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_label tool instead"
)
client: NextcloudClient = ctx.request_context.lifespan_context.client
label = await client.deck.get_label(board_id, label_id)
return label.model_dump()
# Read Tools (converted from resources)
@mcp.tool()
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
"""Get all Nextcloud Deck boards"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
boards = await client.deck.get_boards()
return boards
@mcp.tool()
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
"""Get details of a specific Nextcloud Deck board"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id)
return board
@mcp.tool()
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
"""Get all stacks in a Nextcloud Deck board"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
stacks = await client.deck.get_stacks(board_id)
return stacks
@mcp.tool()
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
"""Get details of a specific Nextcloud Deck stack"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id)
return stack
@mcp.tool()
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]:
"""Get all cards in a Nextcloud Deck stack"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
return stack.cards
return []
@mcp.tool()
async def deck_get_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> DeckCard:
"""Get details of a specific Nextcloud Deck card"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
card = await client.deck.get_card(board_id, stack_id, card_id)
return card
@mcp.tool()
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
"""Get all labels in a Nextcloud Deck board"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id)
return board.labels
@mcp.tool()
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
"""Get details of a specific Nextcloud Deck label"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
label = await client.deck.get_label(board_id, label_id)
return label
# Create/Update/Delete Tools
# Tools
@mcp.tool()
async def deck_create_board(
+3 -63
View File
@@ -32,7 +32,7 @@ def configure_notes_tools(mcp: FastMCP):
return NotesSettings(**settings_data)
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str):
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
"""Get a specific attachment from a note"""
ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
@@ -53,7 +53,7 @@ def configure_notes_tools(mcp: FastMCP):
}
@mcp.resource("nc://Notes/{note_id}")
async def nc_get_note_resource(note_id: int):
async def nc_get_note(note_id: int):
"""Get user note using note id"""
ctx: Context = mcp.get_context()
@@ -129,7 +129,7 @@ def configure_notes_tools(mcp: FastMCP):
"""Update an existing note's title, content, or category.
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
Get the current ETag by first retrieving the note using nc://Notes/{note_id} resource.
If the note has been modified by someone else since you retrieved it,
the update will fail with a 412 error."""
logger.info("Updating note %s", note_id)
@@ -258,66 +258,6 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool()
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
"""Get a specific note by its ID"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
try:
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to note {note_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve note {note_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
async def nc_notes_get_attachment(
note_id: int, attachment_filename: str, ctx: Context
) -> dict[str, str]:
"""Get a specific attachment from a note"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
try:
content, mime_type = await client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename
)
return {
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
"mimeType": mime_type,
"data": content,
}
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(
code=-1,
message=f"Attachment {attachment_filename} not found for note {note_id}",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied to attachment {attachment_filename} for note {note_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve attachment: {e.response.reason_phrase}",
)
)
@mcp.tool()
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently"""
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.12.6"
version = "0.12.1"
description = ""
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
@@ -8,7 +8,7 @@ authors = [
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mcp[cli] (>=1.17,<1.18)",
"mcp[cli] (>=1.13,<1.14)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=11.2.1,<12.0.0)",
"icalendar (>=6.0.0,<7.0.0)",
+6 -9
View File
@@ -2,6 +2,7 @@
import logging
from mcp import ClientSession
from mcp.shared.exceptions import McpError
import pytest
@@ -9,15 +10,11 @@ logger = logging.getLogger(__name__)
@pytest.mark.integration
async def test_missing_note_tool_error(nc_mcp_client: ClientSession):
"""Test that accessing a non-existent note via tool returns proper error."""
# Try to get a non-existent note via tool - should return error response
response = await nc_mcp_client.call_tool("nc_notes_get_note", {"note_id": 999999})
# Should return error response (not raise exception) for tools
assert response is not None
assert response.isError is True
assert "Note 999999 not found" in response.content[0].text
async def test_missing_note_resource_error(nc_mcp_client: ClientSession):
"""Test that accessing a non-existent note resource returns proper error."""
# Try to get a non-existent note via resource - should raise McpError with improved message
with pytest.raises(McpError, match=r"Note 999999 not found"):
await nc_mcp_client.read_resource("nc://Notes/999999")
@pytest.mark.integration
+4 -7
View File
@@ -68,8 +68,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
template_uris.append(template.uriTemplate)
# Verify expected resource templates
# Note: Notes attachments are now handled via tools, not resource templates
expected_templates = []
expected_templates = ["nc://Notes/{note_id}/attachments/{attachment_filename}"]
for expected_template in expected_templates:
assert expected_template in template_uris, (
@@ -141,11 +140,9 @@ async def test_mcp_notes_crud_workflow(
# 3. Read note via MCP
logger.info(f"Reading note via MCP: {note_id}")
read_result = await nc_mcp_client.call_tool(
"nc_notes_get_note", {"note_id": note_id}
)
read_note_data = read_result.content[0].text
read_note_data = json.loads(read_note_data)
read_result = await nc_mcp_client.read_resource(f"nc://Notes/{note_id}")
assert len(read_result.contents) == 1, "Expected exactly one content item"
read_note_data = json.loads(read_result.contents[0].text)
assert read_note_data["title"] == test_title
assert read_note_data["content"] == test_content
Generated
+303 -493
View File
File diff suppressed because it is too large Load Diff