Skip to content

Commit e22f2ee

Browse files
authored
feat(mcp): HTTP header support and runtime deps for stdio MCPs (#16)
Two related features for remote/local MCP server support: 1. HTTP headers for MCP HTTP upstreams - Add `Headers map[string]string` to MCPUpstreamRow/Opts/UpstreamConfig - New DB migration adds nullable `headers` JSON column to mcp_upstreams - HTTPUpstream applies configured headers on every request (Send + Stop) - CLI: `sluice mcp add --header "KEY=VAL"` (repeatable via flag.Func) - Reject --header on non-HTTP transports to fail loud on misuse 2. Vault template substitution in env/header values - New `{vault:<name>}` substring template form, alongside the existing whole-value `vault:<name>` prefix form - Shared via resolveVaultMap helper for both env and header values - Enables e.g. `--header "Authorization=Bearer {vault:github_pat}"` - Binding templates keep their `{value}` syntax (binding has an implicit single credential); doc comment explains the difference 3. Runtime dependencies in Docker image - Install nodejs + npm (npx) and python3 to the alpine image - Download uv/uvx as static musl binaries from astral.sh (pipx on alpine installs to /root/.local/bin which the sluice user cannot access; direct binary install avoids that) - Enables stdio MCP servers distributed via npx or uvx to run inside the sluice container without a separate sidecar Image size grows from ~30MB to ~220MB (nodejs+python+uv). Tests cover: whole-value form, template form, multiple substitutions, missing credential errors, for both env and headers.
1 parent 3671534 commit e22f2ee

12 files changed

Lines changed: 288 additions & 43 deletions

Dockerfile

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,24 @@ COPY . .
77
RUN go build -ldflags='-s -w -linkmode external -extldflags "-static"' -o /sluice ./cmd/sluice/
88

99
FROM alpine:3.21
10-
RUN apk add --no-cache ca-certificates wget && \
10+
# Runtimes installed so sluice can spawn common stdio MCP servers:
11+
# nodejs + npm -> npx for JavaScript/TypeScript MCPs
12+
# python3 + uv -> uvx for Python MCPs
13+
# uv is downloaded as a static binary from astral.sh to avoid Python
14+
# packaging headaches on Alpine (musl + PEP 668).
15+
# ca-certificates/wget are for sluice itself.
16+
RUN apk add --no-cache ca-certificates wget nodejs npm python3 && \
17+
ARCH=$(uname -m) && \
18+
case "$ARCH" in \
19+
x86_64) UV_ARCH=x86_64-unknown-linux-musl ;; \
20+
aarch64) UV_ARCH=aarch64-unknown-linux-musl ;; \
21+
*) echo "unsupported arch for uv: $ARCH" && exit 1 ;; \
22+
esac && \
23+
wget -qO- "https://github.com/astral-sh/uv/releases/latest/download/uv-${UV_ARCH}.tar.gz" | \
24+
tar xz -C /tmp && \
25+
mv "/tmp/uv-${UV_ARCH}/uv" /usr/local/bin/uv && \
26+
mv "/tmp/uv-${UV_ARCH}/uvx" /usr/local/bin/uvx && \
27+
rm -rf "/tmp/uv-${UV_ARCH}" && \
1128
adduser -D -h /home/sluice sluice && \
1229
mkdir -p /home/sluice/ca /home/sluice/.sluice /home/sluice/data /home/sluice/mcp /var/log/sluice /etc/sluice && \
1330
chown sluice:sluice /home/sluice/ca /home/sluice/.sluice /home/sluice/data /home/sluice/mcp /var/log/sluice /etc/sluice

cmd/sluice/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ func main() {
525525
Command: r.Command,
526526
Args: r.Args,
527527
Env: r.Env,
528+
Headers: r.Headers,
528529
TimeoutSec: r.TimeoutSec,
529530
Transport: r.Transport,
530531
}

