fix: route all MCP data queries through lib RBAC, kill API roundtrip

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 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-10 03:17:59 -05:00
parent e1b40ab872
commit 6546167d67
2 changed files with 132 additions and 117 deletions

View File

@ -503,12 +503,12 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
return return
} }
// Use accessToken for internal API calls (dossierID available if needed) // dossierID = authenticated user's ID (used for RBAC in all lib calls)
_ = dossierID // accessToken = kept only for image/journal API calls
switch params.Name { switch params.Name {
case "list_dossiers": case "list_dossiers":
result, err := mcpListDossiers(accessToken) result, err := mcpListDossiers(dossierID)
if err != nil { if err != nil {
sendMCPError(w, req.ID, -32000, err.Error()) sendMCPError(w, req.ID, -32000, err.Error())
return return
@ -521,7 +521,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
sendMCPError(w, req.ID, -32602, "dossier required") sendMCPError(w, req.ID, -32602, "dossier required")
return return
} }
result, err := mcpListStudies(accessToken, dossier) result, err := mcpListStudies(dossierID, dossier)
if err != nil { if err != nil {
sendMCPError(w, req.ID, -32000, err.Error()) sendMCPError(w, req.ID, -32000, err.Error())
return return
@ -535,7 +535,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
sendMCPError(w, req.ID, -32602, "dossier and study required") sendMCPError(w, req.ID, -32602, "dossier and study required")
return return
} }
result, err := mcpListSeries(accessToken, dossier, study) result, err := mcpListSeries(dossierID, dossier, study)
if err != nil { if err != nil {
sendMCPError(w, req.ID, -32000, err.Error()) sendMCPError(w, req.ID, -32000, err.Error())
return return
@ -549,7 +549,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
sendMCPError(w, req.ID, -32602, "dossier and series required") sendMCPError(w, req.ID, -32602, "dossier and series required")
return return
} }
result, err := mcpListSlices(accessToken, dossier, series) result, err := mcpListSlices(dossierID, dossier, series)
if err != nil { if err != nil {
sendMCPError(w, req.ID, -32000, err.Error()) sendMCPError(w, req.ID, -32000, err.Error())
return return
@ -601,7 +601,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
from, _ := params.Arguments["from"].(string) from, _ := params.Arguments["from"].(string)
to, _ := params.Arguments["to"].(string) to, _ := params.Arguments["to"].(string)
limit, _ := params.Arguments["limit"].(float64) 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 { if err != nil {
sendMCPError(w, req.ID, -32000, err.Error()) sendMCPError(w, req.ID, -32000, err.Error())
return 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", fmt.Printf("[MCP] query_genome: dossier=%s gene=%s category=%s repute=%s limit=%d offset=%d\n",
dossier, gene, category, repute, limit, offset) 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 { if err != nil {
fmt.Printf("[MCP] query_genome error: %v\n", err) fmt.Printf("[MCP] query_genome error: %v\n", err)
sendMCPError(w, req.ID, -32000, err.Error()) sendMCPError(w, req.ID, -32000, err.Error())

View File

@ -16,10 +16,12 @@ import (
) )
// MCP Tool Implementations // 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) { func mcpAPICall(accessToken, path string, params map[string]string) ([]byte, error) {
u := apiBaseURL + path u := apiBaseURL + path
if params != nil && len(params) > 0 { 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) log.Printf("[MCP] Request error: %v", err)
return nil, err return nil, err
} }
// Use the access token for auth
req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{} client := &http.Client{}
@ -66,50 +67,145 @@ func mcpAPICall(accessToken, path string, params map[string]string) ([]byte, err
return body, nil return body, nil
} }
func mcpListDossiers(accessToken string) (string, error) { // --- Data query tools: all go through lib with RBAC ---
body, err := mcpAPICall(accessToken, "/api/v1/dossiers", nil)
func mcpListDossiers(accessorID string) (string, error) {
ctx := &lib.AccessContext{AccessorID: accessorID}
dossiers, err := lib.DossierListAccessible(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
// Pretty print JSON var result []map[string]any
var data interface{} for _, d := range dossiers {
json.Unmarshal(body, &data) result = append(result, map[string]any{
pretty, _ := json.MarshalIndent(data, "", " ") "id": d.DossierID,
"name": d.Name,
})
}
pretty, _ := json.MarshalIndent(result, "", " ")
return string(pretty), nil return string(pretty), nil
} }
func mcpListStudies(accessToken, dossier string) (string, error) { func mcpListStudies(accessorID, dossier string) (string, error) {
body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/entries", map[string]string{"category": "imaging"}) entries, err := lib.EntryList(accessorID, "", lib.CategoryImaging, &lib.EntryFilter{DossierID: dossier})
if err != nil { if err != nil {
return "", err return "", err
} }
var data interface{} return formatEntries(entries), nil
json.Unmarshal(body, &data) }
pretty, _ := json.MarshalIndent(data, "", " ")
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 return string(pretty), nil
} }
func mcpListSeries(accessToken, dossier, study string) (string, error) { func mcpQueryGenome(accessorID, dossier, gene, search, category, rsids string, minMag float64, repute string, includeHidden bool, limit, offset int) (string, error) {
body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/entries", map[string]string{"parent": study}) 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 { if err != nil {
return "", err return "", err
} }
var data interface{} pretty, _ := json.MarshalIndent(result, "", " ")
json.Unmarshal(body, &data)
pretty, _ := json.MarshalIndent(data, "", " ")
return string(pretty), nil return string(pretty), nil
} }
func mcpListSlices(accessToken, dossier, series string) (string, error) { // formatEntries converts entries to the standard MCP response format.
body, err := mcpAPICall(accessToken, "/api/v1/dossiers/"+dossier+"/entries", map[string]string{"parent": series}) func formatEntries(entries []*lib.Entry) string {
if err != nil { var result []map[string]any
return "", err 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,
} }
var data interface{} result = append(result, entry)
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) { func mcpFetchImage(accessToken, dossier, slice string, wc, ww float64) (map[string]interface{}, error) {
params := map[string]string{} params := map[string]string{}
@ -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 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) { // --- Journal tools (in development — kept as API passthrough) ---
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
func mcpListJournals(accessToken, dossier string, days int, status *int, journalType string) (string, error) { func mcpListJournals(accessToken, dossier string, days int, status *int, journalType string) (string, error) {
params := map[string]string{"dossier": dossier} params := map[string]string{"dossier": dossier}