mirror of https://github.com/usememos/memos
feat(mcp): enhance MCP server with full capabilities and new tools (#5720)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>pull/5724/head^2
parent
d0b0652a7c
commit
b8e9ee2b26
@ -0,0 +1,171 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
mcpserver "github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/usememos/memos/server/auth"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type reactionJSON struct {
|
||||
ID int32 `json:"id"`
|
||||
Creator string `json:"creator"`
|
||||
ReactionType string `json:"reaction_type"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
}
|
||||
|
||||
func (s *MCPService) registerReactionTools(mcpSrv *mcpserver.MCPServer) {
|
||||
mcpSrv.AddTool(mcp.NewTool("list_reactions",
|
||||
mcp.WithDescription("List all reactions on a memo. Returns reaction type and creator for each reaction."),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description(`Memo resource name, e.g. "memos/abc123"`)),
|
||||
), s.handleListReactions)
|
||||
|
||||
mcpSrv.AddTool(mcp.NewTool("upsert_reaction",
|
||||
mcp.WithDescription("Add a reaction (emoji) to a memo. If the same reaction already exists from the same user, this is a no-op. Requires authentication."),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description(`Memo resource name, e.g. "memos/abc123"`)),
|
||||
mcp.WithString("reaction_type", mcp.Required(), mcp.Description(`Reaction emoji, e.g. "👍", "❤️", "🎉"`)),
|
||||
), s.handleUpsertReaction)
|
||||
|
||||
mcpSrv.AddTool(mcp.NewTool("delete_reaction",
|
||||
mcp.WithDescription("Remove a reaction by its ID. Requires authentication and ownership of the reaction."),
|
||||
mcp.WithNumber("id", mcp.Required(), mcp.Description("Reaction ID to delete")),
|
||||
), s.handleDeleteReaction)
|
||||
}
|
||||
|
||||
func (s *MCPService) handleListReactions(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
userID := auth.GetUserID(ctx)
|
||||
|
||||
uid, err := parseMemoUID(req.GetString("name", ""))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
memo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &uid})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get memo: %v", err)), nil
|
||||
}
|
||||
if memo == nil {
|
||||
return mcp.NewToolResultError("memo not found"), nil
|
||||
}
|
||||
if err := checkMemoAccess(memo, userID); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
contentID := "memos/" + uid
|
||||
reactions, err := s.store.ListReactions(ctx, &store.FindReaction{ContentID: &contentID})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to list reactions: %v", err)), nil
|
||||
}
|
||||
|
||||
results := make([]reactionJSON, len(reactions))
|
||||
for i, r := range reactions {
|
||||
results[i] = reactionJSON{
|
||||
ID: r.ID,
|
||||
Creator: fmt.Sprintf("users/%d", r.CreatorID),
|
||||
ReactionType: r.ReactionType,
|
||||
CreateTime: r.CreatedTs,
|
||||
}
|
||||
}
|
||||
|
||||
out, err := marshalJSON(results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(out), nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleUpsertReaction(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
userID, err := extractUserID(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
uid, err := parseMemoUID(req.GetString("name", ""))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
reactionType := req.GetString("reaction_type", "")
|
||||
if reactionType == "" {
|
||||
return mcp.NewToolResultError("reaction_type is required"), nil
|
||||
}
|
||||
|
||||
memo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &uid})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get memo: %v", err)), nil
|
||||
}
|
||||
if memo == nil {
|
||||
return mcp.NewToolResultError("memo not found"), nil
|
||||
}
|
||||
if err := checkMemoAccess(memo, userID); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
// Validate reaction type against allowed reactions.
|
||||
memoRelatedSetting, err := s.store.GetInstanceMemoRelatedSetting(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get reaction settings: %v", err)), nil
|
||||
}
|
||||
allowed := false
|
||||
for _, r := range memoRelatedSetting.Reactions {
|
||||
if r == reactionType {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("reaction %q is not in the allowed reaction list", reactionType)), nil
|
||||
}
|
||||
|
||||
contentID := "memos/" + uid
|
||||
reaction, err := s.store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: userID,
|
||||
ContentID: contentID,
|
||||
ReactionType: reactionType,
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to upsert reaction: %v", err)), nil
|
||||
}
|
||||
|
||||
out, err := marshalJSON(reactionJSON{
|
||||
ID: reaction.ID,
|
||||
Creator: fmt.Sprintf("users/%d", reaction.CreatorID),
|
||||
ReactionType: reaction.ReactionType,
|
||||
CreateTime: reaction.CreatedTs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(out), nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleDeleteReaction(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
userID, err := extractUserID(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
reactionID := int32(req.GetInt("id", 0))
|
||||
if reactionID == 0 {
|
||||
return mcp.NewToolResultError("id is required"), nil
|
||||
}
|
||||
|
||||
reaction, err := s.store.GetReaction(ctx, &store.FindReaction{ID: &reactionID})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get reaction: %v", err)), nil
|
||||
}
|
||||
if reaction == nil {
|
||||
return mcp.NewToolResultError("reaction not found"), nil
|
||||
}
|
||||
if reaction.CreatorID != userID {
|
||||
return mcp.NewToolResultError("permission denied: can only delete your own reactions"), nil
|
||||
}
|
||||
|
||||
if err := s.store.DeleteReaction(ctx, &store.DeleteReaction{ID: reactionID}); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to delete reaction: %v", err)), nil
|
||||
}
|
||||
return mcp.NewToolResultText(`{"deleted":true}`), nil
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
mcpserver "github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type relationJSON struct {
|
||||
Memo string `json:"memo"`
|
||||
RelatedMemo string `json:"related_memo"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (s *MCPService) registerRelationTools(mcpSrv *mcpserver.MCPServer) {
|
||||
mcpSrv.AddTool(mcp.NewTool("list_memo_relations",
|
||||
mcp.WithDescription("List all relations (references and comments) for a memo. Requires read access to the memo."),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description(`Memo resource name, e.g. "memos/abc123"`)),
|
||||
mcp.WithString("type",
|
||||
mcp.Enum("REFERENCE", "COMMENT"),
|
||||
mcp.Description("Filter by relation type (optional)"),
|
||||
),
|
||||
), s.handleListMemoRelations)
|
||||
|
||||
mcpSrv.AddTool(mcp.NewTool("create_memo_relation",
|
||||
mcp.WithDescription("Create a reference relation between two memos. Requires authentication. For comments, use create_memo_comment instead."),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description(`Source memo resource name, e.g. "memos/abc123"`)),
|
||||
mcp.WithString("related_memo", mcp.Required(), mcp.Description(`Target memo resource name, e.g. "memos/def456"`)),
|
||||
), s.handleCreateMemoRelation)
|
||||
|
||||
mcpSrv.AddTool(mcp.NewTool("delete_memo_relation",
|
||||
mcp.WithDescription("Delete a reference relation between two memos. Requires authentication and ownership of the source memo."),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description(`Source memo resource name, e.g. "memos/abc123"`)),
|
||||
mcp.WithString("related_memo", mcp.Required(), mcp.Description(`Target memo resource name, e.g. "memos/def456"`)),
|
||||
), s.handleDeleteMemoRelation)
|
||||
}
|
||||
|
||||
func (s *MCPService) handleListMemoRelations(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
uid, err := parseMemoUID(req.GetString("name", ""))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
memo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &uid})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get memo: %v", err)), nil
|
||||
}
|
||||
if memo == nil {
|
||||
return mcp.NewToolResultError("memo not found"), nil
|
||||
}
|
||||
|
||||
find := &store.FindMemoRelation{
|
||||
MemoIDList: []int32{memo.ID},
|
||||
}
|
||||
if typeStr := req.GetString("type", ""); typeStr != "" {
|
||||
switch store.MemoRelationType(typeStr) {
|
||||
case store.MemoRelationReference, store.MemoRelationComment:
|
||||
t := store.MemoRelationType(typeStr)
|
||||
find.Type = &t
|
||||
default:
|
||||
return mcp.NewToolResultError(fmt.Sprintf("type must be REFERENCE or COMMENT, got %q", typeStr)), nil
|
||||
}
|
||||
}
|
||||
|
||||
relations, err := s.store.ListMemoRelations(ctx, find)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to list relations: %v", err)), nil
|
||||
}
|
||||
|
||||
// Resolve memo IDs to UIDs.
|
||||
idSet := make(map[int32]struct{})
|
||||
for _, r := range relations {
|
||||
idSet[r.MemoID] = struct{}{}
|
||||
idSet[r.RelatedMemoID] = struct{}{}
|
||||
}
|
||||
ids := make([]int32, 0, len(idSet))
|
||||
for id := range idSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
memos, err := s.store.ListMemos(ctx, &store.FindMemo{IDList: ids, ExcludeContent: true})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memos: %v", err)), nil
|
||||
}
|
||||
uidByID := make(map[int32]string, len(memos))
|
||||
for _, m := range memos {
|
||||
uidByID[m.ID] = m.UID
|
||||
}
|
||||
|
||||
results := make([]relationJSON, 0, len(relations))
|
||||
for _, r := range relations {
|
||||
memoUID, ok1 := uidByID[r.MemoID]
|
||||
relatedUID, ok2 := uidByID[r.RelatedMemoID]
|
||||
if !ok1 || !ok2 {
|
||||
continue
|
||||
}
|
||||
results = append(results, relationJSON{
|
||||
Memo: "memos/" + memoUID,
|
||||
RelatedMemo: "memos/" + relatedUID,
|
||||
Type: string(r.Type),
|
||||
})
|
||||
}
|
||||
|
||||
out, err := marshalJSON(results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(out), nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleCreateMemoRelation(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
userID, err := extractUserID(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
srcUID, err := parseMemoUID(req.GetString("name", ""))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
dstUID, err := parseMemoUID(req.GetString("related_memo", ""))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
srcMemo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &srcUID})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get source memo: %v", err)), nil
|
||||
}
|
||||
if srcMemo == nil {
|
||||
return mcp.NewToolResultError("source memo not found"), nil
|
||||
}
|
||||
if srcMemo.CreatorID != userID {
|
||||
return mcp.NewToolResultError("permission denied: must own the source memo"), nil
|
||||
}
|
||||
|
||||
dstMemo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &dstUID})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get related memo: %v", err)), nil
|
||||
}
|
||||
if dstMemo == nil {
|
||||
return mcp.NewToolResultError("related memo not found"), nil
|
||||
}
|
||||
|
||||
relation, err := s.store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
||||
MemoID: srcMemo.ID,
|
||||
RelatedMemoID: dstMemo.ID,
|
||||
Type: store.MemoRelationReference,
|
||||
})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to create relation: %v", err)), nil
|
||||
}
|
||||
|
||||
out, err := marshalJSON(relationJSON{
|
||||
Memo: "memos/" + srcUID,
|
||||
RelatedMemo: "memos/" + dstUID,
|
||||
Type: string(relation.Type),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mcp.NewToolResultText(out), nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleDeleteMemoRelation(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
userID, err := extractUserID(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
srcUID, err := parseMemoUID(req.GetString("name", ""))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
dstUID, err := parseMemoUID(req.GetString("related_memo", ""))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
srcMemo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &srcUID})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get source memo: %v", err)), nil
|
||||
}
|
||||
if srcMemo == nil {
|
||||
return mcp.NewToolResultError("source memo not found"), nil
|
||||
}
|
||||
if srcMemo.CreatorID != userID {
|
||||
return mcp.NewToolResultError("permission denied: must own the source memo"), nil
|
||||
}
|
||||
|
||||
dstMemo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &dstUID})
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get related memo: %v", err)), nil
|
||||
}
|
||||
if dstMemo == nil {
|
||||
return mcp.NewToolResultError("related memo not found"), nil
|
||||
}
|
||||
|
||||
refType := store.MemoRelationReference
|
||||
if err := s.store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
|
||||
MemoID: &srcMemo.ID,
|
||||
RelatedMemoID: &dstMemo.ID,
|
||||
Type: &refType,
|
||||
}); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to delete relation: %v", err)), nil
|
||||
}
|
||||
return mcp.NewToolResultText(`{"deleted":true}`), nil
|
||||
}
|
||||
Loading…
Reference in New Issue