Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f138e7539 | |||
| 2baacc0ae8 |
@@ -1,3 +1,12 @@
|
|||||||
|
## v0.34.0 (2025-11-13)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Complete Phase 5 - Instrument all 93 MCP tools
|
||||||
|
- Add instrumentation decorator and apply to notes tools (Phase 5)
|
||||||
|
- Add OAuth token and database metrics (Phases 3-4)
|
||||||
|
- Add metrics instrumentation for queue, health, and database operations
|
||||||
|
|
||||||
## v0.33.1 (2025-11-13)
|
## v0.33.1 (2025-11-13)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.33.1
|
version: 0.34.0
|
||||||
appVersion: "0.33.1"
|
appVersion: "0.34.0"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
|
|||||||
@@ -12,24 +12,13 @@ class NotesSearchController:
|
|||||||
"""
|
"""
|
||||||
Search notes using token-based matching with relevance ranking.
|
Search notes using token-based matching with relevance ranking.
|
||||||
Returns notes sorted by relevance score.
|
Returns notes sorted by relevance score.
|
||||||
If query is empty, returns all notes.
|
|
||||||
"""
|
"""
|
||||||
search_results = []
|
search_results = []
|
||||||
query_tokens = self._process_query(query)
|
query_tokens = self._process_query(query)
|
||||||
|
|
||||||
# If empty query after processing, return all notes
|
# If empty query after processing, return empty results
|
||||||
if not query_tokens:
|
if not query_tokens:
|
||||||
async for note in notes:
|
return []
|
||||||
search_results.append(
|
|
||||||
{
|
|
||||||
"id": note.get("id"),
|
|
||||||
"title": note.get("title"),
|
|
||||||
"category": note.get("category"),
|
|
||||||
"modified": note.get("modified"),
|
|
||||||
"_score": None, # No score for unfiltered results
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return search_results
|
|
||||||
|
|
||||||
# Process and score each note
|
# Process and score each note
|
||||||
async for note in notes:
|
async for note in notes:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.33.1"
|
version = "0.34.0"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Testing Separate Clients Architecture ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check both clients exist in Keycloak
|
||||||
|
echo "1. Verifying Keycloak clients..."
|
||||||
|
docker compose exec -T app curl -s http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null && echo "✓ Keycloak realm available"
|
||||||
|
|
||||||
|
# Check user_oidc provider configuration
|
||||||
|
echo ""
|
||||||
|
echo "2. Checking user_oidc provider..."
|
||||||
|
PROVIDER_INFO=$(docker compose exec -T app php occ user_oidc:provider keycloak)
|
||||||
|
echo "$PROVIDER_INFO" | grep -q "nextcloud" && echo "✓ user_oidc configured with 'nextcloud' client"
|
||||||
|
|
||||||
|
# Get token from nextcloud-mcp-server client
|
||||||
|
echo ""
|
||||||
|
echo "3. Getting token from 'nextcloud-mcp-server' client..."
|
||||||
|
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||||
|
-d "grant_type=password" \
|
||||||
|
-d "client_id=nextcloud-mcp-server" \
|
||||||
|
-d "client_secret=mcp-secret-change-in-production" \
|
||||||
|
-d "username=admin" \
|
||||||
|
-d "password=admin" \
|
||||||
|
-d "scope=openid profile email offline_access" | jq -r '.access_token')
|
||||||
|
|
||||||
|
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||||
|
echo "✗ Failed to get token"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Got token from nextcloud-mcp-server client"
|
||||||
|
|
||||||
|
# Check token claims
|
||||||
|
echo ""
|
||||||
|
echo "4. Inspecting token claims..."
|
||||||
|
CLAIMS=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '{aud, azp, iss, preferred_username}')
|
||||||
|
echo "$CLAIMS"
|
||||||
|
|
||||||
|
AUD=$(echo "$CLAIMS" | jq -r '.aud')
|
||||||
|
AZP=$(echo "$CLAIMS" | jq -r '.azp')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Architecture validation:"
|
||||||
|
if [ "$AUD" = "nextcloud" ]; then
|
||||||
|
echo " ✓ aud='nextcloud' - Token intended for Nextcloud resource server"
|
||||||
|
else
|
||||||
|
echo " ✗ FAILED: aud='$AUD', expected 'nextcloud'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$AZP" = "nextcloud-mcp-server" ]; then
|
||||||
|
echo " ✓ azp='nextcloud-mcp-server' - Token requested by MCP Server client"
|
||||||
|
else
|
||||||
|
echo " ✗ FAILED: azp='$AZP', expected 'nextcloud-mcp-server'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test with Nextcloud API
|
||||||
|
echo ""
|
||||||
|
echo "5. Testing token with Nextcloud API..."
|
||||||
|
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/nc_response.json \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8080/ocs/v2.php/cloud/capabilities?format=json")
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "✓ Token validated successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "SUCCESS: Separate Clients Architecture Working!"
|
||||||
|
echo "===================================================================="
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo " - MCP Server client: 'nextcloud-mcp-server' (requests tokens)"
|
||||||
|
echo " - Resource server: 'nextcloud' (validates tokens via user_oidc)"
|
||||||
|
echo " - Token audience: 'nextcloud' (proper resource targeting)"
|
||||||
|
echo " - Token azp: 'nextcloud-mcp-server' (identifies requester)"
|
||||||
|
echo ""
|
||||||
|
echo "This architecture supports:"
|
||||||
|
echo " - Future multi-resource tokens: aud=['nextcloud', 'other-service']"
|
||||||
|
echo " - Clear separation of OAuth client vs resource server"
|
||||||
|
echo " - RFC 8707 Resource Indicators compliance"
|
||||||
|
else
|
||||||
|
echo "✗ Token validation failed"
|
||||||
|
cat /tmp/nc_response.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1053,7 +1053,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.33.1"
|
version = "0.34.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user