Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion backend/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6525,6 +6525,30 @@ const docTemplate = `{
}
}
},
"/settings/mcp-policy": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"settings"
],
"summary": "查询 MCP 工具运行策略",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_DEEIX-AI_DEEIX-Chat_backend_internal_shared_response.Envelope"
}
}
}
}
},
"/settings/model-option-policy": {
"get": {
"security": [
Expand Down Expand Up @@ -11713,7 +11737,7 @@ const docTemplate = `{
},
"selectedToolIDs": {
"type": "array",
"maxItems": 32,
"maxItems": 128,
"items": {
"type": "integer"
}
Expand Down
26 changes: 25 additions & 1 deletion backend/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -6518,6 +6518,30 @@
}
}
},
"/settings/mcp-policy": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"settings"
],
"summary": "查询 MCP 工具运行策略",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_DEEIX-AI_DEEIX-Chat_backend_internal_shared_response.Envelope"
}
}
}
}
},
"/settings/model-option-policy": {
"get": {
"security": [
Expand Down Expand Up @@ -11706,7 +11730,7 @@
},
"selectedToolIDs": {
"type": "array",
"maxItems": 32,
"maxItems": 128,
"items": {
"type": "integer"
}
Expand Down
16 changes: 15 additions & 1 deletion backend/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3278,7 +3278,7 @@ definitions:
selectedToolIDs:
items:
type: integer
maxItems: 32
maxItems: 128
type: array
sourceMessagePublicID:
maxLength: 32
Expand Down Expand Up @@ -7628,6 +7628,20 @@ paths:
summary: 查询公开登录页配置
tags:
- settings
/settings/mcp-policy:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/github_com_DEEIX-AI_DEEIX-Chat_backend_internal_shared_response.Envelope'
security:
- BearerAuth: []
summary: 查询 MCP 工具运行策略
tags:
- settings
/settings/model-option-policy:
get:
produces:
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/application/conversation/errs.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ var (
ErrEmbeddingUnavailable = errors.New("embedding unavailable")
// ErrTooManyMessageFiles 单条消息文件数超限。
ErrTooManyMessageFiles = errors.New("too many message files")
// ErrTooManySelectedTools 单条消息选择的 MCP 工具数超限。
ErrTooManySelectedTools = errors.New("too many selected tools")
// ErrInvalidMessageBranch 消息分支参数无效。
ErrInvalidMessageBranch = errors.New("invalid message branch")
// ErrMessageNotFound 消息不存在或无权限。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ func (s *Service) sendMessageInternal(
if len(input.FileIDs) > maxFiles {
return nil, ErrTooManyMessageFiles
}
// application 层保留兜底校验,保证非 HTTP 调用路径也遵守同一 MCP 工具数量策略。
if err := s.ValidateSelectedToolIDs(input.SelectedToolIDs); err != nil {
return nil, err
}

startedAt := time.Now()
runID := normalizeRunID(input.ClientRunID)
Expand Down
20 changes: 20 additions & 0 deletions backend/internal/application/conversation/service_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"time"

"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/mcp"
)

Expand Down Expand Up @@ -59,6 +60,25 @@ func (s *Service) resolveMaxToolCallsPerRun() int {
return maxCalls
}

func (s *Service) resolveMaxSelectedToolsPerMessage() int {
maxTools := s.cfg.Snapshot().MCPMaxSelectedToolsPerMessage
if maxTools <= 0 {
maxTools = config.DefaultMCPMaxSelectedToolsPerMessage
}
if maxTools > config.MaxMCPSelectedToolsPerMessage {
maxTools = config.MaxMCPSelectedToolsPerMessage
}
return maxTools
}

// ValidateSelectedToolIDs 校验单次消息选择的 MCP 工具数量。
func (s *Service) ValidateSelectedToolIDs(toolIDs []uint) error {
if len(toolIDs) > s.resolveMaxSelectedToolsPerMessage() {
return ErrTooManySelectedTools
}
return nil
}

func (s *Service) resolveMaxLLMCallsPerRun() int {
maxCalls := s.cfg.Snapshot().MCPMaxLLMCallsPerRun
if maxCalls <= 0 {
Expand Down
11 changes: 11 additions & 0 deletions backend/internal/application/conversation/service_tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,14 @@ func TestResolveMaxLLMCallsPerRunRequiresFollowUpRound(t *testing.T) {
t.Fatalf("expected minimum LLM calls per run to be 2, got %d", got)
}
}

func TestValidateSelectedToolIDsUsesRuntimeLimit(t *testing.T) {
service := &Service{cfg: config.NewRuntime(config.Config{MCPMaxSelectedToolsPerMessage: 2})}

if err := service.ValidateSelectedToolIDs([]uint{1, 2}); err != nil {
t.Fatalf("expected two selected tools to pass, got %v", err)
}
if err := service.ValidateSelectedToolIDs([]uint{1, 2, 3}); err != ErrTooManySelectedTools {
t.Fatalf("expected ErrTooManySelectedTools, got %v", err)
}
}
8 changes: 8 additions & 0 deletions backend/internal/application/settings/runtime_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ func (r *RuntimeSettings) applyItem(cfg *config.Config, item domainsettings.Syst
cfg.MCPToolRetryCount = toInt(item.Value, cfg.MCPToolRetryCount)
case "mcp:mcp_max_concurrent_calls":
cfg.MCPMaxConcurrentCalls = toInt(item.Value, cfg.MCPMaxConcurrentCalls)
case "mcp:mcp_max_selected_tools_per_message":
cfg.MCPMaxSelectedToolsPerMessage = toInt(item.Value, cfg.MCPMaxSelectedToolsPerMessage)
case "mcp:mcp_max_llm_calls_per_run":
cfg.MCPMaxLLMCallsPerRun = toInt(item.Value, cfg.MCPMaxLLMCallsPerRun)
case "mcp:mcp_max_tool_calls_per_run":
Expand Down Expand Up @@ -372,6 +374,12 @@ func (r *RuntimeSettings) normalizeConfig(cfg *config.Config) {
if strings.TrimSpace(cfg.NativeToolAllowedTypes) == "" {
cfg.NativeToolAllowedTypes = config.DefaultNativeToolAllowedTypesJSON()
}
if cfg.MCPMaxSelectedToolsPerMessage <= 0 {
cfg.MCPMaxSelectedToolsPerMessage = config.DefaultMCPMaxSelectedToolsPerMessage
}
if cfg.MCPMaxSelectedToolsPerMessage > config.MaxMCPSelectedToolsPerMessage {
cfg.MCPMaxSelectedToolsPerMessage = config.MaxMCPSelectedToolsPerMessage
}
if !cfg.FileFullContextLimitEnabled {
cfg.FileFullContextMaxBytes = 0
cfg.FileFullContextMaxTokens = 0
Expand Down
1 change: 1 addition & 0 deletions backend/internal/application/settings/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ func defaultSettings() []domainsettings.SystemSetting {
{Namespace: "mcp", Key: "mcp_tool_timeout_seconds", Value: "10", ValueType: "int", Description: "MCP Tool Call 超时(秒)"},
{Namespace: "mcp", Key: "mcp_tool_retry_count", Value: "0", ValueType: "int", Description: "MCP Tool Call 重试次数"},
{Namespace: "mcp", Key: "mcp_max_concurrent_calls", Value: "8", ValueType: "int", Description: "MCP Tool Call 并发上限"},
{Namespace: "mcp", Key: "mcp_max_selected_tools_per_message", Value: "32", ValueType: "int", Description: "单次消息最多可选择的 MCP 工具数量"},
{Namespace: "mcp", Key: "mcp_max_llm_calls_per_run", Value: "5", ValueType: "int", Description: "单次 MCP 工具运行最大 LLM 请求次数(最小 2,首次请求 + 工具后续请求 + 最终总结)"},
{Namespace: "mcp", Key: "mcp_max_tool_calls_per_run", Value: "8", ValueType: "int", Description: "单次 MCP 工具运行最大 MCP Tool Call 次数"},

Expand Down
2 changes: 2 additions & 0 deletions backend/internal/application/settings/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,8 @@ func validatePatchItem(item PatchItem) error {
return validateIntMinMax(value, 1, 64, key)
case "mcp:mcp_max_concurrent_calls":
return validateIntMinMax(value, 1, 64, key)
case "mcp:mcp_max_selected_tools_per_message":
return validateIntMinMax(value, 1, config.MaxMCPSelectedToolsPerMessage, key)
case "mcp:mcp_tool_timeout_seconds":
return validateIntMinMax(value, 1, 120, key)
case "mcp:mcp_tool_retry_count":
Expand Down
12 changes: 12 additions & 0 deletions backend/internal/application/settings/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ func TestValidateModelOptionPolicySettings(t *testing.T) {
}
}

func TestValidateMCPSelectedToolsSetting(t *testing.T) {
if err := validatePatchItem(PatchItem{Namespace: "mcp", Key: "mcp_max_selected_tools_per_message", Value: "32"}); err != nil {
t.Fatalf("expected selected tool limit to pass, got %v", err)
}
if err := validatePatchItem(PatchItem{Namespace: "mcp", Key: "mcp_max_selected_tools_per_message", Value: "0"}); err == nil {
t.Fatal("expected zero selected tool limit to fail")
}
if err := validatePatchItem(PatchItem{Namespace: "mcp", Key: "mcp_max_selected_tools_per_message", Value: "129"}); err == nil {
t.Fatal("expected selected tool limit above safe maximum to fail")
}
}

func TestValidateFullContextLimitsAllowUnlimitedValues(t *testing.T) {
cases := []PatchItem{
{Namespace: "file", Key: "full_context_limit_enabled", Value: "true"},
Expand Down
21 changes: 15 additions & 6 deletions backend/internal/infra/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ const (
defaultHTTPMaxHeaderBytes = 1 << 20
)

const (
// DefaultMCPMaxSelectedToolsPerMessage 是单次消息可选择 MCP 工具数量的默认值。
DefaultMCPMaxSelectedToolsPerMessage = 32
// MaxMCPSelectedToolsPerMessage 是运行时配置允许的安全上限,防止一次请求暴露过多工具 schema。
MaxMCPSelectedToolsPerMessage = 128
)

// DefaultModelOptionAllowedPathsJSON 返回用户可透传模型参数的默认白名单。
func DefaultModelOptionAllowedPathsJSON() string {
return `{
Expand Down Expand Up @@ -433,12 +440,13 @@ type Config struct {
ProcessTracePersistInflight bool // 是否在流式阶段持久化轨迹
ContextArtifactRetentionDays int // 上下文证据保留天数,<=0 表示不自动过期
// MCP 配置
MCPEnable bool
MCPToolTimeoutSeconds int
MCPToolRetryCount int
MCPMaxConcurrentCalls int
MCPMaxLLMCallsPerRun int
MCPMaxToolCallsPerRun int
MCPEnable bool
MCPToolTimeoutSeconds int
MCPToolRetryCount int
MCPMaxConcurrentCalls int
MCPMaxSelectedToolsPerMessage int
MCPMaxLLMCallsPerRun int
MCPMaxToolCallsPerRun int
}

// defaultYAMLPaths 固定读取仓库根目录的 config.yaml。
Expand Down Expand Up @@ -623,6 +631,7 @@ func Load() Config {
MCPToolTimeoutSeconds: 10,
MCPToolRetryCount: 0,
MCPMaxConcurrentCalls: 8,
MCPMaxSelectedToolsPerMessage: DefaultMCPMaxSelectedToolsPerMessage,
MCPMaxLLMCallsPerRun: 5,
MCPMaxToolCallsPerRun: 8,
}
Expand Down
1 change: 1 addition & 0 deletions backend/internal/shared/response/error_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ var exactErrorSpecs = map[string]errorSpec{
"invalid message branch": {Code: "message.invalid_branch", Message: "invalid message branch"},
"message generation canceled": {Code: "conversation_run.canceled", Message: "message generation canceled"},
"too many files in one message": {Code: "message.too_many_files", Message: "too many files in one message"},
"too many selected tools": {Code: "message.too_many_selected_tools", Message: "too many selected tools"},
"generation stream not found": {Code: "conversation_run.stream_not_found", Message: "generation stream not found"},
"image prompt is required": {Code: "media.image_prompt_required", Message: "image prompt is required"},
"image generation does not accept input images": {Code: "media.image_generation_rejects_inputs", Message: "image generation does not accept input images"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ type SendMessageRequest struct {
Options map[string]interface{} `json:"options"`
ClientRunID string `json:"clientRunID" binding:"omitempty,max=64"`
FileIDs []string `json:"fileIDs" binding:"max=20"`
SelectedToolIDs []uint `json:"selectedToolIDs" binding:"max=32"`
SelectedToolIDs []uint `json:"selectedToolIDs" binding:"max=128"`
ParentMessagePublicID string `json:"parentMessagePublicID" binding:"omitempty,max=32"`
SourceMessagePublicID string `json:"sourceMessagePublicID" binding:"omitempty,max=32"`
BranchReason string `json:"branchReason" binding:"omitempty,oneof=default retry edit"`
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/transport/http/conversation/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ func mapStreamError(err error) streamError {
case errors.Is(err, appconversation.ErrTooManyMessageFiles):
status = http.StatusBadRequest
message = "too many files in one message"
case errors.Is(err, appconversation.ErrTooManySelectedTools):
status = http.StatusBadRequest
message = "too many selected tools"
case errors.Is(err, appconversation.ErrFileProcessingNotReady):
status = http.StatusBadRequest
message = "file processing not ready"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ func (h *Handler) parseSendMessageInput(c *gin.Context) (appconversation.SendMes
}
req.ClientRunID = appconversation.EnsureMessageGenerationRunID(req.ClientRunID)
req.Options = sanitizeMessageOptions(req.Options)
// 流式接口写入响应头前先拦截明显超限请求,避免后续只能用 NDJSON error 表达 400。
if err = h.service.ValidateSelectedToolIDs(req.SelectedToolIDs); err != nil {
handleSendMessageError(c, err)
return appconversation.SendMessageInput{}, nil, nil, err
}

conversation, err := h.service.GetConversationByPublicID(c.Request.Context(), userID, publicID)
if err != nil {
Expand Down Expand Up @@ -237,6 +242,8 @@ func handleSendMessageError(c *gin.Context, err error) {
response.Error(c, http.StatusBadRequest, "invalid file reference")
case errors.Is(err, appconversation.ErrTooManyMessageFiles):
response.Error(c, http.StatusBadRequest, "too many files in one message")
case errors.Is(err, appconversation.ErrTooManySelectedTools):
response.Error(c, http.StatusBadRequest, "too many selected tools")
case errors.Is(err, appconversation.ErrInvalidMessageBranch):
response.Error(c, http.StatusBadRequest, "invalid message branch")
case errors.Is(err, appconversation.ErrFileProcessingNotReady):
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/transport/http/settings/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ type ModelOptionPolicyResponse struct {
NativeToolAllowedTypesJSON string `json:"nativeToolAllowedTypesJSON"`
}

// MCPPolicyResponse 返回聊天侧需要遵守的 MCP 工具运行策略。
type MCPPolicyResponse struct {
MaxSelectedToolsPerMessage int `json:"maxSelectedToolsPerMessage"`
}

// ── mapping 函数 ─────────────────────────────────────────────────────────────

func toAppPatchItems(items []PatchItem) []appsettings.PatchItem {
Expand Down
19 changes: 19 additions & 0 deletions backend/internal/transport/http/settings/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,25 @@ func (h *Handler) GetModelOptionPolicy(c *gin.Context) {
})
}

// GetMCPPolicy godoc
// @Summary 查询 MCP 工具运行策略
// @Tags settings
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Envelope
// @Router /settings/mcp-policy [get]
func (h *Handler) GetMCPPolicy(c *gin.Context) {
cfg := h.runtime.Snapshot()
limit := cfg.MCPMaxSelectedToolsPerMessage
if limit <= 0 {
limit = config.DefaultMCPMaxSelectedToolsPerMessage
}
if limit > config.MaxMCPSelectedToolsPerMessage {
limit = config.MaxMCPSelectedToolsPerMessage
}
response.Success(c, MCPPolicyResponse{MaxSelectedToolsPerMessage: limit})
}

// Patch godoc
// @Summary 批量更新配置项
// @Description 批量更新动态配置并清除缓存,下次读取自动刷新
Expand Down
1 change: 1 addition & 0 deletions backend/internal/transport/http/settings/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ func (m *Module) RegisterPublicRoutes(api *gin.RouterGroup) {

func (m *Module) RegisterRoutes(api *gin.RouterGroup) {
api.GET("/settings/model-option-policy", m.Handler.GetModelOptionPolicy)
api.GET("/settings/mcp-policy", m.Handler.GetMCPPolicy)
}

// RegisterAdminRoutes 注册 settings 管理路由(由管理员中间件保护)。
Expand Down
Loading
Loading