| """ |
| SkinProAI MCP Server - Pure JSON-RPC 2.0 stdio server (no mcp library required). |
| |
| Uses sys.executable (venv Python) so all ML packages (torch, transformers, etc.) |
| are available. Tools are loaded lazily on first call. |
| |
| Run standalone: python mcp_server/server.py |
| (Should start silently, waiting on stdin.) |
| """ |
|
|
| import sys |
| import json |
| import os |
|
|
| |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
| from mcp_server.tool_registry import get_monet, get_convnext, get_gradcam, get_rag |
|
|
|
|
| |
| |
| |
|
|
| def _monet_analyze(arguments: dict) -> dict: |
| from PIL import Image |
| image = Image.open(arguments["image_path"]).convert("RGB") |
| return get_monet().analyze(image) |
|
|
|
|
| def _classify_lesion(arguments: dict) -> dict: |
| from PIL import Image |
| image = Image.open(arguments["image_path"]).convert("RGB") |
| monet_scores = arguments.get("monet_scores") |
| return get_convnext().classify( |
| clinical_image=image, |
| derm_image=None, |
| monet_scores=monet_scores, |
| ) |
|
|
|
|
| def _generate_gradcam(arguments: dict) -> dict: |
| from PIL import Image |
| import tempfile |
| image = Image.open(arguments["image_path"]).convert("RGB") |
| result = get_gradcam().analyze(image) |
|
|
| gradcam_file = tempfile.NamedTemporaryFile(suffix="_gradcam.png", delete=False) |
| gradcam_path = gradcam_file.name |
| gradcam_file.close() |
| result["overlay"].save(gradcam_path) |
|
|
| return { |
| "gradcam_path": gradcam_path, |
| "predicted_class": result["predicted_class"], |
| "predicted_class_full": result["predicted_class_full"], |
| "confidence": result["confidence"], |
| } |
|
|
|
|
| def _search_guidelines(arguments: dict) -> dict: |
| query = arguments.get("query", "") |
| diagnosis = arguments.get("diagnosis") or "" |
| rag = get_rag() |
| context, references = rag.get_management_context(diagnosis, query) |
| references_display = rag.format_references_for_display(references) |
| return { |
| "context": context, |
| "references": references, |
| "references_display": references_display, |
| } |
|
|
|
|
| def _compare_images(arguments: dict) -> dict: |
| from PIL import Image |
| import tempfile |
| image1 = Image.open(arguments["image1_path"]).convert("RGB") |
| image2 = Image.open(arguments["image2_path"]).convert("RGB") |
|
|
| from models.overlay_tool import get_overlay_tool |
| comparison = get_overlay_tool().generate_comparison_overlay( |
| image1, image2, label1="Previous", label2="Current" |
| ) |
| comparison_path = comparison["path"] |
|
|
| monet = get_monet() |
| prev_result = monet.analyze(image1) |
| curr_result = monet.analyze(image2) |
|
|
| monet_deltas = {} |
| for name in curr_result["features"]: |
| prev_val = prev_result["features"].get(name, 0.0) |
| curr_val = curr_result["features"][name] |
| delta = curr_val - prev_val |
| if abs(delta) > 0.1: |
| monet_deltas[name] = { |
| "previous": prev_val, |
| "current": curr_val, |
| "delta": delta, |
| } |
|
|
| |
| prev_gradcam_path = None |
| curr_gradcam_path = None |
| try: |
| gradcam = get_gradcam() |
| prev_gc = gradcam.analyze(image1) |
| curr_gc = gradcam.analyze(image2) |
|
|
| f1 = tempfile.NamedTemporaryFile(suffix="_gradcam.png", delete=False) |
| prev_gradcam_path = f1.name |
| f1.close() |
| prev_gc["overlay"].save(prev_gradcam_path) |
|
|
| f2 = tempfile.NamedTemporaryFile(suffix="_gradcam.png", delete=False) |
| curr_gradcam_path = f2.name |
| f2.close() |
| curr_gc["overlay"].save(curr_gradcam_path) |
| except Exception: |
| pass |
|
|
| return { |
| "comparison_path": comparison_path, |
| "monet_deltas": monet_deltas, |
| "prev_gradcam_path": prev_gradcam_path, |
| "curr_gradcam_path": curr_gradcam_path, |
| } |
|
|
|
|
| TOOLS = { |
| "monet_analyze": _monet_analyze, |
| "classify_lesion": _classify_lesion, |
| "generate_gradcam": _generate_gradcam, |
| "search_guidelines": _search_guidelines, |
| "compare_images": _compare_images, |
| } |
|
|
| TOOLS_LIST = [ |
| { |
| "name": "monet_analyze", |
| "description": "Extract MONET concept-presence scores from a skin lesion image.", |
| "inputSchema": { |
| "type": "object", |
| "properties": {"image_path": {"type": "string"}}, |
| "required": ["image_path"], |
| }, |
| }, |
| { |
| "name": "classify_lesion", |
| "description": "Classify a skin lesion using ConvNeXt dual-encoder.", |
| "inputSchema": { |
| "type": "object", |
| "properties": { |
| "image_path": {"type": "string"}, |
| "monet_scores": {"type": "array"}, |
| }, |
| "required": ["image_path"], |
| }, |
| }, |
| { |
| "name": "generate_gradcam", |
| "description": "Generate a Grad-CAM attention overlay for a skin lesion image.", |
| "inputSchema": { |
| "type": "object", |
| "properties": {"image_path": {"type": "string"}}, |
| "required": ["image_path"], |
| }, |
| }, |
| { |
| "name": "search_guidelines", |
| "description": "Search clinical guidelines RAG for management context.", |
| "inputSchema": { |
| "type": "object", |
| "properties": { |
| "query": {"type": "string"}, |
| "diagnosis": {"type": "string"}, |
| }, |
| "required": ["query"], |
| }, |
| }, |
| { |
| "name": "compare_images", |
| "description": "Generate comparison overlay and MONET deltas for two lesion images.", |
| "inputSchema": { |
| "type": "object", |
| "properties": { |
| "image1_path": {"type": "string"}, |
| "image2_path": {"type": "string"}, |
| }, |
| "required": ["image1_path", "image2_path"], |
| }, |
| }, |
| ] |
|
|
|
|
| |
| |
| |
|
|
| def handle_request(request: dict): |
| method = request.get("method") |
| req_id = request.get("id") |
| params = request.get("params", {}) |
|
|
| if method == "initialize": |
| return { |
| "jsonrpc": "2.0", |
| "id": req_id, |
| "result": { |
| "protocolVersion": "2024-11-05", |
| "capabilities": {"tools": {"listChanged": False}}, |
| "serverInfo": {"name": "SkinProAI", "version": "1.0.0"}, |
| }, |
| } |
|
|
| if method in ("notifications/initialized",): |
| return None |
|
|
| if method == "tools/list": |
| return { |
| "jsonrpc": "2.0", |
| "id": req_id, |
| "result": {"tools": TOOLS_LIST}, |
| } |
|
|
| if method == "tools/call": |
| name = params.get("name") |
| arguments = params.get("arguments", {}) |
| if name not in TOOLS: |
| return { |
| "jsonrpc": "2.0", |
| "id": req_id, |
| "error": {"code": -32601, "message": f"Unknown tool: {name}"}, |
| } |
| try: |
| result = TOOLS[name](arguments) |
| return { |
| "jsonrpc": "2.0", |
| "id": req_id, |
| "result": { |
| "content": [{"type": "text", "text": json.dumps(result)}], |
| "isError": False, |
| }, |
| } |
| except Exception as e: |
| return { |
| "jsonrpc": "2.0", |
| "id": req_id, |
| "result": { |
| "content": [{"type": "text", "text": f"Tool error: {e}"}], |
| "isError": True, |
| }, |
| } |
|
|
| |
| if req_id is not None: |
| return { |
| "jsonrpc": "2.0", |
| "id": req_id, |
| "error": {"code": -32601, "message": f"Method not found: {method}"}, |
| } |
|
|
| return None |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| for line in sys.stdin: |
| line = line.strip() |
| if not line: |
| continue |
| try: |
| request = json.loads(line) |
| except json.JSONDecodeError: |
| continue |
| response = handle_request(request) |
| if response is not None: |
| sys.stdout.write(json.dumps(response) + "\n") |
| sys.stdout.flush() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|