inou/.claude/worktrees/vibrant-nash/mcp-client/main.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")
}