Skip to content

Commit 3bf6160

Browse files
electrolobzikclaude
andcommitted
feat(upstream): launch local HTTP/SSE upstreams via launcher (Phase 1)
Wires internal/upstream/launcher into Client.Connect for the http, sse, and streamable-http transports when ServerConfig.Command is also set. The launcher spawns the child process, blocks on launcher.WaitForURL until the listener accepts a TCP connection, then hands off to the existing transport-level connectHTTP / connectSSE. On Disconnect, the MCP client is closed first (so the child sees the network drop cleanly) and the launcher Handle is Stop()ed second (SIGTERM -> grace -> SIGKILL). Stdio is deliberately excluded: mcp-go's Stdio transport spawns its own child via its CommandFunc, and routing that through launcher.Spawn would require patching mcp-go. Instead, both the stdio CommandFunc path and the new HTTP/SSE launcher path call into the same set of *Client command-prep helpers (setupDockerIsolation, wrapWithUserShell, injectEnvVarsIntoDockerArgs, insertCidfileIntoShellDockerCommand), preserving the spec's "Docker isolation in one place" requirement without the riskier stdio refactor. Config / API: - ServerConfig.LauncherWaitTimeout (Duration, default 30s) caps the wait between spawn and listener-up. CopyServerConfig carries it. - Client gains launcherHandle / launcherCIDFile fields. Stdio Clients leave them nil so existing cleanup paths (killProcessGroup, processCmd.Process.Kill) keep their single owner. Behaviour back-compat: when Command is set together with a URL but no explicit protocol, Command still wins -> stdio, URL ignored (matches today). To opt into the launcher you must set protocol to "http", "sse", or "streamable-http" explicitly. Locking: Connect holds c.mu for its duration, so the failure-cleanup path inlines the launcher teardown and releases c.mu briefly around handle.Stop (which can block until the child is reaped). The connecting flag prevents a concurrent Connect from sneaking in during that window. DisconnectWithContext releases c.mu before its stopLauncher call, so the public method uses normal locking. Watch goroutine: on unexpected child exit while connected, watchLauncher invokes Disconnect so the existing reconnect loop in internal/upstream/manager takes over instead of waiting for the transport's keepalive to time out. Verified by go vet + the full internal/upstream/... and internal/config/... test suites (existing tests stay green; deadlock in connect-failure cleanup that surfaced in TestClient_Connect_SSE_ NotSupported was fixed before commit). Refs spec: specs/046-local-launcher-for-http-sse/plan.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e9d0ccc commit 3bf6160

6 files changed

Lines changed: 393 additions & 12 deletions

File tree

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,13 @@ type ServerConfig struct {
224224
Updated time.Time `json:"updated,omitempty" mapstructure:"updated"`
225225
Isolation *IsolationConfig `json:"isolation,omitempty" mapstructure:"isolation"` // Per-server isolation settings
226226
ReconnectOnUse bool `json:"reconnect_on_use,omitempty" mapstructure:"reconnect-on-use"` // Attempt reconnection when a tool call targets a disconnected server
227+
228+
// LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched
229+
// HTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted
230+
// when the server is configured with both Command and an HTTP/SSE URL — i.e.,
231+
// mcpproxy starts the process AND connects via network. Stdio servers ignore
232+
// this field. Zero or unset → 30s default.
233+
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
227234
}
228235

229236
// OAuthConfig represents OAuth configuration for a server

internal/config/merge.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -528,17 +528,18 @@ func CopyServerConfig(src *ServerConfig) *ServerConfig {
528528
}
529529

530530
dst := &ServerConfig{
531-
Name: src.Name,
532-
URL: src.URL,
533-
Protocol: src.Protocol,
534-
Command: src.Command,
535-
WorkingDir: src.WorkingDir,
536-
Enabled: src.Enabled,
537-
Quarantined: src.Quarantined,
538-
SkipQuarantine: src.SkipQuarantine,
539-
Shared: src.Shared,
540-
Created: src.Created,
541-
Updated: src.Updated,
531+
Name: src.Name,
532+
URL: src.URL,
533+
Protocol: src.Protocol,
534+
Command: src.Command,
535+
WorkingDir: src.WorkingDir,
536+
Enabled: src.Enabled,
537+
Quarantined: src.Quarantined,
538+
SkipQuarantine: src.SkipQuarantine,
539+
Shared: src.Shared,
540+
Created: src.Created,
541+
Updated: src.Updated,
542+
LauncherWaitTimeout: src.LauncherWaitTimeout,
542543
}
543544

