Skip to content

Commit 7856619

Browse files
committed
feat: add stdio-proxy command for HTTP-to-stdio MCP bridging
Add a new `stdio-proxy` command that launches a stdio MCP server subprocess and exposes it via HTTP streaming. This enables stdio-only MCP servers to be accessed over HTTP. Features: - Subprocess lifecycle management with graceful shutdown - Automatic capability discovery and forwarding (tools, prompts, resources) - Request/response logging (summaries at info, full JSON-RPC at debug) - Terminal-aware logging (text for TTY, JSON for daemon mode) - Concurrent request handling with shared subprocess - Health checking with 503 responses when subprocess is unavailable - Support for notification handlers (tool/prompt/resource list changes) The implementation uses the official MCP Go SDK's CommandTransport for subprocess communication and StreamableHTTPHandler for HTTP serving. Tests use in-memory transports for fast, reliable execution without spawning actual subprocesses.
1 parent be496c6 commit 7856619

11 files changed

Lines changed: 1579 additions & 19 deletions

File tree

Dockerfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ WORKDIR /go
33
COPY go.mod go.sum ./
44
RUN go mod download
55
COPY . .
6-
RUN go build -o /bin/server ./cmd/main.go
6+
RUN go build -o /bin/server ./cmd/
77

88
FROM gcr.io/distroless/base-debian12@sha256:27769871031f67460f1545a52dfacead6d18a9f197db77110cfc649ca2a91f44
9-
ENV PORT=8080
109
COPY --from=build /bin/server /bin/server
1110
ENTRYPOINT ["/bin/server"]

cmd/main.go

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,48 @@ package main
22

33
import (
44
"context"
5-
"log"
5+
"fmt"
66
"os"
7-
8-
"github.com/pomerium/mcp-servers/httputil"
9-
"github.com/pomerium/mcp-servers/server"
7+
"path/filepath"
108
)
119

