From 70e85335ac1d2912a0e7f8be46c4bea212bf07ff Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 25 Apr 2026 16:04:06 +0530 Subject: [PATCH 1/2] feat: Add MCP annotations to tools --- src/lampyrid/tools/_annotations.py | 51 ++++++++++++++++++++++++++++++ src/lampyrid/tools/accounts.py | 16 ++++++++-- src/lampyrid/tools/budgets.py | 31 ++++++++++++++---- src/lampyrid/tools/insights.py | 21 +++++++++--- src/lampyrid/tools/transactions.py | 51 ++++++++++++++++++++++++------ 5 files changed, 147 insertions(+), 23 deletions(-) create mode 100644 src/lampyrid/tools/_annotations.py diff --git a/src/lampyrid/tools/_annotations.py b/src/lampyrid/tools/_annotations.py new file mode 100644 index 0000000..628c86e --- /dev/null +++ b/src/lampyrid/tools/_annotations.py @@ -0,0 +1,51 @@ +"""Shared MCP tool annotation helpers. + +This module centralizes FastMCP annotation hints so tool definitions can stay +concise and consistent across domains. +""" + +from mcp.types import ToolAnnotations + + +def readonly_annotations(title: str) -> ToolAnnotations: + """Build annotations for read-only MCP tools. + + Args: + title: Human-friendly tool title for MCP clients. + + Returns: + ToolAnnotations: Annotations suitable for FastMCP's ``@tool`` decorator. + + """ + return ToolAnnotations( + title=title, + readOnlyHint=True, + idempotentHint=True, + openWorldHint=False, + ) + + +def mutating_annotations( + title: str, + *, + destructive: bool = False, + idempotent: bool = False, +) -> ToolAnnotations: + """Build annotations for mutating MCP tools. + + Args: + title: Human-friendly tool title for MCP clients. + destructive: Whether the tool performs destructive changes. + idempotent: Whether repeated identical calls have no additional effect. + + Returns: + ToolAnnotations: Annotations suitable for FastMCP's ``@tool`` decorator. + + """ + return ToolAnnotations( + title=title, + readOnlyHint=False, + destructiveHint=destructive, + idempotentHint=idempotent, + openWorldHint=False, + ) diff --git a/src/lampyrid/tools/accounts.py b/src/lampyrid/tools/accounts.py index f0c6155..c50c77f 100644 --- a/src/lampyrid/tools/accounts.py +++ b/src/lampyrid/tools/accounts.py @@ -16,6 +16,7 @@ SearchAccountRequest, ) from ..services.accounts import AccountService +from ._annotations import readonly_annotations def create_accounts_server(client: FireflyClient) -> FastMCP: @@ -32,7 +33,10 @@ def create_accounts_server(client: FireflyClient) -> FastMCP: accounts_mcp = FastMCP('accounts') - @accounts_mcp.tool(tags={'accounts'}) + @accounts_mcp.tool( + tags={'accounts'}, + annotations=readonly_annotations('List Accounts'), + ) async def list_accounts(req: ListAccountRequest) -> List[Account]: """Retrieve accounts from Firefly III. @@ -41,7 +45,10 @@ async def list_accounts(req: ListAccountRequest) -> List[Account]: """ return await account_service.list_accounts(req) - @accounts_mcp.tool(tags={'accounts'}) + @accounts_mcp.tool( + tags={'accounts'}, + annotations=readonly_annotations('Get Account'), + ) async def get_account(req: GetAccountRequest) -> Account: """Retrieve detailed account information including current balance and currency. @@ -49,7 +56,10 @@ async def get_account(req: GetAccountRequest) -> Account: """ return await account_service.get_account(req) - @accounts_mcp.tool(tags={'accounts'}) + @accounts_mcp.tool( + tags={'accounts'}, + annotations=readonly_annotations('Search Accounts'), + ) async def search_accounts(req: SearchAccountRequest) -> List[Account]: """Find accounts by partial name matching. diff --git a/src/lampyrid/tools/budgets.py b/src/lampyrid/tools/budgets.py index 5c4998b..7ff2317 100644 --- a/src/lampyrid/tools/budgets.py +++ b/src/lampyrid/tools/budgets.py @@ -22,6 +22,7 @@ ListBudgetsRequest, ) from ..services.budgets import BudgetService +from ._annotations import mutating_annotations, readonly_annotations def create_budgets_server(client: FireflyClient) -> FastMCP: @@ -38,7 +39,10 @@ def create_budgets_server(client: FireflyClient) -> FastMCP: budgets_mcp = FastMCP('budgets') - @budgets_mcp.tool(tags={'budgets'}) + @budgets_mcp.tool( + tags={'budgets'}, + annotations=readonly_annotations('List Budgets'), + ) async def list_budgets(req: ListBudgetsRequest) -> List[Budget]: """Retrieve your budgets for expense tracking and financial planning. @@ -46,7 +50,10 @@ async def list_budgets(req: ListBudgetsRequest) -> List[Budget]: """ return await budget_service.list_budgets(req) - @budgets_mcp.tool(tags={'budgets'}) + @budgets_mcp.tool( + tags={'budgets'}, + annotations=readonly_annotations('Get Budget'), + ) async def get_budget(req: GetBudgetRequest) -> Budget: """Retrieve detailed budget information including name, status, and notes. @@ -54,7 +61,10 @@ async def get_budget(req: GetBudgetRequest) -> Budget: """ return await budget_service.get_budget(req) - @budgets_mcp.tool(tags={'budgets', 'analysis'}) + @budgets_mcp.tool( + tags={'budgets', 'analysis'}, + annotations=readonly_annotations('Get Budget Spending'), + ) async def get_budget_spending(req: GetBudgetSpendingRequest) -> BudgetSpending: """Analyze spending against a budget. @@ -63,7 +73,10 @@ async def get_budget_spending(req: GetBudgetSpendingRequest) -> BudgetSpending: """ return await budget_service.get_budget_spending(req) - @budgets_mcp.tool(tags={'budgets', 'analysis'}) + @budgets_mcp.tool( + tags={'budgets', 'analysis'}, + annotations=readonly_annotations('Get Budget Summary'), + ) async def get_budget_summary(req: GetBudgetSummaryRequest) -> BudgetSummary: """Comprehensive overview of all budget performance with totals and spending analysis. @@ -71,7 +84,10 @@ async def get_budget_summary(req: GetBudgetSummaryRequest) -> BudgetSummary: """ return await budget_service.get_budget_summary(req) - @budgets_mcp.tool(tags={'budgets', 'analysis'}) + @budgets_mcp.tool( + tags={'budgets', 'analysis'}, + annotations=readonly_annotations('Get Available Budget'), + ) async def get_available_budget(req: GetAvailableBudgetRequest) -> AvailableBudget: """Check unallocated budget available for new budgets or unexpected expenses. @@ -79,7 +95,10 @@ async def get_available_budget(req: GetAvailableBudgetRequest) -> AvailableBudge """ return await budget_service.get_available_budget(req) - @budgets_mcp.tool(tags={'budgets', 'create'}) + @budgets_mcp.tool( + tags={'budgets', 'create'}, + annotations=mutating_annotations('Create Budget'), + ) async def create_budget(req: CreateBudgetRequest) -> Budget: """Create a new budget for expense tracking and financial planning. diff --git a/src/lampyrid/tools/insights.py b/src/lampyrid/tools/insights.py index 8430fa9..153bc12 100644 --- a/src/lampyrid/tools/insights.py +++ b/src/lampyrid/tools/insights.py @@ -18,6 +18,7 @@ TransferInsightResult, ) from ..services.insights import InsightService +from ._annotations import readonly_annotations def create_insights_server(client: FireflyClient) -> FastMCP: @@ -34,7 +35,10 @@ def create_insights_server(client: FireflyClient) -> FastMCP: insights_mcp = FastMCP('insights') - @insights_mcp.tool(tags={'insights', 'expenses', 'analysis'}) + @insights_mcp.tool( + tags={'insights', 'expenses', 'analysis'}, + annotations=readonly_annotations('Get Expense Insight'), + ) async def get_expense_insight(req: GetExpenseInsightRequest) -> ExpenseInsightResult: """Analyze expenses for a time period with optional grouping. @@ -50,7 +54,10 @@ async def get_expense_insight(req: GetExpenseInsightRequest) -> ExpenseInsightRe """ return await insight_service.get_expense_insight(req) - @insights_mcp.tool(tags={'insights', 'income', 'analysis'}) + @insights_mcp.tool( + tags={'insights', 'income', 'analysis'}, + annotations=readonly_annotations('Get Income Insight'), + ) async def get_income_insight(req: GetIncomeInsightRequest) -> IncomeInsightResult: """Analyze income for a time period with optional grouping. @@ -64,7 +71,10 @@ async def get_income_insight(req: GetIncomeInsightRequest) -> IncomeInsightResul """ return await insight_service.get_income_insight(req) - @insights_mcp.tool(tags={'insights', 'transfers', 'analysis'}) + @insights_mcp.tool( + tags={'insights', 'transfers', 'analysis'}, + annotations=readonly_annotations('Get Transfer Insight'), + ) async def get_transfer_insight(req: GetTransferInsightRequest) -> TransferInsightResult: """Analyze transfers for a time period with optional account breakdown. @@ -78,7 +88,10 @@ async def get_transfer_insight(req: GetTransferInsightRequest) -> TransferInsigh """ return await insight_service.get_transfer_insight(req) - @insights_mcp.tool(tags={'insights', 'summary', 'analysis'}) + @insights_mcp.tool( + tags={'insights', 'summary', 'analysis'}, + annotations=readonly_annotations('Get Financial Summary'), + ) async def get_financial_summary(req: GetFinancialSummaryRequest) -> FinancialSummary: """Get a complete financial overview for a time period. diff --git a/src/lampyrid/tools/transactions.py b/src/lampyrid/tools/transactions.py index 380a163..f6a569c 100644 --- a/src/lampyrid/tools/transactions.py +++ b/src/lampyrid/tools/transactions.py @@ -24,6 +24,7 @@ UpdateTransactionRequest, ) from ..services.transactions import TransactionService +from ._annotations import mutating_annotations, readonly_annotations def create_transactions_server(client: FireflyClient) -> FastMCP: @@ -40,7 +41,10 @@ def create_transactions_server(client: FireflyClient) -> FastMCP: transactions_mcp = FastMCP('transactions') - @transactions_mcp.tool(tags={'transactions', 'create'}) + @transactions_mcp.tool( + tags={'transactions', 'create'}, + annotations=mutating_annotations('Create Withdrawal'), + ) async def create_withdrawal(req: CreateWithdrawalRequest) -> Transaction: """Record expenses and spending. @@ -50,7 +54,10 @@ async def create_withdrawal(req: CreateWithdrawalRequest) -> Transaction: transaction = await transaction_service.create_withdrawal(req) return transaction - @transactions_mcp.tool(tags={'transactions', 'create'}) + @transactions_mcp.tool( + tags={'transactions', 'create'}, + annotations=mutating_annotations('Create Deposit'), + ) async def create_deposit(req: CreateDepositRequest) -> Transaction: """Record income and money received. @@ -60,7 +67,10 @@ async def create_deposit(req: CreateDepositRequest) -> Transaction: transaction = await transaction_service.create_deposit(req) return transaction - @transactions_mcp.tool(tags={'transactions', 'create'}) + @transactions_mcp.tool( + tags={'transactions', 'create'}, + annotations=mutating_annotations('Create Transfer'), + ) async def create_transfer(req: CreateTransferRequest) -> Transaction: """Move money between your own accounts. @@ -69,7 +79,10 @@ async def create_transfer(req: CreateTransferRequest) -> Transaction: transaction = await transaction_service.create_transfer(req) return transaction - @transactions_mcp.tool(tags={'transactions', 'create', 'bulk'}) + @transactions_mcp.tool( + tags={'transactions', 'create', 'bulk'}, + annotations=mutating_annotations('Create Bulk Transactions'), + ) async def create_bulk_transactions(req: CreateBulkTransactionsRequest) -> BulkCreateResult: """Efficiently create multiple transactions in one operation. @@ -78,7 +91,10 @@ async def create_bulk_transactions(req: CreateBulkTransactionsRequest) -> BulkCr """ return await transaction_service.create_bulk_transactions(req) - @transactions_mcp.tool(tags={'transactions', 'query'}) + @transactions_mcp.tool( + tags={'transactions', 'query'}, + annotations=readonly_annotations('Get Transaction'), + ) async def get_transaction(req: GetTransactionRequest) -> Transaction: """Retrieve complete transaction details. @@ -87,7 +103,10 @@ async def get_transaction(req: GetTransactionRequest) -> Transaction: """ return await transaction_service.get_transaction(req) - @transactions_mcp.tool(tags={'transactions', 'query'}) + @transactions_mcp.tool( + tags={'transactions', 'query'}, + annotations=readonly_annotations('Get Transactions'), + ) async def get_transactions(req: GetTransactionsRequest) -> TransactionListResponse: """Retrieve transaction history with flexible filtering and pagination. @@ -95,7 +114,10 @@ async def get_transactions(req: GetTransactionsRequest) -> TransactionListRespon """ return await transaction_service.get_transactions(req) - @transactions_mcp.tool(tags={'transactions', 'query'}) + @transactions_mcp.tool( + tags={'transactions', 'query'}, + annotations=readonly_annotations('Search Transactions'), + ) async def search_transactions(req: SearchTransactionsRequest) -> TransactionListResponse: """Search transactions with powerful filtering options. @@ -105,7 +127,10 @@ async def search_transactions(req: SearchTransactionsRequest) -> TransactionList """ return await transaction_service.search_transactions(req) - @transactions_mcp.tool(tags={'transactions', 'manage'}) + @transactions_mcp.tool( + tags={'transactions', 'manage'}, + annotations=mutating_annotations('Delete Transaction', destructive=True), + ) async def delete_transaction(req: DeleteTransactionRequest) -> bool: """Permanently remove a transaction. @@ -114,7 +139,10 @@ async def delete_transaction(req: DeleteTransactionRequest) -> bool: """ return await transaction_service.delete_transaction(req) - @transactions_mcp.tool(tags={'transactions', 'manage'}) + @transactions_mcp.tool( + tags={'transactions', 'manage'}, + annotations=mutating_annotations('Update Transaction'), + ) async def update_transaction(req: UpdateTransactionRequest) -> Transaction: """Modify transaction details such as amounts, descriptions, dates, accounts, etc. @@ -122,7 +150,10 @@ async def update_transaction(req: UpdateTransactionRequest) -> Transaction: """ return await transaction_service.update_transaction(req) - @transactions_mcp.tool(tags={'transactions', 'manage', 'bulk'}) + @transactions_mcp.tool( + tags={'transactions', 'manage', 'bulk'}, + annotations=mutating_annotations('Bulk Update Transactions'), + ) async def bulk_update_transactions(req: BulkUpdateTransactionsRequest) -> BulkUpdateResult: """Efficiently update multiple transactions in one operation. From 209d8f69946302f583bd2fa1ea6cd0c1e5675107 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 25 Apr 2026 16:04:29 +0530 Subject: [PATCH 2/2] Add unit tests for MCP tool annotations --- tests/unit/test_tool_annotations.py | 107 ++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/unit/test_tool_annotations.py diff --git a/tests/unit/test_tool_annotations.py b/tests/unit/test_tool_annotations.py new file mode 100644 index 0000000..c7c5bed --- /dev/null +++ b/tests/unit/test_tool_annotations.py @@ -0,0 +1,107 @@ +"""Unit tests for MCP tool annotations.""" + +from unittest.mock import MagicMock + +import pytest +from fastmcp import FastMCP +from fastmcp.tools import Tool + +from lampyrid.tools.accounts import create_accounts_server +from lampyrid.tools.budgets import create_budgets_server +from lampyrid.tools.insights import create_insights_server +from lampyrid.tools.transactions import create_transactions_server + + +async def _get_tools(server: FastMCP) -> dict[str, Tool]: + """Return registered tools keyed by tool name.""" + tools = await server.list_tools() + return {tool.name: tool for tool in tools} + + +def _assert_readonly_tool(tool: Tool, title: str) -> None: + """Assert standard annotations for read-only tools.""" + annotations = tool.annotations + + assert annotations is not None + assert annotations.title == title + assert annotations.readOnlyHint is True + assert annotations.idempotentHint is True + assert annotations.openWorldHint is False + + +def _assert_mutating_tool( + tool: Tool, + title: str, + *, + destructive: bool = False, +) -> None: + """Assert standard annotations for mutating tools.""" + annotations = tool.annotations + + assert annotations is not None + assert annotations.title == title + assert annotations.readOnlyHint is False + assert annotations.destructiveHint is destructive + assert annotations.idempotentHint is False + assert annotations.openWorldHint is False + + +class TestToolAnnotations: + """Test MCP annotations exposed by tool servers.""" + + @pytest.mark.asyncio + async def test_accounts_tools_have_readonly_annotations(self): + """Account tools should be marked as read-only.""" + server = create_accounts_server(MagicMock()) + tools = await _get_tools(server) + + _assert_readonly_tool(tools['list_accounts'], 'List Accounts') + _assert_readonly_tool(tools['get_account'], 'Get Account') + _assert_readonly_tool(tools['search_accounts'], 'Search Accounts') + + @pytest.mark.asyncio + async def test_budget_tools_have_expected_annotations(self): + """Budget tools should distinguish read-only and mutating operations.""" + server = create_budgets_server(MagicMock()) + tools = await _get_tools(server) + + _assert_readonly_tool(tools['list_budgets'], 'List Budgets') + _assert_readonly_tool(tools['get_budget'], 'Get Budget') + _assert_readonly_tool(tools['get_budget_spending'], 'Get Budget Spending') + _assert_readonly_tool(tools['get_budget_summary'], 'Get Budget Summary') + _assert_readonly_tool(tools['get_available_budget'], 'Get Available Budget') + _assert_mutating_tool(tools['create_budget'], 'Create Budget') + + @pytest.mark.asyncio + async def test_insight_tools_have_readonly_annotations(self): + """Insight tools should be marked as read-only.""" + server = create_insights_server(MagicMock()) + tools = await _get_tools(server) + + _assert_readonly_tool(tools['get_expense_insight'], 'Get Expense Insight') + _assert_readonly_tool(tools['get_income_insight'], 'Get Income Insight') + _assert_readonly_tool(tools['get_transfer_insight'], 'Get Transfer Insight') + _assert_readonly_tool(tools['get_financial_summary'], 'Get Financial Summary') + + @pytest.mark.asyncio + async def test_transaction_tools_have_expected_annotations(self): + """Transaction tools should distinguish query, mutating, and destructive operations.""" + server = create_transactions_server(MagicMock()) + tools = await _get_tools(server) + + _assert_mutating_tool(tools['create_withdrawal'], 'Create Withdrawal') + _assert_mutating_tool(tools['create_deposit'], 'Create Deposit') + _assert_mutating_tool(tools['create_transfer'], 'Create Transfer') + _assert_mutating_tool(tools['create_bulk_transactions'], 'Create Bulk Transactions') + + _assert_readonly_tool(tools['get_transaction'], 'Get Transaction') + _assert_readonly_tool(tools['get_transactions'], 'Get Transactions') + _assert_readonly_tool(tools['search_transactions'], 'Search Transactions') + + _assert_mutating_tool( + tools['delete_transaction'], + 'Delete Transaction', + destructive=True, + ) + _assert_mutating_tool(tools['update_transaction'], 'Update Transaction') + _assert_mutating_tool(tools['bulk_update_transactions'], 'Bulk Update Transactions')