From 6253faee19804eda198392bf658e1d14e3cb3b0c Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 13 Nov 2025 16:40:56 +0100 Subject: [PATCH] feat: Add instrumentation decorator and apply to notes tools (Phase 5) Created @instrument_tool decorator for automatic MCP tool metrics collection. Applied to all 7 tools in notes.py. Changes: - observability/metrics.py: * New instrument_tool() decorator for automatic timing and error tracking * Compatible with @mcp.tool() and @require_scopes() decorators * Records tool_name, duration, and success/error status - server/notes.py: * Applied @instrument_tool to all 7 tool functions * nc_notes_create_note, nc_notes_update_note, nc_notes_append_content * nc_notes_search_notes, nc_notes_get_note, nc_notes_get_attachment * nc_notes_delete_note These metrics will populate the MCP Tool Calls dashboard panels. Part of PR #295 - Complete metrics instrumentation (Phase 5) Remaining: 86 tools across 8 server files --- nextcloud_mcp_server/observability/metrics.py | 46 +++++++++++++++++++ nextcloud_mcp_server/server/notes.py | 8 ++++ 2 files changed, 54 insertions(+) diff --git a/nextcloud_mcp_server/observability/metrics.py b/nextcloud_mcp_server/observability/metrics.py index 6a67b49..e97596f 100644 --- a/nextcloud_mcp_server/observability/metrics.py +++ b/nextcloud_mcp_server/observability/metrics.py @@ -395,3 +395,49 @@ def update_vector_sync_queue_size(size: int) -> None: size: Current queue size """ vector_sync_queue_size.set(size) + + +# ============================================================================= +# Decorator for Automatic Tool Instrumentation +# ============================================================================= + + +def instrument_tool(func): + """ + Decorator to automatically instrument MCP tool functions with metrics. + + Wraps async tool functions to record execution time and success/error status. + Compatible with @mcp.tool() and @require_scopes() decorators. + + Usage: + @mcp.tool() + @require_scopes("notes:write") + @instrument_tool + async def nc_notes_create_note(...): + ... + + Args: + func: The async function to instrument + + Returns: + Wrapped function with metrics instrumentation + """ + import functools + import time + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + tool_name = func.__name__ + start_time = time.time() + try: + result = await func(*args, **kwargs) + duration = time.time() - start_time + record_tool_call(tool_name, duration, "success") + return result + except Exception as e: + duration = time.time() - start_time + record_tool_call(tool_name, duration, "error") + record_tool_error(tool_name, type(e).__name__) + raise + + return wrapper diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index 17de067..2a393cd 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -17,6 +17,7 @@ from nextcloud_mcp_server.models.notes import ( SearchNotesResponse, UpdateNoteResponse, ) +from nextcloud_mcp_server.observability.metrics import instrument_tool logger = logging.getLogger(__name__) @@ -86,6 +87,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:write") + @instrument_tool async def nc_notes_create_note( title: str, content: str, category: str, ctx: Context ) -> CreateNoteResponse: @@ -132,6 +134,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:write") + @instrument_tool async def nc_notes_update_note( note_id: int, etag: str, @@ -197,6 +200,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:write") + @instrument_tool async def nc_notes_append_content( note_id: int, content: str, ctx: Context ) -> AppendContentResponse: @@ -247,6 +251,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:read") + @instrument_tool async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: """Search notes by title or content, returning only id, title, and category (requires notes:read scope).""" client = await get_client(ctx) @@ -293,6 +298,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:read") + @instrument_tool async def nc_notes_get_note(note_id: int, ctx: Context) -> Note: """Get a specific note by its ID (requires notes:read scope)""" client = await get_client(ctx) @@ -322,6 +328,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:read") + @instrument_tool async def nc_notes_get_attachment( note_id: int, attachment_filename: str, ctx: Context ) -> dict[str, str]: @@ -368,6 +375,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:write") + @instrument_tool async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: """Delete a note permanently""" logger.info("Deleting note %s", note_id)