Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# LamPyrid

A Model Context Protocol (MCP) server providing comprehensive tools for interacting with [Firefly III](https://github.com/firefly-iii/firefly-iii) personal finance software. LamPyrid enables automated personal finance workflows and analysis through 18 MCP tools with support for account management, transaction operations, and budget management.
A Model Context Protocol (MCP) server providing comprehensive tools for interacting with [Firefly III](https://github.com/firefly-iii/firefly-iii) personal finance software. LamPyrid enables automated personal finance workflows and analysis through 22 MCP tools with support for account management, transaction operations, budget management, and financial insights.

> **What is Firefly III?** [Firefly III](https://www.firefly-iii.org/) is a free and open-source personal finance manager that helps you track expenses, income, budgets, and more. LamPyrid provides an MCP interface to automate interactions with your Firefly III instance.

Expand Down Expand Up @@ -159,14 +159,15 @@ Enable persistent authentication across server restarts:
| `bulk_update_transactions` | Update multiple transactions in a single operation |
| `delete_transaction` | Delete transactions by ID |

### Budget Management (5 tools)
### Budget Management (6 tools)
| Tool | Description |
|------|-------------|
| `list_budgets` | List all budgets with optional filtering |
| `get_budget` | Get detailed budget information |
| `get_budget_spending` | Analyze spending for specific budgets and periods |
| `get_budget_summary` | Comprehensive summary of all budgets with spending |
| `get_available_budget` | Check available budget amounts for periods |
| `create_budget` | Create new budgets with auto-budget options |

## Docker

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ markers = [
"transactions: transaction-related tests",
"budgets: budget-related tests",
"insights: insight analysis tests",
"unit: marks tests as unit tests",
]

[tool.datamodel-codegen]
Expand Down
7 changes: 7 additions & 0 deletions src/lampyrid/clients/firefly.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,13 @@ async def create_budget(self, budget_store: BudgetStore) -> BudgetSingle:
r.raise_for_status()
return BudgetSingle.model_validate(r.json())

async def delete_budget(self, budget_id: str) -> bool:
"""Delete a budget by ID."""
r = await self._client.delete(f'/api/v1/budgets/{budget_id}')
self._handle_api_error(r)
r.raise_for_status()
return r.status_code == 204

async def get_available_budgets(
self, start_date: Optional[date] = None, end_date: Optional[date] = None
) -> AvailableBudgetArray:
Expand Down
20 changes: 17 additions & 3 deletions src/lampyrid/models/firefly_models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
# generated by datamodel-codegen:
# filename: firefly-iii-6.4.16-v1.yaml
# timestamp: 2026-01-29T18:10:22+00:00
#
# NOTE: Manual modifications have been made to this file to work around
# Firefly III API bugs. See comments marked with "MANUAL FIX" below.

from __future__ import annotations

from datetime import date as date_aliased
from enum import Enum
from typing import Any
from typing import Annotated, Any

from pydantic import AnyUrl, AwareDatetime, BaseModel, EmailStr, Field, RootModel
from pydantic import AnyUrl, AwareDatetime, BaseModel, BeforeValidator, EmailStr, Field, RootModel


# MANUAL FIX: Firefly III API bug (Issue #43) returns currency_id as int instead of str
# in spent_in_budgets/spent_outside_budgets arrays. This validator coerces int to str.
def _coerce_to_str(v: Any) -> str | None:
if v is None:
return None
return str(v)


class AutocompleteAccount(BaseModel):
Expand Down Expand Up @@ -529,7 +540,10 @@ class InsightTransferEntry(BaseModel):


class ArrayEntryWithCurrencyAndSum(BaseModel):
currency_id: str | None = Field(None, examples=['5'])
# MANUAL FIX: Use Annotated with BeforeValidator to coerce int to str (Issue #43)
currency_id: Annotated[str | None, BeforeValidator(_coerce_to_str)] = Field(
None, examples=['5']
)
currency_code: str | None = Field(None, examples=['USD'])
currency_symbol: str | None = Field(None, examples=['$'])
currency_decimal_places: int | None = Field(
Expand Down
52 changes: 52 additions & 0 deletions src/lampyrid/models/lampyrid_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,58 @@ class GetAvailableBudgetRequest(BaseModel):
)


class CreateBudgetRequest(BaseModel):
"""Request model for creating a new budget."""

model_config = ConfigDict(extra='forbid')

name: str = Field(
...,
description='Name of the budget (e.g., "Groceries", "Entertainment")',
min_length=1,
)
auto_budget_type: Optional[Literal['none', 'reset', 'rollover']] = Field(
None,
description=(
'Auto-budget behavior: none (manual), reset (fixed amount each period), '
'rollover (unused balance carries forward)'
),
)
auto_budget_amount: Optional[float] = Field(
None,
description='Amount to auto-allocate each period (required if auto_budget_type is set)',
gt=0,
)
auto_budget_period: Optional[
Literal['daily', 'weekly', 'monthly', 'quarterly', 'half-year', 'yearly']
] = Field(
None,
description='How often to reset/add to the budget (required if auto_budget_type is set)',
)
auto_budget_currency_code: Optional[str] = Field(
None,
description='Currency code for auto-budget amount (ISO 4217, e.g., "USD", "EUR")',
)
active: bool = Field(
True,
description='Whether the budget is active for new transactions',
)
notes: Optional[str] = Field(
None,
description='Optional notes or description about this budget',
)

@model_validator(mode='after')
def validate_auto_budget(self) -> 'CreateBudgetRequest':
"""Ensure auto-budget fields are provided when auto_budget_type is set."""
if self.auto_budget_type and self.auto_budget_type != 'none':
if self.auto_budget_amount is None:
raise ValueError('auto_budget_amount is required when auto_budget_type is set')
if self.auto_budget_period is None:
raise ValueError('auto_budget_period is required when auto_budget_type is set')
return self


class CreateBulkTransactionsRequest(BaseModel):
"""Create multiple transactions in one operation."""

Expand Down
30 changes: 28 additions & 2 deletions src/lampyrid/services/budgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@

from ..clients.firefly import FireflyClient
from ..models.firefly_models import (
AutoBudgetPeriod,
AutoBudgetPeriodEnum,
AutoBudgetType,
AutoBudgetTypeEnum,
BudgetStore,
)
from ..models.lampyrid_models import (
AvailableBudget,
Budget,
BudgetSpending,
BudgetSummary,
CreateBudgetRequest,
GetAvailableBudgetRequest,
GetBudgetRequest,
GetBudgetSpendingRequest,
Expand Down Expand Up @@ -194,15 +199,36 @@ async def get_available_budget(self, req: GetAvailableBudgetRequest) -> Availabl
end_date=req.end_date or today,
)

async def create_budget(self, budget_store: BudgetStore) -> Budget:
async def create_budget(self, req: CreateBudgetRequest) -> Budget:
"""Create a new budget.

Args:
budget_store: Budget data for creation
req: Request containing budget creation parameters

Returns:
Created budget details

"""
budget_store = BudgetStore(
name=req.name,
active=req.active,
notes=req.notes,
)

# Handle auto-budget settings if provided
if req.auto_budget_type is not None:
budget_store.auto_budget_type = AutoBudgetType(AutoBudgetTypeEnum(req.auto_budget_type))

if req.auto_budget_amount is not None:
budget_store.auto_budget_amount = str(req.auto_budget_amount)

if req.auto_budget_period is not None:
budget_store.auto_budget_period = AutoBudgetPeriod(
AutoBudgetPeriodEnum(req.auto_budget_period)
)

if req.auto_budget_currency_code is not None:
budget_store.auto_budget_currency_code = req.auto_budget_currency_code

budget_single = await self._client.create_budget(budget_store)
return Budget.from_budget_read(budget_single.data)
10 changes: 10 additions & 0 deletions src/lampyrid/tools/budgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Budget,
BudgetSpending,
BudgetSummary,
CreateBudgetRequest,
GetAvailableBudgetRequest,
GetBudgetRequest,
GetBudgetSpendingRequest,
Expand Down Expand Up @@ -78,4 +79,13 @@ async def get_available_budget(req: GetAvailableBudgetRequest) -> AvailableBudge
"""
return await budget_service.get_available_budget(req)

@budgets_mcp.tool(tags={'budgets', 'create'})
async def create_budget(req: CreateBudgetRequest) -> Budget:
"""Create a new budget for expense tracking and financial planning.

Budgets help organize spending by category. Use auto-budget options for
automatic allocation each period (daily/weekly/monthly/etc).
"""
return await budget_service.create_budget(req)

return budgets_mcp
30 changes: 28 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@
AccountRolePropertyEnum,
AccountStore,
AccountTypeFilter,
BudgetStore,
ShortAccountTypeProperty,
)
from lampyrid.models.lampyrid_models import (
Account,
Budget,
CreateBudgetRequest,
CreateDepositRequest,
CreateTransferRequest,
CreateWithdrawalRequest,
Expand Down Expand Up @@ -193,7 +193,7 @@ async def _setup_test_data():
break

if test_budget is None:
budget_store = BudgetStore(name='Test Budget', active=True)
budget_store = CreateBudgetRequest(name='Test Budget', active=True)
test_budget = await budget_service.create_budget(budget_store)
_created_budget_ids.append(test_budget.id)

Expand Down Expand Up @@ -494,6 +494,32 @@ async def test_create_transaction(firefly_client, transaction_cleanup):
print(f'Failed to cleanup transaction {transaction_id}: {e}')


@pytest.fixture
async def budget_cleanup(firefly_client: FireflyClient):
"""Fixture to track and cleanup budgets created during tests.

Usage:
@pytest.mark.asyncio
async def test_create_budget(mcp_client, budget_cleanup):
result = await mcp_client.call_tool('create_budget', {...})
budget_cleanup.append(result.structured_content['id'])
# Test code here
# Budget will be deleted after test completes
"""
created_budget_ids: List[str] = []

# Provide the list to the test
yield created_budget_ids

# Cleanup after test
for budget_id in created_budget_ids:
try:
await firefly_client.delete_budget(budget_id)
print(f'Cleaned up budget: {budget_id}')
except Exception as e:
print(f'Failed to cleanup budget {budget_id}: {e}')


@pytest.fixture(scope='session', autouse=True)
async def _cleanup_seed_transactions():
"""Cleanup seed transactions at session end.
Expand Down
24 changes: 24 additions & 0 deletions tests/fixtures/budgets.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Test data factories for budget-related tests."""

from datetime import date, timedelta
from typing import Literal

from lampyrid.models.lampyrid_models import (
CreateBudgetRequest,
GetAvailableBudgetRequest,
GetBudgetRequest,
GetBudgetSpendingRequest,
Expand Down Expand Up @@ -64,3 +66,25 @@ def make_get_available_budget_request(
end = next_month.replace(day=1) - timedelta(days=1)

return GetAvailableBudgetRequest(start_date=start, end_date=end)


def make_create_budget_request(
name: str,
auto_budget_type: Literal['none', 'reset', 'rollover'] | None = None,
auto_budget_amount: float | None = None,
auto_budget_period: Literal['daily', 'weekly', 'monthly', 'quarterly', 'half-year', 'yearly']
| None = None,
auto_budget_currency_code: str | None = None,
active: bool = True,
notes: str | None = None,
) -> CreateBudgetRequest:
"""Create a CreateBudgetRequest for testing."""
return CreateBudgetRequest(
name=name,
auto_budget_type=auto_budget_type,
auto_budget_amount=auto_budget_amount,
auto_budget_period=auto_budget_period,
auto_budget_currency_code=auto_budget_currency_code,
active=active,
notes=notes,
)
Loading