inou/portal/mcp_http.go

540 lines
19 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) {
// FIXED(review-2026-02-28): Use origin allowlist instead of wildcard
origin := r.Header.Get("Origin")
if corsAllowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
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)
// Verify the dossier exists (important when switching between prod/staging)
if d, _ := lib.DossierGet(lib.SystemAccessorID, token.DossierID); d == nil {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(
`Bearer resource_metadata="%s/.well-known/oauth-protected-resource", error="invalid_token", error_description="Dossier not found"`,
baseURL,
))
http.Error(w, "Invalid token: dossier not found", http.StatusUnauthorized)
return
}
// 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":
sendMCPResult(w, req.ID, map[string]interface{}{"prompts": []interface{}{}})
case "prompts/get":
sendMCPError(w, req.ID, -32601, "No prompts available")
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{}{},
},
"serverInfo": map[string]interface{}{
"name": mcpServerName,
"version": mcpServerVersion,
},
"instructions": "inou gives you access to a patient's raw health data — imaging, labs, genome, vitals, and more. " +
"Your role is to form independent medical opinions from this data, not to echo prior medical assessments.\n\n" +
"Medical opinion categories (diagnoses, consultation notes, assessments, imaging reports) unlock after you've queried " +
"all available raw data categories for the patient. A top-level listing counts — you don't need to review every entry, " +
"just see what's there and use your judgement whether you need to dive deeper to answer your user's question.\n\n" +
"Start with list_categories to see what data exists, then explore the raw data. " +
"Cheating is possible but hurts your user — anchoring on prior opinions defeats the purpose of independent analysis.",
})
}
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_categories",
"description": "List data categories for a dossier with entry counts. Start here to see what's available before querying specific data.",
"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_entries",
"description": "List entries by category, type, or parent. All data is hierarchical — use parent to navigate deeper. For imaging: list studies (category='imaging'), then series (parent=study_id), then slices (parent=series_id). For labs: use search_key with LOINC code (e.g., '718-7'). For genome: search_key with gene name (e.g., 'MTHFR').",
"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 name (use list_categories to discover)"},
"type": map[string]interface{}{"type": "string", "description": "Entry type within category"},
"search_key": map[string]interface{}{"type": "string", "description": "LOINC code for labs, gene name for genome"},
"parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical navigation"},
"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": "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": "fetch_document",
"description": "Fetch full document content including extracted text, findings, and metadata. Use after finding documents via list_entries.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
"entry_id": map[string]interface{}{"type": "string", "description": "Document entry ID (16-char hex)"},
},
"required": []string{"dossier", "entry_id"},
},
"annotations": readOnly,
},
{
"name": "get_version",
"description": "Get 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, &params); err != nil {
sendMCPError(w, req.ID, -32602, "Invalid params")
return
}
// dossierID = authenticated user's ID (used for RBAC in all lib calls)
// accessToken = forwarded to API for image/journal calls (API enforces RBAC)
switch params.Name {
case "list_dossiers":
result, err := mcpListDossiers(dossierID)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, mcpTextContent(result))
case "list_categories":
dossier, _ := params.Arguments["dossier"].(string)
if dossier == "" {
sendMCPError(w, req.ID, -32602, "dossier required")
return
}
result, err := mcpGetCategories(dossier, dossierID)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, mcpTextContent(result))
case "list_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(dossierID, 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 "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 "fetch_document":
dossier, _ := params.Arguments["dossier"].(string)
entryID, _ := params.Arguments["entry_id"].(string)
if dossier == "" || entryID == "" {
sendMCPError(w, req.ID, -32602, "dossier and entry_id required")
return
}
result, err := mcpFetchDocument(dossierID, dossier, entryID)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
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},
},
}
}
// RegisterMCPRoutes registers MCP HTTP endpoints
func RegisterMCPRoutes(mux *http.ServeMux) {
mux.HandleFunc("/.well-known/oauth-protected-resource", handleOAuthProtectedResource)
mux.HandleFunc("/.well-known/oauth-protected-resource/mcp", handleOAuthProtectedResource)
mux.HandleFunc("/.well-known/oauth-authorization-server", handleOAuthAuthorizationServer)
mux.HandleFunc("/register", handleDynamicClientRegistration)
mux.HandleFunc("/mcp", handleMCP)
}