| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| package controller |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "strings" |
| |
| "github.com/apache/answer/internal/base/pager" |
| "github.com/apache/answer/internal/entity" |
| "github.com/apache/answer/internal/schema" |
| answercommon "github.com/apache/answer/internal/service/answer_common" |
| "github.com/apache/answer/internal/service/comment" |
| "github.com/apache/answer/internal/service/content" |
| "github.com/apache/answer/internal/service/embedding" |
| "github.com/apache/answer/internal/service/feature_toggle" |
| questioncommon "github.com/apache/answer/internal/service/question_common" |
| "github.com/apache/answer/internal/service/siteinfo_common" |
| tagcommonser "github.com/apache/answer/internal/service/tag_common" |
| usercommon "github.com/apache/answer/internal/service/user_common" |
| "github.com/apache/answer/plugin" |
| "github.com/mark3labs/mcp-go/mcp" |
| "github.com/segmentfault/pacman/log" |
| ) |
| |
| type MCPController struct { |
| searchService *content.SearchService |
| siteInfoService siteinfo_common.SiteInfoCommonService |
| tagCommonService *tagcommonser.TagCommonService |
| questioncommon *questioncommon.QuestionCommon |
| commentRepo comment.CommentRepo |
| userCommon *usercommon.UserCommon |
| answerRepo answercommon.AnswerRepo |
| featureToggleSvc *feature_toggle.FeatureToggleService |
| embeddingService *embedding.EmbeddingService |
| } |
| |
| // NewMCPController new site info controller. |
| func NewMCPController( |
| searchService *content.SearchService, |
| siteInfoService siteinfo_common.SiteInfoCommonService, |
| tagCommonService *tagcommonser.TagCommonService, |
| questioncommon *questioncommon.QuestionCommon, |
| commentRepo comment.CommentRepo, |
| userCommon *usercommon.UserCommon, |
| answerRepo answercommon.AnswerRepo, |
| featureToggleSvc *feature_toggle.FeatureToggleService, |
| embeddingService *embedding.EmbeddingService, |
| ) *MCPController { |
| return &MCPController{ |
| searchService: searchService, |
| siteInfoService: siteInfoService, |
| tagCommonService: tagCommonService, |
| questioncommon: questioncommon, |
| commentRepo: commentRepo, |
| userCommon: userCommon, |
| answerRepo: answerRepo, |
| featureToggleSvc: featureToggleSvc, |
| embeddingService: embeddingService, |
| } |
| } |
| |
| func (c *MCPController) ensureMCPEnabled(ctx context.Context) error { |
| if c.featureToggleSvc == nil { |
| return nil |
| } |
| return c.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureMCP) |
| } |
| |
| func (c *MCPController) MCPQuestionsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| if err := c.ensureMCPEnabled(ctx); err != nil { |
| return nil, err |
| } |
| cond := schema.NewMCPSearchCond(request) |
| |
| siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| log.Errorf("get site general info failed: %v", err) |
| return nil, err |
| } |
| |
| searchResp, err := c.searchService.Search(ctx, &schema.SearchDTO{ |
| Query: cond.ToQueryString() + " is:question", |
| Page: 1, |
| Size: 5, |
| Order: "newest", |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| resp := make([]*schema.MCPSearchQuestionInfoResp, 0) |
| for _, question := range searchResp.SearchResults { |
| t := &schema.MCPSearchQuestionInfoResp{ |
| QuestionID: question.Object.QuestionID, |
| Title: question.Object.Title, |
| Content: question.Object.Excerpt, |
| Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.Object.QuestionID), |
| } |
| resp = append(resp, t) |
| } |
| |
| data, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(data)), nil |
| } |
| } |
| |
| func (c *MCPController) MCPQuestionDetailHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| if err := c.ensureMCPEnabled(ctx); err != nil { |
| return nil, err |
| } |
| cond := schema.NewMCPSearchQuestionDetail(request) |
| |
| siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| log.Errorf("get site general info failed: %v", err) |
| return nil, err |
| } |
| |
| question, err := c.questioncommon.Info(ctx, cond.QuestionID, "") |
| if err != nil { |
| log.Errorf("get question failed: %v", err) |
| return mcp.NewToolResultText("No question found."), nil |
| } |
| |
| resp := &schema.MCPSearchQuestionInfoResp{ |
| QuestionID: question.ID, |
| Title: question.Title, |
| Content: question.Content, |
| Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.ID), |
| } |
| res, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(res)), nil |
| } |
| } |
| |
| func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| if err := c.ensureMCPEnabled(ctx); err != nil { |
| return nil, err |
| } |
| cond := schema.NewMCPSearchAnswerCond(request) |
| |
| siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| log.Errorf("get site general info failed: %v", err) |
| return nil, err |
| } |
| |
| if len(cond.QuestionID) > 0 { |
| answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID}) |
| if err != nil { |
| log.Errorf("get answers failed: %v", err) |
| return nil, err |
| } |
| resp := make([]*schema.MCPSearchAnswerInfoResp, 0) |
| for _, answer := range answerList { |
| if answer.Status != entity.AnswerStatusAvailable { |
| continue |
| } |
| t := &schema.MCPSearchAnswerInfoResp{ |
| QuestionID: answer.QuestionID, |
| AnswerID: answer.ID, |
| AnswerContent: answer.OriginalText, |
| Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID), |
| } |
| resp = append(resp, t) |
| } |
| data, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(data)), nil |
| } |
| |
| answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID}) |
| if err != nil { |
| log.Errorf("get answers failed: %v", err) |
| return nil, err |
| } |
| resp := make([]*schema.MCPSearchAnswerInfoResp, 0) |
| for _, answer := range answerList { |
| if answer.Status != entity.AnswerStatusAvailable { |
| continue |
| } |
| t := &schema.MCPSearchAnswerInfoResp{ |
| QuestionID: answer.QuestionID, |
| AnswerID: answer.ID, |
| AnswerContent: answer.OriginalText, |
| Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID), |
| } |
| resp = append(resp, t) |
| } |
| data, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(data)), nil |
| } |
| } |
| |
| func (c *MCPController) MCPCommentsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| if err := c.ensureMCPEnabled(ctx); err != nil { |
| return nil, err |
| } |
| cond := schema.NewMCPSearchCommentCond(request) |
| |
| siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| log.Errorf("get site general info failed: %v", err) |
| return nil, err |
| } |
| |
| dto := &comment.CommentQuery{ |
| PageCond: pager.PageCond{Page: 1, PageSize: 5}, |
| QueryCond: "newest", |
| ObjectID: cond.ObjectID, |
| } |
| commentList, total, err := c.commentRepo.GetCommentPage(ctx, dto) |
| if err != nil { |
| return nil, err |
| } |
| if total == 0 { |
| return mcp.NewToolResultText("No comments found."), nil |
| } |
| |
| resp := make([]*schema.MCPSearchCommentInfoResp, 0) |
| for _, comment := range commentList { |
| t := &schema.MCPSearchCommentInfoResp{ |
| CommentID: comment.ID, |
| Content: comment.OriginalText, |
| ObjectID: comment.ObjectID, |
| Link: fmt.Sprintf("%s/comments/%s", siteGeneral.SiteUrl, comment.ID), |
| } |
| resp = append(resp, t) |
| } |
| data, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(data)), nil |
| } |
| } |
| |
| func (c *MCPController) MCPTagsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| if err := c.ensureMCPEnabled(ctx); err != nil { |
| return nil, err |
| } |
| cond := schema.NewMCPSearchTagCond(request) |
| |
| siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| log.Errorf("get site general info failed: %v", err) |
| return nil, err |
| } |
| |
| tags, total, err := c.tagCommonService.GetTagPage(ctx, 1, 10, &entity.Tag{DisplayName: cond.TagName}, "newest") |
| if err != nil { |
| log.Errorf("get tags failed: %v", err) |
| return nil, err |
| } |
| |
| if total == 0 { |
| res := strings.Builder{} |
| res.WriteString("No tags found.\n") |
| return mcp.NewToolResultText(res.String()), nil |
| } |
| |
| resp := make([]*schema.MCPSearchTagResp, 0) |
| for _, tag := range tags { |
| t := &schema.MCPSearchTagResp{ |
| TagName: tag.SlugName, |
| DisplayName: tag.DisplayName, |
| Description: tag.OriginalText, |
| Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName), |
| } |
| resp = append(resp, t) |
| } |
| data, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(data)), nil |
| } |
| } |
| |
| func (c *MCPController) MCPTagDetailsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| if err := c.ensureMCPEnabled(ctx); err != nil { |
| return nil, err |
| } |
| cond := schema.NewMCPSearchTagCond(request) |
| |
| siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| log.Errorf("get site general info failed: %v", err) |
| return nil, err |
| } |
| |
| tag, exist, err := c.tagCommonService.GetTagBySlugName(ctx, cond.TagName) |
| if err != nil { |
| log.Errorf("get tag failed: %v", err) |
| return nil, err |
| } |
| if !exist { |
| return mcp.NewToolResultText("Tag not found."), nil |
| } |
| |
| resp := &schema.MCPSearchTagResp{ |
| TagName: tag.SlugName, |
| DisplayName: tag.DisplayName, |
| Description: tag.OriginalText, |
| Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName), |
| } |
| res, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(res)), nil |
| } |
| } |
| |
| func (c *MCPController) MCPUserDetailsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| if err := c.ensureMCPEnabled(ctx); err != nil { |
| return nil, err |
| } |
| cond := schema.NewMCPSearchUserCond(request) |
| |
| siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| log.Errorf("get site general info failed: %v", err) |
| return nil, err |
| } |
| |
| user, exist, err := c.userCommon.GetUserBasicInfoByUserName(ctx, cond.Username) |
| if err != nil { |
| log.Errorf("get user failed: %v", err) |
| return nil, err |
| } |
| if !exist { |
| return mcp.NewToolResultText("User not found."), nil |
| } |
| |
| resp := &schema.MCPSearchUserInfoResp{ |
| Username: user.Username, |
| DisplayName: user.DisplayName, |
| Avatar: user.Avatar, |
| Link: fmt.Sprintf("%s/users/%s", siteGeneral.SiteUrl, user.Username), |
| } |
| res, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(res)), nil |
| } |
| } |
| |
| func (c *MCPController) MCPSemanticSearchHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| if err := c.ensureMCPEnabled(ctx); err != nil { |
| return nil, err |
| } |
| cond := schema.NewMCPSemanticSearchCond(request) |
| if len(cond.Query) == 0 { |
| return mcp.NewToolResultText("Query is required for semantic search."), nil |
| } |
| |
| siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| log.Errorf("get site general info failed: %v", err) |
| return nil, err |
| } |
| |
| results, err := c.embeddingService.SearchSimilar(ctx, cond.Query, cond.TopK) |
| if err != nil { |
| log.Errorf("semantic search failed: %v", err) |
| return mcp.NewToolResultText("Semantic search is not available. Embedding may not be configured."), nil |
| } |
| if len(results) == 0 { |
| return mcp.NewToolResultText("No semantically similar content found."), nil |
| } |
| |
| resp := make([]*schema.MCPSemanticSearchResp, 0, len(results)) |
| for _, r := range results { |
| var meta plugin.VectorSearchMetadata |
| _ = json.Unmarshal([]byte(r.Metadata), &meta) |
| |
| item := &schema.MCPSemanticSearchResp{ |
| ObjectID: r.ObjectID, |
| ObjectType: r.ObjectType, |
| Score: r.Score, |
| } |
| |
| // Compose link from metadata |
| if r.ObjectType == "answer" && meta.AnswerID != "" { |
| item.Link = fmt.Sprintf("%s/questions/%s/%s", siteGeneral.SiteUrl, meta.QuestionID, meta.AnswerID) |
| } else { |
| item.Link = fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, meta.QuestionID) |
| } |
| |
| // Query content from DB using IDs stored in metadata |
| if r.ObjectType == "question" { |
| question, qErr := c.questioncommon.Info(ctx, meta.QuestionID, "") |
| if qErr != nil { |
| log.Warnf("get question %s for semantic search failed: %v", meta.QuestionID, qErr) |
| } else { |
| item.Title = question.Title |
| item.Content = question.Content |
| } |
| |
| // Fetch answers by ID from metadata |
| for _, a := range meta.Answers { |
| answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, a.AnswerID) |
| if aErr != nil || !exist { |
| continue |
| } |
| answerItem := &schema.MCPSemanticSearchAnswer{ |
| AnswerID: a.AnswerID, |
| Content: answerEntity.OriginalText, |
| } |
| // Fetch comments on this answer from DB |
| for _, ac := range a.Comments { |
| cmt, cExist, cErr := c.commentRepo.GetComment(ctx, ac.CommentID) |
| if cErr == nil && cExist { |
| answerItem.Comments = append(answerItem.Comments, &schema.MCPSemanticSearchComment{ |
| CommentID: ac.CommentID, |
| Content: cmt.OriginalText, |
| }) |
| } |
| } |
| item.Answers = append(item.Answers, answerItem) |
| } |
| |
| // Fetch question comments from DB |
| for _, qc := range meta.Comments { |
| cmt, cExist, cErr := c.commentRepo.GetComment(ctx, qc.CommentID) |
| if cErr == nil && cExist { |
| item.Comments = append(item.Comments, &schema.MCPSemanticSearchComment{ |
| CommentID: qc.CommentID, |
| Content: cmt.OriginalText, |
| }) |
| } |
| } |
| } else if r.ObjectType == "answer" { |
| // Fetch question title for context |
| question, qErr := c.questioncommon.Info(ctx, meta.QuestionID, "") |
| if qErr == nil { |
| item.Title = question.Title |
| } |
| |
| // Fetch answer content from DB |
| if meta.AnswerID != "" { |
| answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, meta.AnswerID) |
| if aErr == nil && exist { |
| item.Content = answerEntity.OriginalText |
| } |
| } else if len(meta.Answers) > 0 { |
| answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, meta.Answers[0].AnswerID) |
| if aErr == nil && exist { |
| item.Content = answerEntity.OriginalText |
| } |
| } |
| |
| // Fetch answer comments from DB |
| if len(meta.Answers) > 0 { |
| for _, ac := range meta.Answers[0].Comments { |
| cmt, cExist, cErr := c.commentRepo.GetComment(ctx, ac.CommentID) |
| if cErr == nil && cExist { |
| item.Comments = append(item.Comments, &schema.MCPSemanticSearchComment{ |
| CommentID: ac.CommentID, |
| Content: cmt.OriginalText, |
| }) |
| } |
| } |
| } |
| } |
| |
| resp = append(resp, item) |
| } |
| |
| data, _ := json.Marshal(resp) |
| return mcp.NewToolResultText(string(data)), nil |
| } |
| } |