🐛 fix(mcp-server): 修复容器镜像因监听校验启动失败

- 为非回环监听新增环境变量与命令行显式许可,默认继续限制本机回环地址
- 在 Docker、Compose、Kubernetes、Helm 与 Podman 部署中开启容器监听许可
- 保持远程 HTTP 模式必须配置 Bearer Token 的安全约束
- 启动失败时返回非零退出码,并补充配置与进程回归测试
This commit is contained in:
Syngnat
2026-08-11 17:25:08 +08:00
parent 5d720881a6
commit c206ba0483
15 changed files with 104 additions and 13 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:])

View File

@@ -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())
}
}

View File

@@ -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`

View File

@@ -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

View File

@@ -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: []

View File

@@ -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
仅基础部署:

View File

@@ -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

View File

@@ -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 \

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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) {