mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-13 10:04:18 +08:00
🐛 fix(mcp-server): 修复容器镜像因监听校验启动失败
- 为非回环监听新增环境变量与命令行显式许可,默认继续限制本机回环地址 - 在 Docker、Compose、Kubernetes、Helm 与 Podman 部署中开启容器监听许可 - 保持远程 HTTP 模式必须配置 Bearer Token 的安全约束 - 启动失败时返回非零退出码,并补充配置与进程回归测试
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:])
|
||||
|
||||
28
cmd/gonavi-mcp-server/main_test.go
Normal file
28
cmd/gonavi-mcp-server/main_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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
|
||||
|
||||
仅基础部署:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user