# from mcp.server.fastmcp import FastMCP from fastmcp import FastMCP, Context from fastmcp.dependencies import CurrentContext import os import smtplib from email.message import EmailMessage import aiosmtplib import httpx # --- EMAIL CONFIGURATION --- SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.gmail.com") SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SENDER_EMAIL = os.getenv("SENDER_EMAIL") SENDER_PASSWORD = os.getenv("SENDER_PASSWORD") # Fail fast if credentials are missing if not SENDER_EMAIL or not SENDER_PASSWORD: raise ValueError("SMTP credentials are not set in environment variables.") # Initialize Server mcp = FastMCP("escalation_server") # Path Configuration CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) RESOURCE_PATH = os.path.join(CURRENT_DIR, "resource", "jemh114-min (1).md") # --- CENTRALIZED REGISTRY --- FACULTY_DATA = { "Mr. Murty": {"email": "dharmin.naik@webashlar.com", "id": "T-MURTY-01"}, "Mrs. Sunita":{"email": "dummyaiapi03@gmail.com", "id": "T-SUNITA-03"}, "Mr. Patel": {"email": "dev.patel@webashlar.com", "id": "T-PATEL-05"}, "Dr. Rao": {"email": "pratham.lakdawala@webashlar.com", "id": "T-RAO-02"}, "Mr. Khanna": {"email": "dummyuseai@gmail.com", "id": "T-KHANNA-04"}, "Ms. Joshi": {"email": "dummyaiapi532@gmail.com", "id": "T-JOSHI-06"} } CLASS_ASSIGNMENTS = { "10-A": {"Math": "Mr. Murty", "Science": "Dr. Rao"}, "10-B": {"Math": "Mrs. Sunita", "Science": "Mr. Khanna"}, "10-C": {"Math": "Mr. Patel", "Science": "Ms. Joshi"} } # Input Normalization Map SUBJECT_MAP = { "mathematics": "Math", "math": "Math", "maths": "Math", "statistics": "Math", "stats": "Math", "science": "Science", "physics": "Science", # For this demo, mapping physics to general science "biology": "Science" } def normalize_subject(subject: str) -> str: """Standardizes subject names so 'Mathematics' becomes 'Math'.""" return SUBJECT_MAP.get(subject.lower(), subject.title()) # --- 1. MCP RESOURCES --- # Resources are "read-only" data sources the LLM can reference. def read_resource_file() -> str: if not os.path.exists(RESOURCE_PATH): return "Error: Statistics markdown file not found." with open(RESOURCE_PATH, "r", encoding="utf-8") as f: return f.read() @mcp.resource("study://math/statistics") def statistics_resource() -> str: """Exposes the material to the LLM as a read-only resource.""" return read_resource_file() # @mcp.resource("study://math/statistics") # def get_stats_material() -> str: # """ # Exposes the Class X Statistics chapter. # The AI uses this to find formulas for Mean, Median, and Mode. # """ # if not os.path.exists(RESOURCE_PATH): # return "Error: Statistics markdown file not found at the specified path." # with open(RESOURCE_PATH, "r", encoding="utf-8") as f: # content = f.read() # return content @mcp.resource("system://tutor/instructions") def tutor_instructions() -> str: """The mandatory operational manual for this tutoring session.""" return """ # OPERATIONAL PROTOCOL 1. You are a gateway, not a teacher. 2. Max 3 attempts allowed using 'explain_from_resource' or 'generate_quick_revision'. 3. If a tool returns 'LIMIT_REACHED', immediately ask for Class ID. 4. Use get_teacher_info -> escalate_to_teacher. 5. NO internal brainstorming. Use the provided textbook material ONLY. """ # --- 2. MCP TOOLS --- @mcp.tool() async def explain_from_resource(concept: str, ctx: Context = CurrentContext()): # 1. Properly await the state state_val = await ctx.get_state("math_attempts") attempts = state_val or 0 # 2. Comparison now works because 'attempts' is an integer if attempts >= 3: # FIX: Changed 'tools=' to 'names=' await ctx.disable_components(names=["explain_from_resource", "generate_quick_revision"]) return ( "FATAL_ERROR: TUTORING_LIMIT_REACHED. " "Please request Class ID and notify the teacher." ) # 3. Properly await the update new_attempts = attempts + 1 await ctx.set_state("math_attempts", new_attempts) material = read_resource_file() return f""" {new_attempts}/3 {material} Explain {concept} concisely. Use LaTeX. """ @mcp.tool() async def generate_quick_revision(type: str = "summary", ctx: Context = CurrentContext()) -> str: # 1. Properly await the state state_val = await ctx.get_state("math_attempts") attempts = state_val or 0 if attempts >= 3: # FIX: Changed 'tools=' to 'names=' await ctx.disable_components(names=["explain_from_resource", "generate_quick_revision"]) return "FATAL_ERROR: TUTORING_LIMIT_REACHED. Transition to escalation immediately." # 2. Properly await the update new_attempts = attempts + 1 await ctx.set_state("math_attempts", new_attempts) material = read_resource_file() return f""" {new_attempts}/3 {material} Create a {type} for quick revision. """ def normalize_class_id(class_id: str) -> str: """Converts 'Class 10B', '10b', or 'ten b' into '10-B'.""" clean = class_id.upper().replace("CLASS", "").replace("GRADE", "").replace(" ", "").strip() # If student just says "10", we can't know which section, # but we can format "10B" to "10-B" if len(clean) == 3 and clean[2].isalpha(): return f"{clean[:2]}-{clean[2:]}" return clean # --- 3. ESCALATION TOOLS (From our previous discussion) --- @mcp.tool() async def get_teacher_info(subject: str, class_id: str) -> dict: """ Finds the specific teacher for a subject and class. Ensures Math queries go to Math teachers and Science to Science teachers. """ # teacher_name = CLASS_ASSIGNMENTS.get(class_id, {}).get(subject) std_subject = normalize_subject(subject) std_class = normalize_class_id(class_id) # Verify class exists first # class_data = CLASS_ASSIGNMENTS.get(class_id) class_data = CLASS_ASSIGNMENTS.get(std_class) # if not class_data: # return {"error": f"Class {class_id} does not exist in our registry."} if not class_data: # NEW: Suggest valid classes if the lookup fails valid_classes = ", ".join(CLASS_ASSIGNMENTS.keys()) return {"error": f"Class '{std_class}' not found. Please use one of: {valid_classes}"} teacher_name = class_data.get(std_subject) if not teacher_name: return {"error": f"No {std_subject} teacher assigned to Class {class_id}."} teacher_details = FACULTY_DATA.get(teacher_name) return { "name": teacher_name, "id": teacher_details["id"], "subject": subject } # --- RESEND EMAIL CONFIGURATION --- # RESEND_API_KEY = os.getenv("RESEND_API_KEY") VERCEL_EMAIL_API_URL = os.getenv("VERCEL_EMAIL_API_URL", "https://smtp-email-service-a61d.vercel.app/api/send_email") @mcp.tool() async def escalate_to_teacher( teacher_name: str, subject: str, student_query: str, ai_response: str, ctx: Context = CurrentContext() ) -> str: """Sends a real-time email via the Vercel SMTP Bridge API.""" # 1. Get recipient from registry teacher_info = FACULTY_DATA.get(teacher_name) recipient = teacher_info["email"] if teacher_info else "dummyuseai@gmail.com" # 2. Prepare the email content email_subject = f"🚨 Escalation: {subject} Doubt for {teacher_name}" html_body = f"""

