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") }