10+
var programName = filepath.Base(os.Args[0])
11+
1212
func main() {
13-
err := run(context.Background())
14-
if err != nil {
15-
log.Fatal(err)
13+
if err := run(context.Background(), os.Args[1:]); err != nil {
14+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
15+
os.Exit(1)
1616
}
1717
}
1818

19-
func run(ctx context.Context) error {
20-
port, ok := os.LookupEnv("PORT")
21-
if !ok {
22-
port = "8080"
19+
func run(ctx context.Context, args []string) error {
20+
if len(args) == 0 {
21+
return printUsage()
22+
}
23+
24+
cmd, cmdArgs := args[0], args[1:]
25+
26+
switch cmd {
27+
case "serve":
28+
return serveCommand(ctx, cmdArgs)
29+
case "stdio-proxy":
30+
return stdioProxyCommand(ctx, cmdArgs)
31+
case "help", "-h", "--help":
32+
return printUsage()
33+
default:
34+
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd)
35+
return printUsage()
2336
}
24-
handler := server.BuildHandlers(ctx)
25-
return httputil.ListenAndServe(ctx, ":"+port, handler)
37+
}
38+
39+
func printUsage() error {
40+
fmt.Fprintf(os.Stderr, `Usage: %s <command> [options]
41+
42+
Commands:
43+
serve Start the MCP server
44+
stdio-proxy Proxy HTTP requests to a stdio MCP server
45+
46+
Use "%s <command> -h" for more information about a command.
47+
`, programName, programName)
48+
return nil
2649
}

cmd/serve.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
7+
"github.com/pomerium/mcp-servers/httputil"
8+
"github.com/pomerium/mcp-servers/server"
9+
)
10+
11+
func serveCommand(ctx context.Context, _ []string) error {
12+
addr := os.Getenv("ADDR")
13+
if addr == "" {
14+
addr = ":8080"
15+
}
16+
17+
handler := server.BuildHandlers(ctx)
18+
return httputil.ListenAndServe(ctx, addr, handler)
19+
}

cmd/stdio_proxy.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"log/slog"
8+
"os"
9+
10+
"github.com/pomerium/mcp-servers/httputil"
11+
"github.com/pomerium/mcp-servers/stdioproxy"
12+
)
13+
14+
func stdioProxyCommand(ctx context.Context, args []string) error {
15+
fs := flag.NewFlagSet("stdio-proxy", flag.ExitOnError)
16+
17+
addr := fs.String("addr", "", "HTTP bind address (default from ADDR env or :8080)")
18+
logLevel := fs.String("log-level", "", "Log level: debug, info, warn, error (default from LOG_LEVEL env or info)")
19+
workDir := fs.String("work-dir", "", "Working directory for subprocess")
20+
21+
fs.Usage = func() {
22+
fmt.Fprintf(os.Stderr, `Usage: %s stdio-proxy [options] -- <command> [args...]
23+
24+
Proxy HTTP streaming requests to a stdio MCP server.
25+
26+
Options:
27+
`, programName)
28+
fs.PrintDefaults()
29+
fmt.Fprintf(os.Stderr, `
30+
Environment variables:
31+
ADDR HTTP bind address (default :8080)
32+
LOG_LEVEL Log level: debug, info, warn, error (default info)
33+
34+
Examples:
35+
# Proxy requests to a Node.js MCP server
36+
%s stdio-proxy -- node /path/to/server.js
37+
38+
# Proxy with custom port and debug logging
39+
%s stdio-proxy -addr :9090 -log-level debug -- python -m my_mcp_server
40+
41+
# Using environment variables
42+
ADDR=:9090 LOG_LEVEL=debug %s stdio-proxy -- ./my-server
43+
`, programName, programName, programName)
44+
}
45+
46+
if err := fs.Parse(args); err != nil {
47+
return err
48+
}
49+
50+
// Remaining args are the subprocess command
51+
subArgs := fs.Args()
52+
if len(subArgs) == 0 {
53+
fs.Usage()
54+
return fmt.Errorf("missing subprocess command")
55+
}
56+
57+
// Resolve bind address
58+
bindAddr := *addr
59+
if bindAddr == "" {
60+
bindAddr = os.Getenv("ADDR")
61+
}
62+
if bindAddr == "" {
63+
bindAddr = ":8080"
64+
}
65+
66+
// Resolve log level
67+
level := *logLevel
68+
if level == "" {
69+
level = os.Getenv("LOG_LEVEL")
70+
}
71+
if level == "" {
72+
level = "info"
73+
}
74+
75+
// Setup logger (JSON for daemon, text for terminal)
76+
logger := stdioproxy.SetupLogger(stdioproxy.ParseLogLevel(level))
77+
slog.SetDefault(logger)
78+
79+
logger.Info("starting stdio-proxy",
80+
"addr", bindAddr,
81+
"command", subArgs[0],
82+
"args", subArgs[1:],
83+
)
84+
85+
// Create process manager
86+
pm := stdioproxy.NewProcessManager(stdioproxy.ProcessManagerConfig{
87+
Command: subArgs[0],
88+
Args: subArgs[1:],
89+
WorkDir: *workDir,
90+
Logger: logger,
91+
})
92+
93+
// Start the subprocess
94+
if err := pm.Start(ctx); err != nil {
95+
return fmt.Errorf("start subprocess: %w", err)
96+
}
97+
98+
// Ensure cleanup on shutdown
99+
defer func() {
100+
if err := pm.Stop(); err != nil {
101+
logger.Warn("error stopping subprocess", "error", err)
102+
}
103+
}()
104+
105+
// Create proxy server
106+
proxyServer := stdioproxy.NewProxyServer(pm, logger)
107+
108+
// Create HTTP handler
109+
handler, err := stdioproxy.NewHTTPHandler(pm, proxyServer, logger)
110+
if err != nil {
111+
return fmt.Errorf("create HTTP handler: %w", err)
112+
}
113+
114+
// Start HTTP server (blocks until context is cancelled)
115+
return httputil.ListenAndServe(ctx, bindAddr, handler)
116+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ go 1.24.2
44

55
require (
66
github.com/jomei/notionapi v1.13.3
7-
github.com/modelcontextprotocol/go-sdk v1.1.0
7+
github.com/mattn/go-isatty v0.0.20
8+
github.com/modelcontextprotocol/go-sdk v1.2.0
89
github.com/pomerium/sdk-go v0.0.9
910
modernc.org/sqlite v1.38.0
1011
)
@@ -15,7 +16,6 @@ require (
1516
github.com/google/jsonschema-go v0.3.0 // indirect
1617
github.com/google/uuid v1.6.0 // indirect
1718
github.com/hashicorp/golang-lru/v2 v2.0.4 // indirect
18-
github.com/mattn/go-isatty v0.0.20 // indirect
1919
github.com/ncruces/go-strftime v0.1.9 // indirect
2020
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
2121
github.com/stretchr/testify v1.9.0 // indirect

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
55
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
66
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
77
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
8+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
9+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
810
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
911
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
1012
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -20,8 +22,8 @@ github.com/jomei/notionapi v1.13.3 h1:pzEN+pVe1T0FjH85sP9TCqqe58rFRL+Fj+F5yvyBNw
2022
github.com/jomei/notionapi v1.13.3/go.mod h1:BqzP6JBddpBnXvMSIxiR5dCoCjKngmz5QNl1ONDlDoM=
2123
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
2224
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
23-
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
24-
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
25+
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
26+
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
2527
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
2628
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
2729
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

stdioproxy/logging.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Package stdioproxy provides a proxy that exposes a stdio MCP server via HTTP streaming.
2+
package stdioproxy
3+
4+
import (
5+
"log/slog"
6+
"os"
7+
8+
"github.com/mattn/go-isatty"
9+
)
10+
11+
// SetupLogger configures slog based on whether we're running in a terminal or as a daemon.
12+
// Terminal mode uses human-readable text format, daemon mode uses JSON for structured logging.
13+
func SetupLogger(level slog.Level) *slog.Logger {
14+
var handler slog.Handler
15+
16+
// Detect if stderr is a terminal (we log to stderr to keep stdout clean)
17+
if isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd()) {
18+
// Human-readable format for terminal
19+
handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
20+
Level: level,
21+
})
22+
} else {
23+
// JSON format for daemon/log aggregation
24+
handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
25+
Level: level,
26+
})
27+
}
28+
29+
return slog.New(handler)
30+
}
31+
32+
// ParseLogLevel converts a string log level to slog.Level.
33+
// Supported values: debug, info, warn, error. Defaults to info.
34+
func ParseLogLevel(level string) slog.Level {
35+
switch level {
36+
case "debug":
37+
return slog.LevelDebug
38+
case "info":
39+
return slog.LevelInfo
40+
case "warn", "warning":
41+
return slog.LevelWarn
42+
case "error":
43+
return slog.LevelError
44+
default:
45+
return slog.LevelInfo
46+
}
47+
}

0 commit comments

Comments
 (0)