diff --git a/Dockerfile.mcp-server b/Dockerfile.mcp-server index 2b0cf04d..c34c2fb4 100644 --- a/Dockerfile.mcp-server +++ b/Dockerfile.mcp-server @@ -37,6 +37,7 @@ ENV HOME=/var/lib/gonavi \ GONAVI_DATA_ROOT=/data \ GONAVI_LOG_DIR=/var/lib/gonavi/logs \ GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765 \ + GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK=true \ GONAVI_MCP_HTTP_PATH=/mcp \ GONAVI_MCP_SCHEMA_ONLY=true diff --git a/cmd/gonavi-mcp-server/README.md b/cmd/gonavi-mcp-server/README.md index 49399a0c..c8fdd785 100644 --- a/cmd/gonavi-mcp-server/README.md +++ b/cmd/gonavi-mcp-server/README.md @@ -95,10 +95,13 @@ docker compose --env-file docker.mcp-server.env \ - `GONAVI_DATA_ROOT=/data` - `GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765` +- `GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK=true` - `GONAVI_MCP_HTTP_PATH=/mcp` `GONAVI_DATA_ROOT` 会覆盖默认活动数据目录解析逻辑,避免宿主机路径与容器内路径不一致时依赖 `storage_root.json` 的绝对路径。 +`GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK` 是容器部署的显式许可。直接在宿主机运行二进制时默认关闭,仍只允许绑定 `127.0.0.1`、`::1` 或 `localhost`;即使显式开启,Bearer Token 仍然必填。 + 如果你只想手动构建镜像: ```bash diff --git a/cmd/gonavi-mcp-server/main.go b/cmd/gonavi-mcp-server/main.go index 95e7bfd9..20e918d7 100644 --- a/cmd/gonavi-mcp-server/main.go +++ b/cmd/gonavi-mcp-server/main.go @@ -15,6 +15,7 @@ func main() { err := run(ctx, os.Args[1:]) if err != nil { log.Printf("GoNavi MCP Server 退出: %v", err) + os.Exit(1) } } @@ -32,7 +33,7 @@ func run(ctx context.Context, args []string) error { if err != nil { return err } - log.Printf("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s schemaOnly=%v", options.Addr, options.Path, options.SchemaOnly) + log.Printf("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s schemaOnly=%v allowNonLoopback=%v", options.Addr, options.Path, options.SchemaOnly, options.AllowNonLoopback) return mcpserver.RunAppStreamableHTTPServer(ctx, options) case "remote-config", "--remote-config": return mcpserver.WriteRemoteMCPClientConfig(os.Stdout, args[1:]) diff --git a/cmd/gonavi-mcp-server/main_test.go b/cmd/gonavi-mcp-server/main_test.go new file mode 100644 index 00000000..5fca032a --- /dev/null +++ b/cmd/gonavi-mcp-server/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "testing" +) + +func TestMainReturnsNonZeroForStartupFailure(t *testing.T) { + const helperEnv = "GONAVI_MCP_MAIN_FAILURE_HELPER" + if os.Getenv(helperEnv) == "1" { + os.Args = []string{"gonavi-mcp-server", "invalid-mode"} + main() + return + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestMainReturnsNonZeroForStartupFailure$") + cmd.Env = append(os.Environ(), helperEnv+"=1") + err := cmd.Run() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("main returned success for a startup failure: %v", err) + } + if exitErr.ExitCode() != 1 { + t.Fatalf("startup failure exit code = %d, want 1", exitErr.ExitCode()) + } +} diff --git a/deploy/helm/gonavi-mcp-server/README.md b/deploy/helm/gonavi-mcp-server/README.md index c2ac8346..c9a8da78 100644 --- a/deploy/helm/gonavi-mcp-server/README.md +++ b/deploy/helm/gonavi-mcp-server/README.md @@ -80,6 +80,7 @@ helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \ ## 4. 关键参数 - `image.repository` / `image.tag` +- `mcp.httpAddr` / `mcp.allowNonLoopback` - `secret.create` - `secret.name` - `data.mode=secret|hostPath|pvc` diff --git a/deploy/helm/gonavi-mcp-server/templates/deployment.yaml b/deploy/helm/gonavi-mcp-server/templates/deployment.yaml index f1f74827..904d4565 100644 --- a/deploy/helm/gonavi-mcp-server/templates/deployment.yaml +++ b/deploy/helm/gonavi-mcp-server/templates/deployment.yaml @@ -49,6 +49,8 @@ spec: value: {{ .Values.mcp.logDir | quote }} - name: GONAVI_MCP_HTTP_ADDR value: {{ .Values.mcp.httpAddr | quote }} + - name: GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK + value: {{ ternary "true" "false" .Values.mcp.allowNonLoopback | quote }} - name: GONAVI_MCP_HTTP_PATH value: {{ .Values.mcp.httpPath | quote }} - name: GONAVI_MCP_SCHEMA_ONLY diff --git a/deploy/helm/gonavi-mcp-server/values.yaml b/deploy/helm/gonavi-mcp-server/values.yaml index 70231d9f..64017785 100644 --- a/deploy/helm/gonavi-mcp-server/values.yaml +++ b/deploy/helm/gonavi-mcp-server/values.yaml @@ -18,6 +18,7 @@ mcp: dataRoot: /data logDir: /var/lib/gonavi/logs httpAddr: 0.0.0.0:8765 + allowNonLoopback: true httpPath: /mcp schemaOnly: true extraArgs: [] diff --git a/deploy/k8s/gonavi-mcp-server/README.md b/deploy/k8s/gonavi-mcp-server/README.md index e792e9d4..34978486 100644 --- a/deploy/k8s/gonavi-mcp-server/README.md +++ b/deploy/k8s/gonavi-mcp-server/README.md @@ -83,6 +83,8 @@ kubectl apply -k deploy/k8s/gonavi-mcp-server 清单内已经带了基础安全上下文和默认资源配额;如果你的连接数量、对象规模或并发访问更高,建议按实际负载调整 `resources`。 +基础 Deployment 会显式设置 `GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK=true`,允许进程监听 Pod 网卡;Bearer Token 仍由 Secret 强制提供。本机直接运行二进制时不要开启该选项。 + ### 常用 Overlay 仅基础部署: diff --git a/deploy/k8s/gonavi-mcp-server/base/deployment.yaml b/deploy/k8s/gonavi-mcp-server/base/deployment.yaml index b4c2766c..955f7812 100644 --- a/deploy/k8s/gonavi-mcp-server/base/deployment.yaml +++ b/deploy/k8s/gonavi-mcp-server/base/deployment.yaml @@ -39,6 +39,8 @@ spec: value: /var/lib/gonavi/logs - name: GONAVI_MCP_HTTP_ADDR value: 0.0.0.0:8765 + - name: GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK + value: "true" - name: GONAVI_MCP_HTTP_PATH value: /mcp - name: GONAVI_MCP_SCHEMA_ONLY diff --git a/deploy/podman/gonavi-mcp-server/README.md b/deploy/podman/gonavi-mcp-server/README.md index d879ea52..685a35a6 100644 --- a/deploy/podman/gonavi-mcp-server/README.md +++ b/deploy/podman/gonavi-mcp-server/README.md @@ -25,6 +25,8 @@ cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-se 把 `GONAVI_MCP_HTTP_TOKEN` 改成随机值后运行: +环境文件中的 `GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK=true` 仅用于容器监听网卡;Bearer Token 仍然必填。 + ```bash podman run -d --name gonavi-mcp-server --replace \ -p 8765:8765 \ diff --git a/deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example b/deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example index a0d2f8fb..7a7cb0c3 100644 --- a/deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example +++ b/deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example @@ -6,6 +6,7 @@ GONAVI_LOG_DIR=/var/lib/gonavi/logs # MCP HTTP 监听地址与路径。 GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765 +GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK=true GONAVI_MCP_HTTP_PATH=/mcp # 远程 MCP 客户端访问时必须携带的 Bearer Token diff --git a/docker-compose.mcp-server.yml b/docker-compose.mcp-server.yml index b46a77d5..062a95fb 100644 --- a/docker-compose.mcp-server.yml +++ b/docker-compose.mcp-server.yml @@ -10,6 +10,7 @@ services: GONAVI_DATA_ROOT: /data GONAVI_LOG_DIR: /var/lib/gonavi/logs GONAVI_MCP_HTTP_ADDR: 0.0.0.0:8765 + GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK: ${GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK:-true} GONAVI_MCP_HTTP_PATH: ${GONAVI_MCP_HTTP_PATH:-/mcp} GONAVI_MCP_HTTP_TOKEN: ${GONAVI_MCP_HTTP_TOKEN} GONAVI_MCP_SCHEMA_ONLY: ${GONAVI_MCP_SCHEMA_ONLY:-true} diff --git a/docker.mcp-server.env.example b/docker.mcp-server.env.example index 698ffc04..b0710736 100644 --- a/docker.mcp-server.env.example +++ b/docker.mcp-server.env.example @@ -16,5 +16,8 @@ GONAVI_MCP_HTTP_PORT=8765 # MCP HTTP 路径 GONAVI_MCP_HTTP_PATH=/mcp +# 容器需要监听容器网卡;本机直接运行 MCP Server 时不要开启 +GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK=true + # true = 只暴露结构查询工具,不注册 execute_sql GONAVI_MCP_SCHEMA_ONLY=true diff --git a/internal/mcpserver/run.go b/internal/mcpserver/run.go index 587b6d21..f81b26e4 100644 --- a/internal/mcpserver/run.go +++ b/internal/mcpserver/run.go @@ -24,11 +24,12 @@ const ( // HTTPServerOptions 描述远程 Streamable HTTP MCP 入口。 type HTTPServerOptions struct { - Addr string - Path string - Token string - JSONResponse bool - SchemaOnly bool + Addr string + Path string + Token string + JSONResponse bool + SchemaOnly bool + AllowNonLoopback bool } // StreamableHTTPServerHandle 表示一个已启动的 Streamable HTTP MCP server。 @@ -246,11 +247,12 @@ func ParseHTTPServerOptions(args []string) (HTTPServerOptions, error) { } options := HTTPServerOptions{ - Addr: defaultAddr, - Path: defaultPath, - Token: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_TOKEN")), - JSONResponse: true, - SchemaOnly: parseBoolEnvDefault("GONAVI_MCP_SCHEMA_ONLY", false), + Addr: defaultAddr, + Path: defaultPath, + Token: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_TOKEN")), + JSONResponse: true, + SchemaOnly: parseBoolEnvDefault("GONAVI_MCP_SCHEMA_ONLY", false), + AllowNonLoopback: parseBoolEnvDefault("GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK", false), } fs := flag.NewFlagSet("gonavi-mcp-server http", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -259,6 +261,7 @@ func ParseHTTPServerOptions(args []string) (HTTPServerOptions, error) { fs.StringVar(&options.Token, "token", options.Token, "bearer token required by remote MCP clients") fs.BoolVar(&options.JSONResponse, "json-response", options.JSONResponse, "return application/json streamable responses when possible") fs.BoolVar(&options.SchemaOnly, "schema-only", options.SchemaOnly, "only expose schema inspection tools and omit execute_sql") + fs.BoolVar(&options.AllowNonLoopback, "allow-non-loopback", options.AllowNonLoopback, "allow binding HTTP outside loopback for explicit container deployments") if err := fs.Parse(args); err != nil { return HTTPServerOptions{}, err } @@ -284,7 +287,7 @@ func normalizeHTTPServerOptions(options HTTPServerOptions) (HTTPServerOptions, e if options.Addr == "" { options.Addr = defaultStreamableHTTPAddr } - if err := validateLoopbackHTTPAddr(options.Addr); err != nil { + if err := validateHTTPServerAddr(options.Addr, options.AllowNonLoopback); err != nil { return HTTPServerOptions{}, err } options.Path = strings.TrimSpace(options.Path) @@ -301,11 +304,14 @@ func normalizeHTTPServerOptions(options HTTPServerOptions) (HTTPServerOptions, e return options, nil } -func validateLoopbackHTTPAddr(addr string) error { +func validateHTTPServerAddr(addr string, allowNonLoopback bool) error { host, _, err := net.SplitHostPort(addr) if err != nil { return fmt.Errorf("MCP HTTP address must include a loopback host and port: %w", err) } + if allowNonLoopback { + return nil + } host = strings.Trim(host, "[]") if strings.EqualFold(host, "localhost") { return nil diff --git a/internal/mcpserver/run_test.go b/internal/mcpserver/run_test.go index 875feb19..ec4a0b8d 100644 --- a/internal/mcpserver/run_test.go +++ b/internal/mcpserver/run_test.go @@ -57,11 +57,48 @@ func TestNormalizeHTTPServerOptionsRejectsNonLoopbackAddresses(t *testing.T) { } } +func TestNormalizeHTTPServerOptionsAllowsNonLoopbackWithExplicitEnvOptIn(t *testing.T) { + t.Setenv("GONAVI_MCP_HTTP_ADDR", "0.0.0.0:8765") + t.Setenv("GONAVI_MCP_HTTP_TOKEN", "secret") + t.Setenv("GONAVI_MCP_HTTP_ALLOW_NON_LOOPBACK", "true") + + options, err := ParseHTTPServerOptions(nil) + if err != nil { + t.Fatalf("ParseHTTPServerOptions returned error: %v", err) + } + if _, err := normalizeHTTPServerOptions(options); err != nil { + t.Fatalf("explicit non-loopback env opt-in returned error: %v", err) + } +} + +func TestNormalizeHTTPServerOptionsAllowsNonLoopbackWithExplicitFlagOptIn(t *testing.T) { + options, err := ParseHTTPServerOptions([]string{ + "--addr", "0.0.0.0:8765", + "--token", "secret", + "--allow-non-loopback", + }) + if err != nil { + t.Fatalf("ParseHTTPServerOptions returned error: %v", err) + } + if _, err := normalizeHTTPServerOptions(options); err != nil { + t.Fatalf("explicit non-loopback flag opt-in returned error: %v", err) + } +} + func TestNormalizeHTTPServerOptionsRequiresBearerToken(t *testing.T) { _, err := normalizeHTTPServerOptions(HTTPServerOptions{Addr: "127.0.0.1:8765", Path: "/mcp"}) if err == nil || !strings.Contains(err.Error(), "bearer token") { t.Fatalf("expected missing bearer token error, got %v", err) } + + _, err = normalizeHTTPServerOptions(HTTPServerOptions{ + Addr: "0.0.0.0:8765", + Path: "/mcp", + AllowNonLoopback: true, + }) + if err == nil || !strings.Contains(err.Error(), "bearer token") { + t.Fatalf("expected non-loopback opt-in to keep requiring a bearer token, got %v", err) + } } func TestBearerTokenAuthHandlerRejectsMissingOrWrongToken(t *testing.T) {