fix(mcp): http 传输支持跳过 TLS 证书校验(自签证书)
MCP server 走 HTTPS 且用自签证书时,客户端 TLS 校验失败,报 "tls: failed to verify certificate" 无法连接。 mcp_servers 增加 insecure 开关(默认 false),置 true 时远程 MCP 客户端使用 InsecureSkipVerify 的 transport。stdio 传输不受影响。 - mcphttp.New 增加 insecure 参数 - db: MCPServer.Insecure 字段 + schema 加列(补 ALTER 兼容旧库) - create_mcp/update_mcp 工具与前端 http 配置项增加勾选框 Closes #108
This commit is contained in:
1 parent
cde3ce9b25
commit
25b82f2544
8 files changed
+42
-12
No files matched your search
+7
-6
@@ -815,11 +815,12 @@ type MCPServer struct {
|
||||
Env json.RawMessage `json:"env"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Insecure bool `json:"insecure"` // http: skip TLS cert verification (self-signed servers, issue #108)
|
||||
Tools []string `json:"tools,omitempty"` // cached tool names (mcp_tools_cache)
|
||||
}
|
||||
|
||||
func (d *DB) ListMCP() ([]*MCPServer, error) {
|
||||
rows, err := d.Query(`SELECT id,name,transport,COALESCE(command,''),args,env,COALESCE(url,''),enabled FROM mcp_servers ORDER BY id`)
|
||||
rows, err := d.Query(`SELECT id,name,transport,COALESCE(command,''),args,env,COALESCE(url,''),enabled,insecure FROM mcp_servers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -827,7 +828,7 @@ func (d *DB) ListMCP() ([]*MCPServer, error) {
|
||||
for rows.Next() {
|
||||
var m MCPServer
|
||||
var args, env []byte
|
||||
if err := rows.Scan(&m.ID, &m.Name, &m.Transport, &m.Command, &args, &env, &m.URL, &m.Enabled); err != nil {
|
||||
if err := rows.Scan(&m.ID, &m.Name, &m.Transport, &m.Command, &args, &env, &m.URL, &m.Enabled, &m.Insecure); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -917,12 +918,12 @@ func (d *DB) SaveMCP(m *MCPServer) (int64, error) {
|
||||
}
|
||||
if m.ID == 0 {
|
||||
var id int64
|
||||
err := d.QueryRow(`INSERT INTO mcp_servers(name,transport,command,args,env,url,enabled) VALUES ($1,$2,NULLIF($3,''),$4,$5,NULLIF($6,''),$7) RETURNING id`,
|
||||
m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled).Scan(&id)
|
||||
err := d.QueryRow(`INSERT INTO mcp_servers(name,transport,command,args,env,url,enabled,insecure) VALUES ($1,$2,NULLIF($3,''),$4,$5,NULLIF($6,''),$7,$8) RETURNING id`,
|
||||
m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE mcp_servers SET name=$1,transport=$2,command=NULLIF($3,''),args=$4,env=$5,url=NULLIF($6,''),enabled=$7 WHERE id=$8`,
|
||||
m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.ID)
|
||||
_, err := d.Exec(`UPDATE mcp_servers SET name=$1,transport=$2,command=NULLIF($3,''),args=$4,env=$5,url=NULLIF($6,''),enabled=$7,insecure=$8 WHERE id=$9`,
|
||||
m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure, m.ID)
|
||||
return m.ID, err
|
||||
}
|
||||
|
||||
|
||||
@@ -825,9 +825,12 @@ CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||
env JSONB NOT NULL DEFAULT '{}',
|
||||
url TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
insecure BOOLEAN NOT NULL DEFAULT false, -- http: 跳过 TLS 证书校验(自签证书场景, issue #108)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- 旧库补列(schema.sql 每次启动都会 Exec)。
|
||||
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS insecure BOOLEAN NOT NULL DEFAULT false;
|
||||
DROP TRIGGER IF EXISTS trg_mcp_upd ON mcp_servers;
|
||||
CREATE TRIGGER trg_mcp_upd BEFORE UPDATE ON mcp_servers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
+9
-2
@@ -12,6 +12,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -65,12 +66,18 @@ type Client struct {
|
||||
|
||||
// New connects to a remote MCP endpoint and performs the initialize handshake.
|
||||
// headers are sent on every request (Authorization, custom API keys, …).
|
||||
func New(ctx context.Context, server, url string, headers map[string]string) (*Client, error) {
|
||||
// When insecure is true, TLS certificate verification is skipped so servers that
|
||||
// present a self-signed certificate can still be reached (issue #108).
|
||||
func New(ctx context.Context, server, url string, headers map[string]string, insecure bool) (*Client, error) {
|
||||
hc := &http.Client{Timeout: 120 * time.Second}
|
||||
if insecure {
|
||||
hc.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
}
|
||||
c := &Client{
|
||||
server: server,
|
||||
url: url,
|
||||
headers: headers,
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
http: hc,
|
||||
}
|
||||
if err := c.initialize(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -32,7 +32,7 @@ func connectMCP(ctx context.Context, m *db.MCPServer) (mcpClient, error) {
|
||||
return nil, fmt.Errorf("http 传输缺少 URL")
|
||||
}
|
||||
// env map doubles as HTTP headers (e.g. Authorization).
|
||||
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env))
|
||||
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure)
|
||||
default:
|
||||
return nil, fmt.Errorf("未知传输方式 %q", m.Transport)
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ type mcpToolInput struct {
|
||||
Env json.RawMessage `json:"env"`
|
||||
URL string `json:"url"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Insecure *bool `json:"insecure"`
|
||||
}
|
||||
|
||||
func mcpSchema(withID bool) map[string]any {
|
||||
@@ -265,6 +266,7 @@ func mcpSchema(withID bool) map[string]any {
|
||||
"env": map[string]any{"type": "object", "description": "环境变量 {KEY:VALUE}"},
|
||||
"url": strParam("http/sse 的 URL"),
|
||||
"enabled": map[string]any{"type": "boolean", "description": "是否启用(默认 true)"},
|
||||
"insecure": map[string]any{"type": "boolean", "description": "http: 跳过 TLS 证书校验(自签证书时置 true, 默认 false)"},
|
||||
}
|
||||
required := []string{"name", "transport"}
|
||||
if withID {
|
||||
@@ -279,9 +281,13 @@ func (a mcpToolInput) toDB() *db.MCPServer {
|
||||
if a.Enabled != nil {
|
||||
enabled = *a.Enabled
|
||||
}
|
||||
insecure := false
|
||||
if a.Insecure != nil {
|
||||
insecure = *a.Insecure
|
||||
}
|
||||
return &db.MCPServer{
|
||||
ID: a.ID, Name: a.Name, Transport: a.Transport, Command: a.Command,
|
||||
Args: a.Args, Env: a.Env, URL: a.URL, Enabled: enabled,
|
||||
Args: a.Args, Env: a.Env, URL: a.URL, Enabled: enabled, Insecure: insecure,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ func (s *Server) scopeSentryClient(ctx context.Context) (*mcphttp.Client, error)
|
||||
if m.URL == "" {
|
||||
return nil, fmt.Errorf("数据源 %s 未配置 URL,请先配置", scopeSentryMCPName)
|
||||
}
|
||||
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env))
|
||||
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure)
|
||||
}
|
||||
|
||||
// envHasValue reports whether the env/header map has any non-empty value
|
||||
@@ -102,7 +102,7 @@ func (s *Server) syncSSStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if configured && m.Enabled {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
if cl, cerr := mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env)); cerr == nil {
|
||||
if cl, cerr := mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure); cerr == nil {
|
||||
if _, terr := cl.Tools(ctx); terr == nil {
|
||||
resp["reachable"] = true
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type FormState = {
|
||||
args: string;
|
||||
url: string;
|
||||
env: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
@@ -39,6 +40,7 @@ const emptyForm: FormState = {
|
||||
args: "",
|
||||
url: "",
|
||||
env: "",
|
||||
insecure: false,
|
||||
};
|
||||
|
||||
export default function MCPPage() {
|
||||
@@ -111,6 +113,7 @@ export default function MCPPage() {
|
||||
args: (s.args ?? []).join(" "),
|
||||
url: s.url ?? "",
|
||||
env: envToText(s.env),
|
||||
insecure: s.insecure ?? false,
|
||||
});
|
||||
setTab("config");
|
||||
setOpen(true);
|
||||
@@ -151,12 +154,14 @@ export default function MCPPage() {
|
||||
command: "",
|
||||
args: [] as string[],
|
||||
env: parseEnv(form.env), // 远程模式下 env 即请求头
|
||||
insecure: form.insecure,
|
||||
}
|
||||
: {
|
||||
transport: "stdio" as const,
|
||||
command: form.command.trim(),
|
||||
args: form.args.trim() ? form.args.trim().split(/\s+/) : [],
|
||||
env: parseEnv(form.env),
|
||||
insecure: false,
|
||||
};
|
||||
await api.saveMcpServer({
|
||||
...(editing ? { id: editing.id } : {}),
|
||||
@@ -284,6 +289,13 @@ export default function MCPPage() {
|
||||
value={form.url}
|
||||
onChange={(e) => setF({ url: e.target.value })}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={form.insecure}
|
||||
onCheckedChange={(v) => setF({ insecure: v === true })}
|
||||
/>
|
||||
跳过 TLS 证书校验(自签证书)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
|
||||
@@ -1070,6 +1070,7 @@ export interface MCPServer {
|
||||
env: Record<string, string>;
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
insecure?: boolean; // http: skip TLS cert verification (self-signed servers)
|
||||
tools?: string[]; // mcp_tools_cache (names only, for the count)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user