"""Exercise settlement MCP lifecycle through the repository's real stdio server.""" import asyncio from contextlib import closing import inspect import json import os import sqlite3 import sys from pathlib import Path import pytest mcp = pytest.importorskip('mcp') from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft', 'update_settlement_draft', 'refresh_settlement_draft_targets', 'set_settlement_draft_item', 'review_settlement_draft', 'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft', 'list_monthly_settlement_history', 'get_monthly_settlement_history_record') ROOT = Path(__file__).resolve().parents[1] def payload(result): assert not result.isError, result assert len(result.content) == 1 and result.content[0].type == 'text' data = json.loads(result.content[0].text) assert isinstance(data, dict) and isinstance(data.get('success'), bool) return data def require_fields(value, fields, context): missing = set(fields) - set(value) assert not missing, f'{context} missing required public fields: {sorted(missing)}' def test_required_public_field_assertion_detects_omission(): synthetic_contract_result = {'success': True, 'draft': {}} require_fields(synthetic_contract_result, {'success', 'draft'}, 'stdio result') dropped_field_copy = dict(synthetic_contract_result) dropped_field_copy.pop('draft') with pytest.raises(AssertionError, match='missing required public fields.*draft'): require_fields(dropped_field_copy, {'success', 'draft'}, 'dropped-field mutation control') def rows(db, table): assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'} with closing(sqlite3.connect(db)) as connection: return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall() def test_real_stdio_startup_and_all_ten_lifecycle_tools(tmp_path): asyncio.run(_exercise_stdio_lifecycle(tmp_path)) async def _exercise_stdio_lifecycle(tmp_path): from jarca_ledger_mcp import service as service db = tmp_path / 'mcp-runtime.sqlite3' env = os.environ.copy() env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT='stdio', PYTHONDONTWRITEBYTECODE='1') # The test owns this bounded stdio child; stdio_client reaps it on context exit. params = StdioServerParameters(command=sys.executable, args=['-B', '-m', 'jarca_ledger_mcp.server'], env=env, cwd=str(ROOT)) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: initialized = await session.initialize() assert initialized.serverInfo.name == 'jarca-ledger-mcp' listed = await session.list_tools() tool_map = {tool.name: tool for tool in listed.tools} assert set(OPS) <= set(tool_map) for name in OPS: schema = tool_map[name].inputSchema props = schema.get('properties', {}) assert 'db_path' not in props parameters = inspect.signature(getattr(service, name)).parameters assert set(props) == set(parameters) - {'db_path'} assert set(schema.get('required', [])) == {key for key, value in parameters.items() if key != 'db_path' and value.default is inspect.Parameter.empty} # Seed through a real service database setup, never through MCP stubs. from jarca_ledger_mcp.db import init_db init_db(str(db)) account = service.create_ledger_account('Synthetic MCP cash', db_path=str(db))['account']['id'] service.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='mcp-value', db_path=str(db)) async def call(name, args): return payload(await session.call_tool(name, args)) made = await call('create_settlement_draft', {'month':'2026-09','created_by':'stdio-test','idempotency_key':'mcp-draft'}) assert made['success'] require_fields(made, {'success', 'draft', 'items', 'review'}, 'create stdio result') assert made['draft']['status'] == 'active' and made['draft']['version'] == 1 draft_id = made['draft']['id'] listed_drafts = await call('list_settlement_drafts', {'month':'2026-09'}) require_fields(listed_drafts, {'success', 'drafts'}, 'list stdio result') assert listed_drafts['success'] and listed_drafts['drafts'][0]['id'] == draft_id fetched = await call('get_settlement_draft', {'draft_id':draft_id}) require_fields(fetched, {'success', 'draft', 'items', 'review'}, 'get stdio result') assert fetched['success'] and fetched['draft']['id'] == draft_id and fetched['draft']['version'] == 1 draft = fetched['draft'] updated = await call('update_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test','memo':'transport lifecycle'}) require_fields(updated, {'success', 'draft', 'items', 'review'}, 'update stdio result') assert updated['success'] and updated['draft']['memo'] == 'transport lifecycle' and updated['draft']['version'] == 2 draft = updated['draft'] refreshed = await call('refresh_settlement_draft_targets', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test'}) require_fields(refreshed, {'success', 'draft', 'items', 'review'}, 'refresh stdio result') assert refreshed['success'] and refreshed['draft']['version'] == 3 draft = refreshed['draft'] changed = await call('set_settlement_draft_item', {'draft_id':draft_id,'source_type':'account','source_id':account,'expected_version':draft['version'],'selected_by':'stdio-test','valuation_id':service.list_account_valuations(account, db_path=str(db))['valuations'][0]['id']}) require_fields(changed, {'success', 'draft', 'items', 'review'}, 'set-item stdio result') assert changed['success'] and changed['draft']['version'] == 4 draft = changed['draft'] reviewed = await call('review_settlement_draft', {'draft_id':draft_id}) assert reviewed['success'], reviewed require_fields(reviewed, {'success', 'draft_id', 'version', 'review_fingerprint', 'items', 'included_items', 'excluded_items', 'missing_items', 'warnings', 'approval_required', 'totals', 'complete', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'unregistered_liabilities'}, 'review stdio result') selected = reviewed['included_items'][0] for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'selection_mode', 'selection_reason', 'selected_by', 'selected_at', 'item_version'): assert field in selected, f'review selected evidence missing {field}' assert selected['valuation_date'] == '2026-09-30' and selected['age_days'] == 0 and selected['date_difference_days'] == 0 fingerprint = reviewed['review_fingerprint'] approved = await call('approve_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'approved_by':'stdio-test','approval_reason':'transport acceptance','approval_reference':'test'}) require_fields(approved, {'success', 'draft', 'items', 'review'}, 'approve stdio result') require_fields(approved['draft'], {'approved_by', 'approved_at', 'approval_reason', 'approval_reference', 'approval_flags'}, 'approved draft') assert approved['success'] assert approved['draft']['approved_by'] == 'stdio-test' and approved['draft']['approval_reason'] == 'transport acceptance' and approved['draft']['approval_reference'] == 'test' closed = await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) require_fields(closed, {'success', 'draft_id', 'draft_version', 'review_fingerprint', 'snapshot_run_id', 'run_id', 'monthly_source_snapshot_id', 'basis_date', 'close_status', 'closed_at', 'source', 'is_closed_snapshot', 'selection_policy', 'inserted_count', 'inserted_snapshot_ids', 'items', 'excluded_items', 'totals', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'settlement_label', 'warnings', 'carried_items', 'approval', 'correction', 'is_correction', 'correction_of_run_id', 'correction_reason', 'provenance', 'total_assets', 'total_liabilities', 'net_worth'}, 'finalize stdio result') assert closed['success'] and closed['totals']['net_worth'] == 100 assert closed['source'] == 'snapshot' and closed['is_closed_snapshot'] is True assert closed['snapshot_run_id'] == closed['run_id'] == closed['monthly_source_snapshot_id'] assert closed['approval']['approved_by'] == 'stdio-test' assert closed['approval']['allow_missing_exclusions'] is False and closed['approval']['allow_carried_before'] is False and closed['approval']['allow_manual_substitute'] is False item = closed['items'][0] for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'approval', 'draft_id', 'draft_version', 'review_fingerprint'): assert field in item, f'finalized selected evidence missing {field}' provenance = closed['provenance'] for field in ('valuation_date', 'age_days', 'carried_forward', 'verification_status', 'evidence_kind', 'approval', 'correction', 'warnings', 'exclusions'): assert field in provenance, f'final provenance missing {field}' assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days'] before = (await call('get_settlement_draft', {'draft_id':draft_id})) tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items') before_replay = {table: rows(db, table) for table in tables} assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed archived = await call('get_settlement_draft', {'draft_id':draft_id}) require_fields(archived, {'success', 'draft', 'items', 'final_response'}, 'archived get stdio result') assert archived['final_response'] == closed assert archived == before assert {table: rows(db, table) for table in tables} == before_replay history = await call('list_monthly_settlement_history', {'month': '2026-09'}) require_fields(history, {'success', 'history', 'month', 'read_only'}, 'history list stdio result') assert history['success'] and history['read_only'] is True assert [item['run_id'] for item in history['history']] == [closed['run_id']] record = await call('get_monthly_settlement_history_record', {'run_id': closed['run_id']}) require_fields(record, {'success', 'history', 'read_only'}, 'history record stdio result') assert record['success'] and record['read_only'] is True assert record['history']['run_id'] == closed['run_id'] assert record['history']['net_worth'] == 100 second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'}) assert second['success'] cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'}) require_fields(cancelled, {'success', 'draft', 'items', 'review'}, 'cancel stdio result') assert cancelled['success'] and cancelled['draft']['status'] == 'cancelled' assert cancelled['draft']['cancelled_by'] == 'stdio-test' and cancelled['draft']['cancellation_reason'] == 'synthetic cancellation' # Legacy close retains required contract fields; this is a subset check, not exact whole-key equality. legacy_account = service.create_ledger_account('Synthetic legacy cash', db_path=str(db))['account']['id'] service.register_account_valuation(legacy_account, '2026-11-30', 31, verification_status='verified', idempotency_key='legacy-value', db_path=str(db)) preview = await call('preview_asset_snapshot_close', {'basis_date':'2026-11-30','selection_policy':'exact'}) assert preview['success'] and preview['source'] == 'live_authority' and preview['is_closed_snapshot'] is False legacy = await call('close_asset_snapshot', {'basis_date':'2026-11-30','selection_policy':'exact','idempotency_key':'legacy-close','allow_missing_exclusions':True,'approved_by':'stdio-test','approval_reason':'legacy transport test','approval_reference':'legacy-test'}) legacy_fields = {'snapshot_run_id', 'close_status', 'approval', 'correction', 'warnings', 'excluded_items', 'carried_items', 'provenance', 'valuation_date', 'age_days', 'verification_status', 'evidence_kind', 'monthly_source_snapshot_id'} require_fields(legacy, {'success'} | legacy_fields, 'legacy close stdio result') assert not ({'draft_id', 'draft_version', 'review_fingerprint', 'contains_substitutions', 'contains_carried', 'has_approved_missing'} & set(legacy)) assert legacy['monthly_source_snapshot_id'] == legacy['snapshot_run_id']