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{}{}, "trackers": 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{}{"trackers": 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) }