cmd/sluice/mcp.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func handleMCPGateway(args []string) error {
104104
Command: r.Command,
105105
Args: r.Args,
106106
Env: r.Env,
107+
Headers: r.Headers,
107108
TimeoutSec: r.TimeoutSec,
108109
Transport: r.Transport,
109110
}
@@ -251,15 +252,24 @@ func handleMCPAdd(args []string) error {
251252
dbPath := fs.String("db", "data/sluice.db", "path to SQLite database")
252253
command := fs.String("command", "", "command to run (stdio) or URL (http/websocket)")
253254
argsStr := fs.String("args", "", "comma-separated arguments for the command")
254-
envStr := fs.String("env", "", "comma-separated KEY=VAL environment variables")
255+
envStr := fs.String("env", "", "comma-separated KEY=VAL environment variables (VAL may be vault:<name> for the whole value, or contain {vault:<name>} substrings for templated substitution)")
255256
timeout := fs.Int("timeout", 120, "upstream timeout in seconds")
256257
transport := fs.String("transport", "stdio", "transport type: stdio, http, or websocket")
258+
headers := make(map[string]string)
259+
fs.Func("header", "HTTP header to send on every request to an http upstream (repeatable, format: KEY=VAL; VAL may be vault:<name> for the whole value, or contain {vault:<name>} substrings for templated substitution, e.g. \"Authorization=Bearer {vault:github_pat}\")", func(s string) error {
260+
parts := strings.SplitN(s, "=", 2)
261+
if len(parts) != 2 {
262+
return fmt.Errorf("invalid header format %q (expected KEY=VAL)", s)
263+
}
264+
headers[parts[0]] = parts[1]
265+
return nil
266+
})
257267
if err := fs.Parse(args); err != nil {
258268
return err
259269
}
260270

261271
if fs.NArg() == 0 || *command == "" {
262-
return fmt.Errorf("usage: sluice mcp add <name> --command <cmd> [--transport stdio|http|websocket] [--args \"arg1,arg2\"] [--env \"KEY=VAL,...\"] [--timeout 120]")
272+
return fmt.Errorf("usage: sluice mcp add <name> --command <cmd> [--transport stdio|http|websocket] [--args \"arg1,arg2\"] [--env \"KEY=VAL,...\"] [--header \"KEY=VAL\" ...] [--timeout 120]")
263273
}
264274
name := fs.Arg(0)
265275

@@ -271,6 +281,10 @@ func handleMCPAdd(args []string) error {
271281
return fmt.Errorf("invalid transport %q: must be stdio, http, or websocket", *transport)
272282
}
273283

284+
if len(headers) > 0 && *transport != "http" {
285+
return fmt.Errorf("--header is only valid for --transport http")
286+
}
287+
274288
var cmdArgs []string
275289
if *argsStr != "" {
276290
cmdArgs = strings.Split(*argsStr, ",")
@@ -296,6 +310,7 @@ func handleMCPAdd(args []string) error {
296310
id, err := db.AddMCPUpstream(name, *command, store.MCPUpstreamOpts{
297311
Args: cmdArgs,
298312
Env: env,
313+
Headers: headers,
299314
TimeoutSec: *timeout,
300315
Transport: *transport,
301316
})
@@ -363,11 +378,24 @@ func handleMCPList(args []string) error {
363378
}
364379
envStr = " env=" + strings.Join(pairs, ",")
365380
}
381+
headersStr := ""
382+
if len(u.Headers) > 0 {
383+
keys := make([]string, 0, len(u.Headers))
384+
for k := range u.Headers {
385+
keys = append(keys, k)
386+
}
387+
sort.Strings(keys)
388+
pairs := make([]string, 0, len(u.Headers))
389+
for _, k := range keys {
390+
pairs = append(pairs, k+"="+u.Headers[k])
391+
}
392+
headersStr = " headers=" + strings.Join(pairs, ",")
393+
}
366394
timeoutStr := ""
367395
if u.TimeoutSec != 120 {
368396
timeoutStr = fmt.Sprintf(" timeout=%ds", u.TimeoutSec)
369397
}
370-
fmt.Printf("[%d] %s command=%s%s%s%s%s\n", u.ID, u.Name, u.Command, transportStr, argsStr, envStr, timeoutStr)
398+
fmt.Printf("[%d] %s command=%s%s%s%s%s%s\n", u.ID, u.Name, u.Command, transportStr, argsStr, envStr, headersStr, timeoutStr)
371399
}
372400
return nil
373401
}

