Merge branch 'master' into feature/introduce_files_parsing_with_unstructured_service_for_webdav_files_retrieval

This commit is contained in:
yuisheaven
2025-10-25 17:23:38 +02:00
committed by GitHub
60 changed files with 5347 additions and 2390 deletions
+83 -25
View File
@@ -11,6 +11,7 @@ from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
@@ -191,9 +192,20 @@ async def load_oauth_client_credentials(
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
# Get scopes from environment or use defaults
scopes = os.getenv(
"NEXTCLOUD_OIDC_SCOPES", "openid profile email nc:read nc:write"
# Default: all app-specific read/write scopes
default_scopes = (
"openid profile email "
"notes:read notes:write "
"calendar:read calendar:write "
"todo:read todo:write "
"contacts:read contacts:write "
"cookbook:read cookbook:write "
"deck:read deck:write "
"tables:read tables:write "
"files:read files:write "
"sharing:read sharing:write"
)
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
logger.info(f"Requesting OAuth scopes: {scopes}")
# Get token type from environment (Bearer or jwt)
@@ -213,7 +225,7 @@ async def load_oauth_client_credentials(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=storage_path,
client_name="Nextcloud MCP Server",
client_name=f"Nextcloud MCP Server ({token_type})",
redirect_uris=redirect_uris,
scopes=scopes,
token_type=token_type,
@@ -423,9 +435,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
if oauth_enabled:
logger.info("Configuring MCP server for OAuth mode")
# Asynchronously get the OAuth configuration
import asyncio
import anyio
_, token_verifier, auth_settings = asyncio.run(setup_oauth_config())
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_oauth,
@@ -474,7 +486,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
original_list_tools = mcp._tool_manager.list_tools
def list_tools_filtered():
"""List tools filtered by user's token scopes (JWT tokens only)."""
"""List tools filtered by user's token scopes (JWT and Bearer tokens)."""
# Get user's scopes from token using MCP SDK's contextvar
# This works for all request types including list_tools
user_scopes = get_access_token_scopes()
@@ -487,35 +499,36 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Get all tools
all_tools = original_list_tools()
# Only filter for JWT tokens (opaque tokens show all tools)
# JWT tokens have scopes embedded, so we can reliably filter
# Opaque tokens may not have accurate scope information from introspection
if is_jwt and user_scopes:
# Filter tools based on user's token scopes (both JWT and opaque tokens)
# JWT tokens have scopes embedded in payload
# Opaque tokens get scopes via introspection endpoint
# Claude Code now properly respects PRM endpoint for scope discovery
if user_scopes:
allowed_tools = [
tool
for tool in all_tools
if has_required_scopes(tool.fn, user_scopes)
]
token_type = "JWT" if is_jwt else "Bearer"
logger.info(
f"✂️ JWT scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
f"✂️ {token_type} scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
f"available for scopes: {user_scopes}"
)
else:
# Opaque token, BasicAuth mode, or no token - show all tools
# BasicAuth mode or no token - show all tools
allowed_tools = all_tools
reason = (
"opaque token (no filtering)"
if not is_jwt and user_scopes
else "no token/BasicAuth"
logger.info(
f"📋 Showing all {len(all_tools)} tools (no token/BasicAuth)"
)
logger.info(f"📋 Showing all {len(all_tools)} tools ({reason})")
# Return the Tool objects directly (they're already in the correct format)
return allowed_tools
# Replace the tool manager's list_tools method
mcp._tool_manager.list_tools = list_tools_filtered
logger.info("Dynamic tool filtering enabled for OAuth mode (JWT tokens only)")
logger.info(
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
)
if transport == "sse":
mcp_app = mcp.sse_app()
@@ -534,10 +547,13 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
if oauth_enabled:
def oauth_protected_resource_metadata(request):
"""RFC 8959 Protected Resource Metadata endpoint."""
"""RFC 9728 Protected Resource Metadata endpoint."""
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
# Append /mcp to match the actual resource path (FastMCP streamable-http endpoint)
resource_url = f"{mcp_server_url}/mcp"
# Use PUBLIC_ISSUER_URL for authorization server since external clients
# (like Claude) need the publicly accessible URL, not internal Docker URLs
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
@@ -547,14 +563,44 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
return JSONResponse(
{
"resource": mcp_server_url,
"scopes_supported": ["nc:read", "nc:write"],
"resource": resource_url,
"scopes_supported": [
"openid",
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"todo:read",
"todo:write",
"contacts:read",
"contacts:write",
"cookbook:read",
"cookbook:write",
"deck:read",
"deck:write",
"tables:read",
"tables:write",
"files:read",
"files:write",
"sharing:read",
"sharing:write",
],
"authorization_servers": [public_issuer_url],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"],
}
)
# Register PRM endpoint at both path-based and root locations per RFC 9728
# Path-based discovery: /.well-known/oauth-protected-resource{path}
routes.append(
Route(
"/.well-known/oauth-protected-resource/mcp",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
# Root discovery (fallback): /.well-known/oauth-protected-resource
routes.append(
Route(
"/.well-known/oauth-protected-resource",
@@ -562,11 +608,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
methods=["GET"],
)
)
logger.info("Protected Resource Metadata (PRM) endpoint enabled")
logger.info(
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
)
routes.append(Mount("/", app=mcp_app))
app = Starlette(routes=routes, lifespan=lifespan)
# Add CORS middleware to allow browser-based clients like MCP Inspector
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for development
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
# Add exception handler for scope challenges (OAuth mode only)
if oauth_enabled:
@@ -584,7 +642,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
"WWW-Authenticate": (
f'Bearer error="insufficient_scope", '
f'scope="{scope_str}", '
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource"'
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource/mcp"'
)
},
content={
@@ -677,7 +735,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
@click.option(
"--oauth-scopes",
envvar="NEXTCLOUD_OIDC_SCOPES",
default="openid profile email nc:read nc:write",
default="openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
show_default=True,
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
)
@@ -741,7 +799,7 @@ def run(
# OAuth mode with custom scopes and JWT tokens
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
--oauth-scopes="openid nc:read" --oauth-token-type=jwt
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
# OAuth with public issuer URL (for Docker/proxy setups)
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
@@ -8,13 +8,14 @@ import time
from pathlib import Path
from typing import Any
import anyio
import httpx
logger = logging.getLogger(__name__)
class ClientInfo:
"""Client registration information."""
"""Client registration information with RFC 7592 support."""
def __init__(
self,
@@ -23,12 +24,16 @@ class ClientInfo:
client_id_issued_at: int,
client_secret_expires_at: int,
redirect_uris: list[str],
registration_access_token: str | None = None,
registration_client_uri: str | None = None,
):
self.client_id = client_id
self.client_secret = client_secret
self.client_id_issued_at = client_id_issued_at
self.client_secret_expires_at = client_secret_expires_at
self.redirect_uris = redirect_uris
self.registration_access_token = registration_access_token
self.registration_client_uri = registration_client_uri
@property
def is_expired(self) -> bool:
@@ -42,13 +47,18 @@ class ClientInfo:
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for storage."""
return {
result = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"client_id_issued_at": self.client_id_issued_at,
"client_secret_expires_at": self.client_secret_expires_at,
"redirect_uris": self.redirect_uris,
}
if self.registration_access_token:
result["registration_access_token"] = self.registration_access_token
if self.registration_client_uri:
result["registration_client_uri"] = self.registration_client_uri
return result
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
@@ -59,6 +69,8 @@ class ClientInfo:
client_id_issued_at=data["client_id_issued_at"],
client_secret_expires_at=data["client_secret_expires_at"],
redirect_uris=data["redirect_uris"],
registration_access_token=data.get("registration_access_token"),
registration_client_uri=data.get("registration_client_uri"),
)
@@ -125,6 +137,16 @@ async def register_client(
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
)
else:
logger.warning("RFC 7592 fields missing - client deletion may not work")
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
@@ -135,6 +157,8 @@ async def register_client(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get("registration_access_token"),
registration_client_uri=client_info.get("registration_client_uri"),
)
except httpx.HTTPStatusError as e:
@@ -212,6 +236,132 @@ def save_client_to_file(client_info: ClientInfo, storage_path: Path):
raise
async def delete_client(
nextcloud_url: str,
client_id: str,
registration_access_token: str | None = None,
client_secret: str | None = None,
registration_client_uri: str | None = None,
max_retries: int = 3,
) -> bool:
"""
Delete a dynamically registered OAuth client using RFC 7592.
This implements RFC 7592 Section 2.3 (Client Delete Request).
Prefers Bearer token authentication (RFC 7592 standard) but falls back
to HTTP Basic Auth if registration_access_token is not available.
Args:
nextcloud_url: Base URL of the Nextcloud instance
client_id: Client identifier to delete
registration_access_token: RFC 7592 registration access token (preferred)
client_secret: Client secret for fallback HTTP Basic Auth
registration_client_uri: RFC 7592 client configuration URI (optional)
max_retries: Maximum number of retries for 429 responses (default: 3)
Returns:
True if deletion successful, False otherwise
Note:
RFC 7592 deletion endpoint: {registration_client_uri} or {nextcloud_url}/apps/oidc/register/{client_id}
Authentication methods (in order of preference):
1. Bearer token: Authorization: Bearer {registration_access_token} (RFC 7592 standard)
2. HTTP Basic Auth: client_id as username, client_secret as password (fallback)
"""
# Determine deletion endpoint
if registration_client_uri:
deletion_endpoint = registration_client_uri
else:
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as http_client:
for attempt in range(max_retries):
try:
# Prefer RFC 7592 Bearer token authentication
if registration_access_token:
logger.debug("Using RFC 7592 Bearer token authentication")
response = await http_client.delete(
deletion_endpoint,
headers={
"Authorization": f"Bearer {registration_access_token}"
},
)
elif client_secret:
logger.debug(
"Falling back to HTTP Basic Auth (registration_access_token not available)"
)
response = await http_client.delete(
deletion_endpoint,
auth=(client_id, client_secret),
)
else:
logger.error(
"Cannot delete client: no registration_access_token or client_secret provided"
)
return False
# RFC 7592: Successful deletion returns 204 No Content
if response.status_code == 204:
logger.info(
f"Successfully deleted OAuth client: {client_id[:16]}..."
)
return True
elif response.status_code == 429:
# Rate limited - retry with exponential backoff
if attempt < max_retries - 1:
retry_after = int(response.headers.get("Retry-After", 2))
wait_time = min(
retry_after, 2**attempt
) # Exponential backoff, max from header
logger.warning(
f"Rate limited (429) deleting client {client_id[:16]}..., "
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
)
await anyio.sleep(wait_time)
continue
else:
logger.error(
f"Failed to delete client {client_id[:16]}... after {max_retries} attempts: Rate limited (429)"
)
return False
elif response.status_code == 401:
logger.error(
f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)"
)
return False
elif response.status_code == 403:
logger.error(
f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)"
)
return False
else:
logger.error(
f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}"
)
logger.debug(f"Response: {response.text}")
return False
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}"
)
logger.debug(f"Response: {e.response.text}")
return False
except Exception as e:
logger.error(
f"Unexpected error deleting client {client_id[:16]}...: {e}"
)
return False
# Should not reach here, but return False if we do
return False
async def load_or_register_client(
nextcloud_url: str,
registration_endpoint: str,
@@ -46,7 +46,7 @@ def require_scopes(*required_scopes: str):
users who lack the necessary scopes.
Args:
*required_scopes: Variable number of scope strings required (e.g., "nc:read", "nc:write")
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
Returns:
Decorated function that checks scopes before execution
@@ -54,15 +54,15 @@ def require_scopes(*required_scopes: str):
Example:
```python
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("notes:read")
async def nc_notes_get_note(ctx: Context, note_id: int):
# This tool requires the nc:read scope
# This tool requires the notes:read scope
...
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("notes:write")
async def nc_notes_create_note(ctx: Context, ...):
# This tool requires the nc:write scope
# This tool requires the notes:write scope
...
```
@@ -173,7 +173,7 @@ def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
Example:
```python
async def my_tool(ctx: Context):
has_scopes, missing = check_scopes(ctx, "nc:read", "nc:write")
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
if not has_scopes:
# Handle missing scopes
...
@@ -203,11 +203,11 @@ def get_required_scopes(func: Callable) -> list[str]:
Example:
```python
@require_scopes("nc:read", "nc:write")
@require_scopes("notes:read", "notes:write")
async def my_tool():
pass
scopes = get_required_scopes(my_tool) # ["nc:read", "nc:write"]
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
```
"""
return getattr(func, "_required_scopes", [])
@@ -253,14 +253,14 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
Example:
```python
@require_scopes("nc:write")
@require_scopes("notes:write")
async def create_note():
pass
user_scopes = {"nc:read", "nc:write"}
user_scopes = {"notes:read", "notes:write"}
can_see = has_required_scopes(create_note, user_scopes) # True
limited_user_scopes = {"nc:read"}
limited_user_scopes = {"notes:read"}
can_see = has_required_scopes(create_note, limited_user_scopes) # False
```
"""
+29 -5
View File
@@ -309,11 +309,18 @@ class NextcloudTokenVerifier(TokenVerifier):
)
elif response.status_code in (400, 401, 403):
logger.info(f"Token introspection failed: HTTP {response.status_code}")
logger.warning(
f"Token introspection failed: HTTP {response.status_code}. "
f"This may indicate: (1) Client credentials mismatch - trying to introspect "
f"token issued to different OAuth client, (2) Expired client credentials, "
f"(3) Invalid token. Will fall back to userinfo endpoint. "
f"Response: {response.text[:200] if response.text else 'empty'}"
)
return None
else:
logger.warning(
f"Unexpected response from introspection: {response.status_code}"
f"Unexpected response from introspection: {response.status_code}. "
f"Response: {response.text[:200] if response.text else 'empty'}"
)
return None
@@ -420,15 +427,31 @@ class NextcloudTokenVerifier(TokenVerifier):
"""
Extract scopes from userinfo response.
Since the userinfo response doesn't include the original scopes,
we infer them from the claims present in the response.
First attempts to read actual scopes from the 'scope' field (RFC 8693).
If not present, infers scopes from the claims present in the response.
Args:
userinfo: The userinfo response dictionary
Returns:
List of inferred scopes
List of scopes (actual or inferred)
"""
# Try to get actual scopes from userinfo response (if OIDC provider includes it)
scope_string = userinfo.get("scope")
if scope_string:
scopes = scope_string.split() if isinstance(scope_string, str) else []
if scopes:
logger.debug(
f"Using actual scopes from userinfo: {scopes} (scope field present)"
)
return scopes
# Fallback: Infer scopes from claims present in response
# This maintains backward compatibility with OIDC providers that don't
# include the scope field in userinfo responses
logger.debug(
"No scope field in userinfo response, inferring scopes from claims"
)
scopes = ["openid"] # Always present
if "email" in userinfo:
@@ -445,6 +468,7 @@ class NextcloudTokenVerifier(TokenVerifier):
if "groups" in userinfo:
scopes.append("groups")
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
return scopes
def clear_cache(self):
+4 -8
View File
@@ -2,7 +2,7 @@
from typing import List, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from .base import BaseResponse, IdResponse, StatusResponse
@@ -38,8 +38,7 @@ class Nutrition(BaseModel):
None, description="Unsaturated fat (e.g., '40 g')"
)
class Config:
populate_by_name = True
model_config = ConfigDict(populate_by_name=True)
class RecipeStub(BaseModel):
@@ -91,9 +90,7 @@ class Recipe(BaseModel):
)
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
class Config:
populate_by_name = True
extra = "allow" # Allow additional schema.org fields
model_config = ConfigDict(populate_by_name=True, extra="allow")
class Category(BaseModel):
@@ -127,8 +124,7 @@ class VisibleInfoBlocks(BaseModel):
)
tools: Optional[bool] = Field(None, description="Show tools list")
class Config:
populate_by_name = True
model_config = ConfigDict(populate_by_name=True)
class CookbookConfig(BaseModel):
+1 -1
View File
@@ -40,7 +40,7 @@ class DirectoryListing(BaseResponse):
"""Response model for directory listings."""
path: str = Field(description="Directory path")
items: List[FileInfo] = Field(description="Files and directories in the path")
files: List[FileInfo] = Field(description="Files and directories in the path")
total_count: int = Field(description="Total number of items")
directories_count: int = Field(description="Number of directories")
files_count: int = Field(description="Number of files")
+16 -16
View File
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("calendar:read")
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
"""List all available calendars for the user"""
client = get_client(ctx)
@@ -29,7 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("calendar:write")
async def nc_calendar_create_event(
calendar_name: str,
title: str,
@@ -105,7 +105,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("calendar:read")
async def nc_calendar_list_events(
calendar_name: str,
ctx: Context,
@@ -207,7 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
return events
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("calendar:read")
async def nc_calendar_get_event(
calendar_name: str,
event_uid: str,
@@ -219,7 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
return event_data
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("calendar:write")
async def nc_calendar_update_event(
calendar_name: str,
event_uid: str,
@@ -292,7 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("calendar:write")
async def nc_calendar_delete_event(
calendar_name: str,
event_uid: str,
@@ -303,7 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.delete_event(calendar_name, event_uid)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("calendar:write")
async def nc_calendar_create_meeting(
title: str,
date: str,
@@ -369,7 +369,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("calendar:read")
async def nc_calendar_get_upcoming_events(
ctx: Context,
calendar_name: str = "", # Empty = all calendars
@@ -419,7 +419,7 @@ def configure_calendar_tools(mcp: FastMCP):
return all_events[:limit]
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("calendar:read")
async def nc_calendar_find_availability(
duration_minutes: int,
ctx: Context,
@@ -499,7 +499,7 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("calendar:write")
async def nc_calendar_bulk_operations(
operation: str, # "update", "delete", "move"
ctx: Context,
@@ -748,7 +748,7 @@ def configure_calendar_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("calendar:write")
async def nc_calendar_manage_calendar(
action: str, # "create", "delete", "update", "list"
ctx: Context,
@@ -817,7 +817,7 @@ def configure_calendar_tools(mcp: FastMCP):
# ============= Todo/Task Tools =============
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("todo:read", "calendar:read")
async def nc_calendar_list_todos(
calendar_name: str,
ctx: Context,
@@ -862,7 +862,7 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("todo:write", "calendar:read")
async def nc_calendar_create_todo(
calendar_name: str,
summary: str,
@@ -905,7 +905,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_todo(calendar_name, todo_data)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("todo:write", "calendar:read")
async def nc_calendar_update_todo(
calendar_name: str,
todo_uid: str,
@@ -965,7 +965,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("todo:write", "calendar:read")
async def nc_calendar_delete_todo(
calendar_name: str,
todo_uid: str,
@@ -985,7 +985,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.delete_todo(calendar_name, todo_uid)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("todo:read", "calendar:read")
async def nc_calendar_search_todos(
ctx: Context,
status: Optional[str] = None,
+7 -7
View File
@@ -11,21 +11,21 @@ logger = logging.getLogger(__name__)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("contacts:read")
async def nc_contacts_list_addressbooks(ctx: Context):
"""List all addressbooks for the user."""
client = get_client(ctx)
return await client.contacts.list_addressbooks()
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("contacts:read")
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all contacts in the specified addressbook."""
client = get_client(ctx)
return await client.contacts.list_contacts(addressbook=addressbook)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("contacts:write")
async def nc_contacts_create_addressbook(
ctx: Context, *, name: str, display_name: str
):
@@ -41,14 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("contacts:write")
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
"""Delete an addressbook."""
client = get_client(ctx)
return await client.contacts.delete_addressbook(name=name)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("contacts:write")
async def nc_contacts_create_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
):
@@ -65,14 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("contacts:write")
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
"""Delete a contact."""
client = get_client(ctx)
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("contacts:write")
async def nc_contacts_update_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
):
+13 -13
View File
@@ -71,7 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("cookbook:write")
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
"""Import a recipe from a URL using schema.org metadata.
@@ -128,7 +128,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("cookbook:read")
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
"""Get all recipes in the database"""
client = get_client(ctx)
@@ -153,7 +153,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("cookbook:read")
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
"""Get a specific recipe by its ID"""
client = get_client(ctx)
@@ -178,7 +178,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("cookbook:write")
async def nc_cookbook_create_recipe(
name: str,
description: str | None = None,
@@ -257,7 +257,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("cookbook:write")
async def nc_cookbook_update_recipe(
recipe_id: int,
name: str | None = None,
@@ -346,7 +346,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("cookbook:write")
async def nc_cookbook_delete_recipe(
recipe_id: int, ctx: Context
) -> DeleteRecipeResponse:
@@ -381,7 +381,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("cookbook:read")
async def nc_cookbook_search_recipes(
query: str, ctx: Context
) -> SearchRecipesResponse:
@@ -417,7 +417,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("cookbook:read")
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
"""Get all known categories.
@@ -444,7 +444,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("cookbook:read")
async def nc_cookbook_get_recipes_in_category(
category: str, ctx: Context
) -> ListRecipesResponse:
@@ -480,7 +480,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("cookbook:read")
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
"""Get all known keywords/tags"""
client = get_client(ctx)
@@ -505,7 +505,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("cookbook:read")
async def nc_cookbook_get_recipes_with_keywords(
keywords: list[str], ctx: Context
) -> ListRecipesResponse:
@@ -539,7 +539,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("cookbook:write")
async def nc_cookbook_set_config(
folder: str | None = None,
update_interval: int | None = None,
@@ -582,7 +582,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("cookbook:write")
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
"""Trigger a rescan of all recipes into the caching database.
+25 -25
View File
@@ -117,7 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
# Read Tools (converted from resources)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("deck:read")
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
"""Get all Nextcloud Deck boards"""
client = get_client(ctx)
@@ -125,7 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
return boards
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("deck:read")
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
"""Get details of a specific Nextcloud Deck board"""
client = get_client(ctx)
@@ -133,7 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
return board
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("deck:read")
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
"""Get all stacks in a Nextcloud Deck board"""
client = get_client(ctx)
@@ -141,7 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
return stacks
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("deck:read")
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
"""Get details of a specific Nextcloud Deck stack"""
client = get_client(ctx)
@@ -149,7 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
return stack
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("deck:read")
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]:
@@ -161,7 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
return []
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("deck:read")
async def deck_get_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> DeckCard:
@@ -171,7 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
return card
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("deck:read")
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
"""Get all labels in a Nextcloud Deck board"""
client = get_client(ctx)
@@ -179,7 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
return board.labels
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("deck:read")
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
"""Get details of a specific Nextcloud Deck label"""
client = get_client(ctx)
@@ -189,7 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
# Create/Update/Delete Tools
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_create_board(
ctx: Context, title: str, color: str
) -> CreateBoardResponse:
@@ -206,7 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
# Stack Tools
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_create_stack(
ctx: Context, board_id: int, title: str, order: int
) -> CreateStackResponse:
@@ -222,7 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_update_stack(
ctx: Context,
board_id: int,
@@ -248,7 +248,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_delete_stack(
ctx: Context, board_id: int, stack_id: int
) -> StackOperationResponse:
@@ -269,7 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card Tools
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_create_card(
ctx: Context,
board_id: int,
@@ -303,7 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_update_card(
ctx: Context,
board_id: int,
@@ -356,7 +356,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_delete_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -378,7 +378,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_archive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -400,7 +400,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_unarchive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -422,7 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_reorder_card(
ctx: Context,
board_id: int,
@@ -454,7 +454,7 @@ def configure_deck_tools(mcp: FastMCP):
# Label Tools
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_create_label(
ctx: Context, board_id: int, title: str, color: str
) -> CreateLabelResponse:
@@ -470,7 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_update_label(
ctx: Context,
board_id: int,
@@ -496,7 +496,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_delete_label(
ctx: Context, board_id: int, label_id: int
) -> LabelOperationResponse:
@@ -517,7 +517,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card-Label Assignment Tools
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_assign_label_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
@@ -540,7 +540,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_remove_label_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
@@ -564,7 +564,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card-User Assignment Tools
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_assign_user_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
@@ -587,7 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("deck:write")
async def deck_unassign_user_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
+11 -11
View File
@@ -85,11 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("notes:write")
async def nc_notes_create_note(
title: str, content: str, category: str, ctx: Context
) -> CreateNoteResponse:
"""Create a new note (requires nc:write scope)"""
"""Create a new note (requires notes:write scope)"""
client = get_client(ctx)
try:
note_data = await client.notes.create_note(
@@ -131,7 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("notes:write")
async def nc_notes_update_note(
note_id: int,
etag: str,
@@ -140,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
category: str | None,
ctx: Context,
) -> UpdateNoteResponse:
"""Update an existing note's title, content, or category (requires nc:write scope).
"""Update an existing note's title, content, or category (requires notes:write scope).
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.
@@ -196,7 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("notes:write")
async def nc_notes_append_content(
note_id: int, content: str, ctx: Context
) -> AppendContentResponse:
@@ -246,9 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("notes:read")
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content, returning only id, title, and category (requires nc:read scope)."""
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
client = get_client(ctx)
try:
search_results_raw = await client.notes_search_notes(query=query)
@@ -292,9 +292,9 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("notes:read")
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
"""Get a specific note by its ID (requires nc:read scope)"""
"""Get a specific note by its ID (requires notes:read scope)"""
client = get_client(ctx)
try:
note_data = await client.notes.get_note(note_id)
@@ -321,7 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("notes:read")
async def nc_notes_get_attachment(
note_id: int, attachment_filename: str, ctx: Context
) -> dict[str, str]:
@@ -367,7 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("notes:write")
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently"""
logger.info("Deleting note %s", note_id)
+5 -5
View File
@@ -16,7 +16,7 @@ def configure_sharing_tools(mcp: FastMCP):
"""
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("sharing:write")
async def nc_share_create(
path: str,
share_with: str,
@@ -55,7 +55,7 @@ def configure_sharing_tools(mcp: FastMCP):
return json.dumps(share_data, indent=2)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("sharing:write")
async def nc_share_delete(share_id: int, ctx: Context) -> str:
"""Delete a share by its ID.
@@ -74,7 +74,7 @@ def configure_sharing_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("sharing:write")
async def nc_share_get(share_id: int, ctx: Context) -> str:
"""Get information about a specific share.
@@ -92,7 +92,7 @@ def configure_sharing_tools(mcp: FastMCP):
return json.dumps(share_data, indent=2)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("sharing:write")
async def nc_share_list(
ctx: Context, path: str | None = None, shared_with_me: bool = False
) -> str:
@@ -113,7 +113,7 @@ def configure_sharing_tools(mcp: FastMCP):
return json.dumps(shares, indent=2)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("sharing:write")
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
"""Update the permissions of an existing share.
+6 -6
View File
@@ -11,21 +11,21 @@ logger = logging.getLogger(__name__)
def configure_tables_tools(mcp: FastMCP):
# Tables tools
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("tables:read")
async def nc_tables_list_tables(ctx: Context):
"""List all tables available to the user"""
client = get_client(ctx)
return await client.tables.list_tables()
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("tables:read")
async def nc_tables_get_schema(table_id: int, ctx: Context):
"""Get the schema/structure of a specific table including columns and views"""
client = get_client(ctx)
return await client.tables.get_table_schema(table_id)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("tables:read")
async def nc_tables_read_table(
table_id: int,
ctx: Context,
@@ -37,7 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.get_table_rows(table_id, limit, offset)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("tables:write")
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
"""Insert a new row into a table.
@@ -47,7 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.create_row(table_id, data)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("tables:write")
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
"""Update an existing row in a table.
@@ -57,7 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.update_row(row_id, data)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("tables:write")
async def nc_tables_delete_row(row_id: int, ctx: Context):
"""Delete a row from a table"""
client = get_client(ctx)
+34 -15
View File
@@ -10,7 +10,7 @@ from nextcloud_mcp_server.utils.document_parser import (
from nextcloud_mcp_server.config import is_unstructured_parsing_enabled
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models import FileInfo, SearchFilesResponse
from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse
logger = logging.getLogger(__name__)
@@ -18,21 +18,40 @@ logger = logging.getLogger(__name__)
def configure_webdav_tools(mcp: FastMCP):
# WebDAV file system tools
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
@require_scopes("files:read")
async def nc_webdav_list_directory(
ctx: Context, path: str = ""
) -> DirectoryListing:
"""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
DirectoryListing with files, total_count, directories_count, files_count, and total_size
"""
client = get_client(ctx)
return await client.webdav.list_directory(path)
items = await client.webdav.list_directory(path)
# Convert to FileInfo models
file_infos = [FileInfo(**item) for item in items]
# Calculate metadata
directories_count = sum(1 for f in file_infos if f.is_directory)
files_count = sum(1 for f in file_infos if not f.is_directory)
total_size = sum(f.size or 0 for f in file_infos if not f.is_directory)
return DirectoryListing(
path=path,
files=file_infos,
total_count=len(file_infos),
directories_count=directories_count,
files_count=files_count,
total_size=total_size,
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("files:read")
async def nc_webdav_read_file(path: str, ctx: Context):
"""Read the content of a file from NextCloud.
@@ -108,7 +127,7 @@ def configure_webdav_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("files:write")
async def nc_webdav_write_file(
path: str, content: str, ctx: Context, content_type: str | None = None
):
@@ -136,7 +155,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.write_file(path, content_bytes, content_type)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("files:write")
async def nc_webdav_create_directory(path: str, ctx: Context):
"""Create a directory in NextCloud.
@@ -150,7 +169,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.create_directory(path)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("files:write")
async def nc_webdav_delete_resource(path: str, ctx: Context):
"""Delete a file or directory in NextCloud.
@@ -164,7 +183,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.delete_resource(path)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("files:write")
async def nc_webdav_move_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -184,7 +203,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
@require_scopes("files:write")
async def nc_webdav_copy_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -204,7 +223,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("files:read")
async def nc_webdav_search_files(
ctx: Context,
scope: str = "",
@@ -320,7 +339,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("files:read")
async def nc_webdav_find_by_name(
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
@@ -347,7 +366,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("files:read")
async def nc_webdav_find_by_type(
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
@@ -374,7 +393,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
@require_scopes("files:read")
async def nc_webdav_list_favorites(
ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse: