inou/portal/mcp_http.go

880 lines
32 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"log"
"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)
// Verify the dossier exists (important when switching between prod/staging)
if d, _ := lib.DossierGet(lib.SystemContext, 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":
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 with entry counts. Returns category names and how many entries exist in each.",
"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": "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 by gene, rsid, or category. Use gene names (e.g. MTHFR) not concepts (e.g. methylation). Search also matches genotype (e.g. AA, GG). Sensitive variants are redacted; use include_hidden to reveal.",
"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": "Free-text search across gene, genotype, subcategory, summary, rsid"},
"category": map[string]interface{}{"type": "string", "description": "Filter by genome category (e.g. cancer, disease, traits)"},
"rsids": map[string]interface{}{"type": "string", "description": "Comma-separated rsids"},
"min_magnitude": map[string]interface{}{"type": "number", "description": "Minimum magnitude"},
"repute": map[string]interface{}{"type": "string", "description": "Filter by repute: Good, Bad, or Clear"},
"include_hidden": map[string]interface{}{"type": "boolean", "description": "Reveal redacted sensitive variants (audited)"},
"limit": map[string]interface{}{"type": "number", "description": "Max results to return (default 20)"},
"offset": map[string]interface{}{"type": "number", "description": "Skip first N results for pagination"},
},
"required": []string{"dossier"},
},
"annotations": readOnly,
},
{
"name": "list_journals",
"description": "List journal entries (protocols, hypotheses, observations) for a dossier. Returns summaries with title, type, date, status. Use get_journal_entry to fetch full content.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
"days": map[string]interface{}{"type": "number", "description": "Filter to last N days (0 = all)"},
"status": map[string]interface{}{"type": "number", "description": "Filter by status (0=draft, 1=active, 2=resolved, 3=discarded)"},
"type": map[string]interface{}{"type": "string", "description": "Filter by type (protocol, hypothesis, observation, connection, question, reference)"},
},
"required": []string{"dossier"},
},
"annotations": readOnly,
},
{
"name": "get_journal_entry",
"description": "Get full journal entry with complete content, reasoning, and metadata.",
"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": "Journal entry ID (16-char hex)"},
},
"required": []string{"dossier", "entry_id"},
},
"annotations": readOnly,
},
{
"name": "create_journal_entry",
"description": "Create a new journal entry (protocol, hypothesis, observation, etc.). Summary will be auto-generated if not provided.",
"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": "Entry type: protocol, hypothesis, observation, connection, question, reference"},
"title": map[string]interface{}{"type": "string", "description": "Short title (max 200 chars)"},
"summary": map[string]interface{}{"type": "string", "description": "1-2 sentence summary (max 300 chars, auto-generated if omitted)"},
"content": map[string]interface{}{"type": "string", "description": "Full markdown content with details, reasoning, etc."},
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}, "description": "Tags for categorization"},
"status": map[string]interface{}{"type": "number", "description": "0=draft (default), 1=active, 2=resolved, 3=discarded"},
"related_entries": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}, "description": "Related entry IDs"},
"source": map[string]interface{}{"type": "string", "description": "Source model (e.g., opus-4.6)"},
"reasoning": map[string]interface{}{"type": "string", "description": "Why this matters / how we arrived at this"},
"metadata": map[string]interface{}{"type": "object", "description": "Additional context (weight, age, etc.)"},
},
"required": []string{"dossier", "type", "title", "content"},
},
},
{
"name": "update_journal_entry",
"description": "Update journal entry status or append a note to existing entry.",
"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": "Journal entry ID (16-char hex)"},
"status": map[string]interface{}{"type": "number", "description": "New status (0=draft, 1=active, 2=resolved, 3=discarded)"},
"append_note": map[string]interface{}{"type": "string", "description": "Note to append to content (timestamped)"},
},
"required": []string{"dossier", "entry_id"},
},
},
{
"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, &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_studies":
dossier, _ := params.Arguments["dossier"].(string)
if dossier == "" {
sendMCPError(w, req.ID, -32602, "dossier required")
return
}
result, err := mcpListStudies(dossierID, 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(dossierID, 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(dossierID, dossier, series)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, mcpTextContent(result))
case "fetch_image":
log.Printf("[MCP] *** FETCH_IMAGE called: req.ID=%v", req.ID)
dossier, _ := params.Arguments["dossier"].(string)
slice, _ := params.Arguments["slice"].(string)
log.Printf("[MCP] *** FETCH_IMAGE params: dossier=%s slice=%s", dossier, slice)
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 {
log.Printf("[MCP] *** FETCH_IMAGE error: %v", err)
sendMCPError(w, req.ID, -32000, err.Error())
return
}
log.Printf("[MCP] *** FETCH_IMAGE success")
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(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 "get_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 "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)
repute, _ := params.Arguments["repute"].(string)
includeHidden, _ := params.Arguments["include_hidden"].(bool)
limitF, _ := params.Arguments["limit"].(float64)
offsetF, _ := params.Arguments["offset"].(float64)
limit := int(limitF)
offset := int(offsetF)
fmt.Printf("[MCP] query_genome: dossier=%s gene=%s category=%s repute=%s limit=%d offset=%d\n",
dossier, gene, category, repute, limit, offset)
result, err := mcpQueryGenome(dossierID, dossier, gene, search, category, rsids, minMag, repute, includeHidden, limit, offset)
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 "list_journals":
dossier, _ := params.Arguments["dossier"].(string)
if dossier == "" {
sendMCPError(w, req.ID, -32602, "dossier required")
return
}
days, _ := params.Arguments["days"].(float64)
journalType, _ := params.Arguments["type"].(string)
var status *int
if statusVal, ok := params.Arguments["status"].(float64); ok {
s := int(statusVal)
status = &s
}
result, err := mcpListJournals(accessToken, dossier, int(days), status, journalType)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, mcpTextContent(result))
case "get_journal_entry":
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 := mcpGetJournalEntry(accessToken, dossier, entryID)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, mcpTextContent(result))
case "create_journal_entry":
dossier, _ := params.Arguments["dossier"].(string)
if dossier == "" {
sendMCPError(w, req.ID, -32602, "dossier required")
return
}
result, err := mcpCreateJournalEntry(accessToken, dossier, params.Arguments)
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return
}
sendMCPResult(w, req.ID, mcpTextContent(result))
case "update_journal_entry":
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 := mcpUpdateJournalEntry(accessToken, dossier, entryID, params.Arguments)
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},
},
}
}
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, &params); 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)
}