feat(mcp): enhance MCP server with full capabilities and new tools (#5720)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
memoclaw 2026-03-13 18:15:52 +08:00 committed by GitHub
parent d0b0652a7c
commit b8e9ee2b26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 856 additions and 25 deletions

View File

@ -11,6 +11,17 @@ GET /mcp (optional SSE stream for server-to-client messages)
Transport: [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports) (single endpoint, MCP spec 2025-03-26).
## Capabilities
The server advertises the following MCP capabilities:
| Capability | Enabled | Details |
|---|---|---|
| Tools | Yes | List changed notifications supported |
| Resources | Yes | Subscribe + list changed supported |
| Prompts | Yes | List changed notifications supported |
| Logging | Yes | Structured log events |
## Authentication
Every request must include a Personal Access Token (PAT):
@ -19,29 +30,73 @@ Every request must include a Personal Access Token (PAT):
Authorization: Bearer <your-PAT>
```
PATs are long-lived tokens created in Settings → My Account → Access Tokens. Short-lived JWT session tokens are not accepted. Requests without a valid PAT receive `HTTP 401`.
PATs are long-lived tokens created in Settings → My Account → Access Tokens. Short-lived JWT session tokens are also accepted. Requests without a valid token receive `HTTP 401`.
## Tools
All tools are scoped to the authenticated user's memos.
### Memo Tools
| Tool | Description | Required params | Optional params |
|---|---|---|---|
| `list_memos` | List memos | — | `page_size` (int, max 100), `filter` (CEL expression) |
| `list_memos` | List memos | — | `page_size`, `page`, `state`, `order_by_pinned`, `filter` (CEL) |
| `get_memo` | Get a single memo | `name` | — |
| `search_memos` | Full-text search | `query` | — |
| `create_memo` | Create a memo | `content` | `visibility` |
| `update_memo` | Update content or visibility | `name` | `content`, `visibility` |
| `update_memo` | Update a memo | `name` | `content`, `visibility`, `pinned`, `state` |
| `delete_memo` | Delete a memo | `name` | — |
| `list_memo_comments` | List comments | `name` | — |
| `create_memo_comment` | Add a comment | `name`, `content` | — |
**`name`** is the memo resource name, e.g. `memos/abc123`.
### Tag Tools
**`visibility`** accepts `PRIVATE` (default), `PROTECTED`, or `PUBLIC`.
| Tool | Description | Required params |
|---|---|---|
| `list_tags` | List all tags with counts | — |
**`filter`** accepts CEL expressions supported by the memo filter engine, e.g.:
- `content.contains("keyword")`
- `visibility == "PUBLIC"`
- `has_task_list`
### Attachment Tools
| Tool | Description | Required params | Optional params |
|---|---|---|---|
| `list_attachments` | List user's attachments | — | `page_size`, `page`, `memo` |
| `get_attachment` | Get attachment metadata | `name` | — |
| `delete_attachment` | Delete an attachment | `name` | — |
| `link_attachment_to_memo` | Link attachment to memo | `name`, `memo` | — |
### Relation Tools
| Tool | Description | Required params | Optional params |
|---|---|---|---|
| `list_memo_relations` | List relations (refs + comments) | `name` | `type` |
| `create_memo_relation` | Create a reference relation | `name`, `related_memo` | — |
| `delete_memo_relation` | Delete a reference relation | `name`, `related_memo` | — |
### Reaction Tools
| Tool | Description | Required params |
|---|---|---|
| `list_reactions` | List reactions on a memo | `name` |
| `upsert_reaction` | Add a reaction emoji | `name`, `reaction_type` |
| `delete_reaction` | Remove a reaction | `id` |
## Resources
| URI Template | Description | MIME Type |
|---|---|---|
| `memo://memos/{uid}` | Memo content with YAML frontmatter | `text/markdown` |
## Prompts
| Prompt | Description | Arguments |
|---|---|---|
| `capture` | Quick-save a thought as a memo | `content` (required), `tags`, `visibility` |
| `review` | Search and summarize memos on a topic | `topic` (required) |
| `daily_digest` | Summarize recent memo activity | `days` |
| `organize` | Suggest tags/relations for unorganized memos | `scope` |
## Resource Names
- Memos: `memos/<uid>` (e.g. `memos/abc123`)
- Attachments: `attachments/<uid>` (e.g. `attachments/def456`)
## Connecting Claude Code
@ -61,6 +116,11 @@ claude mcp add --scope user --transport http memos http://localhost:5230/mcp \
| File | Responsibility |
|---|---|
| `mcp.go` | `MCPService` struct, constructor, route registration |
| `auth_middleware.go` | Echo middleware — validates Bearer token, sets user ID in context |
| `tools_memo.go` | Tool registration and six memo tool handlers |
| `mcp.go` | `MCPService` struct, constructor, route registration, auth middleware |
| `tools_memo.go` | Memo CRUD tools + helpers (JSON types, visibility/access checks) |
| `tools_tag.go` | Tag listing tool |
| `tools_attachment.go` | Attachment listing, metadata, deletion, linking tools |
| `tools_relation.go` | Memo relation (reference) tools |
| `tools_reaction.go` | Reaction (emoji) tools |
| `resources_memo.go` | Memo resource template handler |
| `prompts.go` | Prompt handlers (capture, review, daily_digest, organize) |

View File

@ -7,17 +7,20 @@ import (
"github.com/labstack/echo/v5/middleware"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/server/auth"
"github.com/usememos/memos/store"
)
type MCPService struct {
profile *profile.Profile
store *store.Store
authenticator *auth.Authenticator
}
func NewMCPService(store *store.Store, secret string) *MCPService {
func NewMCPService(profile *profile.Profile, store *store.Store, secret string) *MCPService {
return &MCPService{
profile: profile,
store: store,
authenticator: auth.NewAuthenticator(store, secret),
}
@ -25,10 +28,16 @@ func NewMCPService(store *store.Store, secret string) *MCPService {
func (s *MCPService) RegisterRoutes(echoServer *echo.Echo) {
mcpSrv := mcpserver.NewMCPServer("Memos", "1.0.0",
mcpserver.WithToolCapabilities(false),
mcpserver.WithToolCapabilities(true),
mcpserver.WithResourceCapabilities(true, true),
mcpserver.WithPromptCapabilities(true),
mcpserver.WithLogging(),
)
s.registerMemoTools(mcpSrv)
s.registerTagTools(mcpSrv)
s.registerAttachmentTools(mcpSrv)
s.registerRelationTools(mcpSrv)
s.registerReactionTools(mcpSrv)
s.registerMemoResources(mcpSrv)
s.registerPrompts(mcpSrv)

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
@ -23,6 +24,9 @@ func (s *MCPService) registerPrompts(mcpSrv *mcpserver.MCPServer) {
mcp.WithArgument("tags",
mcp.ArgumentDescription("Comma-separated tags to apply, e.g. \"work,project\""),
),
mcp.WithArgument("visibility",
mcp.ArgumentDescription("Memo visibility: PRIVATE (default), PROTECTED, or PUBLIC"),
),
),
s.handleCapturePrompt,
)
@ -31,7 +35,8 @@ func (s *MCPService) registerPrompts(mcpSrv *mcpserver.MCPServer) {
mcpSrv.AddPrompt(
mcp.NewPrompt("review",
mcp.WithPromptDescription("Search and review memos on a given topic. "+
"The assistant will call search_memos and summarise the results."),
"The assistant will call search_memos and summarise the results, "+
"including memo resource URIs for easy reference."),
mcp.WithArgument("topic",
mcp.ArgumentDescription("Topic or keyword to search for"),
mcp.RequiredArgument(),
@ -39,6 +44,31 @@ func (s *MCPService) registerPrompts(mcpSrv *mcpserver.MCPServer) {
),
s.handleReviewPrompt,
)
// daily_digest — summarise recent activity.
mcpSrv.AddPrompt(
mcp.NewPrompt("daily_digest",
mcp.WithPromptDescription("Get a summary of recent memo activity. "+
"The assistant will list recent memos, group them by tags, and highlight "+
"any incomplete tasks or pinned items."),
mcp.WithArgument("days",
mcp.ArgumentDescription("Number of days to look back (default: 1)"),
),
),
s.handleDailyDigestPrompt,
)
// organize — suggest tags and relations for untagged memos.
mcpSrv.AddPrompt(
mcp.NewPrompt("organize",
mcp.WithPromptDescription("Analyze untagged or loosely organized memos and suggest "+
"tags, relations, and groupings to improve discoverability."),
mcp.WithArgument("scope",
mcp.ArgumentDescription("Scope of analysis: \"untagged\" (default) for memos without tags, \"all\" for all recent memos"),
),
),
s.handleOrganizePrompt,
)
}
func (*MCPService) handleCapturePrompt(_ context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
@ -48,18 +78,25 @@ func (*MCPService) handleCapturePrompt(_ context.Context, req mcp.GetPromptReque
}
tags := req.Params.Arguments["tags"]
instruction := fmt.Sprintf(
"Please save the following as a new private memo using the create_memo tool.\n\nContent:\n%s",
content,
)
if tags != "" {
instruction += fmt.Sprintf("\n\nAppend these tags inline using #tag syntax: %s", tags)
visibility := req.Params.Arguments["visibility"]
if visibility == "" {
visibility = "PRIVATE"
}
var sb strings.Builder
sb.WriteString("Save the following as a new memo using the create_memo tool.\n\n")
sb.WriteString(fmt.Sprintf("Visibility: %s\n\n", visibility))
sb.WriteString("Content:\n")
sb.WriteString(content)
if tags != "" {
sb.WriteString(fmt.Sprintf("\n\nAppend these tags inline using #tag syntax: %s", tags))
}
sb.WriteString("\n\nAfter creating the memo, confirm by showing the memo resource name (e.g. memo://memos/<uid>) so it can be referenced later.")
return &mcp.GetPromptResult{
Description: "Capture a memo",
Messages: []mcp.PromptMessage{
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(instruction)),
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(sb.String())),
},
}, nil
}
@ -71,7 +108,13 @@ func (*MCPService) handleReviewPrompt(_ context.Context, req mcp.GetPromptReques
}
instruction := fmt.Sprintf(
"Please use the search_memos tool to find memos about %q, then provide a concise summary of what has been written on this topic, grouped by theme. Include the memo names so the user can reference them.",
`Use the search_memos tool to find memos about %q, then:
1. Group results by theme or tag
2. For each memo, include its resource reference (memo://memos/<uid>) so the user can access it directly
3. Provide a concise summary of what has been written on this topic
4. Highlight any memos with incomplete tasks (has_incomplete_tasks)
5. Note the most recent update times to show currency of the information`,
topic,
)
@ -82,3 +125,68 @@ func (*MCPService) handleReviewPrompt(_ context.Context, req mcp.GetPromptReques
},
}, nil
}
func (*MCPService) handleDailyDigestPrompt(_ context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
days := req.Params.Arguments["days"]
if days == "" {
days = "1"
}
instruction := fmt.Sprintf(
`Generate a daily digest of memo activity from the last %s day(s):
1. Use list_memos to fetch recent memos (order by update time, check multiple pages if needed)
2. Use list_tags to get the current tag landscape
3. Group memos by tags and summarize each group
4. Highlight:
- Pinned memos (important items)
- Memos with incomplete tasks (action items)
- New memos created vs. memos updated
5. Include memo resource references (memo://memos/<uid>) for each item
6. End with a brief "action items" section listing incomplete tasks across all memos`, days,
)
return &mcp.GetPromptResult{
Description: "Daily memo digest",
Messages: []mcp.PromptMessage{
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(instruction)),
},
}, nil
}
func (*MCPService) handleOrganizePrompt(_ context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
scope := req.Params.Arguments["scope"]
if scope == "" {
scope = "untagged"
}
var filter string
if scope == "untagged" {
filter = `Focus on memos that have no tags. Use list_memos and identify those with empty tag arrays.`
} else {
filter = `Analyze all recent memos regardless of tagging status.`
}
instruction := fmt.Sprintf(
`Analyze memos and suggest organizational improvements:
1. %s
2. Use list_tags to understand the existing tag taxonomy
3. For each unorganized memo, suggest:
- Appropriate tags from the existing taxonomy, or new tags if needed
- Potential relations (references) to other memos on similar topics
4. Present suggestions as a structured list:
- Memo: memo://memos/<uid> (first line of content as preview)
- Suggested tags: #tag1, #tag2
- Related to: memo://memos/<other-uid> (brief reason)
5. After presenting suggestions, ask the user which changes to apply
6. Apply approved changes using update_memo (for tags in content) and create_memo_relation (for references)`, filter,
)
return &mcp.GetPromptResult{
Description: fmt.Sprintf("Organize memos (scope: %s)", scope),
Messages: []mcp.PromptMessage{
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(instruction)),
},
}, nil
}

