Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*.so
*.dylib
/mcpproxy
/test/launcher-server/launcher-server
__debug_bin*

# Playwright MCP artifacts
Expand Down
8 changes: 8 additions & 0 deletions docs/cli-management-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ mcpproxy upstream restart --all

**Note:** Restart does not require confirmation as it's non-destructive.

**Locally-launched HTTP/SSE upstreams:** when a server is configured with both
`command` and an HTTP/SSE `url` (see [docs/configuration.md](configuration.md#locally-launched-http--sse-servers)),
`restart` stops the spawned child (`SIGTERM` → grace → `SIGKILL`) before
re-running Connect. The grace timeout is fixed at 5s today; the next start
won't begin until the previous child is fully reaped, so you can rely on the
port being free after the command returns. Stop ordering is: close MCP client
→ stop launched child → release per-server state.

---

### `mcpproxy doctor`
Expand Down
53 changes: 51 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ MCPProxy looks for configuration in these locations (in order):
| `args` | array | No | Command arguments |
| `url` | string | Yes* | Server URL (required for `http`/`sse`/`streamable-http` protocols) |
| `headers` | object | No | HTTP headers for HTTP-based protocols |
| `working_dir` | string | No | Working directory for stdio servers (default: current directory) |
| `env` | object | No | Environment variables for stdio servers |
| `working_dir` | string | No | Working directory for stdio servers, or for the locally-launched child of an HTTP/SSE server (default: current directory) |
| `env` | object | No | Environment variables for stdio servers, or for the locally-launched child of an HTTP/SSE server |
| `launcher_wait_timeout` | duration | No | When `command` is set together with an HTTP/SSE `url`, how long mcpproxy waits for that URL to become reachable after spawning the child (e.g. `"15s"`, default `"30s"`) |
| `oauth` | object | No | OAuth configuration (see [OAuth Configuration](#oauth-configuration)) |
| `isolation` | object | No | Per-server Docker isolation settings (see [Docker Isolation](#docker-isolation)) |
| `enabled` | boolean | No | Enable/disable server (default: `true`) |
Expand Down Expand Up @@ -210,6 +211,54 @@ MCPProxy looks for configuration in these locations (in order):
}
```

### Locally-launched HTTP / SSE servers

By default `command` is only used for `stdio` servers. When you set `command`
together with an HTTP/SSE `url` and an explicit `protocol` of `http`, `sse`,
or `streamable-http`, mcpproxy will:

1. Spawn the command (with `args`, `env`, `working_dir`, and Docker isolation
exactly like a stdio server).
2. Wait up to `launcher_wait_timeout` (default 30s) for `url` to accept a TCP
connection.
3. Connect via the configured HTTP/SSE transport.
4. Own the child's lifecycle — the process is stopped (`SIGTERM`, then
`SIGKILL` after a grace period) on disconnect, restart, server-disable, or
mcpproxy shutdown. Unexpected exits trigger an automatic disconnect, which
the existing reconnect path picks up.

```json
{
"name": "local-http-mcp",
"protocol": "http",
"url": "http://127.0.0.1:9999/mcp",
"command": "node",
"args": ["./examples/echo-http-server.js", "--port", "9999"],
"working_dir": "/path/to/repo",
"launcher_wait_timeout": "15s",
"enabled": true
}
```

`stdout` and `stderr` of the child are routed to the per-server log, so
`mcpproxy upstream logs <name>` continues to work the same way it does for
stdio servers.

#### Behaviour matrix when both `command` and `url` are set

| `protocol` | `command` | `url` | Behaviour |
|---|---|---|---|
| `stdio` (explicit) | set | any | Stdio transport, child via stdin/stdout — `url` ignored. |
| `http` / `sse` / `streamable-http` (explicit) | set | set | **Locally-launched HTTP/SSE** — spawn child, wait for URL, connect via network. |
| `http` / `sse` / `streamable-http` (explicit) | unset | set | Connect to remote URL — no spawn. |
| `auto` or unset | set | any | Stdio (`command` wins over `url` for back-compat — set `protocol` explicitly to opt into the launcher). |
| `auto` or unset | unset | set | HTTP/SSE remote — no spawn. |

The "command wins" rule under `auto` is intentional: it preserves backwards
compatibility with configurations written before the launcher feature
existed. To launch a local HTTP/SSE server you **must** set `protocol`
explicitly to one of `http`, `sse`, or `streamable-http`.

### OAuth Configuration

```json
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@ type ServerConfig struct {
Updated time.Time `json:"updated,omitempty" mapstructure:"updated"`
Isolation *IsolationConfig `json:"isolation,omitempty" mapstructure:"isolation"` // Per-server isolation settings
ReconnectOnUse bool `json:"reconnect_on_use,omitempty" mapstructure:"reconnect-on-use"` // Attempt reconnection when a tool call targets a disconnected server

// LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched
// HTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted
// when the server is configured with both Command and an HTTP/SSE URL — i.e.,
// mcpproxy starts the process AND connects via network. Stdio servers ignore
// this field. Zero or unset → 30s default.
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
}

// OAuthConfig represents OAuth configuration for a server
Expand Down
23 changes: 12 additions & 11 deletions internal/config/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,17 +528,18 @@ func CopyServerConfig(src *ServerConfig) *ServerConfig {
}

dst := &ServerConfig{
Name: src.Name,
URL: src.URL,
Protocol: src.Protocol,
Command: src.Command,
WorkingDir: src.WorkingDir,
Enabled: src.Enabled,
Quarantined: src.Quarantined,
SkipQuarantine: src.SkipQuarantine,
Shared: src.Shared,
Created: src.Created,
Updated: src.Updated,
Name: src.Name,
URL: src.URL,
Protocol: src.Protocol,
Command: src.Command,
WorkingDir: src.WorkingDir,
Enabled: src.Enabled,
Quarantined: src.Quarantined,
SkipQuarantine: src.SkipQuarantine,
Shared: src.Shared,
Created: src.Created,
Updated: src.Updated,
LauncherWaitTimeout: src.LauncherWaitTimeout,
}

// Copy slices
Expand Down
9 changes: 9 additions & 0 deletions internal/upstream/core/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secureenv"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/launcher"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/types"

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

// Local launcher tracking — only populated when this Client is using
// HTTP/SSE/streamable-HTTP transport AND ServerConfig.Command is set.
// In that mode mcpproxy spawns the upstream process before connecting,
// and owns its lifecycle via the handle below. Stdio servers leave
// these fields nil — they spawn through mcp-go's stdio transport.
launcherHandle launcher.Handle
launcherCIDFile string

// Notification callback for tools/list_changed
onToolsChanged func(serverName string)
}
Expand Down
46 changes: 46 additions & 0 deletions internal/upstream/core/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package core
import (
"context"
"fmt"
"os"
"time"

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

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

// Stop any locally-launched upstream child the HTTP/SSE path
// started — connectWithLauncher itself only stops it on
// wait-for-url failure, not on subsequent transport-level
// connect failure.
//
// IMPORTANT: c.mu is held for the duration of Connect (see
// the c.mu.Lock at the top of this function), so we can read
// the launcher fields directly. We release the lock briefly
// around handle.Stop because Stop blocks until the child is
// reaped and we don't want to hold c.mu that long; the
// `connecting` flag already prevents a concurrent Connect.
if c.launcherHandle != nil {
handle := c.launcherHandle
cidFile := c.launcherCIDFile
c.launcherHandle = nil
c.launcherCIDFile = ""
c.mu.Unlock()
stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second)
if stopErr := handle.Stop(stopCtx); stopErr != nil {
c.logger.Warn("error stopping launcher during connect-failure cleanup",
zap.String("server", c.config.Name),
zap.Error(stopErr))
}
stopCancel()
if cidFile != "" {
_ = os.Remove(cidFile)
}
c.mu.Lock()
}

return fmt.Errorf("failed to connect: %w", err)
}

Expand Down
Loading
Loading