354 lines
9.1 KiB
Go
354 lines
9.1 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)
|
|
|
|
// 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 {
|
|
v := url.Values{}
|
|
for k, val := range params {
|
|
if val != "" {
|
|
v.Set(k, val)
|
|
}
|
|
}
|
|
u += "?" + v.Encode()
|
|
}
|
|
|
|
log.Printf("[MCP] API call: %s", u)
|
|
|
|
req, err := http.NewRequest("GET", u, nil)
|
|
if err != nil {
|
|
log.Printf("[MCP] Request error: %v", err)
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Printf("[MCP] HTTP error: %v", err)
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Printf("[MCP] Read error: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
errMsg := fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
|
log.Printf("[MCP] API error: %v", errMsg)
|
|
return nil, errMsg
|
|
}
|
|
|
|
log.Printf("[MCP] API success: %d bytes", len(body))
|
|
return body, 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
|
|
}
|
|
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(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: 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 {
|
|
params["wc"] = strconv.FormatFloat(wc, 'f', 0, 64)
|
|
}
|
|
if ww != 0 {
|
|
params["ww"] = strconv.FormatFloat(ww, 'f', 0, 64)
|
|
}
|
|
|
|
body, err := mcpAPICall(accessToken, "/image/"+slice, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 := mcpAPICall(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 := mcpAPICall(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 := mcpAPICall(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
|
|
}
|