544545
// Copy slices

internal/upstream/core/client.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
2020
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secureenv"
2121
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
22+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/launcher"
2223
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/types"
2324

2425
"github.com/mark3labs/mcp-go/client"
@@ -98,6 +99,14 @@ type Client struct {
9899
containerName string // Store container name for cleanup via docker container commands
99100
isDockerCommand bool
100101

102+
// Local launcher tracking — only populated when this Client is using
103+
// HTTP/SSE/streamable-HTTP transport AND ServerConfig.Command is set.
104+
// In that mode mcpproxy spawns the upstream process before connecting,
105+
// and owns its lifecycle via the handle below. Stdio servers leave
106+
// these fields nil — they spawn through mcp-go's stdio transport.
107+
launcherHandle launcher.Handle
108+
launcherCIDFile string
109+
101110
// Notification callback for tools/list_changed
102111
onToolsChanged func(serverName string)
103112
}

internal/upstream/core/connection.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package core
33
import (
44
"context"
55
"fmt"
6+
"os"
67
"time"
78

89
"github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
@@ -118,6 +119,21 @@ func (c *Client) Connect(ctx context.Context) error {
118119

119120
// Create and connect client based on transport type
120121
var err error
122+
// Locally-launched HTTP/SSE upstreams: spawn the child process before
123+
// the transport-level connect, then wait for its URL to become
124+
// reachable. Stdio is excluded because the stdio transport spawns
125+
// through mcp-go itself; running the launcher here would double-spawn.
126+
switch c.transportType {
127+
case transportHTTP, transportHTTPStreamable, transportSSE:
128+
if c.config.Command != "" {
129+
c.logger.Debug("🚀 Launching local upstream before HTTP/SSE connect",
130+
zap.String("server", c.config.Name),
131+
zap.String("transport", c.transportType))
132+
if launchErr := c.connectWithLauncher(ctx); launchErr != nil {
133+
return fmt.Errorf("failed to launch local upstream: %w", launchErr)
134+
}
135+
}
136+
}
121137
switch c.transportType {
122138
case transportStdio:
123139
c.logger.Debug("📡 Using STDIO transport")
@@ -183,6 +199,36 @@ func (c *Client) Connect(ctx context.Context) error {
183199
c.processGroupID = 0
184200
}
185201

202+
// Stop any locally-launched upstream child the HTTP/SSE path
203+
// started — connectWithLauncher itself only stops it on
204+
// wait-for-url failure, not on subsequent transport-level
205+
// connect failure.
206+
//
207+
// IMPORTANT: c.mu is held for the duration of Connect (see
208+
// the c.mu.Lock at the top of this function), so we can read
209+
// the launcher fields directly. We release the lock briefly
210+
// around handle.Stop because Stop blocks until the child is
211+
// reaped and we don't want to hold c.mu that long; the
212+
// `connecting` flag already prevents a concurrent Connect.
213+
if c.launcherHandle != nil {
214+
handle := c.launcherHandle
215+
cidFile := c.launcherCIDFile
216+
c.launcherHandle = nil
217+
c.launcherCIDFile = ""
218+
c.mu.Unlock()
219+
stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second)
220+
if stopErr := handle.Stop(stopCtx); stopErr != nil {
221+
c.logger.Warn("error stopping launcher during connect-failure cleanup",
222+
zap.String("server", c.config.Name),
223+
zap.Error(stopErr))
224+
}
225+
stopCancel()
226+
if cidFile != "" {
227+
_ = os.Remove(cidFile)
228+
}
229+
c.mu.Lock()
230+
}
231+
186232
return fmt.Errorf("failed to connect: %w", err)
187233
}
188234

0 commit comments

Comments
 (0)