382 lines
9.6 KiB
Go
Executable File
382 lines
9.6 KiB
Go
Executable File
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
var (
|
||
Version = "dev"
|
||
BuildTime = "unknown"
|
||
|
||
// Fetched on connect
|
||
latestBridgeVersion string
|
||
downloadURL string
|
||
)
|
||
|
||
func log(format string, args ...interface{}) {
|
||
msg := fmt.Sprintf(format, args...)
|
||
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format("15:04:05.000"), msg)
|
||
}
|
||
|
||
// semverLessThan returns true if a < b (e.g., "1.0.2" < "1.0.10")
|
||
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(server string) {
|
||
url := fmt.Sprintf("%s/version?os=%s&arch=%s", server, runtime.GOOS, runtime.GOARCH)
|
||
resp, err := http.Get(url)
|
||
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"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||
log("Version parse failed: %v", err)
|
||
return
|
||
}
|
||
latestBridgeVersion = info.LatestBridgeVersion
|
||
downloadURL = info.DownloadURL
|
||
log("Latest bridge version: %s (current: %s), download: %s", latestBridgeVersion, Version, downloadURL)
|
||
}
|
||
|
||
func needsUpdate() bool {
|
||
if Version == "dev" || latestBridgeVersion == "" {
|
||
return false
|
||
}
|
||
return semverLessThan(Version, latestBridgeVersion)
|
||
}
|
||
|
||
func prependUpdateWarning(data string, lastMethod string) string {
|
||
if !needsUpdate() {
|
||
return data
|
||
}
|
||
|
||
// Only block actual tool calls, not initialization
|
||
if lastMethod != "tools/call" {
|
||
return data
|
||
}
|
||
|
||
log("Update needed! Version=%s, Latest=%s - returning update instructions only", Version, latestBridgeVersion)
|
||
|
||
// Get our own executable path
|
||
exePath, err := os.Executable()
|
||
if err != nil {
|
||
exePath = "inou_bridge"
|
||
}
|
||
|
||
// Parse response to get the ID
|
||
var resp map[string]interface{}
|
||
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||
return data
|
||
}
|
||
|
||
id, ok := resp["id"]
|
||
if !ok {
|
||
return data
|
||
}
|
||
|
||
// Build platform-appropriate command
|
||
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)
|
||
}
|
||
|
||
// Return success but with only the warning - no actual data
|
||
warning := fmt.Sprintf("⚠️ BRIDGE UPDATE REQUIRED\n\n")
|
||
warning += fmt.Sprintf("Your Inou bridge (%s) is outdated. Latest version: %s\n\n", Version, latestBridgeVersion)
|
||
warning += "I cannot access medical imaging until the bridge is updated.\n\n"
|
||
warning += "Run this command in Desktop Commander:\n\n"
|
||
warning += fmt.Sprintf(" %s\n\n", cmd)
|
||
warning += "Then restart Claude Desktop."
|
||
|
||
successResp := map[string]interface{}{
|
||
"jsonrpc": "2.0",
|
||
"id": id,
|
||
"result": map[string]interface{}{
|
||
"content": []map[string]interface{}{
|
||
{"type": "text", "text": warning},
|
||
},
|
||
},
|
||
}
|
||
|
||
modified, _ := json.Marshal(successResp)
|
||
return string(modified)
|
||
}
|
||
|
||
func handleLocalCall(line, server string) string {
|
||
var req struct {
|
||
JSONRPC string `json:"jsonrpc"`
|
||
ID int `json:"id"`
|
||
Method string `json:"method"`
|
||
Params struct {
|
||
Name string `json:"name"`
|
||
} `json:"params"`
|
||
}
|
||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||
return ""
|
||
}
|
||
|
||
if req.Method != "tools/call" || req.Params.Name != "get_version" {
|
||
return ""
|
||
}
|
||
|
||
// Fetch latest version from server
|
||
resp, err := http.Get(server + "/version")
|
||
if err != nil {
|
||
log("Version fetch failed: %v", err)
|
||
return ""
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var info struct {
|
||
ServerVersion string `json:"server_version"`
|
||
LatestBridgeVersion string `json:"latest_bridge_version"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||
log("Version parse failed: %v", err)
|
||
return ""
|
||
}
|
||
|
||
// Build response
|
||
text := fmt.Sprintf("Bridge version: %s\nServer version: %s\nLatest bridge: %s\n", Version, info.ServerVersion, info.LatestBridgeVersion)
|
||
|
||
if Version != "dev" && Version != info.LatestBridgeVersion {
|
||
text += "\n⚠️ UPDATE REQUIRED: Bridge is outdated.\n\n"
|
||
text += "Please update the bridge:\n"
|
||
text += "1. Download http://inou.com/download/windows/amd64\n"
|
||
text += "2. Save to C:\\Users\\%USERNAME%\\inou\\inou_bridge.exe (overwrite existing)\n"
|
||
text += "3. Restart Claude Desktop\n"
|
||
} else {
|
||
text += "\n✓ Bridge is up to date."
|
||
}
|
||
|
||
response := map[string]interface{}{
|
||
"jsonrpc": "2.0",
|
||
"id": req.ID,
|
||
"result": map[string]interface{}{
|
||
"content": []map[string]interface{}{
|
||
{"type": "text", "text": text},
|
||
},
|
||
},
|
||
}
|
||
|
||
data, _ := json.Marshal(response)
|
||
return string(data)
|
||
}
|
||
|
||
func main() {
|
||
log("mcp-client starting, version=%s, build=%s", Version, BuildTime)
|
||
log("PID=%d, UID=%d", os.Getpid(), os.Getuid())
|
||
log("Args: %v", os.Args)
|
||
|
||
// Log environment
|
||
log("HOME=%s", os.Getenv("HOME"))
|
||
log("USER=%s", os.Getenv("USER"))
|
||
log("PATH=%s", os.Getenv("PATH"))
|
||
|
||
var server, account string
|
||
|
||
for _, arg := range os.Args[1:] {
|
||
if strings.HasPrefix(arg, "--server=") {
|
||
server = strings.TrimPrefix(arg, "--server=")
|
||
} else if strings.HasPrefix(arg, "--account=") {
|
||
account = strings.TrimPrefix(arg, "--account=")
|
||
} else if arg == "--version" || arg == "-v" {
|
||
fmt.Printf("mcp-client %s (built %s)\n", Version, BuildTime)
|
||
os.Exit(0)
|
||
}
|
||
}
|
||
|
||
if server == "" || account == "" {
|
||
fmt.Fprintf(os.Stderr, "mcp-client %s - MCP bridge for Inou medical imaging\n\n", Version)
|
||
fmt.Fprintf(os.Stderr, "Usage: mcp-client --server=http://host:port --account=GUID\n")
|
||
fmt.Fprintf(os.Stderr, " mcp-client --version\n")
|
||
os.Exit(1)
|
||
}
|
||
|
||
log("Server: %s", server)
|
||
log("Account: %s...", account[:8])
|
||
|
||
// Test DNS resolution
|
||
host := strings.TrimPrefix(server, "http://")
|
||
host = strings.TrimPrefix(host, "https://")
|
||
if idx := strings.Index(host, ":"); idx > 0 {
|
||
host = host[:idx]
|
||
}
|
||
log("Resolving host: %s", host)
|
||
ips, err := net.LookupIP(host)
|
||
if err != nil {
|
||
log("DNS lookup failed: %v", err)
|
||
} else {
|
||
log("DNS resolved: %v", ips)
|
||
}
|
||
|
||
// Test TCP connection
|
||
addr := strings.TrimPrefix(server, "http://")
|
||
addr = strings.TrimPrefix(addr, "https://")
|
||
log("Testing TCP connection to %s", addr)
|
||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||
if err != nil {
|
||
log("TCP connect failed: %v", err)
|
||
} else {
|
||
log("TCP connect OK")
|
||
conn.Close()
|
||
}
|
||
|
||
// Fetch latest version info
|
||
fetchLatestVersion(server)
|
||
|
||
// Connect to SSE endpoint (no timeout - SSE stays open)
|
||
sseURL := server + "/sse?account=" + account
|
||
log("HTTP GET %s", sseURL)
|
||
|
||
sseClient := &http.Client{Timeout: 0}
|
||
resp, err := sseClient.Get(sseURL)
|
||
if err != nil {
|
||
log("HTTP request failed: %v", err)
|
||
os.Exit(1)
|
||
}
|
||
log("HTTP response: %d %s", resp.StatusCode, resp.Status)
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != 200 {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
log("SSE error body: %s", string(body))
|
||
os.Exit(1)
|
||
}
|
||
|
||
// Read first event to get session ID
|
||
reader := bufio.NewReader(resp.Body)
|
||
var sessionID string
|
||
for {
|
||
line, err := reader.ReadString('\n')
|
||
if err != nil {
|
||
log("Failed to read session: %v", err)
|
||
os.Exit(1)
|
||
}
|
||
log("SSE line: %s", strings.TrimSpace(line))
|
||
if strings.HasPrefix(line, "data: /message?session=") {
|
||
sessionID = strings.TrimPrefix(line, "data: /message?session=")
|
||
sessionID = strings.TrimSpace(sessionID)
|
||
break
|
||
}
|
||
}
|
||
log("Session established: %s", sessionID)
|
||
|
||
messageURL := server + "/message?session=" + sessionID
|
||
log("Message URL: %s", messageURL)
|
||
|
||
// Track last method for version check
|
||
var lastMethod string
|
||
var lastMethodMu sync.Mutex
|
||
|
||
// Goroutine to read SSE responses and write to stdout
|
||
go func() {
|
||
for {
|
||
line, err := reader.ReadString('\n')
|
||
if err != nil {
|
||
log("SSE reader error: %v", err)
|
||
return
|
||
}
|
||
if strings.HasPrefix(line, "data: ") {
|
||
data := strings.TrimPrefix(line, "data: ")
|
||
data = strings.TrimSpace(data)
|
||
if data != "" {
|
||
// Check if we need to block with update warning
|
||
lastMethodMu.Lock()
|
||
method := lastMethod
|
||
lastMethodMu.Unlock()
|
||
data = prependUpdateWarning(data, method)
|
||
log("SSE -> stdout: %d bytes", len(data))
|
||
fmt.Println(data)
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
log("Ready, reading stdin...")
|
||
|
||
// Read stdin, send to server
|
||
scanner := bufio.NewScanner(os.Stdin)
|
||
buf := make([]byte, 64*1024)
|
||
scanner.Buffer(buf, 50*1024*1024)
|
||
|
||
postClient := &http.Client{Timeout: 30 * time.Second}
|
||
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
log("stdin -> server: %d bytes", len(line))
|
||
|
||
// Validate JSON and extract method
|
||
var req struct {
|
||
Method string `json:"method"`
|
||
}
|
||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||
log("Invalid JSON, skipping: %v", err)
|
||
continue
|
||
}
|
||
|
||
// Track method for version check
|
||
lastMethodMu.Lock()
|
||
lastMethod = req.Method
|
||
lastMethodMu.Unlock()
|
||
|
||
// Check if this is a get_version call we handle locally
|
||
if response := handleLocalCall(line, server); response != "" {
|
||
log("Handled locally (get_version)")
|
||
fmt.Println(response)
|
||
continue
|
||
}
|
||
|
||
// POST to server
|
||
httpReq, _ := http.NewRequest("POST", messageURL, bytes.NewBufferString(line))
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := postClient.Do(httpReq)
|
||
if err != nil {
|
||
log("POST failed: %v", err)
|
||
continue
|
||
}
|
||
log("POST response: %d", resp.StatusCode)
|
||
resp.Body.Close()
|
||
}
|
||
|
||
if err := scanner.Err(); err != nil {
|
||
log("stdin error: %v", err)
|
||
}
|
||
log("stdin closed, exiting")
|
||
}
|