From da9032a76ea2af5035b22437d7d7e4f97161cf38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 10 Aug 2026 10:06:00 +0200 Subject: [PATCH] fix(mcp): declare all three MCP hints on every tool (#751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The ChatGPT app directory rejects the submission with: > Every MCP tool must set readOnlyHint, openWorldHint, destructiveHint to true or false. We only ever declared one hint per tool — `#[IsReadOnly]` on the reads, `#[IsDestructive]` on the writes — so the other two were absent from `tools/list` and the portal's scan flagged all 23 tools. ## What - `McpTool::annotations()` now defaults all three hints, so every tool reports `readOnlyHint`, `destructiveHint` and `openWorldHint` explicitly. The attributes still override: `#[IsReadOnly]` on the eight read tools, `#[IsDestructive]` on the four deletes. - `openWorldHint` is always `false`: every tool reads or writes the user's own account, never the open web. - `destructiveHint` drops to `false` on the eleven create/update/categorize/label tools. Marking them destructive was wrong — the directory reserves it for irreversible operations — and it made ChatGPT ask for confirmation on every write, including recategorizing a transaction. - Tool descriptions trimmed to the portal's 200-character cap (nine were longer, `create_automation_rule` ran to 524). The cuts are facts the server instructions already state — amounts in minor units, whole-account scope. The JsonLogic variable list and example move to the `rules_json` schema field, which the model still reads and the form does not cap. - `chatgpt-app-submission.json` is the submission-import file the portal accepts, carrying the listing metadata, the per-tool hints with their required justifications, and the positive/negative test cases. ## Testing `tests/Unit/Mcp/ToolAnnotationsTest.php` pins both contracts: every tool declares all three hints with `readOnlyHint`/`destructiveHint` matching the expected tool lists, and no description exceeds 200 characters. `tests/Feature/Mcp` still passes. --- app/Mcp/Tools/CategorizeTransaction.php | 2 - app/Mcp/Tools/CreateAutomationRule.php | 10 +- app/Mcp/Tools/CreateBalance.php | 4 +- app/Mcp/Tools/CreateCategory.php | 2 - app/Mcp/Tools/CreateLabel.php | 2 - app/Mcp/Tools/CreateTransaction.php | 4 +- app/Mcp/Tools/DeleteCategory.php | 2 +- app/Mcp/Tools/GetCashflow.php | 2 +- app/Mcp/Tools/GetNetWorth.php | 2 +- app/Mcp/Tools/LabelTransaction.php | 2 - app/Mcp/Tools/McpTool.php | 18 ++ app/Mcp/Tools/SearchTransactions.php | 2 +- app/Mcp/Tools/SpendingByCategory.php | 2 +- app/Mcp/Tools/UpdateAutomationRule.php | 2 - app/Mcp/Tools/UpdateCategory.php | 2 - app/Mcp/Tools/UpdateLabel.php | 2 - app/Mcp/Tools/UpdateTransaction.php | 4 +- app/Mcp/Tools/WriteTool.php | 8 +- chatgpt-app-submission.json | 248 ++++++++++++++++++++++++ tests/Unit/Mcp/ToolAnnotationsTest.php | 61 ++++++ 20 files changed, 344 insertions(+), 37 deletions(-) create mode 100644 chatgpt-app-submission.json create mode 100644 tests/Unit/Mcp/ToolAnnotationsTest.php diff --git a/app/Mcp/Tools/CategorizeTransaction.php b/app/Mcp/Tools/CategorizeTransaction.php index 854ce22c..1c6f938f 100644 --- a/app/Mcp/Tools/CategorizeTransaction.php +++ b/app/Mcp/Tools/CategorizeTransaction.php @@ -8,9 +8,7 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] #[Description('Set (or clear) the category of any transaction, including bank/imported ones. Marks the category as manually assigned. Pass category_id: null to remove the category.')] class CategorizeTransaction extends WriteTool { diff --git a/app/Mcp/Tools/CreateAutomationRule.php b/app/Mcp/Tools/CreateAutomationRule.php index b591acff..4e9cd493 100644 --- a/app/Mcp/Tools/CreateAutomationRule.php +++ b/app/Mcp/Tools/CreateAutomationRule.php @@ -11,12 +11,8 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] -#[Description(<<<'TEXT' -Create an automation rule that auto-applies a category and/or labels to matching transactions. `rules_json` is a JsonLogic object evaluated against these lowercase variables: description, notes, creditor_name, debtor_name, account_name, bank_name, category, transaction_date (YYYY-MM-DD) and amount. Note: amount here is in MAJOR units (e.g. 12.50), not cents. Example: {"and":[{">":[{"var":"amount"},100]},{"in":["grocery",{"var":"description"}]}]}. At least one action (action_category_id or action_label_ids) is required. -TEXT)] +#[Description('Create an automation rule that auto-applies a category and/or labels to matching transactions. At least one action (action_category_id or action_label_ids) is required; see rules_json for its format.')] class CreateAutomationRule extends WriteTool { use DecodesRulesJson; @@ -29,7 +25,9 @@ class CreateAutomationRule extends WriteTool return [ 'title' => $schema->string()->description('Human-readable rule name.')->required(), 'priority' => $schema->integer()->min(0)->description('Lower numbers are evaluated first.')->required(), - 'rules_json' => $schema->object()->description('JsonLogic condition object.')->required(), + 'rules_json' => $schema->object()->description(<<<'TEXT' + JsonLogic condition object, evaluated against these lowercase variables: description, notes, creditor_name, debtor_name, account_name, bank_name, category, transaction_date (YYYY-MM-DD) and amount. Note: amount here is in MAJOR units (e.g. 12.50), not cents. Example: {"and":[{">":[{"var":"amount"},100]},{"in":["grocery",{"var":"description"}]}]} + TEXT)->required(), 'action_category_id' => $schema->string()->description('Category id to assign to matching transactions.'), 'action_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach to matching transactions.'), 'action_note' => $schema->string()->description('Note to append to matching transactions.'), diff --git a/app/Mcp/Tools/CreateBalance.php b/app/Mcp/Tools/CreateBalance.php index a8602889..2cc480f1 100644 --- a/app/Mcp/Tools/CreateBalance.php +++ b/app/Mcp/Tools/CreateBalance.php @@ -8,10 +8,8 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] -#[Description('Record an account balance snapshot on a non-connected (manual) account. Balance is an integer in minor units (cents). Replaces any existing snapshot for that date. Connected accounts are rejected: their balances come from the bank sync and a manual snapshot would be overwritten. They do accept manual transactions via create_transaction.')] +#[Description('Record a balance snapshot on a non-connected (manual) account, replacing any snapshot for that date. Connected accounts are rejected: their balances come from the bank sync.')] class CreateBalance extends WriteTool { /** diff --git a/app/Mcp/Tools/CreateCategory.php b/app/Mcp/Tools/CreateCategory.php index e39990be..d90eb7fa 100644 --- a/app/Mcp/Tools/CreateCategory.php +++ b/app/Mcp/Tools/CreateCategory.php @@ -14,9 +14,7 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] #[Description('Create a category. A child (parent_id set) inherits its parent type and cashflow direction; a root follows its own type. Categories can be nested up to 3 levels deep.')] class CreateCategory extends WriteTool { diff --git a/app/Mcp/Tools/CreateLabel.php b/app/Mcp/Tools/CreateLabel.php index a3910477..6a75ad00 100644 --- a/app/Mcp/Tools/CreateLabel.php +++ b/app/Mcp/Tools/CreateLabel.php @@ -11,9 +11,7 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] #[Description('Create a label. Names are unique within the space.')] class CreateLabel extends WriteTool { diff --git a/app/Mcp/Tools/CreateTransaction.php b/app/Mcp/Tools/CreateTransaction.php index dbf81b2f..4d719288 100644 --- a/app/Mcp/Tools/CreateTransaction.php +++ b/app/Mcp/Tools/CreateTransaction.php @@ -11,10 +11,8 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] -#[Description('Create a manual transaction on any account, including bank-connected ones. Amount is a signed integer in minor units (cents): negative for an expense, positive for income. On a connected account nothing dedups this against the bank feed, so only add what the bank will not sync itself (cash, a split, a charge it missed).')] +#[Description('Create a manual transaction on any account, bank-connected ones included. Amount is signed: negative for an expense, positive for income. Nothing dedups it, so add only what the bank will not sync.')] class CreateTransaction extends WriteTool { /** diff --git a/app/Mcp/Tools/DeleteCategory.php b/app/Mcp/Tools/DeleteCategory.php index c82bb755..ce0f7717 100644 --- a/app/Mcp/Tools/DeleteCategory.php +++ b/app/Mcp/Tools/DeleteCategory.php @@ -16,7 +16,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; #[IsDestructive] -#[Description('Delete a category. The strategy decides what happens to its child categories: "reparent" (default) lifts them to the deleted category\'s parent, "promote" turns them into roots, "cascade" deletes the whole subtree and uncategorizes affected transactions.')] +#[Description('Delete a category. strategy decides its children: "reparent" (default) lifts them to its parent, "promote" makes them roots, "cascade" deletes the subtree and uncategorizes its transactions.')] class DeleteCategory extends WriteTool { /** diff --git a/app/Mcp/Tools/GetCashflow.php b/app/Mcp/Tools/GetCashflow.php index ac618762..2fc2688a 100644 --- a/app/Mcp/Tools/GetCashflow.php +++ b/app/Mcp/Tools/GetCashflow.php @@ -11,7 +11,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('The full cashflow picture for a date range as JSON, mirroring the app\'s cashflow screen: income/expense/savings/investment summary (current vs previous), the income-vs-expense category flow (sankey), and the monthly trend. Amounts are in minor units (cents). Covers the user\'s whole account.')] +#[Description('The app\'s full cashflow picture for a date range: income/expense/savings/investment summary (current vs previous), the category flow sankey and the monthly trend. Covers the whole account.')] class GetCashflow extends McpTool { /** diff --git a/app/Mcp/Tools/GetNetWorth.php b/app/Mcp/Tools/GetNetWorth.php index bb866ec3..8392fd80 100644 --- a/app/Mcp/Tools/GetNetWorth.php +++ b/app/Mcp/Tools/GetNetWorth.php @@ -11,7 +11,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Net worth for a date range as JSON: the current total vs the previous period, plus the per-account balance evolution over time. Set granularity to "monthly" (default) or "daily". Amounts are in minor units (cents). Covers the user\'s whole account.')] +#[Description('Net worth for a date range: the current total vs the previous period, plus each account\'s balance evolution. Set granularity to "monthly" (default) or "daily". Covers the whole account.')] class GetNetWorth extends McpTool { /** diff --git a/app/Mcp/Tools/LabelTransaction.php b/app/Mcp/Tools/LabelTransaction.php index 3000a839..1c087b66 100644 --- a/app/Mcp/Tools/LabelTransaction.php +++ b/app/Mcp/Tools/LabelTransaction.php @@ -7,9 +7,7 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] #[Description('Add and/or remove labels on any transaction, including bank/imported ones. Pass add_label_ids and/or remove_label_ids.')] class LabelTransaction extends WriteTool { diff --git a/app/Mcp/Tools/McpTool.php b/app/Mcp/Tools/McpTool.php index 68cc4504..96c01b41 100644 --- a/app/Mcp/Tools/McpTool.php +++ b/app/Mcp/Tools/McpTool.php @@ -29,6 +29,24 @@ abstract class McpTool extends Tool return Str::snake(class_basename($this)); } + /** + * The ChatGPT app directory requires all three MCP hints to be declared + * explicitly, with a justification per tool. Default them here — read tools + * flip `readOnlyHint` with #[IsReadOnly] and the delete tools flip + * `destructiveHint` with #[IsDestructive]. `openWorldHint` is always false: + * every tool reads or writes the user's own account, never the open web. + * + * @return array + */ + public function annotations(): array + { + return array_merge([ + 'readOnlyHint' => false, + 'destructiveHint' => false, + 'openWorldHint' => false, + ], parent::annotations()); + } + public function handle(Request $request): Response { $user = $request->user(); diff --git a/app/Mcp/Tools/SearchTransactions.php b/app/Mcp/Tools/SearchTransactions.php index 817237bd..8b7d77ec 100644 --- a/app/Mcp/Tools/SearchTransactions.php +++ b/app/Mcp/Tools/SearchTransactions.php @@ -11,7 +11,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Search and filter the user\'s transactions by text, category, account, label, date range and amount. Amounts are integers in minor units (cents). Use this to analyse spending or to find recurring charges by grouping results by merchant.')] +#[Description('Search and filter the user\'s transactions by text, category, account, label, date range and amount. Use it to analyse spending, or to find recurring charges by grouping results by merchant.')] class SearchTransactions extends McpTool { /** diff --git a/app/Mcp/Tools/SpendingByCategory.php b/app/Mcp/Tools/SpendingByCategory.php index 22da0ec3..f9451ef9 100644 --- a/app/Mcp/Tools/SpendingByCategory.php +++ b/app/Mcp/Tools/SpendingByCategory.php @@ -12,7 +12,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Expense spending rolled up by category for a date range. Without parent_category_id, root categories are returned; pass one to drill into its children. Amounts are in minor units (cents). Covers the user\'s whole account.')] +#[Description('Expense spending rolled up by category for a date range, across the user\'s whole account. Without parent_category_id it returns root categories; pass one to drill into its children.')] class SpendingByCategory extends McpTool { /** diff --git a/app/Mcp/Tools/UpdateAutomationRule.php b/app/Mcp/Tools/UpdateAutomationRule.php index 7248b9f6..a7edec03 100644 --- a/app/Mcp/Tools/UpdateAutomationRule.php +++ b/app/Mcp/Tools/UpdateAutomationRule.php @@ -11,9 +11,7 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] #[Description('Edit an automation rule. Only the fields you pass are changed. The rule must always keep at least one action (a category or labels). See create_automation_rule for the rules_json format.')] class UpdateAutomationRule extends WriteTool { diff --git a/app/Mcp/Tools/UpdateCategory.php b/app/Mcp/Tools/UpdateCategory.php index 179768c2..3987c660 100644 --- a/app/Mcp/Tools/UpdateCategory.php +++ b/app/Mcp/Tools/UpdateCategory.php @@ -16,9 +16,7 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] #[Description('Edit a category. Only the fields you pass are changed. Moving it under a parent (or clearing parent_id to make it a root) re-derives its type/cashflow and cascades the type to its descendants.')] class UpdateCategory extends WriteTool { diff --git a/app/Mcp/Tools/UpdateLabel.php b/app/Mcp/Tools/UpdateLabel.php index bf157207..9eb2cf92 100644 --- a/app/Mcp/Tools/UpdateLabel.php +++ b/app/Mcp/Tools/UpdateLabel.php @@ -11,9 +11,7 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] #[Description('Edit a label. Only the fields you pass are changed.')] class UpdateLabel extends WriteTool { diff --git a/app/Mcp/Tools/UpdateTransaction.php b/app/Mcp/Tools/UpdateTransaction.php index 64e7ab13..015b4d23 100644 --- a/app/Mcp/Tools/UpdateTransaction.php +++ b/app/Mcp/Tools/UpdateTransaction.php @@ -11,10 +11,8 @@ use Illuminate\Support\Carbon; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; -#[IsDestructive] -#[Description('Edit a manually-created transaction. Only manual transactions can be edited; bank/imported ones keep their core fields locked (use categorize_transaction or label_transaction for those). Only the fields you pass are changed.')] +#[Description('Edit a manually-created transaction; only the fields you pass change. Bank/imported ones keep their core fields locked — use categorize_transaction or label_transaction for those instead.')] class UpdateTransaction extends WriteTool { /** diff --git a/app/Mcp/Tools/WriteTool.php b/app/Mcp/Tools/WriteTool.php index 8f499bfd..c09e9b5c 100644 --- a/app/Mcp/Tools/WriteTool.php +++ b/app/Mcp/Tools/WriteTool.php @@ -20,9 +20,11 @@ use Laravel\Mcp\Response; * read+write, and Sanctum personal access tokens must carry the `mcp:write` * ability, so a read-only PAT can analyse data but never change it. * - * Each concrete write tool must additionally carry the #[IsDestructive] - * annotation. PHP attributes are not inherited, so the framework only reports - * one declared directly on the served tool class — it cannot live here. + * Only the irreversible tools (the deletes) carry #[IsDestructive]; creating, + * updating, categorizing and labelling are reversible and inherit + * `destructiveHint: false` from McpTool. PHP attributes are not inherited, so + * the framework only reports one declared directly on the served tool class — + * it cannot live here. */ abstract class WriteTool extends McpTool { diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json new file mode 100644 index 00000000..68117890 --- /dev/null +++ b/chatgpt-app-submission.json @@ -0,0 +1,248 @@ +{ + "$schema": "https://developers.openai.com/apps-sdk/schemas/chatgpt-app-submission.v1.json", + "schema_version": 1, + "app_info": { + "display_name": "Whisper Money", + "subtitle": "Chat with your own finances", + "description": "Whisper Money is a privacy-first personal finance app: your financial data is yours, and it is never shared with third parties. This app connects ChatGPT to your own Whisper Money account so you can analyse your money in plain language instead of clicking through dashboards.\n\nAsk questions like \"how much did I spend on groceries last month\", \"what's my net worth trend this year\", \"which subscriptions are draining me\" or \"break down Q2 by category\" — the app reads your transactions, accounts, categories, labels, cashflow and net worth and answers with your real numbers. You can also keep your books tidy from the conversation: log manual transactions, recategorize and label existing ones, record balances for manual accounts, and manage categories, labels and automation rules.\n\nYour synced bank data is protected: transactions imported from a bank connection can be categorized and labelled, but never edited or deleted, and balances can only be recorded on manual accounts. You can still add your own manual transactions to a bank-connected account — a sync never removes them. Data is scoped to the account you sign in with, including any shared spaces you belong to. The app reads and writes bookkeeping records only — it can never move money, make payments or reach any account outside Whisper Money.\n\nRequires a Whisper Money account on a paid (Pro) plan. Sign in happens through Whisper Money's own OAuth flow — ChatGPT never sees your password, and access can be revoked at any time from your account settings.", + "category": "FINANCE" + }, + "tools": { + "search_transactions": { + "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Runs a filtered read query over the user's own transactions and returns them as JSON. It writes nothing.", + "open_world_justification": "Reads only the authenticated user's own Whisper Money data, a closed system. It never queries the public internet.", + "destructive_justification": "Read-only, so nothing can be changed or lost." + } + }, + "spending_by_category": { + "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Aggregates the user's own transactions into per-category spending totals and returns them. It writes nothing.", + "open_world_justification": "Aggregates only the authenticated user's own Whisper Money data, a closed system. No external systems are touched.", + "destructive_justification": "Read-only, so nothing can be changed or lost." + } + }, + "get_cashflow": { + "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Computes income vs expenses over a date range from the user's own transactions and returns the result. It writes nothing.", + "open_world_justification": "Computed entirely from the authenticated user's own Whisper Money data, a closed system.", + "destructive_justification": "Read-only, so nothing can be changed or lost." + } + }, + "get_net_worth": { + "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Computes current and historical net worth from the user's own account balances and returns it. It writes nothing.", + "open_world_justification": "Computed entirely from the authenticated user's own Whisper Money data, a closed system.", + "destructive_justification": "Read-only, so nothing can be changed or lost." + } + }, + "list_accounts": { + "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Lists the user's own accounts with their current balances so later calls can reference account ids. It writes nothing.", + "open_world_justification": "Lists only accounts inside the authenticated user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Read-only, so nothing can be changed or lost." + } + }, + "list_categories": { + "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Lists the user's own spending categories so later calls can reference category ids. It writes nothing.", + "open_world_justification": "Lists only categories inside the authenticated user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Read-only, so nothing can be changed or lost." + } + }, + "list_labels": { + "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Lists the user's own labels so later calls can reference label ids. It writes nothing.", + "open_world_justification": "Lists only labels inside the authenticated user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Read-only, so nothing can be changed or lost." + } + }, + "list_spaces": { + "annotations": { "readOnlyHint": true, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Lists the personal and shared spaces the user is a member of, so later calls can target the right one. It writes nothing.", + "open_world_justification": "Lists only the spaces the authenticated user already belongs to, inside Whisper Money, a closed system.", + "destructive_justification": "Read-only, so nothing can be changed or lost." + } + }, + "create_transaction": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it records a new manual transaction in the user's own account.", + "open_world_justification": "Writes a bookkeeping row inside the user's own Whisper Money account, a closed system. It cannot move money or reach any external service.", + "destructive_justification": "Additive and reversible: it creates a new record without altering existing ones, and the user can edit or delete it afterwards. Adding one to a bank-connected account is safe because a sync only inserts rows it has not seen before." + } + }, + "update_transaction": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it edits the fields of an existing manual transaction in the user's own account.", + "open_world_justification": "Edits a bookkeeping row inside the user's own Whisper Money account, a closed system. No external system is affected.", + "destructive_justification": "Reversible: field values can be edited back at any time, and bank-imported transactions are rejected outright so synced data cannot be rewritten." + } + }, + "delete_transaction": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": true }, + "justifications": { + "read_only_justification": "Writes: it removes a manual transaction from the user's own account.", + "open_world_justification": "Deletes a bookkeeping row inside the user's own Whisper Money account, a closed system. No external system is affected.", + "destructive_justification": "Irreversible data loss: the transaction is deleted and cannot be restored, so the model must confirm with the user first. Only manual transactions can be deleted; bank-imported ones are rejected." + } + }, + "categorize_transaction": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it assigns a category to an existing transaction in the user's own account.", + "open_world_justification": "Updates a single field on a row inside the user's own Whisper Money account, a closed system.", + "destructive_justification": "Reversible: only the category assignment changes, and it can be reassigned at any time. No transaction data is removed." + } + }, + "label_transaction": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it sets the labels attached to an existing transaction in the user's own account.", + "open_world_justification": "Updates label associations inside the user's own Whisper Money account, a closed system.", + "destructive_justification": "Reversible: only label associations change and they can be set again at any time. Neither the transaction nor the labels themselves are removed." + } + }, + "create_balance": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it records a new balance snapshot for one of the user's manual accounts.", + "open_world_justification": "Writes a balance row inside the user's own Whisper Money account, a closed system. It reads no external bank data.", + "destructive_justification": "Reversible: it replaces only that account's snapshot for the given date, leaving the rest of the balance history intact, and the user can record the previous figure again. Bank-connected accounts are rejected so synced balances are never overwritten." + } + }, + "create_category": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it creates a new spending category in the user's own workspace.", + "open_world_justification": "Creates a category inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Additive and reversible: nothing existing is modified, and the new category can be deleted afterwards." + } + }, + "update_category": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it renames or restyles an existing category in the user's own workspace.", + "open_world_justification": "Edits a category inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Reversible: only the category's own attributes change, its transactions stay attached, and the values can be edited back." + } + }, + "delete_category": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": true }, + "justifications": { + "read_only_justification": "Writes: it removes a category from the user's own workspace.", + "open_world_justification": "Deletes a category inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Irreversible: the category is gone and every transaction assigned to it becomes uncategorized, so the model must confirm with the user first." + } + }, + "create_label": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it creates a new label in the user's own workspace.", + "open_world_justification": "Creates a label inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Additive and reversible: nothing existing is modified, and the new label can be deleted afterwards." + } + }, + "update_label": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it renames or recolours an existing label in the user's own workspace.", + "open_world_justification": "Edits a label inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Reversible: only the label's name and colour change, its transactions stay attached, and the values can be edited back." + } + }, + "delete_label": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": true }, + "justifications": { + "read_only_justification": "Writes: it removes a label from the user's own workspace.", + "open_world_justification": "Deletes a label inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Irreversible: the label is gone and is detached from every transaction that carried it, so the model must confirm with the user first." + } + }, + "create_automation_rule": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it creates an automation rule that categorizes or labels the user's future transactions.", + "open_world_justification": "Creates a rule inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Additive and reversible: the rule only affects how future transactions are categorized and it can be edited or deleted at any time." + } + }, + "update_automation_rule": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": false }, + "justifications": { + "read_only_justification": "Writes: it edits the conditions or actions of an existing automation rule in the user's own workspace.", + "open_world_justification": "Edits a rule inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Reversible: only the rule definition changes, no transaction data is touched, and the previous definition can be set again." + } + }, + "delete_automation_rule": { + "annotations": { "readOnlyHint": false, "openWorldHint": false, "destructiveHint": true }, + "justifications": { + "read_only_justification": "Writes: it removes an automation rule from the user's own workspace.", + "open_world_justification": "Deletes a rule inside the user's own Whisper Money workspace, a closed system.", + "destructive_justification": "Irreversible: the rule definition is deleted and cannot be restored, so the model must confirm with the user first. Transactions it already categorized are left untouched." + } + } + }, + "test_cases": [ + { + "description": "Category spending lookup for a past period. The app resolves the user's categories and returns real per-category totals for the requested month, in the user's own currency.", + "user_prompt": "How much did I spend on groceries last month?", + "tools_triggered": "list_categories, spending_by_category", + "expected_output": "A total spent on the groceries category for the previous calendar month, taken from the demo account's transactions, with the option to break it down by merchant." + }, + { + "description": "Net worth trend over time. The app returns the current total plus the per-account balance evolution, and the model summarises the direction of travel.", + "user_prompt": "What's my net worth trend over the last 6 months?", + "tools_triggered": "get_net_worth", + "expected_output": "Current net worth, the change versus six months ago, and a month-by-month evolution with the accounts driving it." + }, + { + "description": "Recurring charge discovery. The app searches transactions and the model groups them by merchant and cadence to surface subscriptions.", + "user_prompt": "Find my recurring subscriptions and total what they cost me per month.", + "tools_triggered": "search_transactions", + "expected_output": "A list of merchants charging on a regular cadence with each amount and a combined monthly total." + }, + { + "description": "Logging a manual expense. The app resolves the space and the target account, then records the transaction and echoes the created row back.", + "user_prompt": "Log a 45 euro cash expense for dinner yesterday in my personal space.", + "tools_triggered": "list_spaces, list_accounts, create_transaction", + "expected_output": "Confirmation of the new transaction with its date, amount, account and category, created in the personal space." + }, + { + "description": "Bulk tidy-up of miscategorized data. The app finds the matching transactions and reassigns their category, asking the user to confirm the set before writing.", + "user_prompt": "My Uber charges are filed as Shopping — move them to Transport.", + "tools_triggered": "search_transactions, list_categories, categorize_transaction", + "expected_output": "The matching transactions listed for confirmation, then each one reassigned to the Transport category, including bank-imported ones (categorizing is always allowed)." + } + ], + "negative_test_cases": [ + { + "description": "Bank-connected data is protected. Editing or deleting a transaction imported from a bank connection must be refused by the server, and the model should explain why instead of retrying.", + "user_prompt": "Change the amount of that Amazon charge from my bank account to 10 euros.", + "tools_triggered": "search_transactions, update_transaction", + "expected_output": "The server rejects the write because bank-imported transactions are read-only, and the model explains that only manual transactions can be edited, offering to recategorize or label it instead." + }, + { + "description": "Wide, irreversible deletion must not be executed on a vague instruction. delete_transaction is destructive and single-target, so the model must stop and confirm rather than looping over the user's history.", + "user_prompt": "Delete all my transactions from last year.", + "tools_triggered": null, + "expected_output": "No delete is performed. The model states how many transactions the request would destroy irreversibly and asks for explicit confirmation of a specific set before touching anything." + }, + { + "description": "Out of scope: the app is bookkeeping only and exposes no tool that can move money or reach a bank. The model must decline rather than pretending a payment happened.", + "user_prompt": "Transfer 500 euros from my savings account to my mum's account.", + "tools_triggered": null, + "expected_output": "A refusal explaining that Whisper Money can only record and analyse finance data, not initiate payments or transfers, with an offer to log the transfer as a manual transaction once the user has made it themselves." + } + ] +} diff --git a/tests/Unit/Mcp/ToolAnnotationsTest.php b/tests/Unit/Mcp/ToolAnnotationsTest.php new file mode 100644 index 00000000..a1a5f220 --- /dev/null +++ b/tests/Unit/Mcp/ToolAnnotationsTest.php @@ -0,0 +1,61 @@ +> $tools */ + $tools = (new ReflectionClass(WhisperMoneyServer::class))->getDefaultProperties()['tools']; + + foreach ($tools as $class) { + $tool = new $class; + + expect(mb_strlen((string) $tool->description())) + ->toBeLessThanOrEqual(200, "description for {$tool->name()}"); + } +}); + +it('declares all three MCP hints on every tool', function () use ($readOnly, $destructive) { + /** @var array> $tools */ + $tools = (new ReflectionClass(WhisperMoneyServer::class))->getDefaultProperties()['tools']; + + expect($tools)->toHaveCount(count($readOnly) + 15); + + foreach ($tools as $class) { + $tool = new $class; + $annotations = $tool->annotations(); + $name = $tool->name(); + + expect($annotations)->toHaveKeys(['readOnlyHint', 'destructiveHint', 'openWorldHint']) + ->and($annotations['openWorldHint'])->toBeFalse() + ->and($annotations['readOnlyHint'])->toBe(in_array($name, $readOnly, true), "readOnlyHint for {$name}") + ->and($annotations['destructiveHint'])->toBe(in_array($name, $destructive, true), "destructiveHint for {$name}"); + } +});