Hello {teacher_name},

A student has a {subject} doubt that requires your attention.


Doubt: {student_query}

AI Response: {ai_response}

""" # 3. Construct the payload for your Vercel API # Based on your index.py: to, subject, body, is_html api_payload = { "to": recipient, "subject": email_subject, "body": html_body, "is_html": True } # 4. Call the Vercel API via HTTPS async with httpx.AsyncClient() as client: try: response = await client.post( VERCEL_EMAIL_API_URL, json=api_payload, timeout=30.0 ) if response.status_code == 200: # --- SUCCESS: RESET LOGIC --- await ctx.set_state("math_attempts", 0) # Re-enable tutoring tools if they were disabled await ctx.enable_components(names=["explain_from_resource", "generate_quick_revision"]) return f"Success: {teacher_name} ({subject} Expert) has been notified via Vercel Bridge." else: # Handle API-level errors (400, 401, 502, etc.) error_data = response.json() return f"Vercel API Error ({response.status_code}): {error_data.get('error', 'Unknown error')}" except httpx.ConnectError: return "Critical Error: Could not connect to Vercel API. Check your VERCEL_EMAIL_API_URL." except Exception as e: return f"Unexpected Error: {str(e)}" # @mcp.tool() # async def escalate_to_teacher( # teacher_name: str, # subject: str, # student_query: str, # ai_response: str, # ctx: Context = CurrentContext() # ) -> str: # """Sends a real-time email to the correct teacher by name.""" # # Safely get email from our central registry # teacher_info = FACULTY_DATA.get(teacher_name) # if not teacher_info: # recipient = "dummyuseai@gmail.com" # Fallback to admin # else: # recipient = teacher_info["email"] # msg = EmailMessage() # msg["Subject"] = f"🚨 Escalation: {subject} Doubt for {teacher_name}" # msg["From"] = SENDER_EMAIL # msg["To"] = recipient # html_content = f""" # # #
#

Hello {teacher_name},

#

A student has a {subject} doubt that requires your attention.

#
#

Doubt: {student_query}

#

AI Response: {ai_response}

#
# # # """ # msg.add_alternative(html_content, subtype="html") # try: # await aiosmtplib.send(msg, # hostname=SMTP_SERVER, # port=SMTP_PORT, # username=SENDER_EMAIL, # password=SENDER_PASSWORD, # start_tls=True, # use_tls=False, # timeout=30 # ) # # --- THE FIX: RESET LOGIC --- # # ctx.set_state("math_attempts", 0) # Reset counter to 0 # await ctx.set_state("math_attempts", 0) # # await ctx.enable_components(tools=["explain_from_resource", "generate_quick_revision"]) # Re-enable tools # return f"Success: {teacher_name} ({subject} Expert) has been notified." # except Exception as e: # return f"Error sending email: {str(e)}" # --- 4. MCP PROMPTS --- @mcp.prompt() def revision_assistance(student_name: str): """A pre-configured prompt to help students with exam prep.""" return f"Hi {student_name}, I can help you summarize your chapter or create short notes for formulas. Which one would you like to do first?" @mcp.prompt() def escalation_handover(teacher_name: str, subject: str) -> str: """Professional message used when handing over to a human teacher.""" return ( f"I've shared our {subject} discussion with {teacher_name}. " "They will review it and get back to you on your dashboard soon!" ) # if __name__ == "__main__": # mcp.run() if __name__ == "__main__": # For Hugging Face Spaces, we use transport="sse" or "http" # Port 7860 is the HF default mcp.run( transport="http", port=7860, host="0.0.0.0" )