diff --git a/scripts/add_scope_decorators.py b/scripts/add_scope_decorators.py new file mode 100644 index 0000000..fd5f4c6 --- /dev/null +++ b/scripts/add_scope_decorators.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Script to automatically add @require_scopes decorators to MCP tools. + +This script parses server module files and adds appropriate scope decorators +based on the operation type (read vs write). + +Usage: + python scripts/add_scope_decorators.py [--dry-run] [--file FILE] +""" + +import argparse +import ast +import re +from pathlib import Path +from typing import List, Tuple + +# Operation patterns for classification +READ_PATTERNS = [ + r".*_get_.*", + r".*_get$", + r".*_list_.*", + r".*_list$", + r".*_search_.*", + r".*_search$", + r".*_read_.*", + r".*_read$", + r".*_find_.*", + r".*_find$", + r".*_fetch_.*", + r".*_fetch$", + r".*_retrieve_.*", + r".*_retrieve$", +] + +WRITE_PATTERNS = [ + r".*_create_.*", + r".*_create$", + r".*_update_.*", + r".*_update$", + r".*_delete_.*", + r".*_delete$", + r".*_append_.*", + r".*_append$", + r".*_modify_.*", + r".*_modify$", + r".*_set_.*", + r".*_set$", + r".*_add_.*", + r".*_add$", + r".*_remove_.*", + r".*_remove$", + r".*_edit_.*", + r".*_edit$", + r".*_move_.*", + r".*_move$", + r".*_copy_.*", + r".*_copy$", + r".*_upload_.*", + r".*_upload$", + r".*_download_.*", + r".*_download$", + r".*_share_.*", + r".*_share$", + r".*_unshare_.*", + r".*_unshare$", + r".*_bulk_.*", # Bulk operations are typically writes +] + + +def classify_operation(func_name: str) -> str | None: + """Classify a function as read or write operation. + + Args: + func_name: Function name to classify + + Returns: + "nc:read", "nc:write", or None if cannot classify + """ + # Check write patterns first (more specific) + for pattern in WRITE_PATTERNS: + if re.match(pattern, func_name): + return "nc:write" + + # Check read patterns + for pattern in READ_PATTERNS: + if re.match(pattern, func_name): + return "nc:read" + + return None + + +def has_scope_decorator(decorators: List[ast.expr]) -> bool: + """Check if function already has @require_scopes decorator.""" + for decorator in decorators: + if isinstance(decorator, ast.Call): + if ( + isinstance(decorator.func, ast.Name) + and decorator.func.id == "require_scopes" + ): + return True + elif isinstance(decorator, ast.Name) and decorator.name == "require_scopes": + return True + return False + + +def has_mcp_tool_decorator(decorators: List[ast.expr]) -> bool: + """Check if function has @mcp.tool() decorator.""" + for decorator in decorators: + if isinstance(decorator, ast.Call): + if isinstance(decorator.func, ast.Attribute): + if decorator.func.attr == "tool": + return True + return False + + +def find_tools_needing_decorators( + file_path: Path, verbose: bool = False +) -> List[Tuple[str, int, str]]: + """Find all tools that need scope decorators. + + Returns: + List of (function_name, line_number, required_scope) + """ + with open(file_path) as f: + content = f.read() + + try: + tree = ast.parse(content) + except SyntaxError as e: + print(f" āš ļø Syntax error in {file_path}: {e}") + return [] + + tools_to_update = [] + total_functions = 0 + mcp_tools = 0 + already_has_scope = 0 + cannot_classify = 0 + + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + total_functions += 1 + + if verbose and node.decorator_list: + decorators_str = [ + ast.unparse(d) if hasattr(ast, "unparse") else str(d) + for d in node.decorator_list + ] + print(f" Function {node.name} has decorators: {decorators_str}") + + # Check if it's an MCP tool + if not has_mcp_tool_decorator(node.decorator_list): + continue + + mcp_tools += 1 + + # Check if it already has scope decorator + if has_scope_decorator(node.decorator_list): + already_has_scope += 1 + continue + + # Classify operation + scope = classify_operation(node.name) + if scope: + tools_to_update.append((node.name, node.lineno, scope)) + else: + cannot_classify += 1 + if verbose: + print(f" āš ļø Cannot classify: {node.name}") + + if verbose: + print( + f" Debug: total_functions={total_functions}, mcp_tools={mcp_tools}, already_has_scope={already_has_scope}, cannot_classify={cannot_classify}" + ) + + return tools_to_update + + +def add_decorator_to_file( + file_path: Path, dry_run: bool = False, verbose: bool = False +) -> int: + """Add @require_scopes decorators to tools in a file. + + Returns: + Number of decorators added + """ + tools = find_tools_needing_decorators(file_path, verbose=verbose) + + if not tools: + return 0 + + print(f"\nšŸ“ {file_path.relative_to(Path.cwd())}") + + with open(file_path) as f: + lines = f.readlines() + + # Check if require_scopes is already imported + has_import = False + import_line_idx = None + for i, line in enumerate(lines): + if "from nextcloud_mcp_server.auth import" in line and "require_scopes" in line: + has_import = True + break + elif "from nextcloud_mcp_server.auth import" in line: + import_line_idx = i + + # Add import if needed + if not has_import: + if import_line_idx is not None: + # Add require_scopes to existing import + old_line = lines[import_line_idx] + if "(" in old_line: + # Multi-line import + print( + " āš ļø Multi-line import detected, please add manually: from nextcloud_mcp_server.auth import require_scopes" + ) + else: + # Single line import - add require_scopes + lines[import_line_idx] = ( + old_line.rstrip().rstrip(")").rstrip() + ", require_scopes)\n" + ) + print(" āœ“ Added require_scopes to import") + else: + # No auth import exists, add new import + # Find first import line + for i, line in enumerate(lines): + if line.startswith("from nextcloud_mcp_server"): + lines.insert( + i, "from nextcloud_mcp_server.auth import require_scopes\n" + ) + print( + " āœ“ Added import: from nextcloud_mcp_server.auth import require_scopes" + ) + break + + # Add decorators to tools (in reverse order to preserve line numbers) + for func_name, line_num, scope in reversed(tools): + # Find the @mcp.tool() decorator line + for i in range(line_num - 1, max(0, line_num - 10), -1): + if "@mcp.tool()" in lines[i]: + # Get indentation from @mcp.tool() line + indent = len(lines[i]) - len(lines[i].lstrip()) + decorator_line = " " * indent + f'@require_scopes("{scope}")\n' + lines.insert(i + 1, decorator_line) + print(f' āœ“ {func_name}:{line_num} → @require_scopes("{scope}")') + break + + if not dry_run: + with open(file_path, "w") as f: + f.writelines(lines) + print(" šŸ’¾ Saved changes") + else: + print(" šŸ” DRY RUN - no changes written") + + return len(tools) + + +def main(): + parser = argparse.ArgumentParser( + description="Add @require_scopes decorators to MCP tools" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be changed without modifying files", + ) + parser.add_argument( + "--file", + type=Path, + help="Process a single file instead of all server modules", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show debug information", + ) + args = parser.parse_args() + + server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server" + + if args.file: + files = [args.file] + else: + files = sorted(server_dir.glob("*.py")) + files = [f for f in files if f.name != "__init__.py"] + + print("šŸ” Scanning for tools needing scope decorators...") + print( + f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}" + ) + + total_added = 0 + for file_path in files: + added = add_decorator_to_file( + file_path, dry_run=args.dry_run, verbose=args.verbose + ) + total_added += added + + print(f"\n{'šŸ“Š Summary (dry run)' if args.dry_run else 'āœ… Complete'}") + print(f" Total decorators added: {total_added}") + + if args.dry_run: + print("\nšŸ’” Run without --dry-run to apply changes") + + +if __name__ == "__main__": + main() diff --git a/scripts/add_scope_decorators_simple.py b/scripts/add_scope_decorators_simple.py new file mode 100644 index 0000000..71d85fa --- /dev/null +++ b/scripts/add_scope_decorators_simple.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Simpler script to add @require_scopes decorators using regex. + +This script uses regex patterns to find @mcp.tool() decorators and adds +the appropriate @require_scopes decorator based on function name patterns. + +Usage: + python scripts/add_scope_decorators_simple.py [--dry-run] +""" + +import argparse +import re +from pathlib import Path + +# Operation patterns for classification +READ_KEYWORDS = [ + "get", + "list", + "search", + "read", + "find", + "fetch", + "retrieve", + "upcoming", +] +WRITE_KEYWORDS = [ + "create", + "update", + "delete", + "append", + "modify", + "set", + "add", + "remove", + "edit", + "move", + "copy", + "upload", + "download", + "share", + "unshare", + "bulk", + "manage", + "import", + "reindex", + "archive", + "unarchive", + "reorder", + "assign", + "unassign", + "insert", + "write", +] + + +def classify_function(func_name: str) -> str | None: + """Classify a function name as read or write operation.""" + func_lower = func_name.lower() + + # Check write keywords first (more specific) + for keyword in WRITE_KEYWORDS: + if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"): + return "nc:write" + + # Check read keywords + for keyword in READ_KEYWORDS: + if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"): + return "nc:read" + + return None + + +def process_file(file_path: Path, dry_run: bool = False) -> int: + """Process a single file to add @require_scopes decorators. + + Returns: + Number of decorators added + """ + with open(file_path) as f: + lines = f.readlines() + + # Check if require_scopes is already imported + has_import = False + import_line_idx = None + + for i, line in enumerate(lines): + if "from nextcloud_mcp_server.auth import" in line: + if "require_scopes" in line: + has_import = True + else: + import_line_idx = i + + modified = False + decorators_added = 0 + + # Find all @mcp.tool() decorators + i = 0 + while i < len(lines): + line = lines[i] + + # Look for @mcp.tool() decorator + if re.match(r"\s*@mcp\.tool\(\)", line): + # Check if next line already has @require_scopes + if i + 1 < len(lines) and "@require_scopes" in lines[i + 1]: + i += 1 + continue + + # Find the function definition (should be on next line or after other decorators) + func_line_idx = i + 1 + while func_line_idx < len(lines) and not lines[ + func_line_idx + ].strip().startswith("async def"): + func_line_idx += 1 + + if func_line_idx >= len(lines): + i += 1 + continue + + # Extract function name + func_match = re.match(r"\s*async def (\w+)\(", lines[func_line_idx]) + if not func_match: + i += 1 + continue + + func_name = func_match.group(1) + scope = classify_function(func_name) + + if scope: + # Get indentation from @mcp.tool() line + indent = len(line) - len(line.lstrip()) + decorator_line = " " * indent + f'@require_scopes("{scope}")\n' + + # Insert after @mcp.tool() + lines.insert(i + 1, decorator_line) + decorators_added += 1 + modified = True + print(f' āœ“ {func_name} → @require_scopes("{scope}")') + else: + print(f" āš ļø Cannot classify: {func_name}") + + i += 1 + + # Add import if needed and decorators were added + if decorators_added > 0 and not has_import: + if import_line_idx is not None: + # Add to existing import + old_line = lines[import_line_idx] + if old_line.rstrip().endswith(")"): + lines[import_line_idx] = old_line.rstrip()[:-1] + ", require_scopes)\n" + else: + lines[import_line_idx] = old_line.rstrip() + ", require_scopes\n" + print(" āœ“ Added require_scopes to existing import") + modified = True + else: + # No auth import exists, add new import after last 'from nextcloud_mcp_server' import + last_nc_import_idx = None + for i, line in enumerate(lines): + if line.startswith("from nextcloud_mcp_server"): + last_nc_import_idx = i + + if last_nc_import_idx is not None: + lines.insert( + last_nc_import_idx + 1, + "from nextcloud_mcp_server.auth import require_scopes\n", + ) + print( + " āœ“ Added new import: from nextcloud_mcp_server.auth import require_scopes" + ) + modified = True + else: + print(" āš ļø Could not find place to add require_scopes import") + + # Write changes + if modified and not dry_run: + with open(file_path, "w") as f: + f.writelines(lines) + print(f" šŸ’¾ Saved changes to {file_path.name}") + elif dry_run and decorators_added > 0: + print(f" šŸ” DRY RUN - would add {decorators_added} decorators") + + return decorators_added + + +def main(): + parser = argparse.ArgumentParser( + description="Add @require_scopes decorators to MCP tools" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be changed without modifying files", + ) + parser.add_argument( + "--file", + type=Path, + help="Process a single file instead of all server modules", + ) + args = parser.parse_args() + + server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server" + + if args.file: + files = [args.file] + else: + files = sorted(server_dir.glob("*.py")) + files = [f for f in files if f.name != "__init__.py"] + + print("šŸ” Scanning for tools needing scope decorators...") + print( + f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}" + ) + + total_added = 0 + for file_path in files: + file_path = file_path.resolve() # Convert to absolute path + try: + display_path = file_path.relative_to(Path.cwd()) + except ValueError: + display_path = file_path.name + print(f"\nšŸ“ {display_path}") + added = process_file(file_path, dry_run=args.dry_run) + total_added += added + + print(f"\n{'šŸ“Š Summary (dry run)' if args.dry_run else 'āœ… Complete'}") + print(f" Total decorators added: {total_added}") + + if args.dry_run and total_added > 0: + print("\nšŸ’” Run without --dry-run to apply changes") + + +if __name__ == "__main__": + main()