diff --git a/Dockerfile b/Dockerfile index 87dc3e7..ca4f444 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,4 +6,4 @@ COPY . . RUN uv sync --locked --no-dev -CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server.py:mcp"] +CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server/__init__.py:mcp"] diff --git a/docker-compose.yml b/docker-compose.yml index 567514c..94ff076 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,7 @@ services: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_USERNAME=admin - NEXTCLOUD_PASSWORD=admin + - FASTMCP_HOST=0.0.0.0 volumes: nextcloud: diff --git a/nextcloud_mcp_server/server.py b/nextcloud_mcp_server/server.py deleted file mode 100644 index eef5f12..0000000 --- a/nextcloud_mcp_server/server.py +++ /dev/null @@ -1,347 +0,0 @@ -# server.py -import logging -from nextcloud_mcp_server.config import setup_logging -from contextlib import asynccontextmanager -from dataclasses import dataclass -from mcp.server.fastmcp import FastMCP, Context -from collections.abc import AsyncIterator -from nextcloud_mcp_server.client import NextcloudClient - -setup_logging() - - -@dataclass -class AppContext: - client: NextcloudClient - - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context""" - # Initialize on startup - logging.info("Creating Nextcloud client") - client = NextcloudClient.from_env() - logging.info("Client initialization wait complete.") - try: - yield AppContext(client=client) - finally: - # Cleanup on shutdown - await client.close() - - -# Create an MCP server -mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan) - -logger = logging.getLogger(__name__) - - -@mcp.resource("nc://capabilities") -async def nc_get_capabilities(): - """Get the Nextcloud Host capabilities""" - ctx: Context = ( - mcp.get_context() - ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.capabilities() - - -@mcp.resource("notes://settings") -async def notes_get_settings(): - """Get the Notes App settings""" - ctx: Context = ( - mcp.get_context() - ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.notes.get_settings() - - -@mcp.tool() -async def nc_get_note(note_id: int, ctx: Context): - """Get user note using note id""" - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.notes.get_note(note_id) - - -@mcp.tool() -async def nc_notes_create_note(title: str, content: str, category: str, ctx: Context): - """Create a new note""" - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.notes.create_note( - title=title, - content=content, - category=category, - ) - - -@mcp.tool() -async def nc_notes_update_note( - note_id: int, - etag: str, - title: str | None, - content: str | None, - category: str | None, - ctx: Context, -): - logger.info("Updating note %s", note_id) - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.notes.update( - note_id=note_id, - etag=etag, - title=title, - content=content, - category=category, - ) - - -@mcp.tool() -async def nc_notes_append_content(note_id: int, content: str, ctx: Context): - """Append content to an existing note with a clear separator""" - logger.info("Appending content to note %s", note_id) - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.notes.append_content(note_id=note_id, content=content) - - -@mcp.tool() -async def nc_notes_search_notes(query: str, ctx: Context): - """Search notes by title or content, returning only id, title, and category.""" - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.notes_search_notes(query=query) - - -@mcp.tool() -async def nc_notes_delete_note(note_id: int, ctx: Context): - logger.info("Deleting note %s", note_id) - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.notes.delete_note(note_id) - - -# Tables tools -@mcp.tool() -async def nc_tables_list_tables(ctx: Context): - """List all tables available to the user""" - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.tables.list_tables() - - -@mcp.tool() -async def nc_tables_get_schema(table_id: int, ctx: Context): - """Get the schema/structure of a specific table including columns and views""" - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.tables.get_table_schema(table_id) - - -@mcp.tool() -async def nc_tables_read_table( - table_id: int, - ctx: Context, - limit: int | None = None, - offset: int | None = None, -): - """Read rows from a table with optional pagination""" - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.tables.get_table_rows(table_id, limit, offset) - - -@mcp.tool() -async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context): - """Insert a new row into a table. - - Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42} - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.tables.create_row(table_id, data) - - -@mcp.tool() -async def nc_tables_update_row(row_id: int, data: dict, ctx: Context): - """Update an existing row in a table. - - Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99} - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.tables.update_row(row_id, data) - - -@mcp.tool() -async def nc_tables_delete_row(row_id: int, ctx: Context): - """Delete a row from a table""" - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.tables.delete_row(row_id) - - -@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}") -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 - # Assuming a method get_note_attachment exists in the client - # This method should return the raw content and determine the mime type - content, mime_type = await client.webdav.get_note_attachment( - note_id=note_id, filename=attachment_filename - ) - return { - "contents": [ - { - # Use uppercase 'Notes' to match the decorator - "uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}", - "mimeType": mime_type, # Client needs to determine this - "data": content, # Return raw bytes/data - } - ] - } - - -# WebDAV file system tools -@mcp.tool() -async def nc_webdav_list_directory(ctx: Context, path: str = ""): - """List files and directories in the specified NextCloud path. - - Args: - path: Directory path to list (empty string for root directory) - - Returns: - List of items with metadata including name, path, is_directory, size, content_type, last_modified - - Examples: - # List root directory - await nc_webdav_list_directory("") - - # List a specific folder - await nc_webdav_list_directory("Documents/Projects") - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.webdav.list_directory(path) - - -@mcp.tool() -async def nc_webdav_read_file(path: str, ctx: Context): - """Read the content of a file from NextCloud. - - Args: - path: Full path to the file to read - - Returns: - Dict with path, content, content_type, size, and encoding (if binary) - Text files are decoded to UTF-8, binary files are base64 encoded - - Examples: - # Read a text file - result = await nc_webdav_read_file("Documents/readme.txt") - print(result['content']) # Decoded text content - - # Read a binary file - result = await nc_webdav_read_file("Images/photo.jpg") - print(result['encoding']) # 'base64' - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - content, content_type = await client.webdav.read_file(path) - - # For text files, decode content for easier viewing - if content_type and content_type.startswith("text/"): - try: - decoded_content = content.decode("utf-8") - return { - "path": path, - "content": decoded_content, - "content_type": content_type, - "size": len(content), - } - except UnicodeDecodeError: - pass - - # For binary files, return metadata and base64 encoded content - import base64 - - return { - "path": path, - "content": base64.b64encode(content).decode("ascii"), - "content_type": content_type, - "size": len(content), - "encoding": "base64", - } - - -@mcp.tool() -async def nc_webdav_write_file( - path: str, content: str, ctx: Context, content_type: str | None = None -): - """Write content to a file in NextCloud. - - Args: - path: Full path where to write the file - content: File content (text or base64 for binary) - content_type: MIME type (auto-detected if not provided, use 'type;base64' for binary) - - Returns: - Dict with status_code indicating success - - Examples: - # Write a text file - await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...") - - # Write binary data (base64 encoded) - await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64") - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - - # Handle base64 encoded content - if content_type and "base64" in content_type.lower(): - import base64 - - content_bytes = base64.b64decode(content) - content_type = content_type.replace(";base64", "") - else: - content_bytes = content.encode("utf-8") - - return await client.webdav.write_file(path, content_bytes, content_type) - - -@mcp.tool() -async def nc_webdav_create_directory(path: str, ctx: Context): - """Create a directory in NextCloud. - - Args: - path: Full path of the directory to create - - Returns: - Dict with status_code (201 for created, 405 if already exists) - - Examples: - # Create a single directory - await nc_webdav_create_directory("NewProject") - - # Create nested directories (parent must exist) - await nc_webdav_create_directory("Projects/MyApp/docs") - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.webdav.create_directory(path) - - -@mcp.tool() -async def nc_webdav_delete_resource(path: str, ctx: Context): - """Delete a file or directory in NextCloud. - - Args: - path: Full path of the file or directory to delete - - Returns: - Dict with status_code indicating result (404 if not found) - - Examples: - # Delete a file - await nc_webdav_delete_resource("old_document.txt") - - # Delete a directory (will delete all contents) - await nc_webdav_delete_resource("temp_folder") - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - return await client.webdav.delete_resource(path) - - -def run(): - mcp.run() - - -if __name__ == "__main__": - logger.info("Starting now") - mcp.run() diff --git a/nextcloud_mcp_server/server/__init__.py b/nextcloud_mcp_server/server/__init__.py new file mode 100644 index 0000000..f938dc2 --- /dev/null +++ b/nextcloud_mcp_server/server/__init__.py @@ -0,0 +1,62 @@ +import logging +from nextcloud_mcp_server.config import setup_logging +from contextlib import asynccontextmanager +from dataclasses import dataclass +from mcp.server.fastmcp import FastMCP, Context +from nextcloud_mcp_server.client import NextcloudClient +from collections.abc import AsyncIterator + +from nextcloud_mcp_server.server.notes import configure_notes_tools +from nextcloud_mcp_server.server.tables import configure_tables_tools +from nextcloud_mcp_server.server.webdav import configure_webdav_tools + +setup_logging() + + +@dataclass +class AppContext: + client: NextcloudClient + + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context""" + # Initialize on startup + logging.info("Creating Nextcloud client") + client = NextcloudClient.from_env() + logging.info("Client initialization wait complete.") + try: + yield AppContext(client=client) + finally: + # Cleanup on shutdown + await client.close() + + +# Create an MCP server +mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan) + +logger = logging.getLogger(__name__) + + +@mcp.resource("nc://capabilities") +async def nc_get_capabilities(): + """Get the Nextcloud Host capabilities""" + ctx: Context = ( + mcp.get_context() + ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.capabilities() + + +configure_notes_tools(mcp) +configure_tables_tools(mcp) +configure_webdav_tools(mcp) + + +def run(): + mcp.run() + + +# if __name__ == "__main__": +# logger.info("Starting now") +# mcp.run() diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py new file mode 100644 index 0000000..2b9f2d9 --- /dev/null +++ b/nextcloud_mcp_server/server/notes.py @@ -0,0 +1,93 @@ +import logging +from mcp.server.fastmcp import FastMCP, Context +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) + + +def configure_notes_tools(mcp: FastMCP): + @mcp.resource("notes://settings") + async def notes_get_settings(): + """Get the Notes App settings""" + ctx: Context = ( + mcp.get_context() + ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.notes.get_settings() + + @mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}") + 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 + # Assuming a method get_note_attachment exists in the client + # This method should return the raw content and determine the mime type + content, mime_type = await client.webdav.get_note_attachment( + note_id=note_id, filename=attachment_filename + ) + return { + "contents": [ + { + # Use uppercase 'Notes' to match the decorator + "uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}", + "mimeType": mime_type, # Client needs to determine this + "data": content, # Return raw bytes/data + } + ] + } + + @mcp.tool() + async def nc_get_note(note_id: int, ctx: Context): + """Get user note using note id""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.notes.get_note(note_id) + + @mcp.tool() + async def nc_notes_create_note( + title: str, content: str, category: str, ctx: Context + ): + """Create a new note""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.notes.create_note( + title=title, + content=content, + category=category, + ) + + @mcp.tool() + async def nc_notes_update_note( + note_id: int, + etag: str, + title: str | None, + content: str | None, + category: str | None, + ctx: Context, + ): + logger.info("Updating note %s", note_id) + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.notes.update( + note_id=note_id, + etag=etag, + title=title, + content=content, + category=category, + ) + + @mcp.tool() + async def nc_notes_append_content(note_id: int, content: str, ctx: Context): + """Append content to an existing note with a clear separator""" + logger.info("Appending content to note %s", note_id) + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.notes.append_content(note_id=note_id, content=content) + + @mcp.tool() + async def nc_notes_search_notes(query: str, ctx: Context): + """Search notes by title or content, returning only id, title, and category.""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.notes_search_notes(query=query) + + @mcp.tool() + async def nc_notes_delete_note(note_id: int, ctx: Context): + logger.info("Deleting note %s", note_id) + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.notes.delete_note(note_id) diff --git a/nextcloud_mcp_server/server/tables.py b/nextcloud_mcp_server/server/tables.py new file mode 100644 index 0000000..666d2e1 --- /dev/null +++ b/nextcloud_mcp_server/server/tables.py @@ -0,0 +1,55 @@ +import logging +from mcp.server.fastmcp import FastMCP, Context +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) + + +def configure_tables_tools(mcp: FastMCP): + # Tables tools + @mcp.tool() + async def nc_tables_list_tables(ctx: Context): + """List all tables available to the user""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.tables.list_tables() + + @mcp.tool() + async def nc_tables_get_schema(table_id: int, ctx: Context): + """Get the schema/structure of a specific table including columns and views""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.tables.get_table_schema(table_id) + + @mcp.tool() + async def nc_tables_read_table( + table_id: int, + ctx: Context, + limit: int | None = None, + offset: int | None = None, + ): + """Read rows from a table with optional pagination""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.tables.get_table_rows(table_id, limit, offset) + + @mcp.tool() + async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context): + """Insert a new row into a table. + + Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42} + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.tables.create_row(table_id, data) + + @mcp.tool() + async def nc_tables_update_row(row_id: int, data: dict, ctx: Context): + """Update an existing row in a table. + + Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99} + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.tables.update_row(row_id, data) + + @mcp.tool() + async def nc_tables_delete_row(row_id: int, ctx: Context): + """Delete a row from a table""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.tables.delete_row(row_id) diff --git a/nextcloud_mcp_server/server/webdav.py b/nextcloud_mcp_server/server/webdav.py new file mode 100644 index 0000000..9f98f1c --- /dev/null +++ b/nextcloud_mcp_server/server/webdav.py @@ -0,0 +1,149 @@ +import logging +from mcp.server.fastmcp import FastMCP, Context +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) + + +def configure_webdav_tools(mcp: FastMCP): + # WebDAV file system tools + @mcp.tool() + async def nc_webdav_list_directory(ctx: Context, path: str = ""): + """List files and directories in the specified NextCloud path. + + Args: + path: Directory path to list (empty string for root directory) + + Returns: + List of items with metadata including name, path, is_directory, size, content_type, last_modified + + Examples: + # List root directory + await nc_webdav_list_directory("") + + # List a specific folder + await nc_webdav_list_directory("Documents/Projects") + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.webdav.list_directory(path) + + @mcp.tool() + async def nc_webdav_read_file(path: str, ctx: Context): + """Read the content of a file from NextCloud. + + Args: + path: Full path to the file to read + + Returns: + Dict with path, content, content_type, size, and encoding (if binary) + Text files are decoded to UTF-8, binary files are base64 encoded + + Examples: + # Read a text file + result = await nc_webdav_read_file("Documents/readme.txt") + print(result['content']) # Decoded text content + + # Read a binary file + result = await nc_webdav_read_file("Images/photo.jpg") + print(result['encoding']) # 'base64' + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + content, content_type = await client.webdav.read_file(path) + + # For text files, decode content for easier viewing + if content_type and content_type.startswith("text/"): + try: + decoded_content = content.decode("utf-8") + return { + "path": path, + "content": decoded_content, + "content_type": content_type, + "size": len(content), + } + except UnicodeDecodeError: + pass + + # For binary files, return metadata and base64 encoded content + import base64 + + return { + "path": path, + "content": base64.b64encode(content).decode("ascii"), + "content_type": content_type, + "size": len(content), + "encoding": "base64", + } + + @mcp.tool() + async def nc_webdav_write_file( + path: str, content: str, ctx: Context, content_type: str | None = None + ): + """Write content to a file in NextCloud. + + Args: + path: Full path where to write the file + content: File content (text or base64 for binary) + content_type: MIME type (auto-detected if not provided, use 'type;base64' for binary) + + Returns: + Dict with status_code indicating success + + Examples: + # Write a text file + await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...") + + # Write binary data (base64 encoded) + await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64") + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + + # Handle base64 encoded content + if content_type and "base64" in content_type.lower(): + import base64 + + content_bytes = base64.b64decode(content) + content_type = content_type.replace(";base64", "") + else: + content_bytes = content.encode("utf-8") + + return await client.webdav.write_file(path, content_bytes, content_type) + + @mcp.tool() + async def nc_webdav_create_directory(path: str, ctx: Context): + """Create a directory in NextCloud. + + Args: + path: Full path of the directory to create + + Returns: + Dict with status_code (201 for created, 405 if already exists) + + Examples: + # Create a single directory + await nc_webdav_create_directory("NewProject") + + # Create nested directories (parent must exist) + await nc_webdav_create_directory("Projects/MyApp/docs") + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.webdav.create_directory(path) + + @mcp.tool() + async def nc_webdav_delete_resource(path: str, ctx: Context): + """Delete a file or directory in NextCloud. + + Args: + path: Full path of the file or directory to delete + + Returns: + Dict with status_code indicating result (404 if not found) + + Examples: + # Delete a file + await nc_webdav_delete_resource("old_document.txt") + + # Delete a directory (will delete all contents) + await nc_webdav_delete_resource("temp_folder") + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + return await client.webdav.delete_resource(path) diff --git a/pyproject.toml b/pyproject.toml index 78b549d..fc7ca8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,6 @@ dependencies = [ "pillow (>=11.2.1,<12.0.0)" ] -[project.scripts] -nc-mcp-server = "nextcloud_mcp_server.server:run" - [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_test_loop_scope = "session"