Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e3c2c9774 | |||
| 752c22147c | |||
| 4c07ca9f0a | |||
| 55945c6c0f | |||
| 3f8312e6f3 | |||
| c39b69d08c | |||
| 290ad2edc2 | |||
| 144c08c339 | |||
| b461af8aa1 | |||
| 4bdf67b042 | |||
| 93b109e5b9 | |||
| 0c5ebd5d84 | |||
| 79e6250377 | |||
| a5ec712b88 | |||
| cc9650b077 | |||
| 1a37a6c1fe | |||
| 4572287870 | |||
| 67617d7fcc | |||
| 22811f29f6 | |||
| 71da620099 | |||
| de7c848aa6 | |||
| 8d4303a624 | |||
| 4c7880a4e5 | |||
| 0a307b87ae | |||
| 48eced80fb | |||
| aafac732c6 | |||
| 12d48bb920 | |||
| 0600cea87b | |||
| 145141e1d8 | |||
| 948e7a4d91 | |||
| 39ff811d1a | |||
| cfd03a761b | |||
| e7b37312a7 | |||
| 4ad47b4fa3 | |||
| ffbb86df57 | |||
| 7a57247a9c | |||
| 4ea6ce3477 |
+1
-2
@@ -1,8 +1,7 @@
|
||||
*
|
||||
|
||||
!pyproject.toml
|
||||
!poetry.lock
|
||||
!README.md
|
||||
!uv.lock
|
||||
|
||||
!nextcloud_mcp_server/
|
||||
!nextcloud_mcp_server/**/*.py
|
||||
|
||||
@@ -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@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
|
||||
uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
|
||||
uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
## 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
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-alpine@sha256:2a2cae80b7d3b3b3c7f94ec3ed91e9b3ca2524a7a429824fbbadd9954fa5d6b6
|
||||
FROM ghcr.io/astral-sh/uv:0.8.22-python3.11-alpine@sha256:a8d5f7079a3223380ec060fefe48afe45b4c4622d631ce0e495593ac9a38f546
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,4 +6,4 @@ COPY . .
|
||||
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/python", "-m", "nextcloud_mcp_server.app", "--host", "0.0.0.0"]
|
||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -248,6 +248,10 @@ 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
|
||||
|
||||
[](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.
|
||||
|
||||
+2
-2
@@ -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: mariadb:lts@sha256:ec5d50f32359ff020b93cce6834f9bf89147c34aea0e90c952ccf556c94a4fb8
|
||||
image: mariadb:lts@sha256:851a6020c97b9eae7736b6fb275800601d64635222054d3a1b1b3c4abdfa117a
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud:31.0.8@sha256:c3329db9d0d0d79b1fe6433b54b81c28acaefecfe96a400be202b7da80f6b8ca
|
||||
image: nextcloud:31.0.9@sha256:88fe398340a896eeebfe0a4ba847998ff2c8fbb3d72de354ac1f08bc7b44db18
|
||||
#user: www-data:www-data
|
||||
restart: always
|
||||
#post_start:
|
||||
|
||||
@@ -86,7 +86,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
else:
|
||||
elif transport in ("http", "streamable-http"):
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -117,7 +117,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"]),
|
||||
type=click.Choice(["sse", "streamable-http", "http"]),
|
||||
)
|
||||
@click.option(
|
||||
"--enable-app",
|
||||
|
||||
@@ -5,6 +5,10 @@ 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,
|
||||
@@ -25,6 +29,7 @@ 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]
|
||||
@@ -33,6 +38,9 @@ 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()
|
||||
@@ -41,6 +49,9 @@ 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]
|
||||
@@ -49,6 +60,9 @@ 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()
|
||||
@@ -57,6 +71,9 @@ 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:
|
||||
@@ -67,6 +84,9 @@ 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()
|
||||
@@ -75,6 +95,9 @@ 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]
|
||||
@@ -83,11 +106,78 @@ 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()
|
||||
|
||||
# Tools
|
||||
# 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
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_create_board(
|
||||
|
||||
@@ -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(note_id: int, attachment_filename: str):
|
||||
async def nc_notes_get_attachment_resource(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(note_id: int):
|
||||
async def nc_get_note_resource(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/{note_id} resource.
|
||||
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
||||
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,6 +258,66 @@ 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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.12.1"
|
||||
version = "0.12.4"
|
||||
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.13,<1.14)",
|
||||
"mcp[cli] (>=1.15,<1.16)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
from mcp import ClientSession
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -10,11 +9,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -68,7 +68,8 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
template_uris.append(template.uriTemplate)
|
||||
|
||||
# Verify expected resource templates
|
||||
expected_templates = ["nc://Notes/{note_id}/attachments/{attachment_filename}"]
|
||||
# Note: Notes attachments are now handled via tools, not resource templates
|
||||
expected_templates = []
|
||||
|
||||
for expected_template in expected_templates:
|
||||
assert expected_template in template_uris, (
|
||||
@@ -140,9 +141,11 @@ 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.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)
|
||||
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)
|
||||
|
||||
assert read_note_data["title"] == test_title
|
||||
assert read_note_data["content"] == test_content
|
||||
|
||||
Reference in New Issue
Block a user