964 lines
29 KiB
Go
964 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
Version = "dev"
|
|
BuildTime = "unknown"
|
|
)
|
|
|
|
var httpClient = &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
},
|
|
}
|
|
|
|
var (
|
|
server string
|
|
accountHex string // dossier ID for legacy mode or extracted from token
|
|
latestBridgeVersion string
|
|
downloadURL string
|
|
updateMessage string
|
|
|
|
// OAuth token state
|
|
refreshToken string
|
|
accessToken string
|
|
accessTokenExpiry time.Time
|
|
tokenFilePath string
|
|
)
|
|
|
|
func log(format string, args ...interface{}) {
|
|
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format("15:04:05.000"), fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// Token state for persistence
|
|
type tokenState struct {
|
|
RefreshToken string `json:"refresh_token"`
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
DossierID string `json:"dossier_id"`
|
|
}
|
|
|
|
func getTokenFilePath() string {
|
|
if tokenFilePath != "" {
|
|
return tokenFilePath
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
dir := home + "/.config/inou"
|
|
os.MkdirAll(dir, 0700)
|
|
return dir + "/bridge-token.json"
|
|
}
|
|
|
|
func loadTokenState() *tokenState {
|
|
path := getTokenFilePath()
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var state tokenState
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return nil
|
|
}
|
|
return &state
|
|
}
|
|
|
|
func saveTokenState() {
|
|
path := getTokenFilePath()
|
|
if path == "" {
|
|
return
|
|
}
|
|
state := tokenState{
|
|
RefreshToken: refreshToken,
|
|
AccessToken: accessToken,
|
|
ExpiresAt: accessTokenExpiry.Unix(),
|
|
DossierID: accountHex,
|
|
}
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
log("Failed to marshal token state: %v", err)
|
|
return
|
|
}
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
log("Failed to save token state: %v", err)
|
|
}
|
|
}
|
|
|
|
func refreshAccessToken() error {
|
|
if refreshToken == "" {
|
|
return fmt.Errorf("no refresh token available")
|
|
}
|
|
|
|
log("Refreshing access token...")
|
|
|
|
data := url.Values{}
|
|
data.Set("grant_type", "refresh_token")
|
|
data.Set("client_id", "inou-bridge")
|
|
data.Set("refresh_token", refreshToken)
|
|
|
|
req, err := http.NewRequest("POST", server+"/oauth/token", strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := httpClient.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("token refresh failed: HTTP %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var tokenResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Error string `json:"error"`
|
|
ErrorDesc string `json:"error_description"`
|
|
}
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return fmt.Errorf("failed to parse token response: %v", err)
|
|
}
|
|
|
|
if tokenResp.Error != "" {
|
|
return fmt.Errorf("token error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
|
|
}
|
|
|
|
accessToken = tokenResp.AccessToken
|
|
refreshToken = tokenResp.RefreshToken // Rotated token
|
|
accessTokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
|
|
log("Access token refreshed, expires in %d seconds", tokenResp.ExpiresIn)
|
|
saveTokenState()
|
|
return nil
|
|
}
|
|
|
|
func ensureValidToken() error {
|
|
// If no refresh token, we're in legacy mode (using dossier ID directly)
|
|
if refreshToken == "" {
|
|
return nil
|
|
}
|
|
|
|
// Check if access token is still valid (with 60 second buffer)
|
|
if accessToken != "" && time.Now().Add(60*time.Second).Before(accessTokenExpiry) {
|
|
return nil
|
|
}
|
|
|
|
return refreshAccessToken()
|
|
}
|
|
|
|
// fetchDossierID gets the dossier ID from the userinfo endpoint
|
|
func fetchDossierID() error {
|
|
if err := ensureValidToken(); err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", server+"/oauth/userinfo", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := httpClient.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("userinfo failed: HTTP %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var userinfo struct {
|
|
Sub string `json:"sub"`
|
|
}
|
|
if err := json.Unmarshal(body, &userinfo); err != nil {
|
|
return err
|
|
}
|
|
|
|
accountHex = userinfo.Sub
|
|
log("Got dossier ID from userinfo: %s...", accountHex[:min(8, len(accountHex))])
|
|
saveTokenState()
|
|
return nil
|
|
}
|
|
|
|
func getAuthToken() string {
|
|
if accessToken != "" {
|
|
return accessToken
|
|
}
|
|
return accountHex
|
|
}
|
|
|
|
func semverLessThan(a, b string) bool {
|
|
partsA := strings.Split(a, ".")
|
|
partsB := strings.Split(b, ".")
|
|
for i := 0; i < len(partsA) && i < len(partsB); i++ {
|
|
numA, _ := strconv.Atoi(partsA[i])
|
|
numB, _ := strconv.Atoi(partsB[i])
|
|
if numA < numB {
|
|
return true
|
|
}
|
|
if numA > numB {
|
|
return false
|
|
}
|
|
}
|
|
return len(partsA) < len(partsB)
|
|
}
|
|
|
|
func fetchLatestVersion() {
|
|
versionURL := fmt.Sprintf("%s/api/version?os=%s&arch=%s", server, runtime.GOOS, runtime.GOARCH)
|
|
log("Fetching version from %s", versionURL)
|
|
resp, err := httpClient.Get(versionURL)
|
|
if err != nil {
|
|
log("Version fetch failed: %v", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
var info struct {
|
|
LatestBridgeVersion string `json:"latest_bridge_version"`
|
|
DownloadURL string `json:"download_url"`
|
|
UpdateMessage string `json:"update_message"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
log("Version parse failed: %v", err)
|
|
return
|
|
}
|
|
latestBridgeVersion = info.LatestBridgeVersion
|
|
downloadURL = info.DownloadURL
|
|
updateMessage = info.UpdateMessage
|
|
log("Latest bridge: %s (current: %s)", latestBridgeVersion, Version)
|
|
}
|
|
|
|
func needsUpdate() bool {
|
|
if Version == "dev" || latestBridgeVersion == "" {
|
|
return false
|
|
}
|
|
return semverLessThan(Version, latestBridgeVersion)
|
|
}
|
|
|
|
func getUpdateWarning(id interface{}) string {
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
exePath = "inou_bridge"
|
|
}
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = fmt.Sprintf("curl -o \"%s\" %s", exePath, downloadURL)
|
|
} else {
|
|
cmd = fmt.Sprintf("curl -o %s %s && chmod +x %s", exePath, downloadURL, exePath)
|
|
}
|
|
var warning string
|
|
if updateMessage != "" {
|
|
warning = fmt.Sprintf("Current version: %s, Latest: %s\n\n%s\n\nupdate_command: %s", Version, latestBridgeVersion, updateMessage, cmd)
|
|
} else {
|
|
warning = fmt.Sprintf("Bridge update required: %s -> %s\nRun: %s\nThen restart Claude Desktop.", Version, latestBridgeVersion, cmd)
|
|
}
|
|
resp := MCPResponse{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Result: map[string]interface{}{
|
|
"content": []map[string]interface{}{{"type": "text", "text": warning}},
|
|
},
|
|
}
|
|
data, _ := json.Marshal(resp)
|
|
return string(data)
|
|
}
|
|
|
|
type MCPRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID interface{} `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
|
|
type MCPResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID interface{} `json:"id"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
Error *MCPError `json:"error,omitempty"`
|
|
}
|
|
|
|
type MCPError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func respond(id interface{}, result interface{}) {
|
|
resp := MCPResponse{JSONRPC: "2.0", ID: id, Result: result}
|
|
data, _ := json.Marshal(resp)
|
|
fmt.Println(string(data))
|
|
}
|
|
|
|
func respondError(id interface{}, code int, message string) {
|
|
resp := MCPResponse{JSONRPC: "2.0", ID: id, Error: &MCPError{Code: code, Message: message}}
|
|
data, _ := json.Marshal(resp)
|
|
fmt.Println(string(data))
|
|
}
|
|
|
|
func respondContent(id interface{}, text string) {
|
|
respond(id, map[string]interface{}{"content": []map[string]interface{}{{"type": "text", "text": text}}})
|
|
}
|
|
|
|
func respondImage(id interface{}, base64Data, mimeType, text string) {
|
|
respond(id, map[string]interface{}{
|
|
"content": []map[string]interface{}{
|
|
{"type": "image", "data": base64Data, "mimeType": mimeType},
|
|
{"type": "text", "text": text},
|
|
},
|
|
})
|
|
}
|
|
|
|
func apiGet(endpoint string, params map[string]string) ([]byte, error) {
|
|
return apiGetWithRetry(endpoint, params, true)
|
|
}
|
|
|
|
func apiGetWithRetry(endpoint string, params map[string]string, allowRetry bool) ([]byte, error) {
|
|
// Ensure we have a valid token before making the request
|
|
if err := ensureValidToken(); err != nil {
|
|
log("Token refresh failed: %v", err)
|
|
// Continue anyway - maybe the old token still works
|
|
}
|
|
|
|
u := server + endpoint
|
|
if params != nil {
|
|
v := url.Values{}
|
|
for k, val := range params {
|
|
v.Set(k, val)
|
|
}
|
|
u += "?" + v.Encode()
|
|
}
|
|
log("GET %s", u)
|
|
req, err := http.NewRequest("GET", u, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+getAuthToken())
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Handle 401 by refreshing token and retrying once
|
|
if resp.StatusCode == 401 && allowRetry && refreshToken != "" {
|
|
log("Got 401, attempting token refresh...")
|
|
if err := refreshAccessToken(); err != nil {
|
|
log("Token refresh failed: %v", err)
|
|
return nil, fmt.Errorf("HTTP 401: %s (token refresh failed: %v)", string(body), err)
|
|
}
|
|
// Retry with new token
|
|
return apiGetWithRetry(endpoint, params, false)
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func getToolDefinitions() []map[string]interface{} {
|
|
// All tools are read-only (no writes/modifications)
|
|
readOnly := map[string]interface{}{"readOnlyHint": true}
|
|
|
|
return []map[string]interface{}{
|
|
{
|
|
"name": "list_dossiers",
|
|
"description": "List all patient dossiers accessible to this account.",
|
|
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "list_studies",
|
|
"description": "List all imaging studies for a patient dossier.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
},
|
|
"required": []string{"dossier"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "list_series",
|
|
"description": "List series for a study. Filter by description (AX, T1, FLAIR, etc).",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"study": map[string]interface{}{"type": "string", "description": "Study ID (16-char hex)"},
|
|
"filter": map[string]interface{}{"type": "string", "description": "Filter by description"},
|
|
},
|
|
"required": []string{"dossier", "study"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "list_slices",
|
|
"description": "List slices for a series with position info.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"series": map[string]interface{}{"type": "string", "description": "Series ID (16-char hex)"},
|
|
},
|
|
"required": []string{"dossier", "series"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "fetch_image",
|
|
"description": "Fetch slice image as base64 PNG. Optionally set window/level.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"slice": map[string]interface{}{"type": "string", "description": "Slice ID (16-char hex)"},
|
|
"wc": map[string]interface{}{"type": "number", "description": "Window center"},
|
|
"ww": map[string]interface{}{"type": "number", "description": "Window width"},
|
|
},
|
|
"required": []string{"dossier", "slice"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "fetch_contact_sheet",
|
|
"description": "Fetch contact sheet (thumbnail grid) for NAVIGATION ONLY. Use to identify which slices to examine, then fetch those slices at full resolution with fetch_image for actual analysis. NEVER diagnose from contact sheet thumbnails. Optionally set window/level (e.g., lung: wc=-500 ww=1500, mediastinal: wc=40 ww=400).",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"series": map[string]interface{}{"type": "string", "description": "Series ID (16-char hex)"},
|
|
"wc": map[string]interface{}{"type": "number", "description": "Window center (e.g., -500 for lung, 40 for soft tissue)"},
|
|
"ww": map[string]interface{}{"type": "number", "description": "Window width (e.g., 1500 for lung, 400 for soft tissue)"},
|
|
},
|
|
"required": []string{"dossier", "series"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "list_lab_tests",
|
|
"description": "List all lab test names for a patient dossier.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
},
|
|
"required": []string{"dossier"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "get_lab_results",
|
|
"description": "Get lab results. Specify names, optional date range, optional latest flag.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"names": map[string]interface{}{"type": "string", "description": "Comma-separated test names"},
|
|
"from": map[string]interface{}{"type": "string", "description": "Start date YYYY-MM-DD"},
|
|
"to": map[string]interface{}{"type": "string", "description": "End date YYYY-MM-DD"},
|
|
"latest": map[string]interface{}{"type": "boolean", "description": "Most recent only"},
|
|
},
|
|
"required": []string{"dossier", "names"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "get_categories",
|
|
"description": "Get observation categories. Without type: top-level types. With type=genome: genome categories. With type=genome&category=X: subcategories.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"type": map[string]interface{}{"type": "string", "description": "Observation type (e.g., genome)"},
|
|
"category": map[string]interface{}{"type": "string", "description": "Get subcategories within this category"},
|
|
},
|
|
"required": []string{"dossier"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "query_genome",
|
|
"description": "Query genome variants by gene, category, or rsids. Returns matches with magnitude, summary, and categories.",
|
|
"inputSchema": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
|
"gene": map[string]interface{}{"type": "string", "description": "Gene name (e.g., MTHFR, COMT)"},
|
|
"search": map[string]interface{}{"type": "string", "description": "Search gene, subcategory, or summary"},
|
|
"category": map[string]interface{}{"type": "string", "description": "Filter by category"},
|
|
"rsids": map[string]interface{}{"type": "string", "description": "Comma-separated rsids"},
|
|
"min_magnitude": map[string]interface{}{"type": "number", "description": "Minimum magnitude"},
|
|
"include_hidden": map[string]interface{}{"type": "boolean", "description": "Include hidden categories and high-magnitude variants"},
|
|
},
|
|
"required": []string{"dossier"},
|
|
},
|
|
"annotations": readOnly,
|
|
},
|
|
{
|
|
"name": "get_version",
|
|
"description": "Get bridge and server version info.",
|
|
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
|
"annotations": readOnly,
|
|
},
|
|
}
|
|
}
|
|
|
|
func getPromptDefinitions() []map[string]interface{} {
|
|
return []map[string]interface{}{
|
|
{
|
|
"name": "family_health_context",
|
|
"description": "Load health context for all accessible dossiers with recent journal summaries",
|
|
"arguments": []map[string]interface{}{},
|
|
},
|
|
}
|
|
}
|
|
|
|
func handlePromptGet(id interface{}, params json.RawMessage) {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(params, &p); err != nil {
|
|
respondError(id, -32602, "Invalid params")
|
|
return
|
|
}
|
|
|
|
if p.Name == "family_health_context" {
|
|
content, err := buildFamilyHealthContext()
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
|
|
respond(id, map[string]interface{}{
|
|
"messages": []map[string]interface{}{
|
|
{
|
|
"role": "user",
|
|
"content": map[string]interface{}{
|
|
"type": "text",
|
|
"text": content,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
} else {
|
|
respondError(id, -32602, "Unknown prompt: "+p.Name)
|
|
}
|
|
}
|
|
|
|
func buildFamilyHealthContext() (string, error) {
|
|
// Fetch all accessible dossiers
|
|
body, err := apiGet("/api/v1/dossiers", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// API returns array of dossiers directly
|
|
var dossiers []map[string]interface{}
|
|
if err := json.Unmarshal(body, &dossiers); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("=== Family Health Context ===\n\n")
|
|
|
|
for _, dossier := range dossiers {
|
|
dossierID, _ := dossier["id"].(string)
|
|
name, _ := dossier["name"].(string)
|
|
dob, _ := dossier["date_of_birth"].(string)
|
|
sex, _ := dossier["sex"].(string)
|
|
categories, _ := dossier["categories"].([]interface{})
|
|
|
|
sb.WriteString(fmt.Sprintf("%s (%s)\n", name, dossierID))
|
|
|
|
// Show DOB and sex if available
|
|
if dob != "" || sex != "" {
|
|
sb.WriteString("- ")
|
|
if dob != "" {
|
|
sb.WriteString(fmt.Sprintf("DOB: %s", dob))
|
|
if sex != "" {
|
|
sb.WriteString(", ")
|
|
}
|
|
}
|
|
if sex != "" {
|
|
sb.WriteString(fmt.Sprintf("Sex: %s", sex))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Show available data categories
|
|
if len(categories) > 0 {
|
|
sb.WriteString("\nData available:\n")
|
|
for _, cat := range categories {
|
|
if catStr, ok := cat.(string); ok {
|
|
// Capitalize first letter for display
|
|
display := strings.ToUpper(catStr[:1]) + catStr[1:]
|
|
|
|
// For genome, maybe show which genomes exist
|
|
if catStr == "genome" {
|
|
// TODO: Query genome sources when available
|
|
sb.WriteString("• Genome data available\n")
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("• %s\n", display))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: Add journal summaries when journals are implemented
|
|
sb.WriteString("\nRecent journals: (coming soon)\n")
|
|
|
|
sb.WriteString("\n---\n\n")
|
|
}
|
|
|
|
return sb.String(), nil
|
|
}
|
|
|
|
func handleToolCall(id interface{}, params json.RawMessage) {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]interface{} `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(params, &p); err != nil {
|
|
respondError(id, -32602, "Invalid params")
|
|
return
|
|
}
|
|
args := p.Arguments
|
|
|
|
switch p.Name {
|
|
case "list_dossiers":
|
|
body, err := apiGet("/api/v1/dossiers", nil)
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
respondContent(id, string(body))
|
|
|
|
case "list_studies":
|
|
dossierHex, _ := args["dossier"].(string)
|
|
if dossierHex == "" {
|
|
respondError(id, -32602, "dossier required")
|
|
return
|
|
}
|
|
body, err := apiGet("/api/v1/dossiers/"+dossierHex+"/entries", map[string]string{"category": "imaging"})
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
respondContent(id, string(body))
|
|
|
|
case "list_series":
|
|
dossierHex, _ := args["dossier"].(string)
|
|
studyHex, _ := args["study"].(string)
|
|
if dossierHex == "" || studyHex == "" {
|
|
respondError(id, -32602, "dossier and study required")
|
|
return
|
|
}
|
|
body, err := apiGet("/api/v1/dossiers/"+dossierHex+"/entries", map[string]string{"parent": studyHex})
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
respondContent(id, string(body))
|
|
|
|
case "list_slices":
|
|
dossierHex, _ := args["dossier"].(string)
|
|
seriesHex, _ := args["series"].(string)
|
|
if dossierHex == "" || seriesHex == "" {
|
|
respondError(id, -32602, "dossier and series required")
|
|
return
|
|
}
|
|
body, err := apiGet("/api/v1/dossiers/"+dossierHex+"/entries", map[string]string{"parent": seriesHex})
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
respondContent(id, string(body))
|
|
|
|
case "fetch_image":
|
|
dossierHex, _ := args["dossier"].(string)
|
|
sliceHex, _ := args["slice"].(string)
|
|
if dossierHex == "" || sliceHex == "" {
|
|
respondError(id, -32602, "dossier and slice required")
|
|
return
|
|
}
|
|
params := map[string]string{"token": dossierHex}
|
|
if wc, ok := args["wc"].(float64); ok {
|
|
params["wc"] = fmt.Sprintf("%.0f", wc)
|
|
}
|
|
if ww, ok := args["ww"].(float64); ok {
|
|
params["ww"] = fmt.Sprintf("%.0f", ww)
|
|
}
|
|
body, err := apiGet("/image/"+sliceHex, params)
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
b64 := base64.StdEncoding.EncodeToString(body)
|
|
respondImage(id, b64, "image/png", fmt.Sprintf("Slice %s (%d bytes)", sliceHex[:8], len(body)))
|
|
|
|
case "fetch_contact_sheet":
|
|
dossierHex, _ := args["dossier"].(string)
|
|
seriesHex, _ := args["series"].(string)
|
|
if dossierHex == "" || seriesHex == "" {
|
|
respondError(id, -32602, "dossier and series required")
|
|
return
|
|
}
|
|
params := map[string]string{"token": dossierHex}
|
|
if wc, ok := args["wc"].(float64); ok {
|
|
params["wc"] = fmt.Sprintf("%.0f", wc)
|
|
}
|
|
if ww, ok := args["ww"].(float64); ok {
|
|
params["ww"] = fmt.Sprintf("%.0f", ww)
|
|
}
|
|
body, err := apiGet("/contact-sheet.webp/"+seriesHex, params)
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
b64 := base64.StdEncoding.EncodeToString(body)
|
|
respondImage(id, b64, "image/webp", fmt.Sprintf("Contact sheet for series %s (%d bytes)", seriesHex[:8], len(body)))
|
|
|
|
case "list_lab_tests":
|
|
dossierHex, _ := args["dossier"].(string)
|
|
if dossierHex == "" {
|
|
respondError(id, -32602, "dossier required")
|
|
return
|
|
}
|
|
body, err := apiGet("/api/labs/tests", map[string]string{"dossier": dossierHex})
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
respondContent(id, string(body))
|
|
|
|
case "get_lab_results":
|
|
dossierHex, _ := args["dossier"].(string)
|
|
names, _ := args["names"].(string)
|
|
if dossierHex == "" || names == "" {
|
|
respondError(id, -32602, "dossier and names required")
|
|
return
|
|
}
|
|
params := map[string]string{"dossier": dossierHex, "names": names}
|
|
if from, ok := args["from"].(string); ok && from != "" {
|
|
params["from"] = from
|
|
}
|
|
if to, ok := args["to"].(string); ok && to != "" {
|
|
params["to"] = to
|
|
}
|
|
if latest, ok := args["latest"].(bool); ok && latest {
|
|
params["latest"] = "true"
|
|
}
|
|
body, err := apiGet("/api/labs/results", params)
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
respondContent(id, string(body))
|
|
|
|
case "get_categories":
|
|
body, err := apiGet("/api/v1/categories", nil)
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
respondContent(id, string(body))
|
|
|
|
case "query_genome":
|
|
dossierHex, _ := args["dossier"].(string)
|
|
if dossierHex == "" {
|
|
respondError(id, -32602, "dossier required")
|
|
return
|
|
}
|
|
params := map[string]string{"dossier": dossierHex}
|
|
if gene, ok := args["gene"].(string); ok && gene != "" {
|
|
params["gene"] = gene
|
|
}
|
|
if search, ok := args["search"].(string); ok && search != "" {
|
|
params["search"] = search
|
|
}
|
|
if cat, ok := args["category"].(string); ok && cat != "" {
|
|
params["category"] = cat
|
|
}
|
|
if rsids, ok := args["rsids"].(string); ok && rsids != "" {
|
|
params["rsids"] = rsids
|
|
}
|
|
if minMag, ok := args["min_magnitude"].(float64); ok {
|
|
params["min_magnitude"] = strconv.FormatFloat(minMag, 'f', -1, 64)
|
|
}
|
|
if includeHidden, ok := args["include_hidden"].(bool); ok && includeHidden {
|
|
params["include_hidden"] = "true"
|
|
}
|
|
body, err := apiGet("/api/genome", params)
|
|
if err != nil {
|
|
respondError(id, -32000, err.Error())
|
|
return
|
|
}
|
|
respondContent(id, string(body))
|
|
|
|
case "get_version":
|
|
respondContent(id, fmt.Sprintf("Bridge: %s (built %s)\nServer: %s", Version, BuildTime, server))
|
|
|
|
default:
|
|
respondError(id, -32601, "Unknown tool: "+p.Name)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
log("inou-bridge starting, version=%s", Version)
|
|
|
|
for _, arg := range os.Args[1:] {
|
|
if strings.HasPrefix(arg, "--server=") {
|
|
server = strings.TrimPrefix(arg, "--server=")
|
|
} else if strings.HasPrefix(arg, "--account=") {
|
|
accountHex = strings.TrimPrefix(arg, "--account=")
|
|
} else if strings.HasPrefix(arg, "--refresh-token=") {
|
|
refreshToken = strings.TrimPrefix(arg, "--refresh-token=")
|
|
} else if strings.HasPrefix(arg, "--token-file=") {
|
|
tokenFilePath = strings.TrimPrefix(arg, "--token-file=")
|
|
} else if arg == "--version" || arg == "-v" {
|
|
fmt.Printf("inou-bridge %s (built %s)\n", Version, BuildTime)
|
|
os.Exit(0)
|
|
}
|
|
}
|
|
|
|
if server == "" {
|
|
fmt.Fprintf(os.Stderr, "inou-bridge %s - MCP bridge for Inou medical dossier\n\nUsage: inou-bridge --server=https://inou.com --account=ACCOUNT_HEX\n or: inou-bridge --server=https://inou.com --refresh-token=TOKEN\n\nOptions:\n --server=URL API server URL (required)\n --account=HEX Dossier ID for legacy auth\n --refresh-token=TOKEN OAuth refresh token for auto-renewal\n --token-file=PATH Custom path for token state file\n --version, -v Show version\n", Version)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Try to load saved token state
|
|
if savedState := loadTokenState(); savedState != nil {
|
|
log("Loaded saved token state")
|
|
if refreshToken == "" && savedState.RefreshToken != "" {
|
|
refreshToken = savedState.RefreshToken
|
|
}
|
|
if accountHex == "" && savedState.DossierID != "" {
|
|
accountHex = savedState.DossierID
|
|
}
|
|
if savedState.AccessToken != "" && savedState.ExpiresAt > time.Now().Unix() {
|
|
accessToken = savedState.AccessToken
|
|
accessTokenExpiry = time.Unix(savedState.ExpiresAt, 0)
|
|
log("Using saved access token, expires at %s", accessTokenExpiry.Format("15:04:05"))
|
|
}
|
|
}
|
|
|
|
// If we have a refresh token but no account, we need to get an access token and fetch the dossier ID
|
|
if refreshToken != "" && accountHex == "" {
|
|
log("Have refresh token but no account, fetching initial access token...")
|
|
if err := refreshAccessToken(); err != nil {
|
|
log("Initial token refresh failed: %v", err)
|
|
fmt.Fprintf(os.Stderr, "Failed to get initial access token: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
// Get dossier ID from userinfo endpoint
|
|
if err := fetchDossierID(); err != nil {
|
|
log("Failed to get dossier ID: %v", err)
|
|
fmt.Fprintf(os.Stderr, "Failed to get dossier ID from userinfo: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
if accountHex == "" && refreshToken == "" {
|
|
fmt.Fprintf(os.Stderr, "Error: Either --account or --refresh-token is required\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if accountHex != "" {
|
|
log("Server: %s, Account: %s...", server, accountHex[:min(8, len(accountHex))])
|
|
} else {
|
|
log("Server: %s, Using refresh token", server)
|
|
}
|
|
|
|
if refreshToken != "" {
|
|
log("OAuth auto-renewal enabled")
|
|
}
|
|
|
|
fetchLatestVersion()
|
|
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
scanner.Buffer(make([]byte, 64*1024), 50*1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
log("stdin: %d bytes", len(line))
|
|
|
|
var req MCPRequest
|
|
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
|
log("Invalid JSON: %v", err)
|
|
continue
|
|
}
|
|
|
|
switch req.Method {
|
|
case "initialize":
|
|
respond(req.ID, map[string]interface{}{
|
|
"protocolVersion": "2025-06-18",
|
|
"capabilities": map[string]interface{}{
|
|
"tools": map[string]interface{}{},
|
|
"prompts": map[string]interface{}{},
|
|
},
|
|
"serverInfo": map[string]interface{}{"name": "inou-bridge", "version": Version},
|
|
})
|
|
case "notifications/initialized":
|
|
// No response needed
|
|
case "tools/list":
|
|
respond(req.ID, map[string]interface{}{"tools": getToolDefinitions()})
|
|
case "tools/call":
|
|
if needsUpdate() {
|
|
fmt.Println(getUpdateWarning(req.ID))
|
|
} else {
|
|
handleToolCall(req.ID, req.Params)
|
|
}
|
|
case "prompts/list":
|
|
respond(req.ID, map[string]interface{}{"prompts": getPromptDefinitions()})
|
|
case "prompts/get":
|
|
handlePromptGet(req.ID, req.Params)
|
|
default:
|
|
respondError(req.ID, -32601, "Method not found: "+req.Method)
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
log("stdin error: %v", err)
|
|
}
|
|
log("stdin closed, exiting")
|
|
}
|