inou/portal/mcp_tools.go

371 lines
9.6 KiB
Go

package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"inou/lib"
)
// MCP Tool Implementations
// 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 (images only)
// mcpAPIGet calls the internal API with Bearer auth.
func mcpAPIGet(accessToken, path string, params map[string]string) ([]byte, error) {
v := url.Values{}
for k, val := range params {
if val != "" {
v.Set(k, val)
}
}
u := apiBaseURL + path
if len(v) > 0 {
u += "?" + v.Encode()
}
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
// --- Data query tools: all go through lib with RBAC ---
func mcpListDossiers(accessorID string) (string, error) {
rows, err := lib.DossierQuery(accessorID)
if err != nil {
return "", err
}
// Group by dossier_id
type dossierWithCategories struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
DOB string `json:"date_of_birth,omitempty"`
Sex string `json:"sex,omitempty"`
Categories []string `json:"categories"`
}
dossierMap := make(map[string]*dossierWithCategories)
for _, r := range rows {
if _, exists := dossierMap[r.DossierID]; !exists {
dossierMap[r.DossierID] = &dossierWithCategories{
ID: r.DossierID,
Name: r.Name,
Email: r.Email,
DOB: r.DateOfBirth,
Sex: lib.SexTranslate(r.Sex, "en"),
Categories: []string{},
}
}
if r.EntryCount > 0 {
dossierMap[r.DossierID].Categories = append(dossierMap[r.DossierID].Categories, lib.CategoryName(r.Category))
}
}
// Convert map to array
var result []*dossierWithCategories
for _, d := range dossierMap {
result = append(result, d)
}
pretty, _ := json.MarshalIndent(result, "", " ")
return string(pretty), nil
}
func mcpListStudies(accessorID, dossier string) (string, error) {
entries, err := lib.EntryList(accessorID, "", lib.CategoryImaging, &lib.EntryFilter{DossierID: dossier})
if err != nil {
return "", err
}
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 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
}
pretty, _ := json.MarshalIndent(result, "", " ")
return string(pretty), nil
}
// 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)
}
pretty, _ := json.MarshalIndent(result, "", " ")
return string(pretty)
}
// --- Image tools: RBAC via lib, then API for rendering ---
func mcpFetchImage(accessToken, dossier, slice string, wc, ww float64) (map[string]interface{}, error) {
log.Printf("[MCP] fetch_image: dossier=%s slice=%s accessToken=%s...", dossier, slice, accessToken[:16])
params := map[string]string{}
if wc != 0 {
params["wc"] = strconv.FormatFloat(wc, 'f', 0, 64)
}
if ww != 0 {
params["ww"] = strconv.FormatFloat(ww, 'f', 0, 64)
}
body, err := mcpAPIGet(accessToken, "/image/"+slice, params)
if err != nil {
log.Printf("[MCP] fetch_image ERROR: %v", err)
return nil, err
}
log.Printf("[MCP] fetch_image SUCCESS: got %d bytes", len(body))
b64 := base64.StdEncoding.EncodeToString(body)
return mcpImageContent(b64, "image/webp", fmt.Sprintf("Slice %s (%d bytes)", slice[:8], len(body))), nil
}
func mcpFetchContactSheet(accessToken, dossier, series string, wc, ww float64) (map[string]interface{}, error) {
params := map[string]string{}
if wc != 0 {
params["wc"] = strconv.FormatFloat(wc, 'f', 0, 64)
}
if ww != 0 {
params["ww"] = strconv.FormatFloat(ww, 'f', 0, 64)
}
body, err := mcpAPIGet(accessToken, "/contact-sheet.webp/"+series, params)
if err != nil {
return nil, err
}
b64 := base64.StdEncoding.EncodeToString(body)
return mcpImageContent(b64, "image/webp", fmt.Sprintf("Contact sheet %s (%d bytes)", series[:8], len(body))), nil
}
// --- 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}
if days > 0 {
params["days"] = strconv.Itoa(days)
}
if status != nil {
params["status"] = strconv.Itoa(*status)
}
if journalType != "" {
params["type"] = journalType
}
body, err := mcpAPIGet(accessToken, "/api/v1/dossiers/"+dossier+"/journal", params)
if err != nil {
return "", err
}
var data interface{}
json.Unmarshal(body, &data)
pretty, _ := json.MarshalIndent(data, "", " ")
return string(pretty), nil
}
func mcpGetJournalEntry(accessToken, dossier, entryID string) (string, error) {
body, err := mcpAPIGet(accessToken, "/api/v1/dossiers/"+dossier+"/journal/"+entryID, nil)
if err != nil {
return "", err
}
var data interface{}
json.Unmarshal(body, &data)
pretty, _ := json.MarshalIndent(data, "", " ")
return string(pretty), nil
}
func mcpCreateJournalEntry(accessToken, dossier string, params map[string]interface{}) (string, error) {
u := apiBaseURL + "/api/v1/dossiers/" + dossier + "/journal"
jsonData, err := json.Marshal(params)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", u, io.NopCloser(bytes.NewReader(jsonData)))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != 200 && resp.StatusCode != 201 {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var data interface{}
json.Unmarshal(body, &data)
pretty, _ := json.MarshalIndent(data, "", " ")
return string(pretty), nil
}
func mcpUpdateJournalEntry(accessToken, dossier, entryID string, params map[string]interface{}) (string, error) {
u := apiBaseURL + "/api/v1/dossiers/" + dossier + "/journal/" + entryID
jsonData, err := json.Marshal(params)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("PATCH", u, io.NopCloser(bytes.NewReader(jsonData)))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var data interface{}
json.Unmarshal(body, &data)
pretty, _ := json.MarshalIndent(data, "", " ")
return string(pretty), nil
}