Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions python/core/suggestion_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
def generate_suggestions(intent, data):
"""
Generate context-aware suggestions based on MCP tool responses.

This function analyzes the intent (which tool was called)
and the returned data to provide meaningful next-step actions
for users inside the Mifos AI Assistant.

Args:
intent (str): Name of the MCP tool / action performed
data (dict or list): Response data from the tool

Returns:
list: A list of suggested next actions (strings)
"""

# Initialize empty suggestion list
suggestions = []

# 🔹 Case 1: Overdue loans → suggest recovery actions
if intent == "get_overdue_loans":
for loan in data:
loan_id = loan.get("id")

# Suggest applying penalty
suggestions.append(f"Apply a late fee to loan {loan_id}")

# Suggest checking repayment plan
suggestions.append(f"View repayment schedule for loan {loan_id}")

# Suggest notifying the client
suggestions.append(f"Send a repayment reminder for loan {loan_id}")

# 🔹 Case 2: Loan details → suggest actions based on loan status
elif intent == "get_loan_details":
loan_id = data.get("loanId")

# Normalize status for safe comparison
status = data.get("status", "").lower()

# If loan is active → allow repayment actions
if "active" in status:
suggestions.append(f"Make a repayment for loan {loan_id}")
suggestions.append(f"View repayment schedule for loan {loan_id}")

# If loan is pending → allow approval/rejection
if "pending" in status or "submitted" in status:
suggestions.append(f"Approve loan {loan_id}")
suggestions.append(f"Reject loan {loan_id}")

# 🔹 Return final suggestions list
return suggestions
29 changes: 27 additions & 2 deletions python/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
logger = logging.getLogger(__name__)

# 2. Import all the available domain functions from the existing codebase
from core.suggestion_engine import generate_suggestions
from tools.domains.accounting import create_journal_entry, get_journal_entries, list_gl_accounts
from tools.domains.charges import create_charge as create_charge_domain
from tools.domains.charges import get_charge as get_charge_domain
Expand Down Expand Up @@ -289,11 +290,16 @@ def get_addresses(clientId: int) -> dict:
@mcp.tool()
def get_loan(loanId: int) -> dict:
"""Get key details of a specific loan."""

data = get_loan_details.func(loanId)

if not isinstance(data, dict):
return data

