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}