735 lines
25 KiB
Go
735 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"inou/lib"
|
|
)
|
|
|
|
// MCP HTTP Server Implementation
|
|
// Implements Streamable HTTP transport for Anthropic's Connectors Directory
|
|
// See: https://modelcontextprotocol.io/specification/draft/basic/authorization
|
|
|
|
const (
|
|
mcpProtocolVersion = "2025-06-18"
|
|
mcpServerName = "inou-health"
|
|
mcpServerVersion = "1.0.0"
|
|
)
|
|
|
|
// OAuth 2.0 Protected Resource Metadata (RFC9728)
|
|
// GET /.well-known/oauth-protected-resource
|
|
func handleOAuthProtectedResource(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Determine the base URL from the request
|
|
scheme := "https"
|
|
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
|
|
scheme = "http"
|
|
}
|
|
host := r.Host
|
|
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
|
|
|
metadata := map[string]interface{}{
|
|
"resource": baseURL + "/mcp",
|
|
"authorization_servers": []string{baseURL},
|
|
"scopes_supported": []string{"read"},
|
|
"bearer_methods_supported": []string{"header"},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "max-age=3600")
|
|
json.NewEncoder(w).Encode(metadata)
|
|
}
|
|
|
|
// OAuth 2.0 Authorization Server Metadata (RFC8414)
|
|
// GET /.well-known/oauth-authorization-server
|
|
func handleOAuthAuthorizationServer(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
scheme := "https"
|
|
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
|
|
scheme = "http"
|
|
}
|
|
host := r.Host
|
|
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
|
|
|
metadata := map[string]interface{}{
|
|
"issuer": baseURL,
|
|
"authorization_endpoint": baseURL + "/oauth/authorize",
|
|
"token_endpoint": baseURL + "/oauth/token",
|
|
"userinfo_endpoint": baseURL + "/oauth/userinfo",
|
|
"revocation_endpoint": baseURL + "/oauth/revoke",
|
|
"registration_endpoint": baseURL + "/register",
|
|
"response_types_supported": []string{"code"},
|
|
"grant_types_supported": []string{"authorization_code", "refresh_token"},
|
|
"token_endpoint_auth_methods_supported": []string{"none", "client_secret_post"},
|
|
"code_challenge_methods_supported": []string{"S256"},
|
|
"scopes_supported": []string{"read"},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "max-age=3600")
|
|
json.NewEncoder(w).Encode(metadata)
|
|
}
|
|
|
|
// Dynamic Client Registration (RFC 7591)
|
|
// POST /register - Allows clients to register themselves dynamically
|
|
func handleDynamicClientRegistration(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Parse registration request
|
|
var req struct {
|
|
RedirectURIs []string `json:"redirect_uris"`
|
|
ClientName string `json:"client_name"`
|
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
|
GrantTypes []string `json:"grant_types"`
|
|
ResponseTypes []string `json:"response_types"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "invalid_client_metadata",
|
|
"error_description": "Invalid JSON body",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate redirect URIs
|
|
if len(req.RedirectURIs) == 0 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "invalid_redirect_uri",
|
|
"error_description": "At least one redirect_uri is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Create client name if not provided
|
|
clientName := req.ClientName
|
|
if clientName == "" {
|
|
clientName = "Dynamic Client"
|
|
}
|
|
|
|
// Create the client
|
|
client, secret, err := lib.OAuthClientCreate(clientName, req.RedirectURIs)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "server_error",
|
|
"error_description": "Failed to create client",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Return registration response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"client_id": client.ClientID,
|
|
"client_secret": secret,
|
|
"client_name": clientName,
|
|
"redirect_uris": req.RedirectURIs,
|
|
"token_endpoint_auth_method": "client_secret_post",
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
})
|
|
}
|
|
|
|
// MCP JSON-RPC types
|
|
type mcpRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID interface{} `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
type mcpResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID interface{} `json:"id"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
Error *mcpError `json:"error,omitempty"`
|
|
}
|
|
|
|
type mcpError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// MCP HTTP endpoint
|
|
// POST /mcp - Streamable HTTP transport
|
|
func handleMCP(w http.ResponseWriter, r *http.Request) {
|
|
// Handle CORS preflight
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Check authorization
|
|
auth := r.Header.Get("Authorization")
|
|
scheme := "https"
|
|
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
|
|
scheme = "http"
|
|
}
|
|
baseURL := fmt.Sprintf("%s://%s", scheme, r.Host)
|
|
|
|
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(
|
|
`Bearer resource_metadata="%s/.well-known/oauth-protected-resource", scope="read"`,
|
|
baseURL,
|
|
))
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
|
token, err := lib.TokenParse(tokenStr)
|
|
if err != nil {
|
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(
|
|
`Bearer resource_metadata="%s/.well-known/oauth-protected-resource", error="invalid_token"`,
|
|
baseURL,
|
|
))
|
|
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("[MCP] OAuth token authenticated as dossier: %s\n", token.DossierID)
|
|
|
|
// Store the raw access token for passing to internal API calls
|
|
accessToken := tokenStr
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
sendMCPError(w, nil, -32700, "Parse error")
|
|
return
|
|
}
|
|
|
|
var req mcpRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
sendMCPError(w, nil, -32700, "Parse error")
|
|
return
|
|
}
|
|
|
|
// Handle MCP methods
|
|
switch req.Method {
|
|
case "initialize":
|
|
handleMCPInitialize(w, req)
|
|
case "notifications/initialized":
|
|
// No response needed for notifications
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case "tools/list":
|
|
handleMCPToolsList(w, req)
|
|
case "tools/call":
|
|
handleMCPToolsCall(w, req, accessToken, token.DossierID)
|
|
case "prompts/list":
|
|
handleMCPPromptsList(w, req)
|
|
case "prompts/get":
|
|
handleMCPPromptsGet(w, req, accessToken, token.DossierID)
|
|
default:
|
|
sendMCPError(w, req.ID, -32601, "Method not found: "+req.Method)
|
|
}
|
|
}
|
|
|
|
func sendMCPError(w http.ResponseWriter, id interface{}, code int, message string) {
|
|
resp := mcpResponse{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Error: &mcpError{Code: code, Message: message},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func sendMCPResult(w http.ResponseWriter, id interface{}, result interface{}) {
|
|
resp := mcpResponse{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Result: result,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func handleMCPInitialize(w http.ResponseWriter, req mcpRequest) {
|
|
sendMCPResult(w, req.ID, map[string]interface{}{
|
|
"protocolVersion": mcpProtocolVersion,
|
|
"capabilities": map[string]interface{}{
|
|
"tools": map[string]interface{}{},
|
|
"prompts": map[string]interface{}{},
|
|
},
|
|
"serverInfo": map[string]interface{}{
|
|
"name": mcpServerName,
|
|
"version": mcpServerVersion,
|
|
},
|
|
})
|
|
}
|
|
|
|
func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
|
|
readOnly := map[string]interface{}{"readOnlyHint": true}
|
|
|
|
tools := []map[string]interface{}{
|
|
{
|
|
"name": "list_dossiers",
|
|
"description": "List all patient dossiers accessible to this account.",
|
|
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "list_studies",
|
|
"description": "List all imaging studies for a patient dossier.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
},
|
|
"required": []string{"dossier"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "list_series",
|
|
"description": "List series for a study. Filter by description (AX, T1, FLAIR, etc).",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"study": map[string]interface{}{"type": "string", "description": "Study ID (16-char hex)"},
|
|
"filter": map[string]interface{}{"type": "string", "description": "Filter by description"},
|
|
},
|
|
"required": []string{"dossier", "study"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "list_slices",
|
|
"description": "List slices for a series with position info.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"series": map[string]interface{}{"type": "string", "description": "Series ID (16-char hex)"},
|
|
},
|
|
"required": []string{"dossier", "series"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "fetch_image",
|
|
"description": "Fetch slice image as base64 PNG. Optionally set window/level.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"slice": map[string]interface{}{"type": "string", "description": "Slice ID (16-char hex)"},
|
|
"wc": map[string]interface{}{"type": "number", "description": "Window center"},
|
|
"ww": map[string]interface{}{"type": "number", "description": "Window width"},
|
|
},
|
|
"required": []string{"dossier", "slice"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "fetch_contact_sheet",
|
|
"description": "Fetch contact sheet (thumbnail grid) for NAVIGATION ONLY. Use to identify slices, then fetch at full resolution. NEVER diagnose from thumbnails.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"series": map[string]interface{}{"type": "string", "description": "Series ID (16-char hex)"},
|
|
"wc": map[string]interface{}{"type": "number", "description": "Window center"},
|
|
"ww": map[string]interface{}{"type": "number", "description": "Window width"},
|
|
},
|
|
"required": []string{"dossier", "series"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "get_categories",
|
|
"description": "List available data categories for a dossier (imaging, labs, documents, genome). Use this first to discover what data types are available, then query entries by category. For lab results, LOINC codes provide best search accuracy.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"type": map[string]interface{}{"type": "string", "description": "Observation type (e.g., genome) for subcategories"},
|
|
"category": map[string]interface{}{"type": "string", "description": "Get subcategories within this category"},
|
|
},
|
|
"required": []string{"dossier"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "query_entries",
|
|
"description": "Query entries for any category (labs, documents, etc.). For imaging, use list_studies/list_series/list_slices. For genome, use query_genome. For labs: Use search_key with LOINC code (e.g., '2947-0') for fast, accurate results. Use get_categories first to discover available categories.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"category": map[string]interface{}{"type": "string", "description": "Category: 'labs', 'documents', etc. (use get_categories to list)"},
|
|
"type": map[string]interface{}{"type": "string", "description": "Type within category (e.g., test name for labs)"},
|
|
"search_key": map[string]interface{}{"type": "string", "description": "LOINC code for labs (e.g., '2947-0'), gene name for genome (e.g., 'MTHFR')"},
|
|
"parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical queries"},
|
|
"from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"},
|
|
"to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"},
|
|
"limit": map[string]interface{}{"type": "number", "description": "Maximum results"},
|
|
},
|
|
"required": []string{"dossier"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "query_genome",
|
|
"description": "Query genome variants with specialized genome parameters (magnitude, repute, gene, rsids). Returns matches with significance ratings. For non-genome data, use get_categories to find available categories.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"gene": map[string]interface{}{"type": "string", "description": "Gene name(s), comma-separated"},
|
|
"search": map[string]interface{}{"type": "string", "description": "Search gene, subcategory, or summary"},
|
|
"category": map[string]interface{}{"type": "string", "description": "Filter by category"},
|
|
"rsids": map[string]interface{}{"type": "string", "description": "Comma-separated rsids"},
|
|
"min_magnitude": map[string]interface{}{"type": "number", "description": "Minimum magnitude"},
|
|
"include_hidden": map[string]interface{}{"type": "boolean", "description": "Include hidden categories and high-magnitude variants"},
|
|
},
|
|
"required": []string{"dossier"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "get_version",
|
|
"description": "Get bridge and server version info.",
|
|
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
|
"annotations": readOnly,
|
|
},
|
|
}
|
|
|
|
sendMCPResult(w, req.ID, map[string]interface{}{"tools": tools})
|
|
}
|
|
|
|
func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, dossierID string) {
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]interface{} `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
sendMCPError(w, req.ID, -32602, "Invalid params")
|
|
return
|
|
}
|
|
|
|
// Use accessToken for internal API calls (dossierID available if needed)
|
|
_ = dossierID
|
|
|
|
switch params.Name {
|
|
case "list_dossiers":
|
|
result, err := mcpListDossiers(accessToken)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
|
|
|
case "list_studies":
|
|
dossier, _ := params.Arguments["dossier"].(string)
|
|
if dossier == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier required")
|
|
return
|
|
}
|
|
result, err := mcpListStudies(accessToken, dossier)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
|
|
|
case "list_series":
|
|
dossier, _ := params.Arguments["dossier"].(string)
|
|
study, _ := params.Arguments["study"].(string)
|
|
if dossier == "" || study == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier and study required")
|
|
return
|
|
}
|
|
result, err := mcpListSeries(accessToken, dossier, study)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
|
|
|
case "list_slices":
|
|
dossier, _ := params.Arguments["dossier"].(string)
|
|
series, _ := params.Arguments["series"].(string)
|
|
if dossier == "" || series == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier and series required")
|
|
return
|
|
}
|
|
result, err := mcpListSlices(accessToken, dossier, series)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
|
|
|
case "fetch_image":
|
|
dossier, _ := params.Arguments["dossier"].(string)
|
|
slice, _ := params.Arguments["slice"].(string)
|
|
if dossier == "" || slice == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier and slice required")
|
|
return
|
|
}
|
|
wc, _ := params.Arguments["wc"].(float64)
|
|
ww, _ := params.Arguments["ww"].(float64)
|
|
result, err := mcpFetchImage(accessToken, dossier, slice, wc, ww)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, result)
|
|
|
|
case "fetch_contact_sheet":
|
|
dossier, _ := params.Arguments["dossier"].(string)
|
|
series, _ := params.Arguments["series"].(string)
|
|
if dossier == "" || series == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier and series required")
|
|
return
|
|
}
|
|
wc, _ := params.Arguments["wc"].(float64)
|
|
ww, _ := params.Arguments["ww"].(float64)
|
|
result, err := mcpFetchContactSheet(accessToken, dossier, series, wc, ww)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, result)
|
|
|
|
case "query_entries":
|
|
dossier, _ := params.Arguments["dossier"].(string)
|
|
if dossier == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier required")
|
|
return
|
|
}
|
|
category, _ := params.Arguments["category"].(string)
|
|
typ, _ := params.Arguments["type"].(string)
|
|
searchKey, _ := params.Arguments["search_key"].(string)
|
|
parent, _ := params.Arguments["parent"].(string)
|
|
from, _ := params.Arguments["from"].(string)
|
|
to, _ := params.Arguments["to"].(string)
|
|
limit, _ := params.Arguments["limit"].(float64)
|
|
result, err := mcpQueryEntries(accessToken, dossier, category, typ, searchKey, parent, from, to, int(limit))
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
|
|
|
case "get_categories":
|
|
dossier, _ := params.Arguments["dossier"].(string)
|
|
if dossier == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier required")
|
|
return
|
|
}
|
|
typ, _ := params.Arguments["type"].(string)
|
|
category, _ := params.Arguments["category"].(string)
|
|
result, err := mcpGetCategories(accessToken, dossier, typ, category)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
|
|
|
case "query_genome":
|
|
dossier, _ := params.Arguments["dossier"].(string)
|
|
if dossier == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier required")
|
|
return
|
|
}
|
|
gene, _ := params.Arguments["gene"].(string)
|
|
search, _ := params.Arguments["search"].(string)
|
|
category, _ := params.Arguments["category"].(string)
|
|
rsids, _ := params.Arguments["rsids"].(string)
|
|
minMag, _ := params.Arguments["min_magnitude"].(float64)
|
|
includeHidden, _ := params.Arguments["include_hidden"].(bool)
|
|
|
|
fmt.Printf("[MCP] query_genome: dossier=%s gene=%s category=%s includeHidden=%v\n",
|
|
dossier, gene, category, includeHidden)
|
|
|
|
result, err := mcpQueryGenome(accessToken, dossier, gene, search, category, rsids, minMag, includeHidden)
|
|
if err != nil {
|
|
fmt.Printf("[MCP] query_genome error: %v\n", err)
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
fmt.Printf("[MCP] query_genome success: %d bytes\n", len(result))
|
|
sendMCPResult(w, req.ID, mcpTextContent(result))
|
|
|
|
case "get_version":
|
|
sendMCPResult(w, req.ID, mcpTextContent(fmt.Sprintf("Server: %s v%s", mcpServerName, mcpServerVersion)))
|
|
|
|
default:
|
|
sendMCPError(w, req.ID, -32601, "Unknown tool: "+params.Name)
|
|
}
|
|
}
|
|
|
|
func mcpTextContent(text string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"content": []map[string]interface{}{
|
|
{"type": "text", "text": text},
|
|
},
|
|
}
|
|
}
|
|
|
|
func mcpImageContent(base64Data, mimeType, text string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"content": []map[string]interface{}{
|
|
{"type": "image", "data": base64Data, "mimeType": mimeType},
|
|
{"type": "text", "text": text},
|
|
},
|
|
}
|
|
}
|
|
|
|
func handleMCPPromptsList(w http.ResponseWriter, req mcpRequest) {
|
|
prompts := []map[string]interface{}{
|
|
{
|
|
"name": "family_health_context",
|
|
"description": "Get overview of health data available for family members (accessible dossiers). Shows names, basic demographics, and what data categories exist without revealing actual medical data. Use this at the start of a conversation to understand what information is available.",
|
|
},
|
|
}
|
|
|
|
sendMCPResult(w, req.ID, map[string]interface{}{"prompts": prompts})
|
|
}
|
|
|
|
func handleMCPPromptsGet(w http.ResponseWriter, req mcpRequest, accessToken, dossierID string) {
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]interface{} `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
sendMCPError(w, req.ID, -32602, "Invalid params")
|
|
return
|
|
}
|
|
|
|
switch params.Name {
|
|
case "family_health_context":
|
|
result, err := buildFamilyHealthContext(accessToken)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, map[string]interface{}{
|
|
"description": "Family health data overview",
|
|
"messages": []map[string]interface{}{
|
|
{
|
|
"role": "user",
|
|
"content": map[string]interface{}{
|
|
"type": "text",
|
|
"text": result,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
default:
|
|
sendMCPError(w, req.ID, -32601, "Unknown prompt: "+params.Name)
|
|
}
|
|
}
|
|
|
|
func buildFamilyHealthContext(accessToken string) (string, error) {
|
|
// Parse the API response
|
|
var dossiers []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
DOB string `json:"date_of_birth"`
|
|
Sex string `json:"sex"`
|
|
Categories []string `json:"categories"`
|
|
}
|
|
|
|
// The mcpListDossiers returns formatted text, so we need to call the API directly
|
|
apiURL := "http://localhost:8081/api/v1/dossiers"
|
|
client := &http.Client{}
|
|
apiReq, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
apiReq.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := client.Do(apiReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("API request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&dossiers); err != nil {
|
|
return "", fmt.Errorf("failed to decode API response: %v", err)
|
|
}
|
|
|
|
// Build the formatted output
|
|
var output strings.Builder
|
|
output.WriteString("# Family Health Data Overview\n\n")
|
|
output.WriteString("The following health records are accessible to you:\n\n")
|
|
|
|
for _, d := range dossiers {
|
|
output.WriteString(fmt.Sprintf("## %s\n", d.Name))
|
|
if d.DOB != "" {
|
|
output.WriteString(fmt.Sprintf("- DOB: %s\n", d.DOB))
|
|
}
|
|
if d.Sex != "" {
|
|
output.WriteString(fmt.Sprintf("- Sex: %s\n", d.Sex))
|
|
}
|
|
|
|
if len(d.Categories) > 0 {
|
|
output.WriteString("- Available data:\n")
|
|
for _, cat := range d.Categories {
|
|
output.WriteString(fmt.Sprintf(" - %s\n", cat))
|
|
}
|
|
} else {
|
|
output.WriteString("- No health data recorded yet\n")
|
|
}
|
|
output.WriteString("\n")
|
|
}
|
|
|
|
output.WriteString("---\n\n")
|
|
output.WriteString("To access specific data, use the appropriate MCP tools:\n")
|
|
output.WriteString("- Imaging: list_studies, list_series, list_slices, fetch_image\n")
|
|
output.WriteString("- Labs: query_entries with category='labs' (use LOINC codes for best results)\n")
|
|
output.WriteString("- Genome: query_genome (by gene name, rsid, or category)\n")
|
|
output.WriteString("- Documents: query_entries with category='documents'\n")
|
|
output.WriteString("- Other categories: Use get_categories to discover, then query_entries\n")
|
|
|
|
return output.String(), nil
|
|
}
|
|
|
|
// RegisterMCPRoutes registers MCP HTTP endpoints
|
|
func RegisterMCPRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/.well-known/oauth-protected-resource", handleOAuthProtectedResource)
|
|
mux.HandleFunc("/.well-known/oauth-authorization-server", handleOAuthAuthorizationServer)
|
|
mux.HandleFunc("/register", handleDynamicClientRegistration)
|
|
mux.HandleFunc("/mcp", handleMCP)
|
|
}
|