553 lines
20 KiB
Go
553 lines
20 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",
|
|
"title": "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",
|
|
"title": "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",
|
|
"title": "Query Entries",
|
|
"description": "List entries by navigating the hierarchy. Always start with parent=<dossier_id> to get top-level entries, then use returned entry IDs to go deeper. For imaging: dossier → root → studies → series. To view slices, use fetch_contact_sheet on a series, then fetch_image with the slice ID. For labs: dossier → test groups → results. Use search_key for LOINC codes (labs) or gene names (genome).",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"parent": map[string]interface{}{"type": "string", "description": "Parent entry ID — start with the dossier ID, then navigate deeper"},
|
|
"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"},
|
|
"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", "parent"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "fetch_image",
|
|
"title": "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",
|
|
"title": "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",
|
|
"title": "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)"},
|
|
"format": map[string]interface{}{"type": "string", "description": "Output format: 'original' (default, raw JSON), 'markdown' (formatted), 'translation' (English translation via AI)"},
|
|
},
|
|
"required": []string{"dossier", "entry_id"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "get_version",
|
|
"title": "Server 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, ¶ms); 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)
|
|
if parent == "" {
|
|
sendMCPResult(w, req.ID, mcpTextContent("ERROR: parent is required. Start with parent="+dossier+" (the dossier ID) to list top-level entries, then use returned entry IDs to navigate deeper."))
|
|
return
|
|
}
|
|
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)
|
|
format, _ := params.Arguments["format"].(string)
|
|
if dossier == "" || entryID == "" {
|
|
sendMCPError(w, req.ID, -32602, "dossier and entry_id required")
|
|
return
|
|
}
|
|
result, err := mcpFetchDocument(dossierID, dossier, entryID, format)
|
|
if err != nil {
|
|
sendMCPError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
sendMCPResult(w, req.ID, 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)
|
|
}
|