chore: Move tools into separate modules
This commit is contained in:
+1
-1
@@ -6,4 +6,4 @@ COPY . .
|
|||||||
|
|
||||||
RUN uv sync --locked --no-dev
|
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"]
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ services:
|
|||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_USERNAME=admin
|
- NEXTCLOUD_USERNAME=admin
|
||||||
- NEXTCLOUD_PASSWORD=admin
|
- NEXTCLOUD_PASSWORD=admin
|
||||||
|
- FASTMCP_HOST=0.0.0.0
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -13,9 +13,6 @@ dependencies = [
|
|||||||
"pillow (>=11.2.1,<12.0.0)"
|
"pillow (>=11.2.1,<12.0.0)"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
nc-mcp-server = "nextcloud_mcp_server.server:run"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_test_loop_scope = "session"
|
asyncio_default_test_loop_scope = "session"
|
||||||
|
|||||||
Reference in New Issue
Block a user