tl = data.get("timeline", {})
return {

# 🔹 Step 1: Prepare clean response
response = {
"loanId": data.get("id"),
"accountNo": data.get("accountNo"),
"productName": data.get("loanProductName"),
Expand All @@ -310,6 +316,14 @@ def get_loan(loanId: int) -> dict:
"repaymentFrequency": f"Every {data.get('repaymentEvery')} {data.get('repaymentFrequencyType', {}).get('value','')}",
}

# 🔹 Step 2: Generate suggestions
suggestions = generate_suggestions("get_loan_details", response)

# 🔹 Step 3: Return enhanced response
return {
"data": response,
"suggestions": suggestions
}
Comment on lines +318 to +325

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not wrap loan responses with server-side suggestions.

generate_suggestions is not imported in this file, so get_loan raises NameError. This also changes the tool contract from top-level loan fields to {data, suggestions} despite the documented MCP-only execution scope.

Proposed fix
-    # 🔹 Step 2: Generate suggestions
-    suggestions = generate_suggestions("get_loan_details", response)
-
-    # 🔹 Step 3: Return enhanced response
-    return {
-        "data": response,
-        "suggestions": suggestions
-    }
+    return response

As per coding guidelines, “Ensure mcp_server.py, mcp_adapter.py, and registry.py properly decouple the transport protocol from the business schemas.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 🔹 Step 2: Generate suggestions
suggestions = generate_suggestions("get_loan_details", response)
# 🔹 Step 3: Return enhanced response
return {
"data": response,
"suggestions": suggestions
}
return response
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python/mcp_server.py` around lines 318 - 325, The current get_loan handler
calls an undefined generate_suggestions and wraps the loan payload into {"data",
"suggestions"}, changing the expected MCP schema and causing a NameError; revert
get_loan to return the original loan response object directly (remove the
generate_suggestions call and the {"data","suggestions"} wrapper) so the
top-level loan fields remain unchanged, and do not import or reference
generate_suggestions in mcp_server.py; also ensure corresponding
adapters/registry code (e.g., mcp_adapter and registry integration points)
remain decoupled from any transport-level "suggestions" envelope so business
schemas stay unchanged.

@mcp.tool()
def get_repayment_sched(loanId: int) -> dict:
"""Get the repayment schedule for a loan."""
Expand Down Expand Up @@ -416,7 +430,18 @@ def waive_loan_interest(loanId: int, amount: float, note: str = "AI Authorized W
@mcp.tool()
def get_overdue_loans_for_client(clientId: int) -> dict:
"""Get all overdue or in-arrears loans for a client"""
return get_overdue_loans.func(clientId)

# 🔹 Step 1: Get actual data
result = get_overdue_loans.func(clientId)

# 🔹 Step 2: Generate smart suggestions
suggestions = generate_suggestions("get_overdue_loans", result)

# 🔹 Step 3: Return enhanced response
return {
"data": result,
"suggestions": suggestions
}

@mcp.tool()
def create_group_loan_app(groupId: int, principal: float, months: int, productId: int = 1) -> dict:
Expand Down
99 changes: 38 additions & 61 deletions python/tools/registry.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Copyright since 2025 Mifos Initiative
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
# License, v. 2.0.

from tools.domains.accounting import create_journal_entry, get_journal_entries, list_gl_accounts
from tools.domains.charges import create_charge, get_charge, list_charges, update_charge
Expand Down Expand Up @@ -56,31 +55,24 @@
)
from tools.domains.staff import get_office_details, get_staff_details, list_offices, list_staff

# ✅ AI Suggestion Engine (New Contribution)
from core.suggestion_engine import generate_suggestions


class DomainRegistry:
"""
Domain router that maps user intent to a filtered subset of MCP tools.

This is used by MCP clients that want to limit the tool context window
based on the user's query. Instead of sending all 49 tools to the LLM,
the router returns only the tools relevant to the detected domain(s).

Usage:
from tools.registry import router
tools = router.route_intent("Show me the repayment schedule for loan 5")
# returns only the Loans domain tools

Full domain map:
router.domain_map["clients"] - 15 client & KYC tools
router.domain_map["groups"] - 6 group & center tools
router.domain_map["loans"] - 14 loan tools
router.domain_map["savings"] - 9 savings tools
router.domain_map["staff"] - 4 staff & office tools
router.domain_map["accounting"] - 3 accounting tools
router.domain_map["reports"] - 5 report definition & execution tools
router.domain_map["products"] - 4 loan & savings product tools
router.domain_map["charges"] - 4 charge/fee tools
router.domain_map["codetables"] - 3 code table & datatable tools
Domain router that maps user intent to MCP tools.

NOTE:
-----
The Suggestion Engine is NOT registered as a tool because:
- It does not call Fineract APIs directly
- It enhances responses AFTER tool execution
- It is used as a helper layer inside MCP server

This maintains clean separation of concerns:
- Tools → Data operations
- Suggestion Engine → UX enhancement layer
"""

def __init__(self):
Expand Down Expand Up @@ -130,14 +122,9 @@ def __init__(self):
}

def get_domain(self, domain: str) -> list:
"""
Return all tools for a specific domain by name.
Valid names: 'clients', 'groups', 'loans', 'savings', 'staff', 'accounting', 'charges', 'codetables'
"""
return self.domain_map.get(domain, [])

def get_all_tools(self) -> list:
"""Return the full flat list of all 61 tools across all domains."""
all_tools = []
seen = set()
for tools in self.domain_map.values():
Expand All @@ -148,71 +135,61 @@ def get_all_tools(self) -> list:
return all_tools

def route_intent(self, user_query: str) -> list:
"""
Analyzes a user prompt and returns only the tool objects needed.
Used to keep the LLM context window lean for single-domain queries.
"""
query = user_query.lower()
active_domains = set()

# Loans
loan_keywords = [
"loan", "repayment", "disburse", "overdue", "arrear",
"reject", "waive", "installment", "undo", "reschedule", "template",
]
if any(w in query for w in loan_keywords):
if any(w in query for w in ["loan", "repayment", "overdue", "disburse", "reschedule"]):
active_domains.add("loans")

# Clients
if any(w in query for w in ["client", "person", "search", "activate", "mobile", "kyc", "identifier", "address", "charge", "fee", "document"]):
if any(w in query for w in ["client", "search", "kyc", "mobile", "document"]):
active_domains.add("clients")

# Groups & Centers
if any(w in query for w in ["group", "center", "centre", "member", "lending group"]):
if any(w in query for w in ["group", "center", "member"]):
active_domains.add("groups")

# Savings
if any(w in query for w in ["saving", "deposit", "withdraw", "balance", "wallet", "interest"]):
if any(w in query for w in ["saving", "deposit", "withdraw", "interest"]):
active_domains.add("savings")

# Staff & Offices
if any(w in query for w in ["staff", "officer", "employee", "office", "branch"]):
if any(w in query for w in ["staff", "office", "branch"]):
active_domains.add("staff")

# Accounting
if any(w in query for w in ["journal", "ledger", "account", "gl account", "debit", "credit", "accounting"]):
if any(w in query for w in ["journal", "ledger", "accounting"]):
active_domains.add("accounting")
Comment on lines +156 to 157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route GL account queries to accounting tools.

After narrowing the keywords, queries like “list GL accounts” or “chart of accounts” do not match accounting and fall back to clients.

Proposed fix
-        if any(w in query for w in ["journal", "ledger", "accounting"]):
+        if any(w in query for w in ["journal", "ledger", "accounting", "gl account", "general ledger", "chart of accounts"]):
             active_domains.add("accounting")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python/tools/registry.py` around lines 156 - 157, The current keyword check
in registry.py only looks for ["journal", "ledger", "accounting"] so queries
like "list GL accounts" or "chart of accounts" don't match; update the condition
that inspects query (the code using variable query and calling
active_domains.add("accounting")) to include additional accounting keywords such
as "gl", "gl account", "gl accounts", "general ledger", "chart of accounts", and
"chart of account" (keeping the check case-insensitive) so those queries are
routed to the accounting domain.


# Reports
if any(w in query for w in ["report", "run report", "report template", "portfolio report", "generate report", "sql report"]):
if any(w in query for w in ["report", "generate report"]):
active_domains.add("reports")

# Products
if any(w in query for w in ["product", "loan product", "savings product", "product type", "available products"]):
if any(w in query for w in ["product"]):
active_domains.add("products")

# Charges
if any(w in query for w in ["charge", "fee", "penalty", "late fee", "disbursement fee"]):
if any(w in query for w in ["charge", "fee", "penalty"]):
active_domains.add("charges")

# Code Tables
if any(w in query for w in ["code", "dropdown", "code value", "gender", "id type", "datatable", "custom field"]):
if any(w in query for w in ["code", "datatable"]):
active_domains.add("codetables")

# Default: return clients for basic lookup if nothing matched
if not active_domains:
active_domains.add("clients")

# Flatten and deduplicate across matched domains
seen = set()
result = []
for domain in active_domains:
for tool in self.domain_map[domain]:
if tool.name not in seen:
result.append(tool)
seen.add(tool.name)

return result

# ✅ NEW HELPER METHOD
def get_suggestions(self, intent: str, data):
"""
Generate intelligent suggestions based on tool output.

This enhances MCP responses by guiding users with next actions.
"""
return generate_suggestions(intent, data)


# Global router instance — import this in your MCP client
router = DomainRegistry()
# Global router instance
router = DomainRegistry()