From 27fe7c329c8be702ebb7014a463512a7a3120952 Mon Sep 17 00:00:00 2001 From: Diego Garcia Date: Tue, 3 Feb 2026 16:07:13 +0100 Subject: [PATCH] :recycle: Basic version of the Compatibility MCP server with calc_penalty :recycle: --- .env | 17 +- .github/workflows/ci.yml | 18 +- Containerfile | 8 +- README.md | 393 +------ mcpb/manifest.json | 4 +- src/common/compatibility_engine.rs | 1680 ++-------------------------- src/common/metrics.rs | 8 +- src/mcp_server.rs | 2 +- src/stdio_server.rs | 2 +- 9 files changed, 136 insertions(+), 1996 deletions(-) diff --git a/.env b/.env index da804b5..eb4a99e 100644 --- a/.env +++ b/.env @@ -1,13 +1,12 @@ # --- Project Configuration --- -ENGINE_NAME="compatibility-engine" -APP_NAME="${ENGINE_NAME}-mcp-rs" +ENGINE_NAME="penalty-engine" +APP_NAME="penalty-engine" MAINTAINER="Alpha Hack Group " -DESCRIPTION="Compatibility Engine MCP Server - Model Context Protocol server to check benefits" -TITLE="Compatibility Engine MCP Server" -SOURCE="https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git" - -# Version (managed by cargo-release) -VERSION=2.0.2 +TITLE="Penalty Engine MCP Server" +DESCRIPTION="Penalty Engine MCP Server - Model Context Protocol server" +VERSION=1.3.3 +PORT=8000 +SOURCE="https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git#penalty" # Container Configuration BASE_TAG="9.6" @@ -17,5 +16,5 @@ PORT=8000 CONTAINERFILE="Containerfile" # Registry Configuration -ORG=atarazana +ORG=dgarciap REGISTRY=quay.io/${ORG} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 497bee7..fd44df6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI Pipeline on: push: - branches: [ main ] + branches: [ workshop ] pull_request: - branches: [ main ] + branches: [ workshop ] workflow_dispatch: env: @@ -12,7 +12,7 @@ env: jobs: test: - name: Test Compatibility Engine MCP Server + name: Test Penalty Engine MCP Server runs-on: ubuntu-latest steps: @@ -146,7 +146,7 @@ jobs: echo "Checking with $CONTAINER_RUNTIME:" $CONTAINER_RUNTIME images || echo "$CONTAINER_RUNTIME images command failed" - if $CONTAINER_RUNTIME images --format "table {{.Repository}}:{{.Tag}}" 2>/dev/null | grep -q "compatibility-engine-mcp"; then + if $CONTAINER_RUNTIME images --format "table {{.Repository}}:{{.Tag}}" 2>/dev/null | grep -q "penalty-engine-mcp"; then echo "✅ Found image with $CONTAINER_RUNTIME" FOUND=true fi @@ -156,7 +156,7 @@ jobs: echo "Checking with Docker as fallback:" docker images || echo "Docker images command failed" - if docker images --format "table {{.Repository}}:{{.Tag}}" 2>/dev/null | grep -q "compatibility-engine-mcp"; then + if docker images --format "table {{.Repository}}:{{.Tag}}" 2>/dev/null | grep -q "penalty-engine-mcp"; then echo "✅ Found image with Docker" FOUND=true fi @@ -167,7 +167,7 @@ jobs: echo "Checking with Podman as fallback:" podman images || echo "Podman images command failed" - if podman images --format "table {{.Repository}}:{{.Tag}}" 2>/dev/null | grep -q "compatibility-engine-mcp"; then + if podman images --format "table {{.Repository}}:{{.Tag}}" 2>/dev/null | grep -q "penalty-engine-mcp"; then echo "✅ Found image with Podman" FOUND=true fi @@ -226,12 +226,12 @@ jobs: # REGISTRY=${{ secrets.CONTAINER_REGISTRY }} # # Application details (production build) - # APP_NAME=compatibility-engine-mcp-server + # APP_NAME=penalty-engine-mcp-server # MAINTAINER="Alpha Hack Group " - # DESCRIPTION="Compatibility Engine MCP Server - Model Context Protocol server for compatibility evaluation" + # DESCRIPTION="Penalty Engine MCP Server - Model Context Protocol server for penalty calculation" # VERSION=${GITHUB_SHA::8} # PORT=8001 - # SOURCE=https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git + # SOURCE=https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git#workshop # # Base image configuration # BASE_IMAGE=registry.access.redhat.com/ubi9/ubi-minimal diff --git a/Containerfile b/Containerfile index b15dc78..334bf7f 100644 --- a/Containerfile +++ b/Containerfile @@ -5,10 +5,10 @@ ARG BASE_IMAGE=registry.access.redhat.com/ubi9/ubi-minimal ARG BASE_TAG=9.6 ARG VERSION=2.0.2 ARG MAINTAINER="Alpha Hack Group " -ARG DESCRIPTION="Compatibility Engine MCP Server - Model Context Protocol server to check benefits" -ARG APP_NAME=compatibility-engine-mcp-rs +ARG DESCRIPTION="Penalty Engine MCP Server - Model Context Protocol server to calculate penalties" +ARG APP_NAME=penalty-engine-mcp-rs ARG PORT=8000 -ARG SOURCE=https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git +ARG SOURCE=https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git#workshop # Multi-stage build # Stage 1: Build stage with Rust toolchain @@ -86,7 +86,7 @@ RUN microdnf update -y && \ # Create non-root user for security RUN useradd -r -u 1001 -g 0 -s /sbin/nologin \ - -c "Compatibility Engine MCP Server user" mcpserver + -c "Penalty Engine MCP Server user" mcpserver # Set working directory WORKDIR /app diff --git a/README.md b/README.md index ecfb159..8b677c9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# Compatibility Engine MCP Server +# Penalty Engine MCP Server -> **Example Model Context Protocol (MCP) Server providing five calculation and compatibility functions** +> **Example Model Context Protocol (MCP) Server providing a penalty calculation function** [![CI Pipeline](https://github.com/alpha-hack-program/eligibility-engine-mcp-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/alpha-hack-program/eligibility-engine-mcp-rs/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=flat&logo=rust&logoColor=white)](https://www.rust-lang.org/) -An example Model Context Protocol (MCP) server developed in Rust that provides five strongly-typed calculation and compatibility functions. This project demonstrates how to build MCP servers with explicit computational logic. +An example Model Context Protocol (MCP) server developed in Rust that provides a strongly-typed penalty calculation function. This project demonstrates how to build MCP servers with explicit computational logic. ## Why an MCP Server like this? @@ -19,7 +19,7 @@ Some references around this subject: ## ⚠️ **DISCLAIMER** -This server provides five calculation functions that demonstrate various computational patterns commonly used in business applications. All calculations are explicit and transparent. +This server provides a penalty calculation function that demonstrates explicit, transparent business logic. **This is a demonstration/example project only.** The calculations and logic implemented here are for educational and demonstration purposes. This software: @@ -32,20 +32,15 @@ For real financial or legal calculations, please consult appropriate professiona ## Introduction -In fictional Lysmark Republic the Ministry of Technology and Innovation have started to build a ChatBOT to help their citizens with their queries and although they tried to build it around a naïve RAG soon they realized that queries like "My income is 40000 how much are my taxes?" weren't easy to resolve this way. As a small country at Lysmark Republic they tend to be frugal and prefer enginering solution over just throwing resources at problems. +In fictional Lysmark Republic the Ministry of Technology and Innovation have started to build a ChatBOT to help their citizens with their queries and although they tried to build it around a naïve RAG soon they realized that queries like "What penalty applies for a 15-day delay?" weren't easy to resolve this way. As a small country at Lysmark Republic they tend to be frugal and prefer enginering solution over just throwing resources at problems. -So they decided to use agents and in order to provide tools to the agents in a standard way they built an MCP server starting with these legal documents: +So they decided to use agents and in order to provide tools to the agents in a standard way they built an MCP server starting with this legal document: -- [ACT No. 2025/61-FR, PROGRESSIVE INCOME TAX AND SURCHARGE ACT](./docs/2025_61-FR.md) -- [GUIDANCE NOTE No. 2025/61-FR/INT](./docs/2025_61-FR_INT.md) -- [ACT No. 2025/102-SD, PUBLIC HOUSING ASSISTANCE ELIGIBILITY ACT](./docs/2025_102_SD.md) - [ACT No. 2025/73-JU, COMMERCIAL OBLIGATIONS AND LIQUIDATED DAMAGES ACT](./docs/2025_73_JU.md) -- [ACT No. 2025/88-GD, ORGANIZATIONAL VOTING AND QUORUM ACT](./docs/2025_88_GD.md) -- [ACT No. 2025/94-FC, STRUCTURED FINANCE AND CREDITOR PRIORITY ACT](./docs/2025_94_FC.md) ## 🎯 Features -- **5 Calculation Functions**: calc_penalty, calc_tax, check_voting, distribute_waterfall, check_housing_grant +- **Calculation Function**: calc_penalty - **Explicit Logic**: No external dependencies - all logic is transparent and verifiable - **Robust Input Validation**: Demonstrates JSON schema validation with detailed error handling - **Containerization**: Example Podman setup for deployment @@ -70,12 +65,8 @@ So they decided to use agents and in order to provide tools to the agents in a s | Function | Description | Example | |----------|-------------|----------| | **calc_penalty** | Calculate penalty with cap and interest | 12 days late × 100/day = 1,050 with interest | -| **calc_tax** | Progressive tax with surcharge | 40,000 income = 7,140 tax with surcharge | -| **check_voting** | Check voting proposal eligibility | 70 out of 100 voters, 55 yes votes = passes | -| **distribute_waterfall** | Distribute cash in waterfall structure | 15M → Senior: 8M, Junior: 7M, Equity: 0 | -| **check_housing_grant** | Check housing grant eligibility | AMI $50K, size 5, income $32K = eligible | -> **Note**: These functions demonstrate some common multi-tep calculation patterns. +> **Note**: This function demonstrates a common multi-step calculation pattern. ### Example Calculations @@ -88,49 +79,14 @@ If there's a **15 day delay**, then **penalty is $1,050** - **Total Penalty: $1,050** - **Warning:** Base penalty exceeded cap of $1,000 -#### 💰 Tax Calculation - -If **income was 40000** this year then **taxes will be $7,140** -- Bracket 1 (0-10,000): $10,000 × 10.0% = $1,000 -- Bracket 2 (10,000+): $30,000 × 20.0% = $6,000 -- Subtotal: $7,000 -- Surcharge (tax > $5,000): $7,000 × 2.0% = $140 -- **Total Tax: $7,140** - -### 🗳️ Voting Validation - -If we held a board meeting with **150 eligible voters**, **95 people participated** and **52 voted yes** on an ordinary resolution. then **voting ✅ PASSED with WARNING** -- Turnout: 95/150 (63.3%) - meets ≥60% requirement -- Yes votes: 52/95 (54.7%) - exceeds >50% requirement for general proposals -- **Warning:** Low turnout (below 70%) - -### 💸 Waterfall Distributions - -If we have 15 million in cash to distribute, then: -- Senior Debt: $8,000,000 (fully paid) -- Junior Debt: $7,000,000 (partially paid - $5M shortfall) -- Equity: $0 -- **Warnings:** Junior debt underpaid by $5,000,000; Insufficient cash ($15M available vs $20M total debt) - -### 🏠 Housing Grant Eligibility - -If a family of 6 with household income of 35000 is applying for housing assistance, the local AMI is 60000 and they have no other housing subsidies, then: - -**They are ✅ ELIGIBLE** -- Base threshold: 60% of AMI = $36,000 -- Adjusted threshold: $39,600 (10% increase for household size > 4) -- Income: $35,000 ≤ $39,600 ✓ -- No other subsidies ✓ -- **Additional Requirements:** Proof of income, first-time homebuyer criteria, large household documentation - ### 💡 Usage Tips for LLM Integration When querying the LLM with this MCP agent: 1. **Be specific with numbers** - Provide exact figures for calculations -2. **Include context** - Mention if it's for Lysmark jurisdiction, voting type, household details +2. **Include context** - Mention the policy, contract type, or jurisdiction 3. **Ask for explanations** - The tools provide detailed step-by-step breakdowns -4. **Combine scenarios** - You can ask about multiple calculations in one query +4. **Ask for clarity** - The tool returns a step-by-step explanation 5. **Use natural language** - No need to know the exact API parameters ## 🚀 Quick Start @@ -147,7 +103,7 @@ When querying the LLM with this MCP agent: ```bash # Clone the repository -git clone https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git +git clone https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git#workshop cd compatibility-engine-mcp-rs ``` @@ -227,15 +183,12 @@ Now click on `List Tools`, then you should see the list of tools: ![MCP Inspector List Tools](./images/mcp-inspector-3.png) -Finally click on `check_housing_grant`, fill in the form and click `Run tool`: -- **ami:** 50000 -- **has_other_subsidy:** false -- **household_size:** 5 -- **income:** 32000 +Finally click on `calc_penalty`, fill in the form and click `Run tool`: +- **days_late:** 12 ![MCP Inspector List Tools](./images/mcp-inspector-4.png) -Congratulations your Compatibility tool is ready to be used by an MCP enabled agent. +Congratulations your Penalty tool is ready to be used by an MCP enabled agent. ## 📦 Claude Desktop Integration @@ -246,11 +199,11 @@ Congratulations your Compatibility tool is ready to be used by an MCP enabled ag # Create MCPB package for Claude Desktop $ make pack cargo build --release --bin stdio_server - Compiling compatibility-engine-mcp-server v1.0.8 (/Users/.../compatibility-engine-mcp-rs) + Compiling penalty-engine-mcp-server v1.0.8 (/Users/.../compatibility-engine-mcp-rs) Finished `release` profile [optimized] target(s) in 18.23s Packing MCP server for Claude Desktop... chmod +x ./target/release/stdio_server -zip -rX compatibility-engine-mcp-server.mcpb -j mcpb/manifest.json ./target/release/stdio_server +zip -rX penalty-engine-mcp-server.mcpb -j mcpb/manifest.json ./target/release/stdio_server updating: manifest.json (deflated 49%) updating: stdio_server (deflated 63%) ``` @@ -283,7 +236,7 @@ Your're ready to go, open a new chat: ![Install extension](./images/claude-desktop-5.png) -Use this example query "I need to calculate the income tax for a resident of Lysmark who earned 40000 this year. What's their total tax liability including any surcharges?": +Use this example query "We had a 12-day delay on a contract. What penalty applies under our standard terms?": ![Install extension](./images/claude-desktop-6.png) @@ -307,29 +260,12 @@ BIND_ADDRESS=127.0.0.1:8000 ```json { - "days_late": 12, - "rate_per_day": 100, - "cap": 1000, - "interest_rate": 0.05 + "days_late": 12 } ``` **Response:** `1050.0` (penalty capped at $1000 + 5% interest = $1050) -#### Calculate Progressive Tax - -```json -{ - "income": 40000, - "thresholds": [10000], - "rates": [0.10, 0.20], - "surcharge_threshold": 5000, - "surcharge_rate": 0.02 -} -``` - -**Response:** `7140.0` (progressive tax $7000 + 2% surcharge = $7140) - > **Important**: These are example calculations for demonstration purposes only. ## 🐳 Containerization @@ -360,7 +296,7 @@ scripts/image.sh info podman run -p 8001:8001 \ -e BIND_ADDRESS=0.0.0.0:8001 \ -e RUST_LOG=info \ - quay.io/atarazana/compatibility-engine-mcp-server:latest + quay.io/dgarciap/penalty-engine-mcp-server:latest ``` @@ -434,38 +370,6 @@ make help # Show all available commands | `cap` | number | Maximum penalty cap | | `interest_rate` | number | Interest rate (decimal) | -#### calc_tax -| Field | Type | Description | -|-------|------|-------------| -| `income` | number | Total income | -| `thresholds` | array | Tax bracket thresholds | -| `rates` | array | Tax rates for each bracket | -| `surcharge_threshold` | number | Surcharge threshold | -| `surcharge_rate` | number | Surcharge rate (decimal) | - -#### check_voting -| Field | Type | Description | -|-------|------|-------------| -| `eligible_voters` | integer | Total eligible voters | -| `turnout` | integer | Actual turnout | -| `yes_votes` | integer | Number of yes votes | -| `proposal_type` | string | "general" or "amendment" | - -#### distribute_waterfall -| Field | Type | Description | -|-------|------|-------------| -| `cash_available` | number | Total cash to distribute | -| `senior_debt` | number | Senior debt amount | -| `junior_debt` | number | Junior debt amount | - -#### check_housing_grant -| Field | Type | Description | -|-------|------|-------------| -| `ami` | number | Area Median Income | -| `household_size` | integer | Household size | -| `income` | number | Household income | -| `has_other_subsidy` | boolean | Whether household has another subsidy | - ## 🔒 Security - **Input validation**: Strict JSON schemas @@ -616,263 +520,6 @@ When using this MCP agent with an LLM, users can ask natural language questions *The LLM will use `calc_penalty` with the specified days and apply configured defaults (100/day rate, 1000 cap, 5% interest).* -### 💰 Tax Calculations - -#### Example 1: Mid-Income with Surcharge Applied -**Query:** "I need to calculate the income tax for a resident of Lysmark who earned 40000 this year. What's their total tax liability including any surcharges?" - -**Result:** **$7,140** -- Bracket 1 (0-10,000): $10,000 × 10.0% = $1,000 -- Bracket 2 (10,000+): $30,000 × 20.0% = $6,000 -- Subtotal: $7,000 -- Surcharge (tax > $5,000): $7,000 × 2.0% = $140 -- **Total Tax: $7,140** - -#### Example 2: Higher Income with Surcharge -**Query:** "Can you help me figure out the progressive tax calculation for someone with an annual income of 75000 in the Republic of Lysmark?" - -**Result:** **$14,280** -- Bracket 1 (0-10,000): $10,000 × 10.0% = $1,000 -- Bracket 2 (10,000+): $65,000 × 20.0% = $13,000 -- Subtotal: $14,000 -- Surcharge (tax > $5,000): $14,000 × 2.0% = $280 -- **Total Tax: $14,280** - -#### Example 3: High Income with Maximum Surcharge -**Query:** "A taxpayer in our system has declared income of 120000. What's their tax obligation under the current brackets and surcharge rules?" - -**Result:** **$23,460** -- Bracket 1 (0-10,000): $10,000 × 10.0% = $1,000 -- Bracket 2 (10,000+): $110,000 × 20.0% = $22,000 -- Subtotal: $23,000 -- Surcharge (tax > $5,000): $23,000 × 2.0% = $460 -- **Total Tax: $23,460** - -#### Example 4: Lower Income with No Surcharge -**Query:** "What would be the total tax (including surcharge) for a Lysmark resident earning 28000 annually?" - -**Result:** **$4,600** -- Bracket 1 (0-10,000): $10,000 × 10.0% = $1,000 -- Bracket 2 (10,000+): $18,000 × 20.0% = $3,600 -- Subtotal: $4,600 -- No surcharge (tax ≤ $5,000) -- **Total Tax: $4,600** - -#### Progressive Tax System Rules -- **Bracket 1:** 0-$10,000 taxed at 10% -- **Bracket 2:** $10,000+ taxed at 20% -- **Surcharge:** 2% applied when total tax exceeds $5,000 -- **Calculation Order:** Progressive brackets first, then surcharge applied to total tax amount - -*The LLM will use `calc_tax` and apply Lysmark tax brackets (10% up to 10000, 20% above) plus surcharge rules (2% if tax > 5000).* - -### 🗳️ Voting Validations - -#### Example 1: Board Meeting (Ordinary Resolution) -**Query:** "We held a board meeting with 150 eligible voters. 95 people participated and 52 voted yes on an ordinary resolution. Did the proposal pass?" - -**Result:** ✅ **PASSED** with WARNING -- Turnout: 95/150 (63.3%) - meets ≥60% requirement -- Yes votes: 52/95 (54.7%) - exceeds >50% requirement for general proposals -- Warning: Low turnout (below 70%) - -#### Example 2: Shareholder Meeting (Charter Amendment) -**Query:** "Our shareholder meeting had 200 eligible participants, 130 showed up, and 88 voted in favor of amending the corporate charter. Is this amendment approved?" - -**Result:** ✅ **PASSED** with WARNING -- Turnout: 130/200 (65.0%) - meets ≥60% requirement -- Yes votes: 88/130 (67.7%) - meets ≥66.7% requirement for amendments -- Warning: Low turnout (below 70%) - -#### Example 3: Cooperative General Proposal -**Query:** "In our cooperative, we have 80 members eligible to vote. 55 attended the meeting and 35 voted yes on a general proposal. What's the outcome?" - -**Result:** ✅ **PASSED** with WARNING -- Turnout: 55/80 (68.8%) - meets ≥60% requirement -- Yes votes: 35/55 (63.6%) - exceeds >50% requirement for general proposals -- Warning: Low turnout (below 70%) - -#### Example 4: Constitutional Amendment Validation -**Query:** "Can you validate this voting result: 300 eligible voters, 185 turnout, 125 yes votes for a constitutional amendment?" - -**Result:** ✅ **PASSED** with WARNING -- Turnout: 185/300 (61.7%) - meets ≥60% requirement -- Yes votes: 125/185 (67.6%) - meets ≥66.7% requirement for amendments -- Warning: Low turnout (below 70%) - -#### Voting Requirements Summary -- **Minimum Turnout:** ≥60% of eligible voters -- **General Proposals:** >50% of votes cast -- **Amendments:** ≥66.7% of votes cast -- **Optimal Turnout:** ≥70% (to avoid warnings) - -*The LLM will use `check_voting` to verify turnout requirements (≥60%) and approval thresholds (>50% for general, ≥66.7% for amendments).* - -### 💸 Waterfall Distributions - -#### Example 1: Mixed Distribution Scenario -**Query:** "We have 15 million in cash to distribute. Senior debt holders are owed 8 million and junior debt holders are owed 12 million. How should we allocate the funds?" - -**Result:** -- Senior Debt: $8,000,000 (fully paid) -- Junior Debt: $7,000,000 (partially paid - $5M shortfall) -- Equity: $0 -- **Warnings:** Junior debt underpaid by $5,000,000; Insufficient cash ($15M available vs $20M total debt) - -#### Example 2: Full Senior Payment with Junior Shortfall -**Query:** "Our liquidation proceeds total 22 million. We have 18 million in senior debt and 6 million in junior debt. What's the distribution to each class?" - -**Result:** -- Senior Debt: $18,000,000 (fully paid) -- Junior Debt: $4,000,000 (partially paid - $2M shortfall) -- Equity: $0 -- **Warnings:** Junior debt underpaid by $2,000,000; Insufficient cash ($22M available vs $24M total debt) - -#### Example 3: Limited Distribution with Equity Question -**Query:** "Help me calculate the waterfall distribution: 5.5 million available, 4 million senior debt, 3 million junior debt. How much goes to equity?" - -**Result:** -- Senior Debt: $4,000,000 (fully paid) -- Junior Debt: $1,500,000 (partially paid - $1.5M shortfall) -- Equity: $0 (no funds remaining) -- **Warnings:** Junior debt underpaid by $1,500,000; Insufficient cash ($5.5M available vs $7M total debt) - -#### Example 4: Large Distribution with No Equity -**Query:** "We're distributing 30 million in proceeds with 15 million senior obligations and 20 million junior obligations. What's the allocation breakdown?" - -**Result:** -- Senior Debt: $15,000,000 (fully paid) -- Junior Debt: $15,000,000 (partially paid - $5M shortfall) -- Equity: $0 -- **Warnings:** Junior debt underpaid by $5,000,000; Insufficient cash ($30M available vs $35M total debt) - -#### Waterfall Distribution Rules -- **Priority 1:** Senior debt (paid first, up to full amount owed) -- **Priority 2:** Junior debt (paid second, up to full amount owed) -- **Priority 3:** Equity (receives any remaining funds after all debt is satisfied) -- **Payment Order:** Sequential - junior debt only receives payments after senior debt is fully satisfied -- **Warnings Generated:** When junior debt cannot be fully paid or total cash is insufficient for all obligations - -*The LLM will use `distribute_waterfall` to apply priority rules: senior debt paid first, then junior debt, remainder to equity.* - -### 🏠 Housing Grant Eligibility - -#### Example 1: Large Family with No Other Subsidies -**Query:** "A family of 6 with household income of 35000 is applying for housing assistance. The local AMI is 60000 and they have no other housing subsidies. Are they eligible?" - -**Result:** ✅ **ELIGIBLE** -- Base threshold: 60% of AMI = $36,000 -- Adjusted threshold: $39,600 (10% increase for household size > 4) -- Income: $35,000 ≤ $39,600 ✓ -- No other subsidies ✓ -- **Additional Requirements:** Proof of income, first-time homebuyer criteria, large household documentation - -#### Example 2: Small Household Well Under Threshold -**Query:** "Can you check housing grant eligibility for a 3-person household earning 25000 annually? The Area Median Income here is 50000 and they're not receiving other aid." - -**Result:** ✅ **ELIGIBLE** -- Base threshold: 60% of AMI = $30,000 -- No household size adjustment (3 ≤ 4) -- Income: $25,000 ≤ $30,000 ✓ -- No other subsidies ✓ -- **Additional Requirements:** Proof of income, first-time homebuyer criteria - -#### Example 3: Large Family with Existing Subsidy -**Query:** "We need to verify eligibility for a large family (7 members) with 40000 income. AMI is 65000 and they currently receive Section 8 housing assistance. What's their status?" - -**Result:** ❌ **NOT ELIGIBLE** -- Reason: Already has another housing subsidy (Section 8) -- **Requirement:** Must not have any other housing subsidies or assistance - -#### Example 4: Couple Close to Income Threshold -**Query:** "A couple (2-person household) earning 32000 wants to apply for our housing program. Local AMI is 55000 and they have no other subsidies. Do they qualify?" - -**Result:** ✅ **ELIGIBLE** -- Base threshold: 60% of AMI = $33,000 -- No household size adjustment (2 ≤ 4) -- Income: $32,000 ≤ $33,000 ✓ -- No other subsidies ✓ -- **Additional Requirements:** Proof of income, first-time homebuyer criteria, verify all deductions included (close to threshold) - -#### Housing Grant Eligibility Rules -- **Base Income Threshold:** 60% of Area Median Income (AMI) -- **Large Household Adjustment:** 10% increase if household size > 4 -- **Subsidy Restriction:** Cannot have any other housing subsidies -- **Documentation Required:** Proof of income and first-time homebuyer status -- **Special Considerations:** Large households and applicants near income thresholds may require additional verification - -*The LLM will use `check_housing_grant` to check income limits (60% of AMI, +10% for households >4), subsidy exclusivity rules, and provide detailed eligibility determination.* - -### 🔄 Complex Multi-Tool Scenarios - -#### Example 1: Corporate Restructuring Analysis -**Query:** "We're restructuring a company with the following situation: 25 million in liquidation proceeds to distribute, 12 million senior debt, 18 million junior debt. The board vote was 180 eligible, 125 turnout, 85 yes votes for this restructuring plan. One of our contractors was 18 days late on deliverables. Can you help me understand all the financial and governance implications?" - -**Analysis Results:** - -**💰 Waterfall Distribution:** -- Senior Debt: $12,000,000 (fully paid) -- Junior Debt: $13,000,000 (partially paid - $5M shortfall) -- Equity: $0 -- **Warnings:** Junior debt underpaid by $5,000,000; Insufficient cash ($25M vs $30M total debt) - -**🗳️ Board Vote on Restructuring:** -- **Result: PASSED** ✅ -- Turnout: 125/180 (69.4%) - meets ≥60% requirement -- Yes votes: 85/125 (68.0%) - exceeds >50% requirement for general proposals -- **Warning:** Low turnout (below 70%) - -**⚖️ Contractor Penalty:** -- **Penalty: $1,050** -- Base penalty: 18 days × $100 = $1,800 (capped at $1,000) -- Interest: $1,000 × 5% = $50 -- **Warning:** Base penalty exceeded cap - -**Overall Implications:** -- Restructuring is legally approved despite low board engagement -- Junior creditors face significant losses ($5M shortfall) -- Additional contractor penalties reduce available cash by $1,050 - ---- - -#### Example 2: Multi-Client Compliance Assessment -**Query:** "I'm advising a client on multiple compliance issues: Their annual income is 95000 (need tax calculation), they're 22 days late on a contract (penalty calculation), they want to apply for housing assistance (family of 5, income 28000, AMI 45000, no other subsidies). What are all the financial impacts?" - -**Analysis Results:** - -**💸 Tax Liability (Income: $95,000):** -- **Total Tax: $18,360** -- Bracket 1 (0-10,000): $1,000 -- Bracket 2 (10,000+): $17,000 -- Surcharge (2%): $360 - -**⚖️ Contract Penalty (22 Days Late):** -- **Total Penalty: $1,050** -- Base penalty: 22 days × $100 = $2,200 (capped at $1,000) -- Interest: $1,000 × 5% = $50 -- **Warning:** Base penalty exceeded cap - -**🏠 Housing Grant Eligibility (Family of 5, Income: $28,000):** -- **Result: ELIGIBLE** ✅ -- Base threshold: $27,000 (60% of AMI) -- Adjusted threshold: $29,700 (10% increase for large household) -- Income: $28,000 ≤ $29,700 ✓ -- **Requirements:** Income documentation, first-time homebuyer criteria, large household documentation -- **Note:** Income very close to threshold - verify all deductions - -**Financial Summary:** -- **Tax Obligation:** $18,360 -- **Contract Penalty:** $1,050 -- **Housing Assistance:** Qualified (potential benefit) -- **Total Financial Impact:** -$19,410 in obligations, plus potential housing assistance savings - -**Recommendations:** -- Pay tax liability and penalty promptly to avoid additional interest -- Proceed with housing grant application immediately (income is close to threshold) -- Review contract management to prevent future penalties - -*The LLM will use multiple tools (`distribute_waterfall`, `check_voting`, `calc_penalty`, `calc_tax`, `check_housing_grant`) and provide comprehensive analysis.* - ## 📄 License This project is licensed under the MIT License - see [LICENSE](LICENSE) for details. @@ -894,7 +541,7 @@ The project includes a comprehensive GitHub Actions workflow: ## 🏷️ Tags -`mcp` `model-context-protocol` `rust` `compatibility-engine` `calculations` `explicit-logic` `claude` `computation-engine` `cargo-release` `professional-rust` `containerization` `ci-cd` +`mcp` `model-context-protocol` `rust` `penalty-engine` `penalty` `explicit-logic` `claude` `computation-engine` `cargo-release` `professional-rust` `containerization` `ci-cd` --- diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 981706b..2197c20 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -1,9 +1,9 @@ { "dxt_version": "0.1", "name": "compatibility-engine-mcp-server", - "display_name": "Compatibility Engine MCP Server", + "display_name": "Penalty Engine MCP Server", "version": "2.0.2", - "description": "Compatibility Engine MCP Server with 5 calculation functions", + "description": "Penalty Engine MCP Server with a penalty calculation function", "author": { "name": "Carlos Vicens" }, diff --git a/src/common/compatibility_engine.rs b/src/common/compatibility_engine.rs index 298ee2e..c9e680e 100644 --- a/src/common/compatibility_engine.rs +++ b/src/common/compatibility_engine.rs @@ -14,7 +14,8 @@ use rmcp::{ schemars, tool, tool_handler, tool_router }; -// =================== CONFIGURATION =================== +// =================== 1. CONFIGURATION =================== +// Defaults loaded from environment variables for penalty logic. #[derive(Debug, Clone)] pub struct EngineConfig { @@ -22,12 +23,6 @@ pub struct EngineConfig { pub default_rate_per_day: f64, pub default_cap: f64, pub default_interest_rate: f64, - - // Tax calculation defaults - pub default_thresholds: Vec, - pub default_rates: Vec, - pub default_surcharge_threshold: f64, - pub default_surcharge_rate: f64, } impl EngineConfig { @@ -47,41 +42,70 @@ impl EngineConfig { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(0.05), // From LyFin-Compliance-Annex.md: "5 percent annual" - - default_thresholds: env::var("ENGINE_DEFAULT_THRESHOLDS") - .ok() - .and_then(|s| Self::parse_vec_f64(&s)) - .unwrap_or_else(|| vec![10000.0]), // From 2025_61-FR.md: "First bracket: 10% on income up to 10000" - - default_rates: env::var("ENGINE_DEFAULT_RATES") - .ok() - .and_then(|s| Self::parse_vec_f64(&s)) - .unwrap_or_else(|| vec![0.10, 0.20]), // From 2025_61-FR.md: "10% up to 10000", "20% exceeding 10000" - - default_surcharge_threshold: env::var("ENGINE_DEFAULT_SURCHARGE_THRESHOLD") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(5000.0), // From 2025_61-FR.md: "Where the tax calculated... exceeds 5000" - - default_surcharge_rate: env::var("ENGINE_DEFAULT_SURCHARGE_RATE") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(0.02), // From 2025_61-FR.md: "a surcharge of 2% of the total tax liability" } } - - fn parse_vec_f64(s: &str) -> Option> { - let parsed: Result, _> = s - .split(',') - .map(|part| part.trim().parse::()) - .collect(); - parsed.ok() - } } static CONFIG: Lazy = Lazy::new(EngineConfig::from_env); -// =================== PARSING UTILITIES =================== +// =================== 2. CUSTOM DESERIALIZERS =================== +// Serde visitors that accept numbers or strings and store them as strings. + +/// Custom deserializer that accepts both f64 numbers and strings, then parses them +fn deserialize_flexible_f64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct FlexibleF64Visitor; + + impl<'de> de::Visitor<'de> for FlexibleF64Visitor { + type Value = String; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a number or a string representing a number") + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(value.to_string()) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + Ok(value) + } + } + + deserializer.deserialize_any(FlexibleF64Visitor) +} + +// =================== 3. PARSING UTILITIES =================== +// Input validation and string-to-number parsing helpers. /// Sanitize user input for safe inclusion in error messages /// Prevents JSON injection, XSS, log injection, and other attacks @@ -172,210 +196,8 @@ fn parse_f64_from_string(s: &str) -> Result { } } -/// Parse a string to i32, handling various formats with security validation -fn parse_i32_from_string(s: &str) -> Result { - let trimmed = s.trim(); - - // Security validation first - if let Err(e) = validate_input_security(trimmed, "integer") { - return Err(e); - } - - // Handle empty strings - if trimmed.is_empty() { - return Err("Empty string cannot be parsed as integer".to_string()); - } - - // Sanitize input for error messages - let sanitized = sanitize_for_error_message(trimmed); - - // Remove common formatting characters - let cleaned = trimmed.replace(',', ""); // Remove thousands separators - - match cleaned.parse::() { - Ok(value) => Ok(value), - Err(_) => Err(format!("Cannot parse '{}' as an integer", sanitized)) - } -} - -/// Parse a string to bool, handling various formats with security validation -fn parse_bool_from_string(s: &str) -> Result { - let trimmed = s.trim(); - - // Security validation first - if let Err(e) = validate_input_security(trimmed, "boolean") { - return Err(e); - } - - // Handle empty strings - if trimmed.is_empty() { - return Err("Empty string cannot be parsed as boolean".to_string()); - } - - // Sanitize input for error messages - let sanitized = sanitize_for_error_message(trimmed); - - // Parse various boolean representations (case-insensitive) - match trimmed.to_lowercase().as_str() { - "true" | "t" | "yes" | "y" | "1" | "on" => Ok(true), - "false" | "f" | "no" | "n" | "0" | "off" => Ok(false), - _ => Err(format!("Cannot parse '{}' as a boolean (expected: true/false, yes/no, 1/0, etc.)", sanitized)) - } -} - -// =================== CUSTOM DESERIALIZERS =================== - -/// Custom deserializer that accepts both f64 numbers and strings, then parses them -fn deserialize_flexible_f64<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - struct FlexibleF64Visitor; - - impl<'de> de::Visitor<'de> for FlexibleF64Visitor { - type Value = String; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a number or a string representing a number") - } - - fn visit_i64(self, value: i64) -> Result - where - E: de::Error, - { - Ok(value.to_string()) - } - - fn visit_u64(self, value: u64) -> Result - where - E: de::Error, - { - Ok(value.to_string()) - } - - fn visit_f64(self, value: f64) -> Result - where - E: de::Error, - { - Ok(value.to_string()) - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - Ok(value.to_string()) - } - - fn visit_string(self, value: String) -> Result - where - E: de::Error, - { - Ok(value) - } - } - - deserializer.deserialize_any(FlexibleF64Visitor) -} - -/// Custom deserializer that accepts both i32 numbers and strings, then parses them -fn deserialize_flexible_i32<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - struct FlexibleI32Visitor; - - impl<'de> de::Visitor<'de> for FlexibleI32Visitor { - type Value = String; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("an integer or a string representing an integer") - } - - fn visit_i64(self, value: i64) -> Result - where - E: de::Error, - { - Ok(value.to_string()) - } - - fn visit_u64(self, value: u64) -> Result - where - E: de::Error, - { - Ok(value.to_string()) - } - - fn visit_f64(self, value: f64) -> Result - where - E: de::Error, - { - // Convert float to int if it's a whole number - if value.fract() == 0.0 { - Ok((value as i64).to_string()) - } else { - Err(E::custom(format!("Expected integer, got float: {}", value))) - } - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - Ok(value.to_string()) - } - - fn visit_string(self, value: String) -> Result - where - E: de::Error, - { - Ok(value) - } - } - - deserializer.deserialize_any(FlexibleI32Visitor) -} - -/// Custom deserializer that accepts both booleans and strings, then parses them -fn deserialize_flexible_bool<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - struct FlexibleBoolVisitor; - - impl<'de> de::Visitor<'de> for FlexibleBoolVisitor { - type Value = String; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a boolean or a string representing a boolean") - } - - fn visit_bool(self, value: bool) -> Result - where - E: de::Error, - { - Ok(if value { "true".to_string() } else { "false".to_string() }) - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - Ok(value.to_string()) - } - - fn visit_string(self, value: String) -> Result - where - E: de::Error, - { - Ok(value) - } - } - - deserializer.deserialize_any(FlexibleBoolVisitor) -} - -// =================== DATA STRUCTURES =================== +// =================== 4. DATA STRUCTURES =================== +// Request/response types for calc_penalty. #[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] pub struct CalcPenaltyParams { @@ -384,51 +206,6 @@ pub struct CalcPenaltyParams { pub days_late: String, } -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct CalcTaxParams { - #[serde(deserialize_with = "deserialize_flexible_f64")] - #[schemars(description = "Total income")] - pub income: String, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct CheckVotingParams { - #[serde(deserialize_with = "deserialize_flexible_i32")] - #[schemars(description = "Total number of eligible voters")] - pub eligible_voters: String, - #[serde(deserialize_with = "deserialize_flexible_i32")] - #[schemars(description = "Actual turnout (number of people who voted)")] - pub turnout: String, - #[serde(deserialize_with = "deserialize_flexible_i32")] - #[schemars(description = "Number of yes votes")] - pub yes_votes: String, - #[schemars(description = "Type of proposal: 'general' or 'amendment'")] - pub proposal_type: String, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct DistributeWaterfallParams { - #[serde(deserialize_with = "deserialize_flexible_f64")] - #[schemars(description = "Total cash available for distribution")] - pub cash_available: String, - #[serde(deserialize_with = "deserialize_flexible_f64")] - #[schemars(description = "Senior debt amount")] - pub senior_debt: String, - #[serde(deserialize_with = "deserialize_flexible_f64")] - #[schemars(description = "Junior debt amount")] - pub junior_debt: String, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct DistributeWaterfallResult { - #[schemars(description = "Amount allocated to senior debt")] - pub senior: f64, - #[schemars(description = "Amount allocated to junior debt")] - pub junior: f64, - #[schemars(description = "Amount allocated to equity")] - pub equity: f64, -} - // Response structures with explanations #[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] pub struct CalcPenaltyResponse { @@ -442,71 +219,8 @@ pub struct CalcPenaltyResponse { pub warnings: Vec, } -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct CalcTaxResponse { - #[schemars(description = "Calculated tax amount")] - pub tax: f64, - #[schemars(description = "Explanation of calculation steps")] - pub explanation: String, - #[schemars(description = "Any errors in input validation")] - pub errors: Vec, - #[schemars(description = "Warnings or additional information")] - pub warnings: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct CheckVotingResponse { - #[schemars(description = "Whether the proposal passes")] - pub passes: bool, - #[schemars(description = "Explanation of voting calculation")] - pub explanation: String, - #[schemars(description = "Any errors in input validation")] - pub errors: Vec, - #[schemars(description = "Warnings or additional information")] - pub warnings: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct DistributeWaterfallResponse { - #[schemars(description = "Distribution results")] - pub distribution: DistributeWaterfallResult, - #[schemars(description = "Explanation of waterfall distribution")] - pub explanation: String, - #[schemars(description = "Any errors in input validation")] - pub errors: Vec, - #[schemars(description = "Warnings or additional information")] - pub warnings: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct CheckHousingGrantResponse { - #[schemars(description = "Whether eligible for housing grant")] - pub eligible: bool, - #[schemars(description = "Explanation of eligibility calculation")] - pub explanation: String, - #[schemars(description = "Any errors in input validation")] - pub errors: Vec, - #[schemars(description = "Additional requirements or warnings")] - pub additional_requirements: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -pub struct CheckHousingGrantParams { - #[serde(deserialize_with = "deserialize_flexible_f64")] - #[schemars(description = "Area Median Income (AMI)")] - pub ami: String, - #[serde(deserialize_with = "deserialize_flexible_i32")] - #[schemars(description = "Household size")] - pub household_size: String, - #[serde(deserialize_with = "deserialize_flexible_f64")] - #[schemars(description = "Household income")] - pub income: String, - #[serde(deserialize_with = "deserialize_flexible_bool")] - #[schemars(description = "Whether the household has another subsidy (true/false, yes/no, 1/0)")] - pub has_other_subsidy: String, -} - -// =================== COMPATIBILITY ENGINE =================== +// =================== 5. COMPATIBILITY ENGINE =================== +// Core calculation logic for penalties and progressive tax. #[derive(Debug, Clone)] pub struct CompatibilityEngine { @@ -580,423 +294,11 @@ impl CompatibilityEngine { } } - /// Calculate progressive tax with surcharge - fn calc_tax_internal( - income: f64, - thresholds: Vec, - rates: Vec, - surcharge_threshold: f64, - surcharge_rate: f64, - ) -> CalcTaxResponse { - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - let mut explanation_parts = Vec::new(); - - // Validation - if income < 0.0 { - errors.push("Income cannot be negative".to_string()); - } - if rates.len() != thresholds.len() + 1 { - errors.push(format!("Invalid bracket configuration: {} rates for {} thresholds (should be {} rates)", - rates.len(), thresholds.len(), thresholds.len() + 1)); - } - if surcharge_threshold < 0.0 { - errors.push("Surcharge threshold cannot be negative".to_string()); - } - if surcharge_rate < 0.0 { - errors.push("Surcharge rate cannot be negative".to_string()); - } - - // Check if thresholds are sorted - for i in 1..thresholds.len() { - if thresholds[i] <= thresholds[i-1] { - errors.push("Tax thresholds must be in ascending order".to_string()); - break; - } - } - - if !errors.is_empty() { - return CalcTaxResponse { - tax: 0.0, - explanation: "Tax calculation failed due to invalid inputs".to_string(), - errors, - warnings, - }; - } - - let mut tax = 0.0; - let mut remaining_income = income; - explanation_parts.push(format!("Starting income: {:.2}", income)); - - // Apply progressive brackets - for (i, &threshold) in thresholds.iter().enumerate() { - if remaining_income <= 0.0 { - break; - } - - let prev_threshold = if i == 0 { 0.0 } else { thresholds[i - 1] }; - let bracket_size = threshold - prev_threshold; - let taxable_in_bracket = if remaining_income > bracket_size { - bracket_size - } else { - remaining_income - }; - - let bracket_tax = taxable_in_bracket * rates[i]; - tax += bracket_tax; - remaining_income -= taxable_in_bracket; - - explanation_parts.push(format!( - "Bracket {} ({:.0}-{:.0}): {:.2} × {:.1}% = {:.2}", - i + 1, prev_threshold, threshold, taxable_in_bracket, rates[i] * 100.0, bracket_tax - )); - } - - // Apply highest bracket rate to remaining income - if remaining_income > 0.0 { - let highest_rate = rates[rates.len() - 1]; - let highest_bracket_tax = remaining_income * highest_rate; - tax += highest_bracket_tax; - - let prev_threshold = if thresholds.is_empty() { 0.0 } else { thresholds[thresholds.len() - 1] }; - explanation_parts.push(format!( - "Highest bracket ({:.0}+): {:.2} × {:.1}% = {:.2}", - prev_threshold, remaining_income, highest_rate * 100.0, highest_bracket_tax - )); - } - - explanation_parts.push(format!("Subtotal tax: {:.2}", tax)); - - // Apply surcharge if tax exceeds threshold - if tax > surcharge_threshold { - let surcharge = tax * surcharge_rate; - tax += surcharge; - explanation_parts.push(format!( - "Surcharge applied (tax {:.2} > {:.2}): {:.2} × {:.1}% = {:.2}", - tax - surcharge, surcharge_threshold, tax - surcharge, surcharge_rate * 100.0, surcharge - )); - explanation_parts.push(format!("Final tax with surcharge: {:.2}", tax)); - } else { - explanation_parts.push(format!("No surcharge (tax {:.2} ≤ {:.2})", tax, surcharge_threshold)); - } - - if surcharge_rate > 0.05 { - warnings.push(format!("High surcharge rate: {:.1}%", surcharge_rate * 100.0)); - } - - CalcTaxResponse { - tax, - explanation: explanation_parts.join(". "), - errors, - warnings, - } - } - - /// Check if voting proposal passes - fn check_voting_internal( - eligible_voters: i32, - turnout: i32, - yes_votes: i32, - proposal_type: &str, - ) -> CheckVotingResponse { - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - let mut explanation_parts = Vec::new(); - - // Validation - if eligible_voters <= 0 { - errors.push("Eligible voters must be positive".to_string()); - } - if turnout < 0 { - errors.push("Turnout cannot be negative".to_string()); - } - if yes_votes < 0 { - errors.push("Yes votes cannot be negative".to_string()); - } - if turnout > eligible_voters { - errors.push("Turnout cannot exceed eligible voters".to_string()); - } - if yes_votes > turnout { - errors.push("Yes votes cannot exceed turnout".to_string()); - } - if !matches!(proposal_type, "general" | "amendment") { - errors.push(format!("Invalid proposal type '{}' (must be 'general' or 'amendment')", proposal_type)); - } - - if !errors.is_empty() { - return CheckVotingResponse { - passes: false, - explanation: "Voting check failed due to invalid inputs".to_string(), - errors, - warnings, - }; - } - - // Check minimum turnout (60%) - let turnout_percentage = turnout as f64 / eligible_voters as f64; - explanation_parts.push(format!( - "Turnout: {} out of {} eligible voters ({:.1}%)", - turnout, eligible_voters, turnout_percentage * 100.0 - )); - - if turnout_percentage < 0.60 { - explanation_parts.push("Turnout requirement: ≥60% - FAILED".to_string()); - explanation_parts.push("Proposal fails due to insufficient turnout".to_string()); - - return CheckVotingResponse { - passes: false, - explanation: explanation_parts.join(". "), - errors, - warnings, - }; - } else { - explanation_parts.push("Turnout requirement: ≥60% - PASSED".to_string()); - } - - // Check voting threshold based on proposal type - let yes_percentage = yes_votes as f64 / turnout as f64; - explanation_parts.push(format!( - "Yes votes: {} out of {} ({:.1}%)", - yes_votes, turnout, yes_percentage * 100.0 - )); - - let passes = match proposal_type { - "general" => { - let required = 50.0; - explanation_parts.push(format!("General proposal requirement: >{}%", required)); - let passes = yes_percentage > 0.50; - explanation_parts.push(format!( - "Vote threshold: {:.1}% > {}% - {}", - yes_percentage * 100.0, required, if passes { "PASSED" } else { "FAILED" } - )); - passes - }, - "amendment" => { - let required = 66.7; - explanation_parts.push(format!("Amendment requirement: ≥{:.1}%", required)); - let passes = yes_percentage >= 2.0 / 3.0; - explanation_parts.push(format!( - "Vote threshold: {:.1}% ≥ {:.1}% - {}", - yes_percentage * 100.0, required, if passes { "PASSED" } else { "FAILED" } - )); - passes - }, - _ => false, - }; - - explanation_parts.push(format!("Final result: Proposal {}", if passes { "PASSES" } else { "FAILS" })); - - if turnout_percentage < 0.70 { - warnings.push("Low turnout (below 70%)".to_string()); - } - if turnout > 0 && yes_votes == 0 { - warnings.push("No yes votes recorded".to_string()); - } - - CheckVotingResponse { - passes, - explanation: explanation_parts.join(". "), - errors, - warnings, - } - } - - /// Distribute cash in waterfall structure - fn distribute_waterfall_internal( - cash_available: f64, - senior_debt: f64, - junior_debt: f64, - ) -> DistributeWaterfallResponse { - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - let mut explanation_parts = Vec::new(); - - // Validation - if cash_available < 0.0 { - errors.push("Cash available cannot be negative".to_string()); - } - if senior_debt < 0.0 { - errors.push("Senior debt cannot be negative".to_string()); - } - if junior_debt < 0.0 { - errors.push("Junior debt cannot be negative".to_string()); - } - - if !errors.is_empty() { - return DistributeWaterfallResponse { - distribution: DistributeWaterfallResult { senior: 0.0, junior: 0.0, equity: 0.0 }, - explanation: "Waterfall distribution failed due to invalid inputs".to_string(), - errors, - warnings, - }; - } - - let mut remaining = cash_available; - explanation_parts.push(format!("Starting cash: {:.2}", cash_available)); - - // Pay senior debt first - let senior_payment = remaining.min(senior_debt); - remaining -= senior_payment; - - if senior_debt > 0.0 { - if senior_payment == senior_debt { - explanation_parts.push(format!("Senior debt: {:.2} fully paid", senior_debt)); - } else { - explanation_parts.push(format!("Senior debt: {:.2} partially paid ({:.2} of {:.2})", senior_payment, senior_payment, senior_debt)); - warnings.push(format!("Senior debt underpaid by {:.2}", senior_debt - senior_payment)); - } - } else { - explanation_parts.push("No senior debt to pay".to_string()); - } - - explanation_parts.push(format!("Remaining after senior: {:.2}", remaining)); - - // Pay junior debt second - let junior_payment = remaining.min(junior_debt); - remaining -= junior_payment; - - if junior_debt > 0.0 { - if junior_payment == junior_debt { - explanation_parts.push(format!("Junior debt: {:.2} fully paid", junior_debt)); - } else if junior_payment > 0.0 { - explanation_parts.push(format!("Junior debt: {:.2} partially paid ({:.2} of {:.2})", junior_payment, junior_payment, junior_debt)); - warnings.push(format!("Junior debt underpaid by {:.2}", junior_debt - junior_payment)); - } else { - explanation_parts.push("Junior debt: no funds available".to_string()); - warnings.push(format!("Junior debt unpaid ({:.2})", junior_debt)); - } - } else { - explanation_parts.push("No junior debt to pay".to_string()); - } - - explanation_parts.push(format!("Remaining for equity: {:.2}", remaining)); - - // Remainder goes to equity - let equity_payment = remaining; - - if equity_payment > 0.0 { - explanation_parts.push(format!("Equity distribution: {:.2}", equity_payment)); - } else { - explanation_parts.push("No funds available for equity".to_string()); - } - - let total_debt = senior_debt + junior_debt; - if cash_available < total_debt { - warnings.push(format!("Insufficient cash: {:.2} available vs {:.2} total debt", cash_available, total_debt)); - } - - DistributeWaterfallResponse { - distribution: DistributeWaterfallResult { - senior: senior_payment, - junior: junior_payment, - equity: equity_payment, - }, - explanation: explanation_parts.join(". "), - errors, - warnings, - } - } - - /// Check housing grant eligibility - fn check_housing_grant_internal( - ami: f64, - household_size: i32, - income: f64, - has_other_subsidy: bool, - ) -> CheckHousingGrantResponse { - let mut errors = Vec::new(); - let mut additional_requirements = Vec::new(); - let mut explanation_parts = Vec::new(); - - // Validation - if ami <= 0.0 { - errors.push("Area Median Income (AMI) must be positive".to_string()); - } - if household_size <= 0 { - errors.push("Household size must be positive".to_string()); - } - if income < 0.0 { - errors.push("Income cannot be negative".to_string()); - } - - if !errors.is_empty() { - return CheckHousingGrantResponse { - eligible: false, - explanation: "Housing grant eligibility check failed due to invalid inputs".to_string(), - errors, - additional_requirements, - }; - } - - explanation_parts.push(format!("Area Median Income (AMI): {:.2}", ami)); - explanation_parts.push(format!("Household size: {}", household_size)); - explanation_parts.push(format!("Household income: {:.2}", income)); - explanation_parts.push(format!("Has other subsidy: {}", if has_other_subsidy { "Yes" } else { "No" })); - - // Check subsidy requirement first - if has_other_subsidy { - explanation_parts.push("Subsidy check: FAILED (already has another subsidy)".to_string()); - explanation_parts.push("Result: NOT ELIGIBLE".to_string()); - - additional_requirements.push("Must not have any other housing subsidies or assistance".to_string()); - - return CheckHousingGrantResponse { - eligible: false, - explanation: explanation_parts.join(". "), - errors, - additional_requirements, - }; - } else { - explanation_parts.push("Subsidy check: PASSED (no other subsidies)".to_string()); - } - - // Calculate threshold - let base_threshold = 0.60 * ami; - explanation_parts.push(format!("Base income threshold: 60% of AMI = {:.2}", base_threshold)); - - let threshold = if household_size > 4 { - let adjusted_threshold = base_threshold * 1.10; - explanation_parts.push(format!( - "Household size adjustment: {} > 4, threshold increased by 10% to {:.2}", - household_size, adjusted_threshold - )); - adjusted_threshold - } else { - explanation_parts.push(format!("No household size adjustment needed ({} ≤ 4)", household_size)); - base_threshold - }; - - // Check income eligibility - let eligible = income <= threshold; - explanation_parts.push(format!( - "Income eligibility: {:.2} {} {:.2} - {}", - income, - if eligible { "≤" } else { ">" }, - threshold, - if eligible { "PASSED" } else { "FAILED" } - )); - - explanation_parts.push(format!("Final result: {}", if eligible { "ELIGIBLE" } else { "NOT ELIGIBLE" })); - - // Add additional requirements - additional_requirements.push("Must provide proof of income documentation".to_string()); - additional_requirements.push("Must be a first-time homebuyer or meet other program criteria".to_string()); - if household_size > 4 { - additional_requirements.push("Large household size may require additional documentation".to_string()); - } - if income > threshold * 0.9 { - additional_requirements.push("Income is close to threshold - verify all deductions are included".to_string()); - } - - CheckHousingGrantResponse { - eligible, - explanation: explanation_parts.join(". "), - errors, - additional_requirements, - } - } + // Other tool implementations removed. } +// =================== 6. MCP TOOL ROUTES =================== +// Tool registration and request handling. #[tool_router] impl CompatibilityEngine { pub fn new() -> Self { @@ -1039,291 +341,37 @@ impl CompatibilityEngine { "Calculation errors: {}", result.errors.join(", ") ))])); } - - match serde_json::to_string_pretty(&result) { - Ok(json_str) => Ok(CallToolResult::success(vec![Content::text(json_str)])), - Err(e) => { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Error serializing response: {}", e - ))])) - } - } - } - - /// Calculate progressive tax with surcharge - /// Logic: apply progressive brackets defined by thresholds and rates. If total tax > surcharge_threshold, add surcharge = tax × surcharge_rate - #[tool(description = "Calculate progressive tax with surcharge. Returns structured response with tax amount, detailed explanation of bracket calculations and surcharge application, errors for invalid inputs, and warnings. Logic: apply progressive brackets defined by thresholds and rates. If total tax > surcharge_threshold, add surcharge = tax × surcharge_rate. Tax brackets, rates, and surcharge values are configured via environment variables. Example: '40000' income → uses configured tax brackets")] - pub async fn calc_tax( - &self, - Parameters(params): Parameters - ) -> Result { - let _timer = RequestTimer::new(); - increment_requests(); - - // Parse string parameter - let income = match parse_f64_from_string(¶ms.income) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid income parameter: {}", parse_error - ))])); - } - }; - - let result = Self::calc_tax_internal( - income, - CONFIG.default_thresholds.clone(), - CONFIG.default_rates.clone(), - CONFIG.default_surcharge_threshold, - CONFIG.default_surcharge_rate, - ); - - if !result.errors.is_empty() { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Calculation errors: {}", result.errors.join(", ") - ))])) - } else { - match serde_json::to_string_pretty(&result) { - Ok(json_str) => Ok(CallToolResult::success(vec![Content::text(json_str)])), - Err(e) => { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Error serializing response: {}", e - ))])) - } - } - } - } - - /// Check voting proposal eligibility - /// Logic: turnout must be ≥60% of eligible. Then check: If proposal_type = "general" → yes_votes / turnout > 0.50. If proposal_type = "amendment" → yes_votes / turnout ≥ 2/3 - #[tool(description = "Check voting proposal eligibility. Returns structured response with pass/fail result, detailed explanation of turnout and voting threshold checks, validation errors, and warnings. Logic: turnout must be ≥60% of eligible. Then check: If proposal_type = 'general' → yes_votes / turnout > 0.50. If proposal_type = 'amendment' → yes_votes / turnout ≥ 2/3. Example: '100' eligible, turnout = '70', yes_votes = '55', proposal_type = 'amendment' → turnout = 70%, yes% = 78.6%, passes")] - pub async fn check_voting( - &self, - Parameters(params): Parameters - ) -> Result { - let _timer = RequestTimer::new(); - increment_requests(); - - // Parse string parameters - let eligible_voters = match parse_i32_from_string(¶ms.eligible_voters) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid eligible_voters parameter: {}", parse_error - ))])); - } - }; - - let turnout = match parse_i32_from_string(¶ms.turnout) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid turnout parameter: {}", parse_error - ))])); - } - }; - - let yes_votes = match parse_i32_from_string(¶ms.yes_votes) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid yes_votes parameter: {}", parse_error - ))])); - } - }; - - let result = Self::check_voting_internal( - eligible_voters, - turnout, - yes_votes, - ¶ms.proposal_type, - ); - - if !result.errors.is_empty() { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Validation errors: {}", result.errors.join(", ") - ))])) - } else { - match serde_json::to_string_pretty(&result) { - Ok(json_str) => Ok(CallToolResult::success(vec![Content::text(json_str)])), - Err(e) => { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Error serializing response: {}", e - ))])) - } - } - } - } - - /// Distribute cash in waterfall structure - /// Logic: Pay senior first (up to senior_debt). Then junior (up to junior_debt). Any remainder goes to equity - #[tool(description = "Distribute cash in waterfall structure. Returns structured response with distribution amounts, detailed explanation of waterfall payments, validation errors, and warnings about underpayments. Logic: Pay senior first (up to senior_debt). Then junior (up to junior_debt). Any remainder goes to equity. Example: cash = '15000000', senior = '8000000', junior = '10000000' → {senior: 8M, junior: 7M, equity: 0}")] - pub async fn distribute_waterfall( - &self, - Parameters(params): Parameters - ) -> Result { - let _timer = RequestTimer::new(); - increment_requests(); - - // Parse string parameters - let cash_available = match parse_f64_from_string(¶ms.cash_available) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid cash_available parameter: {}", parse_error - ))])); - } - }; - - let senior_debt = match parse_f64_from_string(¶ms.senior_debt) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid senior_debt parameter: {}", parse_error - ))])); - } - }; - - let junior_debt = match parse_f64_from_string(¶ms.junior_debt) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid junior_debt parameter: {}", parse_error - ))])); - } - }; - - let result = Self::distribute_waterfall_internal( - cash_available, - senior_debt, - junior_debt, - ); - - if !result.errors.is_empty() { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Validation errors: {}", result.errors.join(", ") - ))])) - } else { - match serde_json::to_string_pretty(&result) { - Ok(json_str) => Ok(CallToolResult::success(vec![Content::text(json_str)])), - Err(e) => { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Error serializing response: {}", e - ))])) - } - } - } - } - - /// Check housing grant eligibility - /// Logic: Base threshold = 0.60 × AMI. If household_size > 4, threshold = threshold × 1.10. Must satisfy income ≤ threshold. Must not have another subsidy - #[tool(description = "Check housing grant eligibility. Returns structured response with eligibility result, detailed explanation of threshold calculations and checks, validation errors, and additional requirements. Logic: Base threshold = 0.60 × AMI. If household_size > 4, threshold = threshold × 1.10. Must satisfy income ≤ threshold. Must not have another subsidy. Example A: AMI = '50000', household_size = '5', income = '32000', has_other_subsidy = 'false' → eligible. Example B: same AMI & size, income = '34000' → not eligible. Example C: income = '32000' but has_other_subsidy = 'true' → not eligible")] - pub async fn check_housing_grant( - &self, - Parameters(params): Parameters - ) -> Result { - let _timer = RequestTimer::new(); - increment_requests(); - - // Parse string parameters - let ami = match parse_f64_from_string(¶ms.ami) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid ami parameter: {}", parse_error - ))])); - } - }; - - let household_size = match parse_i32_from_string(¶ms.household_size) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid household_size parameter: {}", parse_error - ))])); - } - }; - - let income = match parse_f64_from_string(¶ms.income) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid income parameter: {}", parse_error - ))])); - } - }; - - let has_other_subsidy = match parse_bool_from_string(¶ms.has_other_subsidy) { - Ok(value) => value, - Err(parse_error) => { - increment_errors(); - return Ok(CallToolResult::error(vec![Content::text(format!( - "Invalid has_other_subsidy parameter: {}", parse_error - ))])); - } - }; - - let result = Self::check_housing_grant_internal( - ami, - household_size, - income, - has_other_subsidy, - ); - - if !result.errors.is_empty() { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Validation errors: {}", result.errors.join(", ") - ))])) - } else { - match serde_json::to_string_pretty(&result) { - Ok(json_str) => Ok(CallToolResult::success(vec![Content::text(json_str)])), - Err(e) => { - increment_errors(); - Ok(CallToolResult::error(vec![Content::text(format!( - "Error serializing response: {}", e - ))])) - } + + match serde_json::to_string_pretty(&result) { + Ok(json_str) => Ok(CallToolResult::success(vec![Content::text(json_str)])), + Err(e) => { + increment_errors(); + Ok(CallToolResult::error(vec![Content::text(format!( + "Error serializing response: {}", e + ))])) } } } + + // Other tool routes removed. } +// =================== 7. MCP SERVER HANDLER =================== +// Server metadata and capabilities exposed to MCP clients. #[tool_handler] impl ServerHandler for CompatibilityEngine { fn get_info(&self) -> ServerInfo { // Read basic information from .env file (replaced by sync script during release) - let name = "compatibility-engine-mcp-rs".to_string(); + let name = "penalty-engine-mcp-rs".to_string(); let version = "2.0.2".to_string(); - let title = "Compatibility Engine MCP Server".to_string(); - let website_url = "https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git".to_string(); + let title = "Penalty Engine MCP Server".to_string(); + let website_url = "https://github.com/alpha-hack-program/compatibility-engine-mcp-rs.git#workshop".to_string(); ServerInfo { instructions: Some( - "Compatibility Engine providing five calculation and eligibility functions:\ - \n\n1. calc_penalty - Calculate penalty with cap and interest\ - \n2. calc_tax - Calculate progressive tax with surcharge\ - \n3. check_voting - Check voting proposal eligibility\ - \n4. distribute_waterfall - Distribute cash in waterfall structure\ - \n5. check_housing_grant - Check housing grant eligibility\ - \n\nAll functions are strongly typed and provide explicit calculations.".into() + "Penalty Engine providing a penalty calculation function:\ + \n\n1. calc_penalty - Calculate penalty with cap and interest\ + \n\nThe function is strongly typed and provides explicit calculations.".into() ), capabilities: ServerCapabilities::builder().enable_tools().build(), server_info: rmcp::model::Implementation { @@ -1338,6 +386,8 @@ impl ServerHandler for CompatibilityEngine { } } +// =================== 8. TESTS =================== +// Unit tests covering parsing and tool behavior. #[cfg(test)] mod tests { use super::*; @@ -1364,154 +414,6 @@ mod tests { assert!(response.explanation.contains("Interest")); } - #[tokio::test] - async fn test_calc_tax() { - let engine = CompatibilityEngine::new(); - let params = CalcTaxParams { - income: "40000".to_string(), - }; - - let result = engine.calc_tax(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CalcTaxResponse = serde_json::from_str(json_text).unwrap(); - - // Expected: 10000 * 0.10 + 30000 * 0.20 = 1000 + 6000 = 7000 - // Surcharge: 7000 > 5000 (surcharge_threshold), so 7000 + (7000 * 0.02) = 7,140 - assert_eq!(response.tax, 7140.0); - assert!(response.errors.is_empty()); - assert!(response.explanation.contains("Bracket 1")); - assert!(response.explanation.contains("Surcharge applied")); - } - - #[tokio::test] - async fn test_check_voting_amendment_passes() { - let engine = CompatibilityEngine::new(); - let params = CheckVotingParams { - eligible_voters: "100".to_string(), - turnout: "70".to_string(), - yes_votes: "55".to_string(), - proposal_type: "amendment".to_string(), - }; - - let result = engine.check_voting(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CheckVotingResponse = serde_json::from_str(json_text).unwrap(); - - // Expected: turnout = 70%, yes% = 55/70 = 78.6% ≥ 66.67%, passes - assert_eq!(response.passes, true); - assert!(response.errors.is_empty()); - assert!(response.explanation.contains("70.0%")); - assert!(response.explanation.contains("PASSED")); - } - - #[tokio::test] - async fn test_distribute_waterfall() { - let engine = CompatibilityEngine::new(); - let params = DistributeWaterfallParams { - cash_available: "15000000".to_string(), - senior_debt: "8000000".to_string(), - junior_debt: "10000000".to_string(), - }; - - let result = engine.distribute_waterfall(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: DistributeWaterfallResponse = serde_json::from_str(json_text).unwrap(); - - // Expected: senior = 8M, junior = 7M, equity = 0 - assert_eq!(response.distribution.senior, 8_000_000.0); - assert_eq!(response.distribution.junior, 7_000_000.0); - assert_eq!(response.distribution.equity, 0.0); - assert!(response.errors.is_empty()); - assert!(response.explanation.contains("Senior debt: 8000000.00 fully paid")); - assert!(response.explanation.contains("Junior debt: 7000000.00 partially paid")); - } - - #[tokio::test] - async fn test_check_housing_grant_eligible() { - let engine = CompatibilityEngine::new(); - let params = CheckHousingGrantParams { - ami: "50000".to_string(), - household_size: "5".to_string(), - income: "32000".to_string(), - has_other_subsidy: "false".to_string(), - }; - - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CheckHousingGrantResponse = serde_json::from_str(json_text).unwrap(); - - // Expected: threshold = 0.60 * 50000 * 1.10 = 33000, income 32000 ≤ 33000, eligible - assert_eq!(response.eligible, true); - assert!(response.errors.is_empty()); - assert!(response.explanation.contains("5 > 4, threshold increased by 10%")); - assert!(response.explanation.contains("ELIGIBLE")); - } - - #[tokio::test] - async fn test_check_housing_grant_not_eligible_income() { - let engine = CompatibilityEngine::new(); - let params = CheckHousingGrantParams { - ami: "50000".to_string(), - household_size: "5".to_string(), - income: "34000".to_string(), - has_other_subsidy: "false".to_string(), - }; - - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CheckHousingGrantResponse = serde_json::from_str(json_text).unwrap(); - - // Expected: threshold = 33000, income 34000 > 33000, not eligible - assert_eq!(response.eligible, false); - assert!(response.errors.is_empty()); - assert!(response.explanation.contains("NOT ELIGIBLE")); - } - - #[tokio::test] - async fn test_check_housing_grant_not_eligible_subsidy() { - let engine = CompatibilityEngine::new(); - let params = CheckHousingGrantParams { - ami: "50000".to_string(), - household_size: "5".to_string(), - income: "32000".to_string(), - has_other_subsidy: "true".to_string(), - }; - - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CheckHousingGrantResponse = serde_json::from_str(json_text).unwrap(); - - // Expected: has other subsidy, not eligible - assert_eq!(response.eligible, false); - assert!(response.errors.is_empty()); - assert!(response.explanation.contains("already has another subsidy")); - assert!(!response.additional_requirements.is_empty()); - } - #[tokio::test] async fn test_calc_penalty_with_errors() { let engine = CompatibilityEngine::new(); @@ -1531,46 +433,6 @@ mod tests { assert!(error_text.contains("Days late cannot be negative") || error_text.contains("Calculation errors")); } - #[tokio::test] - async fn test_calc_tax_invalid_brackets() { - // This test is no longer relevant since we use fixed configuration - // but let's keep it to test that the default configuration is valid - let engine = CompatibilityEngine::new(); - let params = CalcTaxParams { - income: "40000".to_string(), - }; - - let result = engine.calc_tax(Parameters(params)).await; - assert!(result.is_ok()); - let call_result = result.unwrap(); - // Should succeed since we use valid default configuration - assert!(!call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CalcTaxResponse = serde_json::from_str(json_text).unwrap(); - assert!(response.errors.is_empty()); - } - - #[tokio::test] - async fn test_check_voting_invalid_proposal_type() { - let engine = CompatibilityEngine::new(); - let params = CheckVotingParams { - eligible_voters: "100".to_string(), - turnout: "70".to_string(), - yes_votes: "55".to_string(), - proposal_type: "invalid_type".to_string(), - }; - - let result = engine.check_voting(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let error_text = content[0].raw.as_text().unwrap().text.as_str(); - assert!(error_text.contains("Invalid proposal type")); - } - #[tokio::test] async fn test_calc_penalty_small_amount() { let engine = CompatibilityEngine::new(); @@ -1592,75 +454,6 @@ mod tests { assert!(response.errors.is_empty()); } - #[tokio::test] - async fn test_calc_tax_with_surcharge() { - let engine = CompatibilityEngine::new(); - let params = CalcTaxParams { - income: "50000".to_string(), - }; - - let result = engine.calc_tax(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CalcTaxResponse = serde_json::from_str(json_text).unwrap(); - - // Uses configured defaults: thresholds=[10000], rates=[0.10,0.20] - // surcharge_threshold=5000, surcharge_rate=0.02 - // Expected: 10000 * 0.10 + 40000 * 0.20 = 1000 + 8000 = 9000 - // Surcharge: 9000 > 5000, so 9000 + (9000 * 0.02) = 9000 + 180 = 9,180 - assert_eq!(response.tax, 9180.0); - assert!(response.errors.is_empty()); - } - - #[tokio::test] - async fn test_string_parsing_with_commas() { - let engine = CompatibilityEngine::new(); - let params = CalcTaxParams { - income: "40,000.00".to_string(), // Test comma-separated thousands - }; - - let result = engine.calc_tax(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(!call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CalcTaxResponse = serde_json::from_str(json_text).unwrap(); - - // Should parse as 40000.0 and give same result - assert_eq!(response.tax, 7140.0); - assert!(response.errors.is_empty()); - } - - #[tokio::test] - async fn test_string_parsing_with_dollar_sign() { - let engine = CompatibilityEngine::new(); - let params = DistributeWaterfallParams { - cash_available: "$15,000,000".to_string(), // Test dollar sign and commas - senior_debt: "$8000000".to_string(), - junior_debt: "$10,000,000.00".to_string(), - }; - - let result = engine.distribute_waterfall(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(!call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: DistributeWaterfallResponse = serde_json::from_str(json_text).unwrap(); - - // Should parse correctly and give expected result - assert_eq!(response.distribution.senior, 8_000_000.0); - assert_eq!(response.distribution.junior, 7_000_000.0); - assert_eq!(response.distribution.equity, 0.0); - assert!(response.errors.is_empty()); - } - #[tokio::test] async fn test_string_parsing_invalid_format() { let engine = CompatibilityEngine::new(); @@ -1679,27 +472,6 @@ mod tests { assert!(error_text.contains("Cannot parse 'not-a-number' as a number")); } - #[tokio::test] - async fn test_string_parsing_empty_string() { - let engine = CompatibilityEngine::new(); - let params = CheckVotingParams { - eligible_voters: "".to_string(), // Empty string - turnout: "70".to_string(), - yes_votes: "55".to_string(), - proposal_type: "general".to_string(), - }; - - let result = engine.check_voting(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let error_text = content[0].raw.as_text().unwrap().text.as_str(); - assert!(error_text.contains("Invalid eligible_voters parameter")); - assert!(error_text.contains("Empty string cannot be parsed")); - } - #[tokio::test] async fn test_string_parsing_with_whitespace() { let engine = CompatibilityEngine::new(); @@ -1886,289 +658,11 @@ mod tests { assert!(error_text.contains("12??malicious??payload")); } - #[tokio::test] - async fn test_boolean_parsing_variations() { - let engine = CompatibilityEngine::new(); - - // Test various "true" representations - for true_value in ["true", "TRUE", "True", "t", "T", "yes", "YES", "y", "Y", "1", "on", "ON"] { - let params = CheckHousingGrantParams { - ami: "50000".to_string(), - household_size: "3".to_string(), - income: "25000".to_string(), // Same qualifying income as false test - has_other_subsidy: true_value.to_string(), - }; - - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(!call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CheckHousingGrantResponse = serde_json::from_str(json_text).unwrap(); - - // Should be ineligible due to having other subsidy (true) - assert_eq!(response.eligible, false); - assert!(response.explanation.contains("already has another subsidy")); - } - - // Test various "false" representations - for false_value in ["false", "FALSE", "False", "f", "F", "no", "NO", "n", "N", "0", "off", "OFF"] { - let params = CheckHousingGrantParams { - ami: "50000".to_string(), - household_size: "3".to_string(), - income: "25000".to_string(), // Set income below threshold (0.60 * 50000 = 30000) - has_other_subsidy: false_value.to_string(), - }; - - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(!call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CheckHousingGrantResponse = serde_json::from_str(json_text).unwrap(); - - // Should be eligible (no other subsidy + income qualifies) - assert_eq!(response.eligible, true); - } - } - - #[tokio::test] - async fn test_boolean_parsing_invalid() { - let engine = CompatibilityEngine::new(); - let params = CheckHousingGrantParams { - ami: "50000".to_string(), - household_size: "3".to_string(), - income: "32000".to_string(), - has_other_subsidy: "maybe".to_string(), // Invalid boolean - }; - - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let error_text = content[0].raw.as_text().unwrap().text.as_str(); - - assert!(error_text.contains("Invalid has_other_subsidy parameter")); - assert!(error_text.contains("Cannot parse 'maybe' as a boolean")); - } - - #[tokio::test] - async fn test_boolean_parsing_empty_string() { - let engine = CompatibilityEngine::new(); - let params = CheckHousingGrantParams { - ami: "50000".to_string(), - household_size: "3".to_string(), - income: "32000".to_string(), - has_other_subsidy: "".to_string(), // Empty string - }; - - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(call_result.is_error.unwrap_or(false)); - let content = call_result.content; - let error_text = content[0].raw.as_text().unwrap().text.as_str(); - - assert!(error_text.contains("Invalid has_other_subsidy parameter")); - assert!(error_text.contains("Empty string cannot be parsed as boolean")); - } - - #[tokio::test] - async fn test_llm_generated_boolean_strings() { - let engine = CompatibilityEngine::new(); - - // Simulate the exact error scenario from the terminal log: - // "has_other_subsidy": String("true") instead of boolean true - let params = CheckHousingGrantParams { - ami: "65000".to_string(), - household_size: "7".to_string(), - income: "40000".to_string(), - has_other_subsidy: "true".to_string(), // This was causing the original error - }; - - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(!call_result.is_error.unwrap_or(false)); // Should NOT be an error anymore - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CheckHousingGrantResponse = serde_json::from_str(json_text).unwrap(); - - // Should be ineligible due to having other subsidy - assert_eq!(response.eligible, false); - assert!(response.explanation.contains("already has another subsidy")); - } - - #[tokio::test] - async fn test_native_json_types() { - // Test that we can deserialize native JSON types directly - let json_data = r#"{ - "ami": 65000, - "household_size": 7, - "income": 40000, - "has_other_subsidy": true - }"#; - - let params: CheckHousingGrantParams = serde_json::from_str(json_data).unwrap(); - - // Should have been converted to strings internally - assert_eq!(params.ami, "65000"); - assert_eq!(params.household_size, "7"); - assert_eq!(params.income, "40000"); - assert_eq!(params.has_other_subsidy, "true"); - - // Test that the engine can process these - let engine = CompatibilityEngine::new(); - let result = engine.check_housing_grant(Parameters(params)).await; - assert!(result.is_ok()); - - let call_result = result.unwrap(); - assert!(!call_result.is_error.unwrap_or(false)); - } - - #[tokio::test] - async fn test_mixed_types() { - // Test mixing native types and strings - let json_data = r#"{ - "ami": "65000", - "household_size": 7, - "income": 40000.5, - "has_other_subsidy": "false" - }"#; - - let params: CheckHousingGrantParams = serde_json::from_str(json_data).unwrap(); - - assert_eq!(params.ami, "65000"); - assert_eq!(params.household_size, "7"); - assert_eq!(params.income, "40000.5"); - assert_eq!(params.has_other_subsidy, "false"); - } - #[tokio::test] async fn test_all_parameter_types_with_numbers() { // Test CalcPenaltyParams with native number let json_penalty = r#"{"days_late": 12.5}"#; let penalty_params: CalcPenaltyParams = serde_json::from_str(json_penalty).unwrap(); assert_eq!(penalty_params.days_late, "12.5"); - - // Test CalcTaxParams with native number - let json_tax = r#"{"income": 50000}"#; - let tax_params: CalcTaxParams = serde_json::from_str(json_tax).unwrap(); - assert_eq!(tax_params.income, "50000"); - - // Test CheckVotingParams with native numbers - let json_voting = r#"{ - "eligible_voters": 100, - "turnout": 75, - "yes_votes": 60, - "proposal_type": "amendment" - }"#; - let voting_params: CheckVotingParams = serde_json::from_str(json_voting).unwrap(); - assert_eq!(voting_params.eligible_voters, "100"); - assert_eq!(voting_params.turnout, "75"); - assert_eq!(voting_params.yes_votes, "60"); - - // Test DistributeWaterfallParams with native numbers - let json_waterfall = r#"{ - "cash_available": 15000000.0, - "senior_debt": 8000000, - "junior_debt": 10000000.5 - }"#; - let waterfall_params: DistributeWaterfallParams = serde_json::from_str(json_waterfall).unwrap(); - assert_eq!(waterfall_params.cash_available, "15000000"); - assert_eq!(waterfall_params.senior_debt, "8000000"); - assert_eq!(waterfall_params.junior_debt, "10000000.5"); - } - - #[tokio::test] - async fn test_float_to_int_conversion_error() { - // Test that floats are rejected for integer fields - let json_data = r#"{ - "eligible_voters": 100.5, - "turnout": 75, - "yes_votes": 60, - "proposal_type": "amendment" - }"#; - - let result = serde_json::from_str::(json_data); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Expected integer, got float")); - } - - #[tokio::test] - async fn test_end_to_end_with_native_types() { - let engine = CompatibilityEngine::new(); - - // Simulate the exact payload from the terminal log that was failing - let json_data = r#"{ - "ami": 65000, - "has_other_subsidy": true, - "household_size": 7, - "income": 40000 - }"#; - - let params: CheckHousingGrantParams = serde_json::from_str(json_data).unwrap(); - let result = engine.check_housing_grant(Parameters(params)).await; - - assert!(result.is_ok()); - let call_result = result.unwrap(); - assert!(!call_result.is_error.unwrap_or(false)); // Should NOT error anymore - - let content = call_result.content; - let json_text = content[0].raw.as_text().unwrap().text.as_str(); - let response: CheckHousingGrantResponse = serde_json::from_str(json_text).unwrap(); - - // Should be ineligible due to having subsidy - assert_eq!(response.eligible, false); - } - - #[test] - fn test_exact_terminal_log_scenario() { - // Test the exact JSON structure that was failing in the terminal log - // (excluding session_id which is not part of the parameter struct) - let json_data = r#"{ - "ami": 65000, - "has_other_subsidy": true, - "household_size": 7, - "income": 40000 - }"#; - - // This should now deserialize successfully - let params: Result = serde_json::from_str(json_data); - assert!(params.is_ok()); - - let params = params.unwrap(); - assert_eq!(params.ami, "65000"); - assert_eq!(params.has_other_subsidy, "true"); - assert_eq!(params.household_size, "7"); - assert_eq!(params.income, "40000"); - } - - #[test] - fn test_scenario_2_from_terminal_log() { - // Test the second failing scenario - let json_data = r#"{ - "ami": 55000, - "has_other_subsidy": false, - "household_size": 2, - "income": 32000 - }"#; - - let params: Result = serde_json::from_str(json_data); - assert!(params.is_ok()); - - let params = params.unwrap(); - assert_eq!(params.ami, "55000"); - assert_eq!(params.has_other_subsidy, "false"); - assert_eq!(params.household_size, "2"); - assert_eq!(params.income, "32000"); } } diff --git a/src/common/metrics.rs b/src/common/metrics.rs index 9d7f972..b859c2c 100644 --- a/src/common/metrics.rs +++ b/src/common/metrics.rs @@ -19,21 +19,21 @@ impl CompatibilityMetrics { let requests_total = Counter::with_opts( Opts::new( "compatibility_requests_total", - "Total number of compatibility engine calculation requests" + "Total number of penalty engine calculation requests" ) ).unwrap(); let errors_total = Counter::with_opts( Opts::new( "compatibility_errors_total", - "Total number of errors in compatibility engine calculations" + "Total number of errors in penalty engine calculations" ) ).unwrap(); let request_duration = Histogram::with_opts( HistogramOpts::new( "compatibility_request_duration_seconds", - "Duration of compatibility engine calculation requests in seconds" + "Duration of penalty engine calculation requests in seconds" ) .buckets(vec![0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0]) ).unwrap(); @@ -41,7 +41,7 @@ impl CompatibilityMetrics { let active_requests = Gauge::with_opts( Opts::new( "compatibility_active_requests", - "Number of active compatibility engine calculation requests" + "Number of active penalty engine calculation requests" ) ).unwrap(); diff --git a/src/mcp_server.rs b/src/mcp_server.rs index df65be3..6477d15 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -24,7 +24,7 @@ async fn main() -> anyhow::Result<()> { // Use environment variable or the static value let bind_address = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| BIND_ADDRESS.to_string()); - tracing::info!("Starting streamable-http Compatibility Engine MCP server on {}", bind_address); + tracing::info!("Starting streamable-http Penalty Engine MCP server on {}", bind_address); let service = StreamableHttpService::new( || Ok(CompatibilityEngine::new()), diff --git a/src/stdio_server.rs b/src/stdio_server.rs index d97da82..c3dff22 100644 --- a/src/stdio_server.rs +++ b/src/stdio_server.rs @@ -15,7 +15,7 @@ async fn main() -> Result<()> { .with_ansi(false) .init(); - tracing::info!("Starting Compatibility Engine MCP server using stdio transport"); + tracing::info!("Starting Penalty Engine MCP server using stdio transport"); // Create an instance of our compatibility-engine router let service = CompatibilityEngine::new().serve(stdio()).await.inspect_err(|e| {