Skip to content

Commit c5e4091

Browse files
davidtorciviaclaude
andcommitted
Remove compat shim indirection, simplify MCP tool registration
Replace the fragile pattern where service modules accessed get_settings, boto3, and date through a compat re-export module (services.py) with direct imports. Tests now patch at the point of use, which is the standard Python mocking practice. BackupService.__init__ no longer calls get_settings() when backup_dir is provided explicitly, removing an unnecessary coupling that broke tests. Simplify mcp/server.py from 49 lines of individual function imports to 8 module imports for tool registration. Tests import from the source modules directly instead of through server.py re-exports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2397627 commit c5e4091

7 files changed

Lines changed: 48 additions & 108 deletions

File tree

invoice_machine/mcp/server.py

Lines changed: 11 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,19 @@
22

33
from __future__ import annotations
44

5-
from .analytics_tools import ( # noqa: F401
6-
get_client_invoice_context,
7-
get_client_lifetime_value,
8-
get_revenue_summary,
9-
)
10-
from .client_tools import ( # noqa: F401
11-
create_client,
12-
delete_client,
13-
get_client,
14-
list_clients,
15-
restore_client,
16-
update_client,
5+
# Import tool modules to register @mcp.tool() decorated functions.
6+
# New tools added to any module are automatically registered.
7+
from . import ( # noqa: F401
8+
analytics_tools,
9+
client_tools,
10+
document_tools,
11+
email_tools,
12+
invoice_tools,
13+
profile_tools,
14+
recurring_tools,
15+
search_tools,
1716
)
1817
from .context import mcp
19-
from .document_tools import generate_pdf, list_trash # noqa: F401
20-
from .email_tools import ( # noqa: F401
21-
get_email_templates,
22-
preview_invoice_email,
23-
send_invoice_email,
24-
test_smtp_connection,
25-
update_email_templates,
26-
)
27-
from .invoice_tools import ( # noqa: F401
28-
add_invoice_item,
29-
create_invoice,
30-
delete_invoice,
31-
get_invoice,
32-
list_invoices,
33-
remove_invoice_item,
34-
restore_invoice,
35-
update_invoice,
36-
update_invoice_item,
37-
)
38-
from .profile_tools import ( # noqa: F401
39-
add_payment_method,
40-
get_business_profile,
41-
remove_payment_method,
42-
update_business_profile,
43-
)
44-
from .recurring_tools import ( # noqa: F401
45-
create_recurring_schedule,
46-
delete_recurring_schedule,
47-
get_recurring_schedule,
48-
list_recurring_schedules,
49-
pause_recurring_schedule,
50-
resume_recurring_schedule,
51-
trigger_recurring_schedule,
52-
update_recurring_schedule,
53-
)
54-
from .search_tools import search # noqa: F401
5518

5619

5720
def main():

invoice_machine/service/backups.py

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,13 @@
88
from datetime import UTC, datetime, timedelta
99
from pathlib import Path
1010

11+
import boto3
1112
from botocore.config import Config
1213

14+
from invoice_machine.config import get_settings
1315
from invoice_machine.utils import utc_now
1416

1517

16-
def _compat_get_settings():
17-
"""Read settings through the compatibility module to keep legacy patches working."""
18-
from invoice_machine import services as compat
19-
20-
return compat.get_settings()
21-
22-
23-
def _compat_boto3_client(*args, **kwargs):
24-
"""Create a boto3 client through the compatibility module for legacy patches."""
25-
from invoice_machine import services as compat
26-
27-
return compat.boto3.client(*args, **kwargs)
28-
29-
3018
class BackupService:
3119
"""Service for creating and managing backups."""
3220

@@ -36,15 +24,16 @@ def __init__(
3624
retention_days: int = 30,
3725
s3_config: dict | None = None,
3826
):
39-
settings = _compat_get_settings()
40-
self.backup_dir = backup_dir or settings.data_dir / "backups"
27+
if backup_dir is None:
28+
backup_dir = get_settings().data_dir / "backups"
29+
self.backup_dir = backup_dir
4130
self.retention_days = retention_days
4231
self.s3_config = s3_config
4332
self.backup_dir.mkdir(parents=True, exist_ok=True)
4433

4534
def create_backup(self, compress: bool = True) -> dict:
4635
"""Create a backup of the database and optionally upload it to S3."""
47-
settings = _compat_get_settings()
36+
settings = get_settings()
4837
timestamp = utc_now()
4938
timestamp_str = timestamp.strftime("%Y%m%d_%H%M%S")
5039

@@ -88,7 +77,7 @@ def _upload_to_s3(self, local_path: Path, filename: str):
8877
if not self.s3_config:
8978
raise ValueError("S3 configuration not provided")
9079

91-
s3_client = _compat_boto3_client(
80+
s3_client = boto3.client(
9281
"s3",
9382
endpoint_url=self.s3_config.get("endpoint_url"),
9483
aws_access_key_id=self.s3_config.get("access_key_id"),
@@ -143,7 +132,7 @@ def _cleanup_s3_backups(self, cutoff: datetime) -> int:
143132
if not self.s3_config:
144133
return 0
145134

146-
s3_client = _compat_boto3_client(
135+
s3_client = boto3.client(
147136
"s3",
148137
endpoint_url=self.s3_config.get("endpoint_url"),
149138
aws_access_key_id=self.s3_config.get("access_key_id"),
@@ -222,7 +211,7 @@ def get_backup_path(self, backup_filename: str, must_exist: bool = True) -> Path
222211

223212
def restore_backup(self, backup_filename: str, validate: bool = True) -> dict:
224213
"""Restore the database from a backup using atomic replacement."""
225-
settings = _compat_get_settings()
214+
settings = get_settings()
226215
backup_path = self.get_backup_path(backup_filename)
227216
if validate:
228217
self.validate_backup(backup_path)
@@ -259,7 +248,7 @@ def download_from_s3(self, filename: str) -> Path:
259248
raise ValueError("S3 is not configured")
260249

261250
local_path = self._validate_backup_filename(filename)
262-
s3_client = _compat_boto3_client(
251+
s3_client = boto3.client(
263252
"s3",
264253
endpoint_url=self.s3_config.get("endpoint_url"),
265254
aws_access_key_id=self.s3_config.get("access_key_id"),
@@ -279,7 +268,7 @@ def list_s3_backups(self) -> list[dict]:
279268
return []
280269

281270
try:
282-
s3_client = _compat_boto3_client(
271+
s3_client = boto3.client(
283272
"s3",
284273
endpoint_url=self.s3_config.get("endpoint_url"),
285274
aws_access_key_id=self.s3_config.get("access_key_id"),

invoice_machine/service/recurring.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,6 @@
1616
from invoice_machine.utils import utc_now
1717

1818

19-
def _today() -> date:
20-
"""Read today's date through the compatibility module to keep legacy patches working."""
21-
from invoice_machine import services as compat
22-
23-
return compat.date.today()
24-
25-
2619
class RecurringService:
2720
"""Service for managing recurring invoice schedules."""
2821

@@ -51,7 +44,7 @@ async def create_schedule(
5144
)
5245

5346
if next_invoice_date is None:
54-
today = _today()
47+
today = date.today()
5548
if frequency == "daily":
5649
next_invoice_date = today + timedelta(days=1)
5750
elif frequency == "weekly":
@@ -149,7 +142,7 @@ async def update_schedule(
149142
and "next_invoice_date" not in kwargs
150143
):
151144
kwargs["next_invoice_date"] = RecurringService.calculate_next_date(
152-
_today(), new_frequency, new_schedule_day
145+
date.today(), new_frequency, new_schedule_day
153146
)
154147

155148
for key, value in kwargs.items():
@@ -219,7 +212,7 @@ async def process_due_schedules(session: AsyncSession) -> list[dict]:
219212
"""Process all schedules due today or earlier and create invoices."""
220213
from invoice_machine.service.invoices import InvoiceService
221214

222-
today = _today()
215+
today = date.today()
223216
result = await session.execute(
224217
select(RecurringSchedule).where(
225218
RecurringSchedule.is_active == 1,
@@ -278,7 +271,7 @@ async def trigger_schedule(session: AsyncSession, schedule_id: int) -> dict:
278271
if not schedule:
279272
return {"success": False, "error": "Schedule not found"}
280273

281-
today = _today()
274+
today = date.today()
282275
try:
283276
line_items = json.loads(schedule.line_items) if schedule.line_items else []
284277
invoice = await InvoiceService.create_invoice(

invoice_machine/services.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
"""Compatibility exports for the service layer."""
22

3-
from datetime import date # noqa: F401 — accessed via compat.date by service modules
4-
5-
import boto3 # noqa: F401 — accessed via compat.boto3 by BackupService
6-
7-
from invoice_machine.config import get_settings # noqa: F401 — accessed via compat.get_settings
83
from invoice_machine.service.backups import BackupService, get_backup_service
94
from invoice_machine.service.clients import ClientService
105
from invoice_machine.service.common import (

tests/test_backup.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def test_create_backup_compressed(self, mock_db_file):
7373
backup_dir = Path(tmpdir) / "backups"
7474
service = BackupService(backup_dir=backup_dir)
7575

76-
with patch("invoice_machine.services.get_settings") as mock_settings:
76+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
7777
mock_settings.return_value.data_dir = mock_db_file.parent
7878

7979
result = service.create_backup(compress=True)
@@ -95,7 +95,7 @@ def test_create_backup_uncompressed(self, mock_db_file):
9595
backup_dir = Path(tmpdir) / "backups"
9696
service = BackupService(backup_dir=backup_dir)
9797

98-
with patch("invoice_machine.services.get_settings") as mock_settings:
98+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
9999
mock_settings.return_value.data_dir = mock_db_file.parent
100100

101101
result = service.create_backup(compress=False)
@@ -115,7 +115,7 @@ def test_create_backup_db_not_found(self):
115115
backup_dir = Path(tmpdir) / "backups"
116116
service = BackupService(backup_dir=backup_dir)
117117

118-
with patch("invoice_machine.services.get_settings") as mock_settings:
118+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
119119
mock_settings.return_value.data_dir = Path("/nonexistent")
120120

121121
with pytest.raises(FileNotFoundError, match="Database not found"):
@@ -134,7 +134,7 @@ def test_create_backup_with_s3_upload(self, mock_db_file):
134134
}
135135
service = BackupService(backup_dir=backup_dir, s3_config=s3_config)
136136

137-
with patch("invoice_machine.services.get_settings") as mock_settings:
137+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
138138
mock_settings.return_value.data_dir = mock_db_file.parent
139139

140140
with patch.object(service, "_upload_to_s3") as mock_upload:
@@ -150,7 +150,7 @@ def test_create_backup_s3_upload_failure(self, mock_db_file):
150150
s3_config = {"enabled": True, "bucket": "test-bucket"}
151151
service = BackupService(backup_dir=backup_dir, s3_config=s3_config)
152152

153-
with patch("invoice_machine.services.get_settings") as mock_settings:
153+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
154154
mock_settings.return_value.data_dir = mock_db_file.parent
155155

156156
with patch.object(
@@ -169,7 +169,7 @@ def test_create_backup_timestamp_in_filename(self, mock_db_file):
169169
backup_dir = Path(tmpdir) / "backups"
170170
service = BackupService(backup_dir=backup_dir)
171171

172-
with patch("invoice_machine.services.get_settings") as mock_settings:
172+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
173173
mock_settings.return_value.data_dir = mock_db_file.parent
174174

175175
result = service.create_backup()
@@ -293,7 +293,7 @@ def test_upload_to_s3_success(self):
293293
}
294294
service = BackupService(backup_dir=backup_dir, s3_config=s3_config)
295295

296-
with patch("invoice_machine.services.boto3.client") as mock_boto:
296+
with patch("invoice_machine.service.backups.boto3.client") as mock_boto:
297297
mock_client = MagicMock()
298298
mock_boto.return_value = mock_client
299299

@@ -321,7 +321,7 @@ def test_upload_to_s3_default_prefix(self):
321321
}
322322
service = BackupService(backup_dir=backup_dir, s3_config=s3_config)
323323

324-
with patch("invoice_machine.services.boto3.client") as mock_boto:
324+
with patch("invoice_machine.service.backups.boto3.client") as mock_boto:
325325
mock_client = MagicMock()
326326
mock_boto.return_value = mock_client
327327

@@ -406,7 +406,7 @@ def test_restore_backup_success(self):
406406

407407
service = BackupService(backup_dir=backup_dir)
408408

409-
with patch("invoice_machine.services.get_settings") as mock_settings:
409+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
410410
mock_settings.return_value.data_dir = data_dir
411411

412412
result = service.restore_backup(
@@ -440,7 +440,7 @@ def test_restore_compressed_backup(self):
440440

441441
service = BackupService(backup_dir=backup_dir)
442442

443-
with patch("invoice_machine.services.get_settings") as mock_settings:
443+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
444444
mock_settings.return_value.data_dir = data_dir
445445

446446
result = service.restore_backup(
@@ -481,7 +481,7 @@ def test_restore_creates_pre_restore_backup(self):
481481

482482
service = BackupService(backup_dir=backup_dir)
483483

484-
with patch("invoice_machine.services.get_settings") as mock_settings:
484+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
485485
mock_settings.return_value.data_dir = data_dir
486486

487487
result = service.restore_backup(
@@ -510,7 +510,7 @@ def test_restore_backup_cleans_up_temporary_restore_file(self):
510510

511511
service = BackupService(backup_dir=backup_dir)
512512

513-
with patch("invoice_machine.services.get_settings") as mock_settings:
513+
with patch("invoice_machine.service.backups.get_settings") as mock_settings:
514514
mock_settings.return_value.data_dir = data_dir
515515

516516
service.restore_backup(

0 commit comments

Comments
 (0)