Compare commits

...

4 Commits

Author SHA1 Message Date
Chris Coutinho 79e6250377 update deprecated log warnings 2025-09-24 00:17:57 +02:00
Chris Coutinho cc9650b077 refactor: Add tools for all resources to enable tool-only workflows 2025-09-24 00:13:24 +02:00
Chris Coutinho 4572287870 Merge pull request #165 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.20
2025-09-23 12:35:20 +02:00
renovate-bot-cbcoutinho[bot] 67617d7fcc chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.20 2025-09-23 04:07:43 +00:00
5 changed files with 171 additions and 15 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:0.8.19-python3.11-alpine@sha256:f55e8bf10a21798bee13afc9d12f6923e32d5557528d3368a6e7248aae201e84 FROM ghcr.io/astral-sh/uv:0.8.20-python3.11-alpine@sha256:8c24c223d63cb6b997101852cb7bc767b349918652e05e3cba20c93c899cb3d5
WORKDIR /app WORKDIR /app
+91 -1
View File
@@ -5,6 +5,10 @@ from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.deck import ( from nextcloud_mcp_server.models.deck import (
DeckBoard,
DeckStack,
DeckCard,
DeckLabel,
CreateBoardResponse, CreateBoardResponse,
CreateStackResponse, CreateStackResponse,
StackOperationResponse, StackOperationResponse,
@@ -25,6 +29,7 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_boards_resource(): async def deck_boards_resource():
"""List all Nextcloud Deck boards""" """List all Nextcloud Deck boards"""
ctx: Context = mcp.get_context() 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 client: NextcloudClient = ctx.request_context.lifespan_context.client
boards = await client.deck.get_boards() boards = await client.deck.get_boards()
return [board.model_dump() for board in 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): async def deck_board_resource(board_id: int):
"""Get details of a specific Nextcloud Deck board""" """Get details of a specific Nextcloud Deck board"""
ctx: Context = mcp.get_context() 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 client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id) board = await client.deck.get_board(board_id)
return board.model_dump() return board.model_dump()
@@ -41,6 +49,9 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_stacks_resource(board_id: int): async def deck_stacks_resource(board_id: int):
"""List all stacks in a Nextcloud Deck board""" """List all stacks in a Nextcloud Deck board"""
ctx: Context = mcp.get_context() 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 client: NextcloudClient = ctx.request_context.lifespan_context.client
stacks = await client.deck.get_stacks(board_id) stacks = await client.deck.get_stacks(board_id)
return [stack.model_dump() for stack in stacks] 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): async def deck_stack_resource(board_id: int, stack_id: int):
"""Get details of a specific Nextcloud Deck stack""" """Get details of a specific Nextcloud Deck stack"""
ctx: Context = mcp.get_context() 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 client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
return stack.model_dump() 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): async def deck_cards_resource(board_id: int, stack_id: int):
"""List all cards in a Nextcloud Deck stack""" """List all cards in a Nextcloud Deck stack"""
ctx: Context = mcp.get_context() 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 client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards: 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): async def deck_card_resource(board_id: int, stack_id: int, card_id: int):
"""Get details of a specific Nextcloud Deck card""" """Get details of a specific Nextcloud Deck card"""
ctx: Context = mcp.get_context() 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 client: NextcloudClient = ctx.request_context.lifespan_context.client
card = await client.deck.get_card(board_id, stack_id, card_id) card = await client.deck.get_card(board_id, stack_id, card_id)
return card.model_dump() return card.model_dump()
@@ -75,6 +95,9 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_labels_resource(board_id: int): async def deck_labels_resource(board_id: int):
"""List all labels in a Nextcloud Deck board""" """List all labels in a Nextcloud Deck board"""
ctx: Context = mcp.get_context() 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 client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id) board = await client.deck.get_board(board_id)
return [label.model_dump() for label in board.labels] 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): async def deck_label_resource(board_id: int, label_id: int):
"""Get details of a specific Nextcloud Deck label""" """Get details of a specific Nextcloud Deck label"""
ctx: Context = mcp.get_context() 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 client: NextcloudClient = ctx.request_context.lifespan_context.client
label = await client.deck.get_label(board_id, label_id) label = await client.deck.get_label(board_id, label_id)
return label.model_dump() 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() @mcp.tool()
async def deck_create_board( async def deck_create_board(
+63 -3
View File
@@ -32,7 +32,7 @@ def configure_notes_tools(mcp: FastMCP):
return NotesSettings(**settings_data) return NotesSettings(**settings_data)
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}") @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""" """Get a specific attachment from a note"""
ctx: Context = mcp.get_context() ctx: Context = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client client: NextcloudClient = ctx.request_context.lifespan_context.client
@@ -53,7 +53,7 @@ def configure_notes_tools(mcp: FastMCP):
} }
@mcp.resource("nc://Notes/{note_id}") @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""" """Get user note using note id"""
ctx: Context = mcp.get_context() ctx: Context = mcp.get_context()
@@ -129,7 +129,7 @@ def configure_notes_tools(mcp: FastMCP):
"""Update an existing note's title, content, or category. """Update an existing note's title, content, or category.
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes. 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, If the note has been modified by someone else since you retrieved it,
the update will fail with a 412 error.""" the update will fail with a 412 error."""
logger.info("Updating note %s", note_id) 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() @mcp.tool()
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently""" """Delete a note permanently"""
+9 -6
View File
@@ -2,7 +2,6 @@
import logging import logging
from mcp import ClientSession from mcp import ClientSession
from mcp.shared.exceptions import McpError
import pytest import pytest
@@ -10,11 +9,15 @@ logger = logging.getLogger(__name__)
@pytest.mark.integration @pytest.mark.integration
async def test_missing_note_resource_error(nc_mcp_client: ClientSession): async def test_missing_note_tool_error(nc_mcp_client: ClientSession):
"""Test that accessing a non-existent note resource returns proper error.""" """Test that accessing a non-existent note via tool returns proper error."""
# Try to get a non-existent note via resource - should raise McpError with improved message # Try to get a non-existent note via tool - should return error response
with pytest.raises(McpError, match=r"Note 999999 not found"): response = await nc_mcp_client.call_tool("nc_notes_get_note", {"note_id": 999999})
await nc_mcp_client.read_resource("nc://Notes/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 @pytest.mark.integration
+7 -4
View File
@@ -68,7 +68,8 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
template_uris.append(template.uriTemplate) template_uris.append(template.uriTemplate)
# Verify expected resource templates # 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: for expected_template in expected_templates:
assert expected_template in template_uris, ( assert expected_template in template_uris, (
@@ -140,9 +141,11 @@ async def test_mcp_notes_crud_workflow(
# 3. Read note via MCP # 3. Read note via MCP
logger.info(f"Reading note via MCP: {note_id}") logger.info(f"Reading note via MCP: {note_id}")
read_result = await nc_mcp_client.read_resource(f"nc://Notes/{note_id}") read_result = await nc_mcp_client.call_tool(
assert len(read_result.contents) == 1, "Expected exactly one content item" "nc_notes_get_note", {"note_id": note_id}
read_note_data = json.loads(read_result.contents[0].text) )
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["title"] == test_title
assert read_note_data["content"] == test_content assert read_note_data["content"] == test_content