Files
MyGoNavi/internal/ai/service/mcp_http_server.go
Syngnat 4fcd7ff61f feat(ai): 支持 MCP 少量查数与分服务商思考强度,修复 DeepSeek Anthropic
- MCP HTTP 默认可注册 execute_sql,行数默认 50/上限 200,设置页可切换仅结构模式
- 思考强度按 OpenAI/Anthropic/DeepSeek/Gemini 体系映射档位与请求参数
- 自定义端点 DeepSeek Anthropic 自动纠正 base 路径并回传 thinking 块
- 补充前后端回归测试与多语言文案
2026-07-09 11:34:38 +08:00

446 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package aiservice
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
)
const (
defaultMCPHTTPAddr = "127.0.0.1:8765"
defaultMCPHTTPPath = "/mcp"
)
type mcpHTTPServerRuntime struct {
process mcpHTTPProcess
status ai.MCPHTTPServerStatus
stopping bool
}
type mcpHTTPProcessStartOptions struct {
Addr string
Path string
Token string
SchemaOnly bool
}
type mcpHTTPProcess interface {
Done() <-chan struct{}
Stop(context.Context) error
Wait() error
}
type mcpHTTPTextLookup func(key string, params map[string]any) string
var (
startMCPHTTPProcess = startMCPHTTPCommandProcess
waitMCPHTTPHealth = waitMCPHTTPHealthEndpoint
)
// AIGetMCPHTTPServerStatus 返回客户端内置 HTTP MCP 服务状态。
func (s *Service) AIGetMCPHTTPServerStatus() ai.MCPHTTPServerStatus {
s.mcpHTTPMu.Lock()
defer s.mcpHTTPMu.Unlock()
if s.mcpHTTP != nil {
status := s.mcpHTTP.status
status.Running = true
return status
}
if strings.TrimSpace(s.mcpHTTPLast.Addr) != "" {
return s.mcpHTTPLast
}
return defaultMCPHTTPServerStatus(s.serviceText)
}
// AIStartMCPHTTPServer 从客户端内启动 GoNavi Streamable HTTP MCP 服务。
func (s *Service) AIStartMCPHTTPServer(options ai.MCPHTTPServerOptions) (ai.MCPHTTPServerStatus, error) {
s.mcpHTTPMu.Lock()
if s.mcpHTTP != nil {
status := s.mcpHTTP.status
status.Running = true
s.mcpHTTPMu.Unlock()
return status, nil
}
s.mcpHTTPMu.Unlock()
textLookup := s.serviceText
startOptions, token, err := normalizeInAppMCPHTTPOptions(options, textLookup)
if err != nil {
err = localizeMCPHTTPError(textLookup, "ai_service.backend.error.mcp_http_start_failed", nil, err)
status := defaultMCPHTTPServerStatus(textLookup)
status.Message = err.Error()
return status, err
}
ctx := s.ctx
if ctx == nil {
ctx = context.Background()
}
process, err := startMCPHTTPProcess(ctx, startOptions, textLookup)
if err != nil {
err = localizeMCPHTTPError(textLookup, "ai_service.backend.error.mcp_http_start_failed", nil, err)
status := stoppedMCPHTTPStatus(statusFromMCPHTTPOptions(startOptions, token, textLookup), err.Error())
return status, err
}
status := statusFromMCPHTTPOptions(startOptions, token, textLookup)
if err := waitForMCPHTTPReady(ctx, process, status, textLookup); err != nil {
_ = process.Stop(context.Background())
err = localizeMCPHTTPError(textLookup, "ai_service.backend.error.mcp_http_start_failed", nil, err)
stopped := stoppedMCPHTTPStatus(status, err.Error())
return stopped, err
}
runtime := &mcpHTTPServerRuntime{process: process, status: status}
s.mcpHTTPMu.Lock()
if s.mcpHTTP != nil {
existing := s.mcpHTTP.status
existing.Running = true
s.mcpHTTPMu.Unlock()
_ = process.Stop(context.Background())
return existing, nil
}
s.mcpHTTP = runtime
s.mcpHTTPLast = status
s.mcpHTTPMu.Unlock()
logger.Infof("客户端启动 GoNavi MCP HTTP 服务addr=%s path=%s schemaOnly=%v", status.Addr, status.Path, status.SchemaOnly)
go s.watchMCPHTTPServer(runtime)
return status, nil
}
// AIStopMCPHTTPServer 停止客户端内启动的 GoNavi Streamable HTTP MCP 服务。
func (s *Service) AIStopMCPHTTPServer() (ai.MCPHTTPServerStatus, error) {
return s.stopMCPHTTPServer(context.Background(), s.serviceText("ai_settings.mcp_http.message.stopped", nil))
}
func (s *Service) stopMCPHTTPServer(ctx context.Context, message string) (ai.MCPHTTPServerStatus, error) {
textLookup := s.serviceText
s.mcpHTTPMu.Lock()
runtime := s.mcpHTTP
if runtime == nil {
status := s.mcpHTTPLast
if strings.TrimSpace(status.Addr) == "" {
status = defaultMCPHTTPServerStatus(textLookup)
}
status.Running = false
if strings.TrimSpace(status.Token) != "" {
status.AuthorizationHeader = "Bearer " + strings.TrimSpace(status.Token)
}
status.Message = localizeMCPHTTPText(textLookup, "ai_settings.mcp_http.status.not_running", nil)
s.mcpHTTPLast = status
s.mcpHTTPMu.Unlock()
return status, nil
}
runtime.stopping = true
s.mcpHTTPMu.Unlock()
if ctx == nil {
ctx = context.Background()
}
err := runtime.process.Stop(ctx)
status := stoppedMCPHTTPStatus(runtime.status, message)
if err != nil {
err = localizeMCPHTTPError(textLookup, "ai_service.backend.error.mcp_http_stop_failed", nil, err)
status.Message = err.Error()
}
s.mcpHTTPMu.Lock()
if s.mcpHTTP == runtime {
s.mcpHTTP = nil
s.mcpHTTPLast = status
}
s.mcpHTTPMu.Unlock()
if err == nil {
logger.Infof("客户端停止 GoNavi MCP HTTP 服务addr=%s path=%s", status.Addr, status.Path)
}
return status, err
}
// Shutdown 释放 AI Service 中的运行时资源。
func (s *Service) Shutdown() {
ctx := context.Background()
_, _ = s.stopMCPHTTPServer(ctx, s.serviceText("ai_settings.mcp_http.message.stopped", nil))
}
func (s *Service) watchMCPHTTPServer(runtime *mcpHTTPServerRuntime) {
err := runtime.process.Wait()
s.mcpHTTPMu.Lock()
defer s.mcpHTTPMu.Unlock()
if s.mcpHTTP != runtime {
return
}
message := localizeMCPHTTPText(s.serviceText, "ai_settings.mcp_http.message.stopped", nil)
if err != nil && !runtime.stopping {
message = localizeMCPHTTPText(s.serviceText, "ai_service.backend.error.mcp_http_process_exited", map[string]any{
"detail": err.Error(),
})
logger.Error(err, "GoNavi MCP HTTP 服务异常退出addr=%s path=%s", runtime.status.Addr, runtime.status.Path)
}
s.mcpHTTP = nil
s.mcpHTTPLast = stoppedMCPHTTPStatus(runtime.status, message)
}
func normalizeInAppMCPHTTPOptions(options ai.MCPHTTPServerOptions, textLookup mcpHTTPTextLookup) (mcpHTTPProcessStartOptions, string, error) {
addr := strings.TrimSpace(options.Addr)
if addr == "" {
addr = defaultMCPHTTPAddr
}
path := strings.TrimSpace(options.Path)
if path == "" {
path = defaultMCPHTTPPath
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
token := strings.TrimSpace(options.Token)
if token == "" {
generated, err := generateMCPHTTPToken(textLookup)
if err != nil {
return mcpHTTPProcessStartOptions{}, "", err
}
token = generated
}
return mcpHTTPProcessStartOptions{
Addr: addr,
Path: path,
Token: token,
// 尊重调用方配置false 时注册 execute_sql用于查少量样例数据仍受 AI 安全控制与行数上限约束)。
SchemaOnly: options.SchemaOnly,
}, token, nil
}
func startMCPHTTPCommandProcess(ctx context.Context, options mcpHTTPProcessStartOptions, textLookup mcpHTTPTextLookup) (mcpHTTPProcess, error) {
executable, err := os.Executable()
if err != nil {
return nil, localizeMCPHTTPError(textLookup, "ai_service.backend.error.mcp_http_executable_resolve_failed", nil, err)
}
if ctx == nil {
ctx = context.Background()
}
processCtx, cancel := context.WithCancel(ctx)
args := []string{"mcp-server", "http", "--addr", options.Addr, "--path", options.Path}
if options.SchemaOnly {
args = append(args, "--schema-only")
}
cmd := exec.CommandContext(processCtx, executable, args...)
cmd.Env = append(os.Environ(), "GONAVI_MCP_HTTP_TOKEN="+options.Token)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
cancel()
return nil, err
}
process := &mcpHTTPCommandProcess{
cancel: cancel,
cmd: cmd,
done: make(chan struct{}),
}
go process.wait()
return process, nil
}
type mcpHTTPCommandProcess struct {
cancel context.CancelFunc
cmd *exec.Cmd
done chan struct{}
mu sync.Mutex
err error
}
func (p *mcpHTTPCommandProcess) Done() <-chan struct{} {
return p.done
}
func (p *mcpHTTPCommandProcess) Stop(ctx context.Context) error {
if p == nil {
return nil
}
select {
case <-p.done:
return p.waitErr()
default:
}
if p.cancel != nil {
p.cancel()
}
if ctx == nil {
ctx = context.Background()
}
select {
case <-p.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (p *mcpHTTPCommandProcess) Wait() error {
if p == nil {
return nil
}
<-p.done
return p.waitErr()
}
func (p *mcpHTTPCommandProcess) wait() {
err := p.cmd.Wait()
p.mu.Lock()
p.err = err
p.mu.Unlock()
close(p.done)
}
func (p *mcpHTTPCommandProcess) waitErr() error {
p.mu.Lock()
defer p.mu.Unlock()
return p.err
}
func waitForMCPHTTPReady(ctx context.Context, process mcpHTTPProcess, status ai.MCPHTTPServerStatus, textLookup mcpHTTPTextLookup) error {
readyCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
healthErrCh := make(chan error, 1)
go func() {
healthErrCh <- waitMCPHTTPHealth(readyCtx, buildMCPHTTPURL(status.Addr, "/healthz"), textLookup)
}()
select {
case err := <-healthErrCh:
return err
case <-process.Done():
if err := process.Wait(); err != nil {
return err
}
return fmt.Errorf("%s", localizeMCPHTTPText(textLookup, "ai_service.backend.error.mcp_http_subprocess_exited", nil))
case <-readyCtx.Done():
return readyCtx.Err()
}
}
func waitMCPHTTPHealthEndpoint(ctx context.Context, healthURL string, textLookup mcpHTTPTextLookup) error {
client := http.Client{Timeout: 300 * time.Millisecond}
var lastErr error
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
lastErr = fmt.Errorf("%s", localizeMCPHTTPText(textLookup, "ai_service.backend.error.mcp_http_health_status_failed", map[string]any{
"statusCode": resp.StatusCode,
}))
} else {
lastErr = err
}
select {
case <-ctx.Done():
if lastErr != nil {
return lastErr
}
return ctx.Err()
case <-time.After(120 * time.Millisecond):
}
}
}
func statusFromMCPHTTPOptions(options mcpHTTPProcessStartOptions, token string, textLookup mcpHTTPTextLookup) ai.MCPHTTPServerStatus {
return ai.MCPHTTPServerStatus{
Running: true,
Addr: options.Addr,
Path: options.Path,
URL: buildMCPHTTPURL(options.Addr, options.Path),
SchemaOnly: options.SchemaOnly,
Token: token,
AuthorizationHeader: "Bearer " + token,
StartedAt: time.Now().UnixMilli(),
Message: localizeMCPHTTPText(textLookup, "ai_settings.mcp_http.message.started", nil),
}
}
func generateMCPHTTPToken(textLookup mcpHTTPTextLookup) (string, error) {
var bytes [24]byte
if _, err := rand.Read(bytes[:]); err != nil {
return "", localizeMCPHTTPError(textLookup, "ai_service.backend.error.mcp_http_token_generate_failed", nil, err)
}
return "gnv_" + base64.RawURLEncoding.EncodeToString(bytes[:]), nil
}
func defaultMCPHTTPServerStatus(textLookup mcpHTTPTextLookup) ai.MCPHTTPServerStatus {
return ai.MCPHTTPServerStatus{
Running: false,
Addr: defaultMCPHTTPAddr,
Path: defaultMCPHTTPPath,
URL: buildMCPHTTPURL(defaultMCPHTTPAddr, defaultMCPHTTPPath),
// 默认允许只读 execute_sql 查少量数据;仍可在启动时显式传 schemaOnly=true 关闭。
SchemaOnly: false,
Message: localizeMCPHTTPText(textLookup, "ai_settings.mcp_http.status.not_running", nil),
}
}
func stoppedMCPHTTPStatus(status ai.MCPHTTPServerStatus, message string) ai.MCPHTTPServerStatus {
status.Running = false
if strings.TrimSpace(status.Token) != "" {
status.AuthorizationHeader = "Bearer " + strings.TrimSpace(status.Token)
}
status.Message = message
return status
}
func buildMCPHTTPURL(addr string, path string) string {
path = strings.TrimSpace(path)
if path == "" {
path = defaultMCPHTTPPath
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
host, port, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil {
return "http://" + strings.TrimSpace(addr) + path
}
host = strings.Trim(host, "[]")
if host == "" || host == "::" || host == "0.0.0.0" {
host = "127.0.0.1"
}
return "http://" + net.JoinHostPort(host, port) + path
}
func localizeMCPHTTPText(textLookup mcpHTTPTextLookup, key string, params map[string]any) string {
if textLookup == nil {
return key
}
return textLookup(key, params)
}
func localizeMCPHTTPError(textLookup mcpHTTPTextLookup, key string, params map[string]any, cause error) error {
return serviceErrorFromText(key, localizeMCPHTTPText(textLookup, key, serviceTextWithDetail(params, cause)), cause)
}