inou/portal/mcp_http.go

625 lines
21 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
}
// 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)
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,
},
})
}
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": "list_lab_tests",
"description": "List all lab test names 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": "get_lab_results",
"description": "Get lab results. Specify names, optional date range, optional latest flag.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
"names": map[string]interface{}{"type": "string", "description": "Comma-separated test names"},
"from": map[string]interface{}{"type": "string", "description": "Start date YYYY-MM-DD"},
"to": map[string]interface{}{"type": "string", "description": "End date YYYY-MM-DD"},
"latest": map[string]interface{}{"type": "boolean", "description": "Most recent only"},
},
"required": []string{"dossier", "names"},
},
"annotations": readOnly,
},
{
"name": "get_categories",
"description": "Get observation categories. Without type: top-level. With type=genome: genome categories.",
"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)"},
"category": map[string]interface{}{"type": "string", "description": "Get subcategories within this category"},
},
"required": []string{"dossier"},
},
"annotations": readOnly,
},
{
"name": "query_genome",
"description": "Query genome variants by gene(s), category, or rsids. Returns matches with magnitude, summary, and 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"},
},
"required": []string{"dossier"},
},
"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
}
// 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 "list_lab_tests":
dossier, _ := params.Arguments["dossier"].(string)
if dossier == "" {
sendMCPError(w, req.ID, -32602, "dossier required")
return
}
result, err := mcpListLabTests(accessToken, dossier)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, mcpTextContent(result))
case "get_lab_results":
dossier, _ := params.Arguments["dossier"].(string)
names, _ := params.Arguments["names"].(string)
if dossier == "" || names == "" {
sendMCPError(w, req.ID, -32602, "dossier and names required")
return
}
from, _ := params.Arguments["from"].(string)
to, _ := params.Arguments["to"].(string)
latest, _ := params.Arguments["latest"].(bool)
result, err := mcpGetLabResults(accessToken, dossier, names, from, to, latest)
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)
result, err := mcpQueryGenome(accessToken, dossier, gene, search, category, rsids, minMag, includeHidden)
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-authorization-server", handleOAuthAuthorizationServer)
mux.HandleFunc("/register", handleDynamicClientRegistration)
mux.HandleFunc("/mcp", handleMCP)
}