From 6546167d67c1782d8508e3945cbc7d89af68ba29 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 10 Feb 2026 03:17:59 -0500 Subject: [PATCH] fix: route all MCP data queries through lib RBAC, kill API roundtrip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_dossiers, list_studies, list_series, list_slices, query_entries, get_categories, query_genome — all now call lib directly with AccessContext{AccessorID: dossierID}. No more HTTP roundtrip to the internal API with its separate auth path. Image and journal tools still use API (image rendering logic lives there, and the API already enforces RBAC via lib.CheckAccess). Co-Authored-By: Claude Opus 4.6 --- portal/mcp_http.go | 16 +-- portal/mcp_tools.go | 233 +++++++++++++++++++++++--------------------- 2 files changed, 132 insertions(+), 117 deletions(-) diff --git a/portal/mcp_http.go b/portal/mcp_http.go index 58c403a..e5e17ff 100644 --- a/portal/mcp_http.go +++ b/portal/mcp_http.go @@ -503,12 +503,12 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss return } - // Use accessToken for internal API calls (dossierID available if needed) - _ = dossierID + // dossierID = authenticated user's ID (used for RBAC in all lib calls) + // accessToken = kept only for image/journal API calls switch params.Name { case "list_dossiers": - result, err := mcpListDossiers(accessToken) + result, err := mcpListDossiers(dossierID) if err != nil { sendMCPError(w, req.ID, -32000, err.Error()) return @@ -521,7 +521,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss sendMCPError(w, req.ID, -32602, "dossier required") return } - result, err := mcpListStudies(accessToken, dossier) + result, err := mcpListStudies(dossierID, dossier) if err != nil { sendMCPError(w, req.ID, -32000, err.Error()) return @@ -535,7 +535,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss sendMCPError(w, req.ID, -32602, "dossier and study required") return } - result, err := mcpListSeries(accessToken, dossier, study) + result, err := mcpListSeries(dossierID, dossier, study) if err != nil { sendMCPError(w, req.ID, -32000, err.Error()) return @@ -549,7 +549,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss sendMCPError(w, req.ID, -32602, "dossier and series required") return } - result, err := mcpListSlices(accessToken, dossier, series) + result, err := mcpListSlices(dossierID, dossier, series) if err != nil { sendMCPError(w, req.ID, -32000, err.Error()) return @@ -601,7 +601,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss 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)) + result, err := mcpQueryEntries(dossierID, dossier, category, typ, searchKey, parent, from, to, int(limit)) if err != nil { sendMCPError(w, req.ID, -32000, err.Error()) return @@ -642,7 +642,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss 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(accessToken, dossier, dossierID, gene, search, category, rsids, minMag, repute, includeHidden, 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()) diff --git a/portal/mcp_tools.go b/portal/mcp_tools.go index 6fa840e..fa320cb 100644 --- a/portal/mcp_tools.go +++ b/portal/mcp_tools.go @@ -16,10 +16,12 @@ import ( ) // MCP Tool Implementations -// These call the internal API endpoints to get data +// Data queries go through lib directly with RBAC enforcement. +// Image rendering goes through the API (which also enforces RBAC via lib). -const apiBaseURL = "http://localhost:8082" // Internal API server +const apiBaseURL = "http://localhost:8082" // Internal API server (images only) +// mcpAPICall is used ONLY for image endpoints that require server-side rendering. func mcpAPICall(accessToken, path string, params map[string]string) ([]byte, error) { u := apiBaseURL + path if params != nil && len(params) > 0 { @@ -39,7 +41,6 @@ func mcpAPICall(accessToken, path string, params map[string]string) ([]byte, err log.Printf("[MCP] Request error: %v", err) return nil, err } - // Use the access token for auth req.Header.Set("Authorization", "Bearer "+accessToken) client := &http.Client{} @@ -66,51 +67,146 @@ func mcpAPICall(accessToken, path string, params map[string]string) ([]byte, err return body, nil } -func mcpListDossiers(accessToken string) (string, error) { - body, err := mcpAPICall(accessToken, "/api/v1/dossiers", nil) +// --- Data query tools: all go through lib with RBAC --- + +func mcpListDossiers(accessorID string) (string, error) { + ctx := &lib.AccessContext{AccessorID: accessorID} + dossiers, err := lib.DossierListAccessible(ctx) if err != nil { return "", err } - // Pretty print JSON - var data interface{} - json.Unmarshal(body, &data) - pretty, _ := json.MarshalIndent(data, "", " ") + var result []map[string]any + for _, d := range dossiers { + result = append(result, map[string]any{ + "id": d.DossierID, + "name": d.Name, + }) + } + pretty, _ := json.MarshalIndent(result, "", " ") return string(pretty), nil } -func mcpListStudies(accessToken, dossier string) (string, error) { - body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/entries", map[string]string{"category": "imaging"}) +func mcpListStudies(accessorID, dossier string) (string, error) { + entries, err := lib.EntryList(accessorID, "", lib.CategoryImaging, &lib.EntryFilter{DossierID: dossier}) if err != nil { return "", err } - var data interface{} - json.Unmarshal(body, &data) - pretty, _ := json.MarshalIndent(data, "", " ") + return formatEntries(entries), nil +} + +func mcpListSeries(accessorID, dossier, study string) (string, error) { + entries, err := lib.EntryList(accessorID, study, 0, &lib.EntryFilter{DossierID: dossier}) + if err != nil { + return "", err + } + return formatEntries(entries), nil +} + +func mcpListSlices(accessorID, dossier, series string) (string, error) { + entries, err := lib.EntryList(accessorID, series, 0, &lib.EntryFilter{DossierID: dossier}) + if err != nil { + return "", err + } + return formatEntries(entries), nil +} + +func mcpQueryEntries(accessorID, dossier, category, typ, searchKey, parent, from, to string, limit int) (string, error) { + cat := 0 + if category != "" { + cat = lib.CategoryFromString[category] + } + filter := &lib.EntryFilter{DossierID: dossier} + if typ != "" { + filter.Type = typ + } + if searchKey != "" { + filter.SearchKey = searchKey + } + if from != "" { + filter.FromDate, _ = strconv.ParseInt(from, 10, 64) + } + if to != "" { + filter.ToDate, _ = strconv.ParseInt(to, 10, 64) + } + if limit > 0 { + filter.Limit = limit + } + + entries, err := lib.EntryList(accessorID, parent, cat, filter) + if err != nil { + return "", err + } + return formatEntries(entries), nil +} + +func mcpGetCategories(dossier, accessorID string) (string, error) { + ctx := &lib.AccessContext{AccessorID: accessorID} + result, err := lib.EntryCategoryCounts(ctx, dossier) + if err != nil { + return "", err + } + pretty, _ := json.MarshalIndent(result, "", " ") return string(pretty), nil } -func mcpListSeries(accessToken, dossier, study string) (string, error) { - body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/entries", map[string]string{"parent": study}) +func mcpQueryGenome(accessorID, dossier, gene, search, category, rsids string, minMag float64, repute string, includeHidden bool, limit, offset int) (string, error) { + ctx := &lib.AccessContext{AccessorID: accessorID} + var rsidList []string + if rsids != "" { + rsidList = strings.Split(rsids, ",") + } + + if limit <= 0 { + numTerms := 1 + if gene != "" { + numTerms = len(strings.Split(gene, ",")) + } + if len(rsidList) > numTerms { + numTerms = len(rsidList) + } + limit = 20 * numTerms + } + + result, err := lib.GenomeQuery(ctx, dossier, lib.GenomeQueryOpts{ + Category: category, + Search: search, + Gene: gene, + RSIDs: rsidList, + MinMagnitude: minMag, + Repute: repute, + IncludeHidden: includeHidden, + Limit: limit, + Offset: offset, + AccessorID: accessorID, + }) if err != nil { return "", err } - var data interface{} - json.Unmarshal(body, &data) - pretty, _ := json.MarshalIndent(data, "", " ") + pretty, _ := json.MarshalIndent(result, "", " ") return string(pretty), nil } -func mcpListSlices(accessToken, dossier, series string) (string, error) { - body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/entries", map[string]string{"parent": series}) - if err != nil { - return "", err +// formatEntries converts entries to the standard MCP response format. +func formatEntries(entries []*lib.Entry) string { + var result []map[string]any + for _, e := range entries { + entry := map[string]any{ + "id": e.EntryID, + "parent_id": e.ParentID, + "category": lib.CategoryName(e.Category), + "type": e.Type, + "summary": e.Summary, + "ordinal": e.Ordinal, + "timestamp": e.Timestamp, + } + result = append(result, entry) } - var data interface{} - json.Unmarshal(body, &data) - pretty, _ := json.MarshalIndent(data, "", " ") - return string(pretty), nil + pretty, _ := json.MarshalIndent(result, "", " ") + return string(pretty) } +// --- Image tools: use API (image rendering lives there, API enforces RBAC via lib) --- + func mcpFetchImage(accessToken, dossier, slice string, wc, ww float64) (map[string]interface{}, error) { params := map[string]string{} if wc != 0 { @@ -147,88 +243,7 @@ func mcpFetchContactSheet(accessToken, dossier, series string, wc, ww float64) ( return mcpImageContent(b64, "image/webp", fmt.Sprintf("Contact sheet %s (%d bytes)", series[:8], len(body))), nil } -func mcpQueryEntries(accessToken, dossier, category, typ, searchKey, parent, from, to string, limit int) (string, error) { - params := map[string]string{} - if category != "" { - params["category"] = category - } - if typ != "" { - params["type"] = typ - } - if searchKey != "" { - params["search_key"] = searchKey - } - if parent != "" { - params["parent"] = parent - } - if from != "" { - params["from"] = from - } - if to != "" { - params["to"] = to - } - if limit > 0 { - params["limit"] = strconv.Itoa(limit) - } - - body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/entries", params) - if err != nil { - return "", err - } - var data interface{} - json.Unmarshal(body, &data) - pretty, _ := json.MarshalIndent(data, "", " ") - return string(pretty), nil -} - -func mcpGetCategories(dossier, accessorID string) (string, error) { - ctx := &lib.AccessContext{AccessorID: accessorID} - result, err := lib.EntryCategoryCounts(ctx, dossier) - if err != nil { - return "", err - } - pretty, _ := json.MarshalIndent(result, "", " ") - return string(pretty), nil -} - -func mcpQueryGenome(accessToken, dossier, accessorID, gene, search, category, rsids string, minMag float64, repute string, includeHidden bool, limit, offset int) (string, error) { - ctx := &lib.AccessContext{AccessorID: accessorID} - var rsidList []string - if rsids != "" { - rsidList = strings.Split(rsids, ",") - } - - if limit <= 0 { - numTerms := 1 - if gene != "" { - numTerms = len(strings.Split(gene, ",")) - } - if len(rsidList) > numTerms { - numTerms = len(rsidList) - } - limit = 20 * numTerms - } - - result, err := lib.GenomeQuery(ctx, dossier, lib.GenomeQueryOpts{ - Category: category, - Search: search, - Gene: gene, - RSIDs: rsidList, - MinMagnitude: minMag, - Repute: repute, - IncludeHidden: includeHidden, - Limit: limit, - Offset: offset, - AccessorID: accessorID, - }) - if err != nil { - return "", err - } - pretty, _ := json.MarshalIndent(result, "", " ") - return string(pretty), nil -} - -// Journal MCP Tools +// --- Journal tools (in development — kept as API passthrough) --- func mcpListJournals(accessToken, dossier string, days int, status *int, journalType string) (string, error) { params := map[string]string{"dossier": dossier}