Skip to content
47 changes: 46 additions & 1 deletion cmd/rag-code-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/doITmagic/rag-code-mcp/internal/config"
"github.com/doITmagic/rag-code-mcp/internal/healthcheck"
"github.com/doITmagic/rag-code-mcp/internal/logger"
"github.com/doITmagic/rag-code-mcp/internal/proxy"
"github.com/doITmagic/rag-code-mcp/internal/service/engine"
"github.com/doITmagic/rag-code-mcp/internal/service/search"
"github.com/doITmagic/rag-code-mcp/internal/service/tools"
Expand All @@ -37,7 +38,7 @@ import (
)

var (
Version = "2.1.50"
Version = "2.1.53"
Commit = "none"
Date = "24.10.2025"
)
Expand Down Expand Up @@ -75,6 +76,50 @@ func main() {
os.Exit(0)
}

// ── Single-instance enforcement ──────────────────────────────────────
// If the HTTP port is already occupied by another rag-code-mcp instance,
// we decide our role based on version comparison:
// • Our version is newer → kill the old master, become the new master.
// • Our version is same/older → enter lightweight proxy mode (Stdio↔HTTP).
//
// This ensures only ONE heavy process (Qdrant, Ollama, indexer) runs at
// any time, while multiple IDEs can still use Stdio transport seamlessly.
if *httpPort > 0 && proxy.PortIsOccupied(*httpPort) {
masterVersion := proxy.QueryMasterVersion(*httpPort)
if masterVersion != "" {
logger.Instance.Info("Detected existing master on port %d (version=%s, our version=%s)", *httpPort, masterVersion, Version)
} else {
log.Fatalf("Port %d is occupied but could not verify it corresponds to a rag-code-mcp master. Failing fast to avoid proxying to an unknown service.", *httpPort)
}
Comment thread
doITmagic marked this conversation as resolved.

// Compare versions using semver: take over only if strictly newer.
takeOver := false
current, errC := semver.NewVersion(Version)
master, errM := semver.NewVersion(masterVersion)
if errC == nil && errM == nil && current.GreaterThan(master) {
takeOver = true
}

if takeOver {
logger.Instance.Info("Our version %s > master %s → taking over as new master", Version, masterVersion)
proxy.KillProcessOnPort(*httpPort)
Comment thread
doITmagic marked this conversation as resolved.
// Verify the port is actually free before proceeding as master.
// If kill failed (missing lsof, permissions, stubborn process), the HTTP
// server would fail to bind silently. Instead, fall back to proxy mode.
if proxy.PortIsOccupied(*httpPort) {
logger.Instance.Warn("Port %d still occupied after kill attempt — falling back to proxy mode", *httpPort)
proxy.RunProxyMode(*httpPort)
return
}
// Fall through to normal master startup below.
} else {
// Enter proxy mode — no heavy initialization.
logger.Instance.Info("Entering proxy mode (master version=%s, our version=%s)", masterVersion, Version)
proxy.RunProxyMode(*httpPort)
return
}
}

// Config — resolve bare filenames (e.g. "config.yaml") relative to the
// executable's directory, not CWD. IDEs launch the binary with arbitrary
// working directories, so a relative path would miss the installed config.
Expand Down
165 changes: 165 additions & 0 deletions internal/proxy/portutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package proxy

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os/exec"
"runtime"
"strings"
"time"

"github.com/doITmagic/rag-code-mcp/internal/logger"
)

// PortIsOccupied returns true if the given TCP port is already listening.
func PortIsOccupied(port int) bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 1*time.Second)
if err != nil {
return false
}
conn.Close()
return true
}

// QueryMasterVersion sends an MCP initialize request to the running master
// and returns its reported version string (e.g. "2.1.51").
// Returns "" on any error (timeout, parse failure, etc.).
func QueryMasterVersion(port int) string {
payload := map[string]any{
"jsonrpc": "2.0",
"id": "version-check",
"method": "initialize",
"params": map[string]any{
"protocolVersion": "2025-03-26",
"clientInfo": map[string]string{
"name": "ragcode-proxy",
"version": "1.0.0",
},
"capabilities": map[string]any{},
},
}

body, err := json.Marshal(payload)
if err != nil {
return ""
}

url := fmt.Sprintf("http://127.0.0.1:%d/mcp", port)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return ""
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")

client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()

Comment thread
doITmagic marked this conversation as resolved.
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return ""
}

bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return ""
}

// Parse the response — may be direct JSON or SSE-wrapped.
var result map[string]any
if err := json.Unmarshal(bodyBytes, &result); err != nil {
// Not direct JSON — try SSE extraction if Content-Type matches.
contentType := resp.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "text/event-stream") {
payload := extractSSEPayload(bodyBytes)
if payload == nil {
return ""
}
if err := json.Unmarshal(payload, &result); err != nil {
return ""
}
} else {
return ""
}
}
Comment thread
doITmagic marked this conversation as resolved.

// Navigate: result.serverInfo.version
res, ok := result["result"].(map[string]any)
if !ok {
return ""
}
serverInfo, ok := res["serverInfo"].(map[string]any)
if !ok {
return ""
}
// Validate this is actually a RagCode instance, not another MCP server.
// Prevents misidentifying and killing unrelated services on the same port.
name, _ := serverInfo["name"].(string)
if name != "ragcode" {
return ""
}
version, _ := serverInfo["version"].(string)
Comment thread
doITmagic marked this conversation as resolved.
return version
}

// KillProcessOnPort finds the process listening on the given port and kills it.
// On Linux/macOS uses lsof + kill; on Windows uses netstat + taskkill.
// Waits up to 3 seconds for the port to become free.
func KillProcessOnPort(port int) {
addr := fmt.Sprintf("%d", port)

if runtime.GOOS == "windows" {
// netstat -ano | findstr :3000 → extract PID → taskkill
out, err := exec.Command("cmd", "/C", fmt.Sprintf("netstat -ano | findstr :%s", addr)).Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) >= 5 {
pid := fields[len(fields)-1]
_ = exec.Command("taskkill", "/F", "/PID", pid).Run()
}
}
}
} else {
// lsof -ti :3000 → kill each PID
out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%s", addr)).Output()
if err == nil {
for _, pid := range strings.Fields(string(out)) {
if pid != "" {
logger.Instance.Info("Killing old master process PID=%s on port %d", pid, port)
_ = exec.Command("kill", pid).Run()
}
}
}
}

// Wait for port to become free (max 3s)
for i := 0; i < 6; i++ {
if !PortIsOccupied(port) {
return
}
time.Sleep(500 * time.Millisecond)
}

// Force kill if still occupied
if runtime.GOOS != "windows" {
out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port)).Output()
if err == nil {
for _, pid := range strings.Fields(string(out)) {
if pid != "" {
logger.Instance.Warn("Force-killing stubborn process PID=%s on port %d", pid, port)
_ = exec.Command("kill", "-9", pid).Run()
}
}
}
}

time.Sleep(500 * time.Millisecond)
}
Loading
Loading