2026-03-11 13:19:31 +00:00
import logging
import random
2026-03-18 01:04:16 +00:00
import httpx
2026-02-26 01:32:04 +00:00
from channels . db import database_sync_to_async
from django . conf import settings
2026-03-15 22:19:12 +00:00
from django . db . models import Q
2026-02-26 01:32:04 +00:00
from pgvector . django import CosineDistance
2026-03-15 22:19:12 +00:00
from apps . accounts . models import Role
2026-03-18 23:01:20 +00:00
from apps . knowledge . models import RoleRagDocument , TrainingFile
2026-02-26 01:32:04 +00:00
from apps . onboarding . models import OnboardingSession
2026-03-11 13:19:31 +00:00
logger = logging . getLogger ( __name__ )
2026-03-18 01:04:16 +00:00
_MCP_TOOL_META = ' mcp_tool_meta '
2026-03-08 13:16:26 +00:00
2026-03-11 13:19:31 +00:00
def mcp_tool ( name , description , input_schema ) :
def decorator ( func ) :
2026-03-18 01:04:16 +00:00
setattr ( func , _MCP_TOOL_META , {
2026-03-11 13:19:31 +00:00
' name ' : name ,
' description ' : description ,
' inputSchema ' : input_schema ,
2026-03-12 19:23:18 +00:00
} )
2026-03-11 13:19:31 +00:00
return func
return decorator
def _collect_tools ( class_namespace ) :
tools = [ ]
for method_name , value in class_namespace . items ( ) :
2026-03-18 01:04:16 +00:00
metadata = getattr ( value , _MCP_TOOL_META , None )
2026-03-11 13:19:31 +00:00
if not metadata :
continue
2026-03-18 01:04:16 +00:00
tools . append ( {
' name ' : metadata [ ' name ' ] ,
' method ' : method_name ,
' description ' : metadata [ ' description ' ] ,
' inputSchema ' : metadata [ ' inputSchema ' ] ,
} )
2026-03-11 13:19:31 +00:00
return tools
class MCPRouter :
2026-03-18 01:04:16 +00:00
2026-03-11 13:19:31 +00:00
def get_tool_definitions ( self ) :
return self . tools
2026-02-26 01:32:04 +00:00
async def handle_tool_call ( self , name , arguments ) :
2026-03-11 13:19:31 +00:00
logger . info ( ' MCP tool call received: tool= %s args= %s ' , name , arguments )
arguments = arguments or { }
method_name = self . _tool_name_to_method . get ( name )
if method_name :
method = getattr ( self , method_name , None )
if method :
result = await method ( arguments )
2026-03-18 01:04:16 +00:00
logger . info ( ' MCP tool call completed: tool= %s result= %s ' , name , result )
2026-03-11 13:19:31 +00:00
return result
logger . warning ( ' MCP tool call rejected: unknown tool= %s ' , name )
return { ' error ' : f ' Tool { name } not found ' }
2026-02-26 01:32:04 +00:00
async def _get_embedding ( self , text ) :
2026-03-11 13:19:31 +00:00
logger . info ( ' MCP embedding request started ' )
2026-03-18 23:01:20 +00:00
async with httpx . AsyncClient ( timeout = 60.0 ) as client :
2026-02-26 01:32:04 +00:00
response = await client . post (
2026-03-08 13:16:26 +00:00
settings . INFERENCE_EMBEDDINGS_ENDPOINT ,
2026-03-18 01:04:16 +00:00
json = { ' input ' : text } ,
2026-02-26 01:32:04 +00:00
)
2026-03-11 13:19:31 +00:00
response . raise_for_status ( )
embedding = response . json ( ) [ ' data ' ] [ 0 ] [ ' embedding ' ]
logger . info ( ' MCP embedding request completed ' )
return embedding
2026-02-26 01:32:04 +00:00
2026-03-11 13:19:31 +00:00
@mcp_tool (
name = ' search_knowledge ' ,
description = ' Search the RAG database for role-specific training content. ' ,
input_schema = {
' type ' : ' object ' ,
' properties ' : {
' query ' : { ' type ' : ' string ' } ,
' role_uuid ' : { ' type ' : ' string ' } ,
} ,
' required ' : [ ' query ' , ' role_uuid ' ] ,
} ,
)
2026-02-26 01:32:04 +00:00
async def _search_knowledge ( self , args ) :
2026-03-11 13:19:31 +00:00
query = args . get ( ' query ' )
role_uuid = args . get ( ' role_uuid ' )
2026-02-26 01:32:04 +00:00
if not query or not role_uuid :
2026-03-11 13:19:31 +00:00
logger . warning ( ' MCP search_knowledge missing query or role_uuid ' )
2026-02-26 01:32:04 +00:00
return [ ]
query_vector = await self . _get_embedding ( query )
return await self . _search_knowledge_documents ( role_uuid , query_vector )
@database_sync_to_async
def _search_knowledge_documents ( self , role_uuid , query_vector ) :
2026-03-15 22:19:12 +00:00
role = Role . objects . select_related ( ' organization ' ) . filter ( uuid = role_uuid ) . first ( )
if role is None :
logger . warning ( ' MCP search_knowledge_documents role not found: role_uuid= %s ' , role_uuid )
return [ ]
2026-02-26 01:32:04 +00:00
docs = RoleRagDocument . objects . filter (
2026-03-15 22:19:12 +00:00
organization = role . organization ,
embedding__isnull = False ,
2026-03-11 13:19:31 +00:00
is_active = True ,
2026-03-15 22:19:12 +00:00
) . filter (
Q ( role__uuid = role_uuid ) | Q ( role__isnull = True ) ,
2026-02-26 01:32:04 +00:00
) . annotate (
distance = CosineDistance ( ' embedding ' , query_vector )
) . order_by ( ' distance ' ) [ : 5 ]
2026-03-11 13:19:31 +00:00
results = [
2026-02-26 01:32:04 +00:00
{
2026-03-11 13:19:31 +00:00
' content ' : d . content ,
2026-03-15 22:19:12 +00:00
' source ' : d . metadata . get ( ' file_name ' ) or d . metadata . get ( ' source ' , ' Unknown Source ' ) ,
2026-03-11 13:19:31 +00:00
' relevance ' : round ( 1 - d . distance , 4 ) ,
2026-02-26 01:32:04 +00:00
}
for d in docs
]
2026-03-18 01:04:16 +00:00
logger . info ( ' MCP search_knowledge_documents completed: role_uuid= %s results= %s ' , role_uuid , len ( results ) )
2026-03-11 13:19:31 +00:00
return results
2026-02-26 01:32:04 +00:00
2026-03-11 13:19:31 +00:00
@mcp_tool (
name = ' update_progress ' ,
description = " Update the user ' s score or current module in their session. " ,
input_schema = {
' type ' : ' object ' ,
' properties ' : {
' session_uuid ' : { ' type ' : ' string ' } ,
' score ' : { ' type ' : ' integer ' } ,
' completed_module ' : { ' type ' : ' string ' } ,
} ,
' required ' : [ ' session_uuid ' ] ,
} ,
)
2026-02-26 01:32:04 +00:00
@database_sync_to_async
def _update_progress ( self , args ) :
2026-03-18 01:04:16 +00:00
session_uuid = args . get ( ' session_uuid ' )
session = OnboardingSession . objects . filter ( uuid = session_uuid ) . first ( )
if session is None :
logger . warning ( ' MCP update_progress session not found: session_uuid= %s ' , session_uuid )
return { ' error ' : ' Session not found ' }
2026-03-11 13:19:31 +00:00
2026-02-26 01:32:04 +00:00
state = session . state or { }
2026-03-11 13:19:31 +00:00
if ' score ' in args :
state [ ' last_score ' ] = args [ ' score ' ]
if ' completed_module ' in args :
state . setdefault ( ' completed_modules ' , [ ] ) . append ( args [ ' completed_module ' ] )
2026-02-26 01:32:04 +00:00
session . state = state
session . save ( )
2026-03-18 01:04:16 +00:00
logger . info ( ' MCP update_progress completed: session_uuid= %s ' , session_uuid )
2026-03-11 13:19:31 +00:00
return { ' status ' : ' success ' , ' new_state ' : state }
2026-03-18 23:01:20 +00:00
@mcp_tool (
name = ' get_role_context ' ,
description = ' Get the name, description, and organization for a role. Call this first to understand what the role involves before generating content. ' ,
input_schema = {
' type ' : ' object ' ,
' properties ' : {
' role_uuid ' : { ' type ' : ' string ' , ' description ' : ' The UUID of the role ' } ,
} ,
' required ' : [ ' role_uuid ' ] ,
} ,
)
@database_sync_to_async
def _get_role_context ( self , args ) :
role_uuid = args . get ( ' role_uuid ' )
role = Role . objects . select_related ( ' organization ' ) . filter ( uuid = role_uuid ) . first ( )
if role is None :
logger . warning ( ' MCP get_role_context role not found: role_uuid= %s ' , role_uuid )
return { ' error ' : ' Role not found ' }
logger . info ( ' MCP get_role_context completed: role_uuid= %s ' , role_uuid )
return {
' name ' : role . name ,
' description ' : role . description or ' ' ,
' organization ' : role . organization . name ,
' member_count ' : role . members . count ( ) ,
}
@mcp_tool (
name = ' list_training_files ' ,
description = ' List processed training files available for a role. Use this to understand what source materials exist before generating curriculum or content. ' ,
input_schema = {
' type ' : ' object ' ,
' properties ' : {
' role_uuid ' : { ' type ' : ' string ' , ' description ' : ' The UUID of the role ' } ,
} ,
' required ' : [ ' role_uuid ' ] ,
} ,
)
@database_sync_to_async
def _list_training_files ( self , args ) :
role_uuid = args . get ( ' role_uuid ' )
role = Role . objects . select_related ( ' organization ' ) . filter ( uuid = role_uuid ) . first ( )
if role is None :
logger . warning ( ' MCP list_training_files role not found: role_uuid= %s ' , role_uuid )
return { ' error ' : ' Role not found ' }
files = list (
TrainingFile . objects . filter (
organization = role . organization ,
is_processed = True ,
) . filter (
Q ( role__uuid = role_uuid ) | Q ( role__isnull = True )
) . values ( ' file_name ' , ' description ' , ' file_type ' ) [ : 20 ]
)
logger . info ( ' MCP list_training_files completed: role_uuid= %s count= %s ' , role_uuid , len ( files ) )
return { ' files ' : files , ' count ' : len ( files ) }
2026-03-11 13:19:31 +00:00
@mcp_tool (
name = ' random_int ' ,
description = ' Generate a random integer in an inclusive range. ' ,
input_schema = {
' type ' : ' object ' ,
' properties ' : {
' min ' : { ' type ' : ' integer ' } ,
' max ' : { ' type ' : ' integer ' } ,
} ,
' required ' : [ ' min ' , ' max ' ] ,
} ,
)
async def _random_int ( self , args ) :
try :
2026-03-18 01:04:16 +00:00
min_value = int ( args . get ( ' min ' ) )
max_value = int ( args . get ( ' max ' ) )
except ( TypeError , ValueError ) :
2026-03-11 13:19:31 +00:00
logger . warning ( ' MCP random_int invalid args: %s ' , args )
return { ' error ' : ' min and max must be integers ' }
if min_value > max_value :
min_value , max_value = max_value , min_value
value = random . randint ( min_value , max_value )
2026-03-18 01:04:16 +00:00
logger . info ( ' MCP random_int generated value= %s range=[ %s , %s ] ' , value , min_value , max_value )
2026-03-11 13:19:31 +00:00
return { ' value ' : value , ' min ' : min_value , ' max ' : max_value }
tools = _collect_tools ( locals ( ) )
2026-03-18 00:37:38 +00:00
_tool_name_to_method = { tool [ ' name ' ] : tool [ ' method ' ] for tool in tools }
2026-03-18 01:04:16 +00:00
mcp_router = MCPRouter ( )