#
tokens: 7002/50000 10/10 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── _photos
│   └── roast.png
├── .env.example
├── .gitignore
├── .python-version
├── LICENSE
├── main.py
├── pyproject.toml
├── README.md
├── requirements.txt
├── test.py
├── up_mcp.py
├── uv.lock
└── weather.py
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
1 | 3.13
2 | 
```

--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------

```
1 | UP_TOKEN="up:yeah:YOUR_UP_TOKEN_HERE"
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
1 | __pycache__/
2 | .DS_Store
3 | .venv/
4 | .mypy_cache/
5 | .pytest_cache/
6 | .ruff_cache/
7 | .vscode/
8 | .env
9 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Up Bank MCP Server
  2 | 
  3 | MCP wrapper of the Python Up Bank API Wrapper [up-bank-api](https://github.com/jcwillox/up-bank-api) (credit to [@jcwillox](https://github.com/jcwillox)) (it's wrappers all the way down)
  4 | 
  5 | Made for Claude, by Claude (lol)
  6 | 
  7 | This is a Model Context Protocol (MCP) server that provides tools for interacting with the Up Bank API. It allows you to manage accounts, transactions, categories, tags, and webhooks through MCP-enabled clients like Claude for Desktop.
  8 | 
  9 | Feel free to contribute if you want to better optimise it for LLM, etc. However it's fine as is. I just wanted to learn how to make an MCP server.
 10 | 
 11 | Now Claude can roast me for my transaction history, what else is it good for?
 12 | 
 13 | ![Claude roasting me](_photos/roast.png)
 14 | **Figure: Too much takeout I guess**
 15 | 
 16 | ## Prerequisites
 17 | 
 18 | - Python 3.10 or higher
 19 | - Up Bank API token
 20 | - `uv` package manager
 21 | 
 22 | ## Installation
 23 | 
 24 | 1. First, install `uv` by running:
 25 | 
 26 |    ```bash
 27 |    curl -LsSf https://astral.sh/uv/install.sh | sh
 28 |    ```
 29 | 
 30 |    Make sure to restart your terminal after installing `uv`.
 31 | 
 32 | 2. Clone this repository and navigate to it:
 33 | 
 34 |    ```bash
 35 |    git clone <repository-url>
 36 |    cd up-mcp
 37 |    ```
 38 | 
 39 | 3. Create and activate a virtual environment:
 40 | 
 41 |    ```bash
 42 |    uv venv
 43 |    source .venv/bin/activate  # On Windows, use: .venv\Scripts\activate
 44 |    ```
 45 | 
 46 | 4. Install dependencies:
 47 | 
 48 |    ```bash
 49 |    uv pip install -r requirements.txt
 50 |    ```
 51 | 
 52 | ### Using with Claude for Desktop or other MCP-enabled clients
 53 | 
 54 | 1. Open your Claude (or other MCP-enabled) configuration file:
 55 |    - macOS/Linux: `~/Library/Application Support/Claude/claude_desktop_config.json`
 56 |    - Windows: `%AppData%\Claude\claude_desktop_config.json`
 57 | 
 58 | 2. Add the server configuration:
 59 | 
 60 | ```json
 61 | {
 62 |   "mcpServers": {
 63 |     "up-mcp": {
 64 |       "command": "/ABSOLUTE/PATH/TO/uv",
 65 |       "args": [
 66 |         "--directory",
 67 |         "/ABSOLUTE/PATH/TO/up-mcp",
 68 |         "run",
 69 |         "up_mcp.py"
 70 |       ],
 71 |       "env": {
 72 |         "UP_TOKEN": "up:yeah:your-token-here"
 73 |       }
 74 |     }
 75 |   }
 76 | }
 77 | ```
 78 | 
 79 |    Replace `/ABSOLUTE/PATH/TO/uv` with the absolute path to your `uv` executable.
 80 |    Replace `/ABSOLUTE/PATH/TO/up-mcp` with the absolute path to your project directory.
 81 |    Replace `up:yeah:your-token-here` with your Up Bank API token.
 82 | 
 83 |    Get your Up Bank API token from the [Up Bank website (https://api.up.com.au/)](https://api.up.com.au/).
 84 | 
 85 | 3. Restart Claude for Desktop.
 86 | 
 87 | ## Available Tools
 88 | 
 89 | The server provides the following tools:
 90 | - Account management (get accounts, get specific account)
 91 | - Transaction management (get transactions, get specific transaction)
 92 | - Category management (get categories, categorize transactions)
 93 | - Tag management (get tags, add/remove tags from transactions)
 94 | - Webhook management (create, delete, ping webhooks)
 95 | 
 96 | ## Testing
 97 | 
 98 | You can test the server using the included `test.py` script:
 99 | 
100 | ```bash
101 | python test.py
102 | ```
103 | 
104 | This will run through basic functionality tests including account retrieval and transaction listing.
105 | 
106 | ## Troubleshooting
107 | 
108 | If you encounter issues:
109 | 
110 | 1. Verify your UP_TOKEN environment variable is set correctly
111 | 2. Check that all dependencies are installed correctly
112 | 3. Ensure you're using Python 3.10 or higher
113 | 4. Check Claude's logs for MCP-related issues
```

--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------

```
1 | mcp[cli]
2 | httpx
3 | up-bank-api 
4 | pytest-asyncio
```

--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------

```python
1 | import weather
2 | 
3 | def main():
4 |     weather.mcp.run(transport='stdio')
5 | 
6 | 
7 | if __name__ == "__main__":
8 |     main()
9 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "up-mcp"
 3 | version = "0.1.0"
 4 | description = "Add your description here"
 5 | readme = "README.md"
 6 | requires-python = ">=3.13"
 7 | dependencies = [
 8 |     "httpx>=0.28.1",
 9 |     "mcp[cli]>=1.6.0",
10 | ]
11 | 
```

--------------------------------------------------------------------------------
/weather.py:
--------------------------------------------------------------------------------

```python
 1 | from typing import Any
 2 | import httpx
 3 | from mcp.server.fastmcp import FastMCP
 4 | 
 5 | # Initialize FastMCP server
 6 | mcp = FastMCP("weather")
 7 | 
 8 | # Constants
 9 | NWS_API_BASE = "https://api.weather.gov"
10 | USER_AGENT = "weather-app/1.0"
11 | 
12 | 
13 | async def make_nws_request(url: str) -> dict[str, Any] | None:
14 |     """Make a request to the NWS API with proper error handling."""
15 |     headers = {
16 |         "User-Agent": USER_AGENT,
17 |         "Accept": "application/geo+json"
18 |     }
19 |     async with httpx.AsyncClient() as client:
20 |         try:
21 |             response = await client.get(url, headers=headers, timeout=30.0)
22 |             response.raise_for_status()
23 |             return response.json()
24 |         except Exception:
25 |             return None
26 | 
27 | def format_alert(feature: dict) -> str:
28 |     """Format an alert feature into a readable string."""
29 |     props = feature["properties"]
30 |     return f"""
31 | Event: {props.get('event', 'Unknown')}
32 | Area: {props.get('areaDesc', 'Unknown')}
33 | Severity: {props.get('severity', 'Unknown')}
34 | Description: {props.get('description', 'No description available')}
35 | Instructions: {props.get('instruction', 'No specific instructions provided')}
36 | """
37 | 
38 | @mcp.tool()
39 | async def get_alerts(state: str) -> str:
40 |     """Get weather alerts for a US state.
41 | 
42 |     Args:
43 |         state: Two-letter US state code (e.g. CA, NY)
44 |     """
45 |     url = f"{NWS_API_BASE}/alerts/active/area/{state}"
46 |     data = await make_nws_request(url)
47 | 
48 |     if not data or "features" not in data:
49 |         return "Unable to fetch alerts or no alerts found."
50 | 
51 |     if not data["features"]:
52 |         return "No active alerts for this state."
53 | 
54 |     alerts = [format_alert(feature) for feature in data["features"]]
55 |     return "\n---\n".join(alerts)
56 | 
57 | @mcp.tool()
58 | async def get_forecast(latitude: float, longitude: float) -> str:
59 |     """Get weather forecast for a location.
60 | 
61 |     Args:
62 |         latitude: Latitude of the location
63 |         longitude: Longitude of the location
64 |     """
65 |     # First get the forecast grid endpoint
66 |     points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}"
67 |     points_data = await make_nws_request(points_url)
68 | 
69 |     if not points_data:
70 |         return "Unable to fetch forecast data for this location."
71 | 
72 |     # Get the forecast URL from the points response
73 |     forecast_url = points_data["properties"]["forecast"]
74 |     forecast_data = await make_nws_request(forecast_url)
75 | 
76 |     if not forecast_data:
77 |         return "Unable to fetch detailed forecast."
78 | 
79 |     # Format the periods into a readable forecast
80 |     periods = forecast_data["properties"]["periods"]
81 |     forecasts = []
82 |     for period in periods[:5]:  # Only show next 5 periods
83 |         forecast = f"""
84 |                 {period['name']}:
85 |                 Temperature: {period['temperature']}°{period['temperatureUnit']}
86 |                 Wind: {period['windSpeed']} {period['windDirection']}
87 |                 Forecast: {period['detailedForecast']}
88 |                 """
89 |         forecasts.append(forecast)
90 | 
91 |     return "\n---\n".join(forecasts)
92 | 
93 | if __name__ == "__main__":
94 |     # Initialize and run the server
95 |     mcp.run(transport='stdio')
```

--------------------------------------------------------------------------------
/up_mcp.py:
--------------------------------------------------------------------------------

```python
  1 | from upbankapi import AsyncClient, NotAuthorizedException
  2 | from typing import Any, Optional, Union
  3 | from datetime import datetime, timedelta
  4 | from mcp.server.fastmcp import FastMCP
  5 | import os
  6 | # Initialize FastMCP server
  7 | mcp = FastMCP("up-mcp")
  8 | 
  9 | 
 10 | UP_TOKEN = os.getenv("UP_TOKEN")
 11 | 
 12 | @mcp.tool()
 13 | async def get_user_id() -> str:
 14 |     """Get the user ID for the UP API.
 15 |     """
 16 | 
 17 |     async with AsyncClient(token=UP_TOKEN) as client:
 18 |         try:
 19 |             user_id = await client.ping()
 20 |             return f"Authorized: {user_id}"
 21 |         except NotAuthorizedException:
 22 |             return "The token is invalid"
 23 | 
 24 | @mcp.tool()
 25 | async def get_accounts() -> list[dict[str, Any]]:
 26 |     """Get all accounts for the user.
 27 |     """
 28 |     async with AsyncClient(token=UP_TOKEN) as client:
 29 |         accounts = await client.accounts()
 30 |         account_list = []
 31 |         async for account in accounts:
 32 |             account_list.append({
 33 |                 "id": account.id,
 34 |                 "name": account.name,
 35 |                 "balance": account.balance
 36 |             })
 37 |         return account_list
 38 | 
 39 | @mcp.tool()
 40 | async def get_account(id: str) -> str:
 41 |     """Get an account for the user.
 42 | 
 43 |     Args:
 44 |         id: The ID of the account to get.
 45 |     """
 46 |     async with AsyncClient(token=UP_TOKEN) as client:
 47 |         account = await client.account(id)
 48 |         return str(account)
 49 | 
 50 | @mcp.tool()
 51 | async def get_categories(parent_id: Optional[str] = None) -> list[dict[str, Any]]:
 52 |     """Get all categories or categories under a specific parent.
 53 |     
 54 |     Args:
 55 |         parent_id: Optional ID of the parent category to filter by.
 56 |     """
 57 |     async with AsyncClient(token=UP_TOKEN) as client:
 58 |         categories = await client.categories(parent=parent_id)
 59 |         return [{"id": cat.id, "name": cat.name} for cat in categories]
 60 | 
 61 | @mcp.tool()
 62 | async def get_category(category_id: str) -> str:
 63 |     """Get a specific category by ID.
 64 |     
 65 |     Args:
 66 |         category_id: The ID of the category to get.
 67 |     """
 68 |     async with AsyncClient(token=UP_TOKEN) as client:
 69 |         category = await client.category(category_id)
 70 |         return str(category)
 71 | 
 72 | @mcp.tool()
 73 | async def categorize_transaction(transaction_id: str, category_id: Optional[str]) -> bool:
 74 |     """Categorize a transaction.
 75 |     
 76 |     Args:
 77 |         transaction_id: The ID of the transaction to categorize.
 78 |         category_id: The category ID to assign, or None to remove categorization.
 79 |     """
 80 |     async with AsyncClient(token=UP_TOKEN) as client:
 81 |         return await client.categorize(transaction_id, category_id)
 82 | 
 83 | @mcp.tool()
 84 | async def get_tags() -> list[dict[str, Any]]:
 85 |     """Get all tags for the user."""
 86 |     async with AsyncClient(token=UP_TOKEN) as client:
 87 |         tags = await client.tags()
 88 |         tag_list = []
 89 |         async for tag in tags:
 90 |             tag_list.append({"id": tag.id, "name": tag.name})
 91 |         return tag_list
 92 | 
 93 | @mcp.tool()
 94 | async def add_transaction_tags(transaction_id: str, tags: list[str]) -> bool:
 95 |     """Add tags to a transaction.
 96 |     
 97 |     Args:
 98 |         transaction_id: The ID of the transaction.
 99 |         tags: List of tag IDs to add.
100 |     """
101 |     async with AsyncClient(token=UP_TOKEN) as client:
102 |         return await client.add_tags(transaction_id, *tags)
103 | 
104 | @mcp.tool()
105 | async def remove_transaction_tags(transaction_id: str, tags: list[str]) -> bool:
106 |     """Remove tags from a transaction.
107 |     
108 |     Args:
109 |         transaction_id: The ID of the transaction.
110 |         tags: List of tag IDs to remove.
111 |     """
112 |     async with AsyncClient(token=UP_TOKEN) as client:
113 |         return await client.remove_tags(transaction_id, *tags)
114 | 
115 | @mcp.tool()
116 | async def get_transaction(transaction_id: str) -> str:
117 |     """Get a specific transaction by ID.
118 |     
119 |     Args:
120 |         transaction_id: The ID of the transaction to get.
121 |     """
122 |     async with AsyncClient(token=UP_TOKEN) as client:
123 |         transaction = await client.transaction(transaction_id)
124 |         return str(transaction)
125 | 
126 | @mcp.tool()
127 | async def get_transactions(
128 |     account_id: Optional[str] = None,
129 |     status: Optional[str] = None,
130 |     since: Optional[datetime] = datetime.now() - timedelta(days=7),
131 |     until: Optional[datetime] = None,
132 |     category_id: Optional[str] = None,
133 |     tag_id: Optional[str] = None,
134 |     verbose: bool = False
135 | ) -> list[dict[str, Any]]:
136 |     """Get transactions with optional filters.
137 |     
138 |     Args:
139 |         account_id: Optional account ID to filter by.
140 |         status: Optional transaction status to filter by.
141 |         since: Optional start datetime (defaults to 7 days ago - longer may take longer to load)
142 |         until: Optional end datetime.
143 |         category_id: Optional category ID to filter by.
144 |         tag_id: Optional tag ID to filter by.
145 |     """
146 |     async with AsyncClient(token=UP_TOKEN) as client:
147 |         transactions = await client.transactions(
148 |             account=account_id,
149 |             status=status,
150 |             since=since,
151 |             until=until,
152 |             category=category_id,
153 |             tag=tag_id
154 |         )
155 |         transaction_list = []
156 |         if verbose:
157 |             async for tx in transactions:
158 |                 transaction_list.append({
159 |                     "id": tx.id,
160 |                     "description": tx.description,
161 |                     "amount": tx.amount,
162 |                     "status": tx.status,
163 |                     "created_at": tx.created_at
164 |                 })
165 |         else:
166 |             async for tx in transactions:
167 |                 transaction_list.append({
168 |                     "description": tx.description,
169 |                     "amount": tx.amount,
170 |                 })
171 |         return transaction_list
172 | 
173 | @mcp.tool()
174 | async def get_webhooks() -> list[dict[str, Any]]:
175 |     """Get all webhooks for the user."""
176 |     async with AsyncClient(token=UP_TOKEN) as client:
177 |         webhooks = await client.webhooks()
178 |         webhook_list = []
179 |         async for webhook in webhooks:
180 |             webhook_list.append({
181 |                 "id": webhook.id,
182 |                 "url": webhook.url,
183 |                 "description": webhook.description
184 |             })
185 |         return webhook_list
186 | 
187 | @mcp.tool()
188 | async def create_webhook(url: str, description: Optional[str] = None) -> dict[str, Any]:
189 |     """Create a new webhook.
190 |     
191 |     Args:
192 |         url: The URL that this webhook should post events to.
193 |     """
194 |     async with AsyncClient(token=UP_TOKEN) as client:
195 |         webhook = await client.webhook.create(url, description)
196 |         return {
197 |             "id": webhook.id,
198 |             "url": webhook.url,
199 |             "description": webhook.description,
200 |             "secret_key": webhook.secret_key,
201 |             "created_at": webhook.created_at
202 |         }
203 | 
204 | @mcp.tool()
205 | async def delete_webhook(webhook_id: str) -> bool:
206 |     """Delete a webhook.
207 |     
208 |     Args:
209 |         webhook_id: The ID of the webhook to delete.
210 |     """
211 |     async with AsyncClient(token=UP_TOKEN) as client:
212 |         return await client.webhook.delete(webhook_id)
213 | 
214 | @mcp.tool()
215 | async def ping_webhook(webhook_id: str) -> str:
216 |     """Ping a webhook.
217 |     
218 |     Args:
219 |         webhook_id: The ID of the webhook to ping.
220 |     """
221 |     async with AsyncClient(token=UP_TOKEN) as client:
222 |         event = await client.webhook.ping(webhook_id)
223 |         return str(event)
224 | 
225 | if __name__ == "__main__":
226 |     mcp.run(transport='stdio')
227 | 
```

--------------------------------------------------------------------------------
/test.py:
--------------------------------------------------------------------------------

```python
  1 | import pytest
  2 | import asyncio
  3 | from datetime import datetime, timedelta
  4 | import os
  5 | from typing import AsyncGenerator
  6 | 
  7 | import up_mcp
  8 | from upbankapi import AsyncClient, NotAuthorizedException
  9 | 
 10 | # Fixture to ensure UP_TOKEN is set
 11 | @pytest.fixture(scope="session", autouse=True)
 12 | def check_token():
 13 |     token = os.getenv("UP_TOKEN")
 14 |     if not token:
 15 |         pytest.skip("UP_TOKEN environment variable not set")
 16 |     return token
 17 | 
 18 | # Fixture for async tests
 19 | @pytest.fixture(scope="session")
 20 | def event_loop():
 21 |     loop = asyncio.get_event_loop()
 22 |     yield loop
 23 |     loop.close()
 24 | 
 25 | @pytest.fixture(scope="session")
 26 | async def client(check_token) -> AsyncGenerator[AsyncClient, None]:
 27 |     async with AsyncClient(token=check_token) as client:
 28 |         yield client
 29 | 
 30 | @pytest.mark.asyncio
 31 | async def test_get_user_id():
 32 |     """Test that we can authenticate and get a user ID."""
 33 |     result = await up_mcp.get_user_id()
 34 |     assert result is not None
 35 |     assert isinstance(result, str)
 36 |     
 37 |     # Extract the actual user ID from the result
 38 |     # The format is "Authorized: {user_id}"
 39 |     assert result.startswith("Authorized: ")
 40 |     user_id = result.split("Authorized: ")[1]
 41 |     
 42 |     # Check that the user_id is a valid UUID format
 43 |     assert isinstance(user_id, str)
 44 |     # Optional: validate UUID format if needed
 45 |     import uuid
 46 |     try:
 47 |         uuid_obj = uuid.UUID(user_id)
 48 |         assert str(uuid_obj) == user_id  # Ensures it's a valid UUID string
 49 |     except ValueError:
 50 |         pytest.fail(f"User ID '{user_id}' is not a valid UUID")
 51 | 
 52 | 
 53 | @pytest.mark.asyncio
 54 | async def test_get_accounts():
 55 |     """Test retrieving all accounts."""
 56 |     accounts = await up_mcp.get_accounts()
 57 |     assert accounts is not None
 58 |     assert isinstance(accounts, list)
 59 |     assert len(accounts) > 0
 60 |     
 61 |     # Check account structure based on actual response
 62 |     account = accounts[0]
 63 |     assert "id" in account
 64 |     assert "name" in account
 65 |     assert "balance" in account
 66 |     assert isinstance(account["id"], str)
 67 |     assert isinstance(account["name"], str)
 68 |     assert isinstance(account["balance"], (int, float))
 69 | 
 70 | 
 71 | @pytest.mark.asyncio
 72 | async def test_get_specific_account():
 73 |     """Test retrieving a specific account by ID."""
 74 |     # First get all accounts
 75 |     accounts = await up_mcp.get_accounts()
 76 |     account_id = accounts[0]["id"]
 77 |     
 78 |     # Then get specific account
 79 |     account = await up_mcp.get_account(account_id)
 80 |     assert account is not None
 81 |     
 82 |     # Check the structure of the response
 83 |     # The actual response is formatted like: <Account 'Spending' (TRANSACTIONAL): 9.29 AUD>
 84 |     assert isinstance(account, str)
 85 |     assert account.startswith("<Account ")
 86 |     assert account.endswith(">")
 87 |     
 88 |     # Check that the account name from the first response is in the specific account response
 89 |     account_name = accounts[0]["name"]
 90 |     assert account_name in account
 91 | 
 92 | 
 93 | @pytest.mark.asyncio
 94 | async def test_get_transactions():
 95 |     """Test retrieving transactions with various filters."""
 96 |     # Test with date filter
 97 |     since_date = datetime.now() - timedelta(days=7)
 98 |     transactions = await up_mcp.get_transactions(since=since_date)
 99 |     assert transactions is not None
100 |     assert isinstance(transactions, list)
101 |     
102 |     if len(transactions) > 0:
103 |         # Check transaction structure based on actual response
104 |         transaction = transactions[0]
105 |         assert isinstance(transaction, dict)
106 |         assert "description" in transaction
107 |         assert "amount" in transaction
108 |         assert isinstance(transaction["description"], str)
109 |         assert isinstance(transaction["amount"], (int, float))
110 |         
111 |         # Test with verbose parameter
112 |         verbose_transactions = await up_mcp.get_transactions(since=since_date, verbose=True)
113 |         if len(verbose_transactions) > 0:
114 |             verbose_tx = verbose_transactions[0]
115 |             assert "id" in verbose_tx
116 |             assert "status" in verbose_tx
117 |             assert "created_at" in verbose_tx
118 | 
119 | 
120 | @pytest.mark.asyncio
121 | async def test_get_categories():
122 |     """Test retrieving categories."""
123 |     categories = await up_mcp.get_categories()
124 |     assert categories is not None
125 |     assert isinstance(categories, list)
126 |     
127 |     if len(categories) > 0:
128 |         # Check category structure based on actual response
129 |         category = categories[0]
130 |         assert "id" in category
131 |         assert "name" in category
132 |         assert isinstance(category["id"], str)
133 |         assert isinstance(category["name"], str)
134 | 
135 | 
136 | @pytest.mark.asyncio
137 | async def test_invalid_account_id():
138 |     """Test error handling for invalid account ID."""
139 |     with pytest.raises(Exception):  # Replace with specific exception when implemented
140 |         await up_mcp.get_account("invalid-account-id")
141 | 
142 | 
143 | @pytest.mark.asyncio
144 | async def test_get_transaction():
145 |     """Test retrieving a specific transaction."""
146 |     # First get all transactions with verbose to get IDs
147 |     transactions = await up_mcp.get_transactions(verbose=True)
148 |     if len(transactions) > 0:
149 |         transaction_id = transactions[0]["id"]
150 |         
151 |         # Get the specific transaction
152 |         transaction = await up_mcp.get_transaction(transaction_id)
153 |         assert transaction is not None
154 |         
155 |         # Check that the response is formatted properly
156 |         # Actual response format is like: <Transaction HELD: -34.0 AUD [Claude Ai]>
157 |         assert isinstance(transaction, str)
158 |         assert transaction.startswith("<Transaction ")
159 |         assert transaction.endswith(">")
160 |         
161 |         # Verify the transaction amount and description are present in the response
162 |         tx_amount = str(abs(transactions[0]["amount"]))
163 |         tx_desc = transactions[0]["description"]
164 |         
165 |         # The specific format might vary, but amount and description should be in the string
166 |         assert tx_amount in transaction
167 |         assert tx_desc in transaction
168 | 
169 | 
170 | @pytest.mark.asyncio
171 | async def test_categorize_transaction():
172 |     """Test categorizing a transaction."""
173 |     # First get all transactions with verbose to get IDs
174 |     transactions = await up_mcp.get_transactions(verbose=True)
175 |     if len(transactions) > 0:
176 |         # Get categories to find a valid category ID
177 |         categories = await up_mcp.get_categories()
178 |         if len(categories) > 0:
179 |             transaction_id = transactions[0]["id"]
180 |             category_id = categories[0]["id"]
181 |             
182 |             # Attempt to categorize a transaction
183 |             result = await up_mcp.categorize_transaction(
184 |                 transaction_id=transaction_id,
185 |                 category_id=category_id
186 |             )
187 |             # Verify the result
188 |             assert result is not None
189 |             assert isinstance(result, bool)
190 |             assert result == True
191 | 
192 | 
193 | @pytest.mark.asyncio
194 | async def test_webhooks():
195 |     """Test webhook operations."""
196 |     # Get existing webhooks
197 |     webhooks = await up_mcp.get_webhooks()
198 |     
199 |     # Should at least return an empty list without error
200 |     assert isinstance(webhooks, list)
201 |     
202 |     # Test creating a webhook
203 |     test_url = "https://hello.requestcatcher.com/test"
204 |     result = await up_mcp.create_webhook(url=test_url)
205 |     
206 |     # Verify webhook creation result
207 |     assert result is not None
208 |     assert isinstance(result, dict)
209 |     assert "id" in result
210 |     webhook_id = result["id"]
211 |     
212 |     # Test pinging the webhook
213 |     ping_result = await up_mcp.ping_webhook(webhook_id=webhook_id)
214 |     assert ping_result is not None
215 |     
216 |     # Test deleting the webhook
217 |     delete_result = await up_mcp.delete_webhook(webhook_id=webhook_id)
218 |     assert delete_result is not None
219 |     
220 |     # Verify webhook was deleted by checking the list again
221 |     updated_webhooks = await up_mcp.get_webhooks()
222 |     assert all(webhook["id"] != webhook_id for webhook in updated_webhooks)
223 | 
224 | if __name__ == "__main__":
225 |     pytest.main([__file__, "-v"])
```