Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sure MCP Server

A Model Context Protocol (MCP) server for integrating with the [Sure](https://github.com/we-promise/sure) self-hosted personal finance platform. This server provides access to your financial accounts, transactions, categories, and AI chat through Claude Desktop.
A Model Context Protocol (MCP) server for integrating with the [Sure](https://github.com/we-promise/sure) self-hosted personal finance platform. This server provides access to your financial accounts, transactions, investment trades and holdings, categories, and AI chat through Claude Desktop.

## Quick Start

Expand Down Expand Up @@ -81,6 +81,13 @@ Once configured, use these tools directly in Claude Desktop:
| `create_transaction` | Create new transaction | `account_id`, `amount`, `name`, `date`, `category_id`, `notes`, `nature` |
| `update_transaction` | Update transaction | `transaction_id`, `amount`, `name`, `date`, `category_id`, `notes` |
| `delete_transaction` | Delete transaction | `transaction_id` |
| `get_trades` | Get investment trades with filtering | `limit`, `account_id`, `account_ids`, `start_date`, `end_date` |
| `get_trade` | Get single trade | `trade_id` |
| `create_trade` | Record a stock buy/sell/dividend/deposit/withdrawal/interest | `account_id`, `trade_type`, `date`, `ticker`, `security_id`, `manual_ticker`, `qty`, `price`, `amount`, `fee`, `currency`, `category_id`, `investment_activity_label`, `transfer_account_id` |
| `update_trade` | Update trade | `trade_id`, `trade_type`, `date`, `qty`, `price`, `amount`, `currency`, `category_id`, `investment_activity_label`, `notes` |
| `delete_trade` | Delete trade | `trade_id` |
| `get_holdings` | Get stock holdings (read-only) with filtering | `limit`, `account_id`, `account_ids`, `security_id`, `date`, `start_date`, `end_date` |
| `get_holding` | Get single holding | `holding_id` |
| `get_categories` | Get all categories | None |
| `get_category` | Get single category | `category_id` |
| `sync_accounts` | Trigger account sync | None |
Expand All @@ -107,6 +114,23 @@ For local Docker setup, use `SURE_API_URL=http://localhost:3000` and `SURE_VERIF
- All dates should be in `YYYY-MM-DD` format (e.g., "2024-12-15")
- Transaction amounts: use `nature` field to specify "income" or "expense"

## Investment Trades

`create_trade` requires the target account to be an **investment** account
(or a crypto account with subtype "exchange") -- Sure rejects trades on
regular bank/card accounts. Required fields depend on `trade_type`:

| `trade_type` | Required fields | Notes |
|---|---|---|
| `buy` / `sell` | `account_id`, `date`, `qty`, `price`, plus one of `ticker` / `security_id` / `manual_ticker` | `fee` and `currency` optional |
| `dividend` | `account_id`, `date`, `amount`, plus one of `ticker` / `security_id` / `manual_ticker` | |
| `interest` | `account_id`, `date`, `amount` | `ticker`/`manual_ticker` optional |
| `deposit` / `withdrawal` | `account_id`, `date`, `amount` | optional `transfer_account_id` links it to another account as a transfer |

`get_holdings` / `get_holding` are read-only -- Sure computes current stock
positions automatically from your trade history and market prices, so there
is no create/update/delete for holdings.

## Troubleshooting

### Connection Issues
Expand Down
308 changes: 308 additions & 0 deletions src/sure_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,314 @@ def delete_transaction(transaction_id: str) -> str:
return f"Error deleting transaction: {str(e)}"


@mcp.tool()
def get_trades(
limit: int = 25,
account_id: Optional[str] = None,
account_ids: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> str:
"""
Get investment trades (stock buys/sells/dividends/etc.) from Sure.

Args:
limit: Number of trades per page (default: 25, max: 100)
account_id: Filter by a single investment account ID
account_ids: Comma-separated account IDs to filter by
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format
"""
try:
with get_client() as client:
params: Dict[str, Any] = {"per_page": min(limit, 100)}

if account_id:
params["account_id"] = account_id
if account_ids:
params["account_ids"] = account_ids
if start_date:
params["start_date"] = start_date
if end_date:
params["end_date"] = end_date

response = client.get("/api/v1/trades", params=params)
data = handle_response(response)

# Handle paginated response
trades = data.get("trades") or data.get("data") or data
if isinstance(trades, dict):
trades = trades.get("trades", [])

logger.info(f"✅ Retrieved {len(trades) if isinstance(trades, list) else 'unknown'} trades")
return json.dumps(trades, indent=2, default=str)
except Exception as e:
logger.error(f"Failed to get trades: {e}")
return f"Error getting trades: {str(e)}"


@mcp.tool()
def get_trade(trade_id: str) -> str:
"""
Get a single trade by ID.

Args:
trade_id: The ID of the trade
"""
try:
with get_client() as client:
response = client.get(f"/api/v1/trades/{trade_id}")
data = handle_response(response)

return json.dumps(data, indent=2, default=str)
except Exception as e:
logger.error(f"Failed to get trade: {e}")
return f"Error getting trade: {str(e)}"


@mcp.tool()
def create_trade(
account_id: str,
trade_type: str,
date: str,
ticker: Optional[str] = None,
security_id: Optional[str] = None,
manual_ticker: Optional[str] = None,
qty: Optional[float] = None,
price: Optional[float] = None,
amount: Optional[float] = None,
fee: Optional[float] = None,
currency: Optional[str] = None,
category_id: Optional[str] = None,
investment_activity_label: Optional[str] = None,
transfer_account_id: Optional[str] = None,
) -> str:
"""
Record a stock trade on an investment account in Sure (buy, sell,
dividend, deposit, withdrawal, or interest). The account must be an
investment account (or a crypto account of subtype "exchange") -- Sure
rejects trades on regular bank/card accounts.

Args:
account_id: The investment account ID to record the trade against
trade_type: One of "buy", "sell", "dividend", "deposit", "withdrawal", "interest"
date: Trade date in YYYY-MM-DD format
ticker: Stock ticker symbol (e.g. "AAPL", "RELIANCE") -- required for
buy/sell/dividend unless security_id or manual_ticker is given
security_id: Sure's internal security ID, as an alternative to ticker
manual_ticker: Ticker for a security Sure doesn't track market prices for
qty: Number of shares -- required for buy/sell
price: Price per share -- required for buy/sell
amount: Total cash amount -- required for dividend/deposit/withdrawal/interest
fee: Optional broker fee, applies to buy/sell
currency: Optional currency code, defaults to the account's currency
category_id: Optional category ID
investment_activity_label: Optional activity label (same list as transactions)
transfer_account_id: Optional linked account for deposit/withdrawal transfers
"""
try:
with get_client() as client:
payload: Dict[str, Any] = {
"account_id": account_id,
"type": trade_type,
"date": date,
}

if ticker:
payload["ticker"] = ticker
if security_id:
payload["security_id"] = security_id
if manual_ticker:
payload["manual_ticker"] = manual_ticker
if qty is not None:
payload["qty"] = qty
if price is not None:
payload["price"] = price
if amount is not None:
payload["amount"] = amount
if fee is not None:
payload["fee"] = fee
if currency:
payload["currency"] = currency
if category_id:
payload["category_id"] = category_id
if investment_activity_label:
payload["investment_activity_label"] = investment_activity_label
if transfer_account_id:
payload["transfer_account_id"] = transfer_account_id

response = client.post(
"/api/v1/trades",
json={"trade": payload}
)
data = handle_response(response)

logger.info("✅ Created trade")
return json.dumps(data, indent=2, default=str)
except Exception as e:
logger.error(f"Failed to create trade: {e}")
return f"Error creating trade: {str(e)}"


@mcp.tool()
def update_trade(
trade_id: str,
trade_type: Optional[str] = None,
date: Optional[str] = None,
qty: Optional[float] = None,
price: Optional[float] = None,
amount: Optional[float] = None,
currency: Optional[str] = None,
category_id: Optional[str] = None,
investment_activity_label: Optional[str] = None,
notes: Optional[str] = None,
) -> str:
"""
Update an existing trade in Sure.

Args:
trade_id: The ID of the trade to update
trade_type: New type, one of "buy", "sell", "dividend", "deposit", "withdrawal", "interest"
date: New trade date in YYYY-MM-DD format
qty: New number of shares
price: New price per share
amount: New total cash amount
currency: New currency code
category_id: New category ID
investment_activity_label: New activity label
notes: New notes
"""
try:
with get_client() as client:
payload: Dict[str, Any] = {}

if trade_type is not None:
payload["type"] = trade_type
if date is not None:
payload["date"] = date
if qty is not None:
payload["qty"] = qty
if price is not None:
payload["price"] = price
if amount is not None:
payload["amount"] = amount
if currency is not None:
payload["currency"] = currency
if category_id is not None:
payload["category_id"] = category_id
if investment_activity_label is not None:
payload["investment_activity_label"] = investment_activity_label
if notes is not None:
payload["notes"] = notes

response = client.patch(
f"/api/v1/trades/{trade_id}",
json={"trade": payload}
)
data = handle_response(response)

logger.info(f"✅ Updated trade {trade_id}")
return json.dumps(data, indent=2, default=str)
except Exception as e:
logger.error(f"Failed to update trade: {e}")
return f"Error updating trade: {str(e)}"


@mcp.tool()
def delete_trade(trade_id: str) -> str:
"""
Delete a trade from Sure.

Args:
trade_id: The ID of the trade to delete
"""
try:
with get_client() as client:
response = client.delete(f"/api/v1/trades/{trade_id}")
data = handle_response(response)

logger.info(f"✅ Deleted trade {trade_id}")
return json.dumps(data, indent=2, default=str)
except Exception as e:
logger.error(f"Failed to delete trade: {e}")
return f"Error deleting trade: {str(e)}"


@mcp.tool()
def get_holdings(
limit: int = 25,
account_id: Optional[str] = None,
account_ids: Optional[str] = None,
security_id: Optional[str] = None,
date: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> str:
"""
Get stock holdings (positions) from Sure. Holdings are a read-only
snapshot Sure computes automatically from trades and market prices --
there is no create/update/delete for them, only viewing.

Args:
limit: Number of holdings per page (default: 25, max: 100)
account_id: Filter by a single investment account ID
account_ids: Comma-separated account IDs to filter by
security_id: Filter by a single security ID
date: Only return holdings as of this date (YYYY-MM-DD)
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format
"""
try:
with get_client() as client:
params: Dict[str, Any] = {"per_page": min(limit, 100)}

if account_id:
params["account_id"] = account_id
if account_ids:
params["account_ids"] = account_ids
if security_id:
params["security_id"] = security_id
if date:
params["date"] = date
if start_date:
params["start_date"] = start_date
if end_date:
params["end_date"] = end_date

response = client.get("/api/v1/holdings", params=params)
data = handle_response(response)

# Handle paginated response
holdings = data.get("holdings") or data.get("data") or data
if isinstance(holdings, dict):
holdings = holdings.get("holdings", [])

logger.info(f"✅ Retrieved {len(holdings) if isinstance(holdings, list) else 'unknown'} holdings")
return json.dumps(holdings, indent=2, default=str)
except Exception as e:
logger.error(f"Failed to get holdings: {e}")
return f"Error getting holdings: {str(e)}"


@mcp.tool()
def get_holding(holding_id: str) -> str:
"""
Get a single holding by ID.

Args:
holding_id: The ID of the holding
"""
try:
with get_client() as client:
response = client.get(f"/api/v1/holdings/{holding_id}")
data = handle_response(response)

return json.dumps(data, indent=2, default=str)
except Exception as e:
logger.error(f"Failed to get holding: {e}")
return f"Error getting holding: {str(e)}"


@mcp.tool()
def get_categories() -> str:
"""Get all transaction categories from Sure."""
Expand Down