package mcp import ( "context" "fmt" "github.com/google/uuid" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yan1h/agent-coding-workflow/internal/chat" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/project" ) // registerChatTools 注册 chat 相关 MCP 工具:会话 CRUD、发消息、消息查询、 // prompt 模板 CRUD、模型列表。 // // 范围边界(spec 决策):LLM endpoint/model 管理、用量统计、附件上传、 // 流式接收(SSE)与 cancel/retry(强依赖流式上下文)不经 MCP 暴露。 // send_message 的 assistant 回复异步生成,调用方用 list_recent_messages 轮询。 func registerChatTools(srv *mcpsdk.Server, deps ServerDeps) { registerListRecentMessages(srv, deps) registerListConversations(srv, deps) registerGetConversation(srv, deps) registerCreateConversation(srv, deps) registerUpdateConversationTitle(srv, deps) registerDeleteConversation(srv, deps) registerSendMessage(srv, deps) registerListPromptTemplates(srv, deps) registerCreatePromptTemplate(srv, deps) registerUpdatePromptTemplate(srv, deps) registerDeletePromptTemplate(srv, deps) registerListModels(srv, deps) } // ===== 共用 DTO / helper ===== type conversationDetail struct { ID string `json:"id"` Title string `json:"title,omitempty"` ModelID string `json:"model_id"` ProjectID string `json:"project_id,omitempty"` WorkspaceID string `json:"workspace_id,omitempty"` IssueID string `json:"issue_id,omitempty"` RequirementID string `json:"requirement_id,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func conversationDetailFrom(cv *chat.Conversation) conversationDetail { d := conversationDetail{ ID: cv.ID.String(), ModelID: cv.ModelID.String(), CreatedAt: cv.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: cv.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), } if cv.Title != nil { d.Title = *cv.Title } if cv.ProjectID != nil { d.ProjectID = cv.ProjectID.String() } if cv.WorkspaceID != nil { d.WorkspaceID = cv.WorkspaceID.String() } if cv.IssueID != nil { d.IssueID = cv.IssueID.String() } if cv.RequirementID != nil { d.RequirementID = cv.RequirementID.String() } return d } // mountRefs 是会话挂载维度的 UUID 集合(nil = 未挂载)。 type mountRefs struct { ProjectID *uuid.UUID WorkspaceID *uuid.UUID IssueID *uuid.UUID RequirementID *uuid.UUID } // resolveMountRefs 把 MCP 友好的挂载入参(project_slug + 编号)解析为 UUID, // 并完成 project scope 校验。requirement/issue 编号必须随 project_slug 一起给。 func resolveMountRefs(ctx context.Context, deps ServerDeps, c project.Caller, projectSlug, workspaceID string, requirementNumber, issueNumber int) (mountRefs, error) { var refs mountRefs if projectSlug == "" && (requirementNumber > 0 || issueNumber > 0) { return refs, errs.New(errs.CodeInvalidInput, "project_slug is required when requirement_number/issue_number is set") } if projectSlug != "" { p, err := deps.Projects.Get(ctx, c, projectSlug) if err != nil { return refs, err } if err := CheckProjectFromCtx(ctx, p.ID); err != nil { return refs, err } pid := p.ID refs.ProjectID = &pid if requirementNumber > 0 { r, err := deps.Reqs.Get(ctx, c, projectSlug, requirementNumber) if err != nil { return refs, err } rid := r.ID refs.RequirementID = &rid } if issueNumber > 0 { i, err := deps.Issues.Get(ctx, c, projectSlug, issueNumber) if err != nil { return refs, err } iid := i.ID refs.IssueID = &iid } } if workspaceID != "" { w, _, err := scopedWorkspace(ctx, deps, c, workspaceID) if err != nil { return refs, err } wid := w.ID refs.WorkspaceID = &wid } return refs, nil } // ===== list_recent_messages ===== type listRecentMessagesIn struct { ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` BeforeID *int64 `json:"before_id,omitempty" jsonschema:"cursor: return messages with ID < this value"` Limit int `json:"limit,omitempty" jsonschema:"max messages to return (default 50, max 200)"` } type messageSummary struct { ID int64 `json:"id"` Role string `json:"role"` Content string `json:"content"` Status string `json:"status"` CreatedAt string `json:"created_at"` } type listRecentMessagesOut struct { Messages []messageSummary `json:"messages"` } func messageSummaryFrom(m chat.Message) messageSummary { return messageSummary{ ID: m.ID, Role: string(m.Role), Content: m.Content, Status: string(m.Status), CreatedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } } func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "list_recent_messages", Description: "List recent chat messages in a conversation."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRecentMessagesIn) (*mcpsdk.CallToolResult, listRecentMessagesOut, error) { if err := CheckToolFromCtx(ctx, "list_recent_messages"); err != nil { return nil, listRecentMessagesOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, listRecentMessagesOut{}, err } convID, err := uuid.Parse(in.ConversationID) if err != nil { return nil, listRecentMessagesOut{}, fmt.Errorf("invalid conversation_id: %w", err) } limit := in.Limit if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } msgs, err := deps.Messages.ListMessages(ctx, c.UserID, convID, in.BeforeID, limit) if err != nil { return nil, listRecentMessagesOut{}, err } out := listRecentMessagesOut{Messages: make([]messageSummary, 0, len(msgs))} for _, m := range msgs { out.Messages = append(out.Messages, messageSummaryFrom(m)) } return nil, out, nil }) } // ===== list_conversations ===== type listConversationsIn struct { ProjectSlug string `json:"project_slug,omitempty" jsonschema:"filter by project"` WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"filter by workspace UUID"` RequirementNumber int `json:"requirement_number,omitempty" jsonschema:"filter by requirement (needs project_slug)"` IssueNumber int `json:"issue_number,omitempty" jsonschema:"filter by issue (needs project_slug)"` TitleQuery string `json:"title_query,omitempty" jsonschema:"fuzzy title filter"` BeforeID string `json:"before_id,omitempty" jsonschema:"cursor: conversation UUID"` Limit int `json:"limit,omitempty" jsonschema:"max conversations to return (default 50, max 200)"` } type listConversationsOut struct { Conversations []conversationDetail `json:"conversations"` } func registerListConversations(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "list_conversations", Description: "List the caller's chat conversations."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listConversationsIn) (*mcpsdk.CallToolResult, listConversationsOut, error) { if err := CheckToolFromCtx(ctx, "list_conversations"); err != nil { return nil, listConversationsOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, listConversationsOut{}, err } refs, err := resolveMountRefs(ctx, deps, c, in.ProjectSlug, in.WorkspaceID, in.RequirementNumber, in.IssueNumber) if err != nil { return nil, listConversationsOut{}, err } filter := chat.ConversationFilter{ ProjectID: refs.ProjectID, WorkspaceID: refs.WorkspaceID, IssueID: refs.IssueID, RequirementID: refs.RequirementID, TitleQuery: in.TitleQuery, } limit := in.Limit if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } filter.Limit = limit if in.BeforeID != "" { bid, err := uuid.Parse(in.BeforeID) if err != nil { return nil, listConversationsOut{}, errs.Wrap(err, errs.CodeInvalidInput, "before_id parse") } filter.BeforeID = &bid } cvs, err := deps.Conversations.List(ctx, c.UserID, filter) if err != nil { return nil, listConversationsOut{}, err } out := listConversationsOut{Conversations: make([]conversationDetail, 0, len(cvs))} for i := range cvs { out.Conversations = append(out.Conversations, conversationDetailFrom(&cvs[i])) } return nil, out, nil }) } // ===== get_conversation ===== type getConversationIn struct { ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` Limit int `json:"limit,omitempty" jsonschema:"max recent messages to include (default 50, max 200)"` } type getConversationOut struct { Conversation conversationDetail `json:"conversation"` Messages []messageSummary `json:"messages"` } func registerGetConversation(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "get_conversation", Description: "Get a conversation with its recent messages."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getConversationIn) (*mcpsdk.CallToolResult, getConversationOut, error) { if err := CheckToolFromCtx(ctx, "get_conversation"); err != nil { return nil, getConversationOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, getConversationOut{}, err } convID, err := uuid.Parse(in.ConversationID) if err != nil { return nil, getConversationOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse") } limit := in.Limit if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } cv, msgs, err := deps.Conversations.Get(ctx, c.UserID, convID, limit) if err != nil { return nil, getConversationOut{}, err } out := getConversationOut{ Conversation: conversationDetailFrom(cv), Messages: make([]messageSummary, 0, len(msgs)), } for _, m := range msgs { out.Messages = append(out.Messages, messageSummaryFrom(m)) } return nil, out, nil }) } // ===== create_conversation ===== type createConversationIn struct { ModelID string `json:"model_id" jsonschema:"LLM model UUID (see list_models)"` TemplateID string `json:"template_id,omitempty" jsonschema:"prompt template UUID"` SystemPrompt string `json:"system_prompt,omitempty" jsonschema:"custom system prompt (overrides template)"` ProjectSlug string `json:"project_slug,omitempty" jsonschema:"mount to a project"` WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"mount to a workspace UUID"` RequirementNumber int `json:"requirement_number,omitempty" jsonschema:"mount to a requirement (needs project_slug)"` IssueNumber int `json:"issue_number,omitempty" jsonschema:"mount to an issue (needs project_slug)"` } type conversationCreateOut struct { OK bool `json:"ok"` Conversation conversationDetail `json:"conversation"` } func registerCreateConversation(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "create_conversation", Description: "Create a new chat conversation."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createConversationIn) (*mcpsdk.CallToolResult, conversationCreateOut, error) { if err := CheckToolFromCtx(ctx, "create_conversation"); err != nil { return nil, conversationCreateOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, conversationCreateOut{}, err } modelID, err := uuid.Parse(in.ModelID) if err != nil { return nil, conversationCreateOut{}, errs.Wrap(err, errs.CodeInvalidInput, "model_id parse") } refs, err := resolveMountRefs(ctx, deps, c, in.ProjectSlug, in.WorkspaceID, in.RequirementNumber, in.IssueNumber) if err != nil { return nil, conversationCreateOut{}, err } input := chat.CreateConversationInput{ ModelID: modelID, ProjectID: refs.ProjectID, WorkspaceID: refs.WorkspaceID, IssueID: refs.IssueID, RequirementID: refs.RequirementID, } if in.TemplateID != "" { tid, err := uuid.Parse(in.TemplateID) if err != nil { return nil, conversationCreateOut{}, errs.Wrap(err, errs.CodeInvalidInput, "template_id parse") } input.TemplateID = &tid } if in.SystemPrompt != "" { input.SystemPrompt = &in.SystemPrompt } cv, err := deps.Conversations.Create(ctx, c.UserID, input) if err != nil { return nil, conversationCreateOut{}, err } return nil, conversationCreateOut{OK: true, Conversation: conversationDetailFrom(cv)}, nil }) } // ===== update_conversation_title ===== type updateConversationTitleIn struct { ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` Title string `json:"title" jsonschema:"new title (non-empty)"` } func registerUpdateConversationTitle(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "update_conversation_title", Description: "Rename a conversation."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateConversationTitleIn) (*mcpsdk.CallToolResult, okOut, error) { if err := CheckToolFromCtx(ctx, "update_conversation_title"); err != nil { return nil, okOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, okOut{}, err } convID, err := uuid.Parse(in.ConversationID) if err != nil { return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse") } if err := deps.Conversations.UpdateTitle(ctx, c.UserID, convID, in.Title); err != nil { return nil, okOut{}, err } return nil, okOut{OK: true}, nil }) } // ===== delete_conversation ===== type deleteConversationIn struct { ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` } func registerDeleteConversation(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "delete_conversation", Description: "Soft-delete a conversation."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in deleteConversationIn) (*mcpsdk.CallToolResult, okOut, error) { if err := CheckToolFromCtx(ctx, "delete_conversation"); err != nil { return nil, okOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, okOut{}, err } convID, err := uuid.Parse(in.ConversationID) if err != nil { return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse") } if err := deps.Conversations.SoftDelete(ctx, c.UserID, convID); err != nil { return nil, okOut{}, err } return nil, okOut{OK: true}, nil }) } // ===== send_message ===== type sendMessageIn struct { ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` Content string `json:"content" jsonschema:"message content (non-empty)"` } type sendMessageOut struct { OK bool `json:"ok"` UserMessageID int64 `json:"user_message_id"` AssistantMessageID int64 `json:"assistant_message_id"` } func registerSendMessage(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "send_message", Description: "Send a user message to a conversation. The assistant reply is generated asynchronously; poll list_recent_messages for the result (attachments are not supported via MCP)."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in sendMessageIn) (*mcpsdk.CallToolResult, sendMessageOut, error) { if err := CheckToolFromCtx(ctx, "send_message"); err != nil { return nil, sendMessageOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, sendMessageOut{}, err } convID, err := uuid.Parse(in.ConversationID) if err != nil { return nil, sendMessageOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse") } userMsgID, assistantMsgID, err := deps.Messages.Send(ctx, c.UserID, convID, in.Content, nil) if err != nil { return nil, sendMessageOut{}, err } return nil, sendMessageOut{OK: true, UserMessageID: userMsgID, AssistantMessageID: assistantMsgID}, nil }) } // ===== list_prompt_templates ===== type templateDetail struct { ID string `json:"id"` Name string `json:"name"` Scope string `json:"scope"` Content string `json:"content"` } type listPromptTemplatesOut struct { Templates []templateDetail `json:"templates"` } func registerListPromptTemplates(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "list_prompt_templates", Description: "List prompt templates visible to the caller (system + own)."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listPromptTemplatesOut, error) { if err := CheckToolFromCtx(ctx, "list_prompt_templates"); err != nil { return nil, listPromptTemplatesOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, listPromptTemplatesOut{}, err } ts, err := deps.Templates.List(ctx, c.UserID) if err != nil { return nil, listPromptTemplatesOut{}, err } out := listPromptTemplatesOut{Templates: make([]templateDetail, 0, len(ts))} for _, t := range ts { out.Templates = append(out.Templates, templateDetail{ ID: t.ID.String(), Name: t.Name, Scope: string(t.Scope), Content: t.Content, }) } return nil, out, nil }) } // ===== create_prompt_template ===== type createPromptTemplateIn struct { Name string `json:"name" jsonschema:"template name (non-empty)"` Content string `json:"content" jsonschema:"template content (non-empty)"` Scope string `json:"scope,omitempty" jsonschema:"system|user (default user; system requires admin)"` } type templateCreateOut struct { OK bool `json:"ok"` Template templateDetail `json:"template"` } func registerCreatePromptTemplate(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "create_prompt_template", Description: "Create a prompt template. scope=system requires admin."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPromptTemplateIn) (*mcpsdk.CallToolResult, templateCreateOut, error) { if err := CheckToolFromCtx(ctx, "create_prompt_template"); err != nil { return nil, templateCreateOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, templateCreateOut{}, err } scope := chat.TemplateScope(in.Scope) if scope == "" { scope = chat.TemplateScopeUser } // 与 HTTP handler 对齐:system 模板仅 admin 可建(service 层不做此校验)。 if scope == chat.TemplateScopeSystem && !c.IsAdmin { return nil, templateCreateOut{}, errs.New(errs.CodeForbidden, "admin only: system-scoped template") } t, err := deps.Templates.Create(ctx, c.UserID, in.Name, in.Content, scope) if err != nil { return nil, templateCreateOut{}, err } return nil, templateCreateOut{OK: true, Template: templateDetail{ ID: t.ID.String(), Name: t.Name, Scope: string(t.Scope), Content: t.Content, }}, nil }) } // ===== update_prompt_template ===== type updatePromptTemplateIn struct { TemplateID string `json:"template_id" jsonschema:"template UUID"` Name string `json:"name" jsonschema:"new name (non-empty)"` Content string `json:"content" jsonschema:"new content (non-empty)"` } func registerUpdatePromptTemplate(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "update_prompt_template", Description: "Update a prompt template (owner or system template)."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updatePromptTemplateIn) (*mcpsdk.CallToolResult, okOut, error) { if err := CheckToolFromCtx(ctx, "update_prompt_template"); err != nil { return nil, okOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, okOut{}, err } tid, err := uuid.Parse(in.TemplateID) if err != nil { return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "template_id parse") } if err := deps.Templates.Update(ctx, c.UserID, c.IsAdmin, tid, in.Name, in.Content); err != nil { return nil, okOut{}, err } return nil, okOut{OK: true}, nil }) } // ===== delete_prompt_template ===== type deletePromptTemplateIn struct { TemplateID string `json:"template_id" jsonschema:"template UUID"` } func registerDeletePromptTemplate(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "delete_prompt_template", Description: "Delete a prompt template (owner or system template)."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in deletePromptTemplateIn) (*mcpsdk.CallToolResult, okOut, error) { if err := CheckToolFromCtx(ctx, "delete_prompt_template"); err != nil { return nil, okOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, okOut{}, err } tid, err := uuid.Parse(in.TemplateID) if err != nil { return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "template_id parse") } if err := deps.Templates.Delete(ctx, c.UserID, c.IsAdmin, tid); err != nil { return nil, okOut{}, err } return nil, okOut{OK: true}, nil }) } // ===== list_models ===== type listModelsIn struct { IncludeDisabled bool `json:"include_disabled,omitempty" jsonschema:"include disabled models (default false)"` } type modelSummary struct { ID string `json:"id"` ModelID string `json:"model_id"` DisplayName string `json:"display_name"` ContextWindow int `json:"context_window"` MaxOutputTokens int `json:"max_output_tokens"` Enabled bool `json:"enabled"` } type listModelsOut struct { Models []modelSummary `json:"models"` } func registerListModels(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "list_models", Description: "List available LLM models (for create_conversation)."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listModelsIn) (*mcpsdk.CallToolResult, listModelsOut, error) { if err := CheckToolFromCtx(ctx, "list_models"); err != nil { return nil, listModelsOut{}, err } if _, err := deps.Caller.Resolve(ctx); err != nil { return nil, listModelsOut{}, err } ms, err := deps.Endpoints.ListModels(ctx, !in.IncludeDisabled) if err != nil { return nil, listModelsOut{}, err } out := listModelsOut{Models: make([]modelSummary, 0, len(ms))} for _, m := range ms { out.Models = append(out.Models, modelSummary{ ID: m.ID.String(), ModelID: m.ModelID, DisplayName: m.DisplayName, ContextWindow: m.ContextWindow, MaxOutputTokens: m.MaxOutputTokens, Enabled: m.Enabled, }) } return nil, out, nil }) }