View File

@ -0,0 +1,272 @@
package mcp
import (
"context"
"fmt"
"strings"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/pkg/errors"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/auth"
"github.com/usememos/memos/store"
)
type attachmentJSON struct {
Name string `json:"name"`
Creator string `json:"creator"`
CreateTime int64 `json:"create_time"`
Filename string `json:"filename"`
Type string `json:"type"`
Size int64 `json:"size"`
StorageType string `json:"storage_type"`
ExternalLink string `json:"external_link,omitempty"`
Memo string `json:"memo,omitempty"`
}
func storeAttachmentToJSON(a *store.Attachment) attachmentJSON {
j := attachmentJSON{
Name: "attachments/" + a.UID,
Creator: fmt.Sprintf("users/%d", a.CreatorID),
CreateTime: a.CreatedTs,
Filename: a.Filename,
Type: a.Type,
Size: a.Size,
}
switch a.StorageType {
case storepb.AttachmentStorageType_LOCAL:
j.StorageType = "LOCAL"
case storepb.AttachmentStorageType_S3:
j.StorageType = "S3"
j.ExternalLink = a.Reference
case storepb.AttachmentStorageType_EXTERNAL:
j.StorageType = "EXTERNAL"
j.ExternalLink = a.Reference
default:
j.StorageType = "DATABASE"
}
if a.MemoUID != nil && *a.MemoUID != "" {
j.Memo = "memos/" + *a.MemoUID
}
return j
}
func parseAttachmentUID(name string) (string, error) {
uid, ok := strings.CutPrefix(name, "attachments/")
if !ok || uid == "" {
return "", errors.Errorf(`attachment name must be "attachments/<uid>", got %q`, name)
}
return uid, nil
}
func (s *MCPService) registerAttachmentTools(mcpSrv *mcpserver.MCPServer) {
mcpSrv.AddTool(mcp.NewTool("list_attachments",
mcp.WithDescription("List attachments owned by the authenticated user. Supports pagination and optional filtering by linked memo."),
mcp.WithNumber("page_size", mcp.Description("Maximum attachments to return (1100, default 20)")),
mcp.WithNumber("page", mcp.Description("Zero-based page index (default 0)")),
mcp.WithString("memo", mcp.Description(`Filter by linked memo resource name, e.g. "memos/abc123"`)),
), s.handleListAttachments)
mcpSrv.AddTool(mcp.NewTool("get_attachment",
mcp.WithDescription("Get a single attachment's metadata by resource name. Requires authentication."),
mcp.WithString("name", mcp.Required(), mcp.Description(`Attachment resource name, e.g. "attachments/abc123"`)),
), s.handleGetAttachment)
mcpSrv.AddTool(mcp.NewTool("delete_attachment",
mcp.WithDescription("Permanently delete an attachment and its stored file. Requires authentication and ownership."),
mcp.WithString("name", mcp.Required(), mcp.Description(`Attachment resource name, e.g. "attachments/abc123"`)),
), s.handleDeleteAttachment)
mcpSrv.AddTool(mcp.NewTool("link_attachment_to_memo",
mcp.WithDescription("Link an existing attachment to a memo. Requires authentication and ownership of the attachment."),
mcp.WithString("name", mcp.Required(), mcp.Description(`Attachment resource name, e.g. "attachments/abc123"`)),
mcp.WithString("memo", mcp.Required(), mcp.Description(`Memo resource name, e.g. "memos/abc123"`)),
), s.handleLinkAttachmentToMemo)
}
func (s *MCPService) handleListAttachments(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
userID, err := extractUserID(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pageSize := req.GetInt("page_size", 20)
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
page := req.GetInt("page", 0)
if page < 0 {
page = 0
}
limit := pageSize + 1
offset := page * pageSize
find := &store.FindAttachment{
CreatorID: &userID,
Limit: &limit,
Offset: &offset,
}
if memoName := req.GetString("memo", ""); memoName != "" {
memoUID, err := parseMemoUID(memoName)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
memo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to find memo: %v", err)), nil
}
if memo == nil {
return mcp.NewToolResultError("memo not found"), nil
}
find.MemoID = &memo.ID
}
attachments, err := s.store.ListAttachments(ctx, find)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to list attachments: %v", err)), nil
}
hasMore := len(attachments) > pageSize
if hasMore {
attachments = attachments[:pageSize]
}
results := make([]attachmentJSON, len(attachments))
for i, a := range attachments {
results[i] = storeAttachmentToJSON(a)
}
type listResponse struct {
Attachments []attachmentJSON `json:"attachments"`
HasMore bool `json:"has_more"`
}
out, err := marshalJSON(listResponse{Attachments: results, HasMore: hasMore})
if err != nil {
return nil, err
}
return mcp.NewToolResultText(out), nil
}
func (s *MCPService) handleGetAttachment(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
userID := auth.GetUserID(ctx)
uid, err := parseAttachmentUID(req.GetString("name", ""))
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
attachment, err := s.store.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get attachment: %v", err)), nil
}
if attachment == nil {
return mcp.NewToolResultError("attachment not found"), nil
}
// Check access: creator can always access; linked memo visibility applies otherwise.
if attachment.CreatorID != userID {
if attachment.MemoID != nil {
memo, err := s.store.GetMemo(ctx, &store.FindMemo{ID: attachment.MemoID})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get linked memo: %v", err)), nil
}
if memo != nil {
if err := checkMemoAccess(memo, userID); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
}
} else {
return mcp.NewToolResultError("permission denied"), nil
}
}
out, err := marshalJSON(storeAttachmentToJSON(attachment))
if err != nil {
return nil, err
}
return mcp.NewToolResultText(out), nil
}
func (s *MCPService) handleDeleteAttachment(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
userID, err := extractUserID(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
uid, err := parseAttachmentUID(req.GetString("name", ""))
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
attachment, err := s.store.GetAttachment(ctx, &store.FindAttachment{UID: &uid, CreatorID: &userID})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to find attachment: %v", err)), nil
}
if attachment == nil {
return mcp.NewToolResultError("attachment not found"), nil
}
if err := s.store.DeleteAttachment(ctx, &store.DeleteAttachment{ID: attachment.ID}); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to delete attachment: %v", err)), nil
}
return mcp.NewToolResultText(`{"deleted":true}`), nil
}
func (s *MCPService) handleLinkAttachmentToMemo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
userID, err := extractUserID(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
uid, err := parseAttachmentUID(req.GetString("name", ""))
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
attachment, err := s.store.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get attachment: %v", err)), nil
}
if attachment == nil {
return mcp.NewToolResultError("attachment not found"), nil
}
if attachment.CreatorID != userID {
return mcp.NewToolResultError("permission denied"), nil
}
memoUID, err := parseMemoUID(req.GetString("memo", ""))
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
memo, err := s.store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
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 := s.store.UpdateAttachment(ctx, &store.UpdateAttachment{
ID: attachment.ID,
MemoID: &memo.ID,
}); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to link attachment: %v", err)), nil
}
// Re-fetch to get updated memo UID.
updated, err := s.store.GetAttachment(ctx, &store.FindAttachment{ID: &attachment.ID})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to fetch updated attachment: %v", err)), nil
}
out, err := marshalJSON(storeAttachmentToJSON(updated))
if err != nil {
return nil, err
}
return mcp.NewToolResultText(out), nil
}

View File

@ -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
}

View File

@ -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
}

View File

@ -82,7 +82,7 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
}
// Register MCP server.
mcpService := mcprouter.NewMCPService(s.Store, s.Secret)
mcpService := mcprouter.NewMCPService(s.Profile, s.Store, s.Secret)
mcpService.RegisterRoutes(echoServer)
return s, nil