internal/mcp/gateway.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,21 @@ func NewGateway(cfg GatewayConfig) (*Gateway, error) {
8686
// Store the original config (with vault: prefixes intact) for restart.
8787
gw.upstreamCfgs[ucfg.Name] = ucfg
8888

89-
// Resolve vault: prefixed env values before spawning.
89+
// Resolve vault: prefixed env and header values before spawning.
9090
spawnCfg := ucfg
9191
if gw.credResolver != nil {
92-
resolved, err := resolveVaultEnv(ucfg.Env, gw.credResolver)
92+
resolvedEnv, err := resolveVaultEnv(ucfg.Env, gw.credResolver)
9393
if err != nil {
9494
gw.Stop()
9595
return nil, fmt.Errorf("upstream %s: %w", ucfg.Name, err)
9696
}
97-
spawnCfg.Env = resolved
97+
spawnCfg.Env = resolvedEnv
98+
resolvedHeaders, err := resolveVaultHeaders(ucfg.Headers, gw.credResolver)
99+
if err != nil {
100+
gw.Stop()
101+
return nil, fmt.Errorf("upstream %s: %w", ucfg.Name, err)
102+
}
103+
spawnCfg.Headers = resolvedHeaders
98104
}
99105

100106
u, err := StartUpstreamForTransport(spawnCfg)
@@ -300,14 +306,19 @@ func (gw *Gateway) RestartUpstream(name string) error {
300306
log.Printf("restarting upstream %s for credential rotation", name)
301307
_ = u.Stop()
302308

303-
// Re-resolve vault: prefixed env values to pick up rotated credentials.
309+
// Re-resolve vault: prefixed env and header values to pick up rotated credentials.
304310
spawnCfg := cfg
305311
if gw.credResolver != nil {
306-
resolved, err := resolveVaultEnv(cfg.Env, gw.credResolver)
312+
resolvedEnv, err := resolveVaultEnv(cfg.Env, gw.credResolver)
313+
if err != nil {
314+
return fmt.Errorf("upstream %s: %w", name, err)
315+
}
316+
spawnCfg.Env = resolvedEnv
317+
resolvedHeaders, err := resolveVaultHeaders(cfg.Headers, gw.credResolver)
307318
if err != nil {
308319
return fmt.Errorf("upstream %s: %w", name, err)
309320
}
310-
spawnCfg.Env = resolved
321+
spawnCfg.Headers = resolvedHeaders
311322
}
312323

313324
newU, err := StartUpstreamForTransport(spawnCfg)

internal/mcp/transport_http.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,26 @@ import (
2020
type HTTPUpstream struct {
2121
name string
2222
url string
23+
headers map[string]string
2324
client *http.Client
2425
sessionID string
2526
mu sync.Mutex
2627
nextID atomic.Int64
2728
}
2829

29-
// NewHTTPUpstream creates an HTTPUpstream for the given URL and timeout.
30-
func NewHTTPUpstream(name, url string, timeoutSec int) *HTTPUpstream {
30+
// NewHTTPUpstream creates an HTTPUpstream for the given URL, headers, and timeout.
31+
// Headers are sent on every outbound request (e.g. Authorization for remote
32+
// MCP servers requiring auth).
33+
func NewHTTPUpstream(name, url string, headers map[string]string, timeoutSec int) *HTTPUpstream {
3134
timeout := defaultUpstreamTimeout
3235
if timeoutSec > 0 {
3336
timeout = time.Duration(timeoutSec) * time.Second
3437
}
3538
return &HTTPUpstream{
36-
name: name,
37-
url: url,
38-
client: &http.Client{Timeout: timeout},
39+
name: name,
40+
url: url,
41+
headers: headers,
42+
client: &http.Client{Timeout: timeout},
3943
}
4044
}
4145

@@ -52,6 +56,11 @@ func (h *HTTPUpstream) Send(req JSONRPCRequest) (*JSONRPCResponse, error) {
5256
if err != nil {
5357
return nil, fmt.Errorf("create request: %w", err)
5458
}
59+
// Apply configured headers first so Content-Type and Accept below
60+
// override any user-supplied values for protocol correctness.
61+
for k, v := range h.headers {
62+
httpReq.Header.Set(k, v)
63+
}
5564
httpReq.Header.Set("Content-Type", "application/json")
5665
httpReq.Header.Set("Accept", "application/json, text/event-stream")
5766

@@ -273,6 +282,9 @@ func (h *HTTPUpstream) Stop() error {
273282
if err != nil {
274283
return fmt.Errorf("upstream %s: create DELETE request: %w", h.name, err)
275284
}
285+
for k, v := range h.headers {
286+
req.Header.Set(k, v)
287+
}
276288
req.Header.Set("Mcp-Session-Id", sid)
277289

278290
resp, err := h.client.Do(req)

internal/mcp/transport_http_test.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func TestHTTPUpstreamInitialize(t *testing.T) {
112112
srv := mockHTTPMCPServer(t)
113113
defer srv.Close()
114114

115-
h := NewHTTPUpstream("remote", srv.URL, 0)
115+
h := NewHTTPUpstream("remote", srv.URL, nil, 0)
116116

117117
if err := h.Initialize(); err != nil {
118118
t.Fatalf("Initialize: %v", err)
@@ -127,7 +127,7 @@ func TestHTTPUpstreamDiscoverTools(t *testing.T) {
127127
srv := mockHTTPMCPServer(t)
128128
defer srv.Close()
129129

130-
h := NewHTTPUpstream("remote", srv.URL, 0)
130+
h := NewHTTPUpstream("remote", srv.URL, nil, 0)
131131

132132
if err := h.Initialize(); err != nil {
133133
t.Fatalf("Initialize: %v", err)
@@ -157,7 +157,7 @@ func TestHTTPUpstreamCallTool(t *testing.T) {
157157
srv := mockHTTPMCPServer(t)
158158
defer srv.Close()
159159

160-
h := NewHTTPUpstream("remote", srv.URL, 0)
160+
h := NewHTTPUpstream("remote", srv.URL, nil, 0)
161161

162162
if err := h.Initialize(); err != nil {
163163
t.Fatalf("Initialize: %v", err)
@@ -186,7 +186,7 @@ func TestHTTPUpstreamSessionIDRequired(t *testing.T) {
186186
defer srv.Close()
187187

188188
// Skip Initialize so no session ID is set.
189-
h := NewHTTPUpstream("remote", srv.URL, 0)
189+
h := NewHTTPUpstream("remote", srv.URL, nil, 0)
190190

191191
_, err := h.DiscoverTools()
192192
if err == nil {
@@ -198,7 +198,7 @@ func TestHTTPUpstreamSessionIDPersists(t *testing.T) {
198198
srv := mockHTTPMCPServer(t)
199199
defer srv.Close()
200200

201-
h := NewHTTPUpstream("remote", srv.URL, 0)
201+
h := NewHTTPUpstream("remote", srv.URL, nil, 0)
202202

203203
if err := h.Initialize(); err != nil {
204204
t.Fatalf("Initialize: %v", err)
@@ -219,7 +219,7 @@ func TestHTTPUpstreamStop(t *testing.T) {
219219
srv := mockHTTPMCPServer(t)
220220
defer srv.Close()
221221

222-
h := NewHTTPUpstream("remote", srv.URL, 0)
222+
h := NewHTTPUpstream("remote", srv.URL, nil, 0)
223223

224224
if err := h.Initialize(); err != nil {
225225
t.Fatalf("Initialize: %v", err)
@@ -234,7 +234,7 @@ func TestHTTPUpstreamStopWithoutSession(t *testing.T) {
234234
srv := mockHTTPMCPServer(t)
235235
defer srv.Close()
236236

237-
h := NewHTTPUpstream("remote", srv.URL, 0)
237+
h := NewHTTPUpstream("remote", srv.URL, nil, 0)
238238

239239
// Stop without Initialize should be a no-op.
240240
if err := h.Stop(); err != nil {
@@ -246,15 +246,15 @@ func TestHTTPUpstreamCustomTimeout(t *testing.T) {
246246
srv := mockHTTPMCPServer(t)
247247
defer srv.Close()
248248

249-
h := NewHTTPUpstream("remote", srv.URL, 30)
249+
h := NewHTTPUpstream("remote", srv.URL, nil, 30)
250250

251251
if h.client.Timeout != 30*time.Second {
252252
t.Errorf("expected client timeout 30s, got %v", h.client.Timeout)
253253
}
254254
}
255255

256256
func TestHTTPUpstreamDefaultTimeout(t *testing.T) {
257-
h := NewHTTPUpstream("remote", "http://localhost:9999", 0)
257+
h := NewHTTPUpstream("remote", "http://localhost:9999", nil, 0)
258258

259259
if h.client.Timeout != defaultUpstreamTimeout {
260260
t.Errorf("expected default timeout %v, got %v", defaultUpstreamTimeout, h.client.Timeout)
@@ -344,7 +344,7 @@ func TestHTTPUpstreamSSEResponse(t *testing.T) {
344344
srv := mockSSEMCPServer(t)
345345
defer srv.Close()
346346

347-
h := NewHTTPUpstream("sse-remote", srv.URL, 0)
347+
h := NewHTTPUpstream("sse-remote", srv.URL, nil, 0)
348348

349349
if err := h.Initialize(); err != nil {
350350
t.Fatalf("Initialize: %v", err)
@@ -382,7 +382,7 @@ func TestHTTPUpstreamSSESkipsNotifications(t *testing.T) {
382382
srv := mockSSEMCPServer(t)
383383
defer srv.Close()
384384

385-
h := NewHTTPUpstream("sse-remote", srv.URL, 0)
385+
h := NewHTTPUpstream("sse-remote", srv.URL, nil, 0)
386386
if err := h.Initialize(); err != nil {
387387
t.Fatalf("Initialize: %v", err)
388388
}
@@ -404,7 +404,7 @@ func TestHTTPUpstreamSSESkipsNotifications(t *testing.T) {
404404
}
405405

406406
func TestHTTPUpstreamConnectionRefused(t *testing.T) {
407-
h := NewHTTPUpstream("dead", "http://127.0.0.1:1", 5)
407+
h := NewHTTPUpstream("dead", "http://127.0.0.1:1", nil, 5)
408408

409409
err := h.Initialize()
410410
if err == nil {
@@ -418,7 +418,7 @@ func TestHTTPUpstreamServerError(t *testing.T) {
418418
}))
419419
defer srv.Close()
420420

421-
h := NewHTTPUpstream("error-srv", srv.URL, 0)
421+
h := NewHTTPUpstream("error-srv", srv.URL, nil, 0)
422422
err := h.Initialize()
423423
if err == nil {
424424
t.Fatal("expected error from 500 response")

0 commit comments

Comments
 (0)