Files
wehub-resource-sync 39dbe3a57d
CI / Check and lint (push) Failing after 1s
Lint / Lint (ubuntu-latest) (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:22:32 +08:00

491 lines
16 KiB
Go

/*
* 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
}
}