From b8e9ee2b26a59e1b19aab6db3ca39656fc18785a Mon Sep 17 00:00:00 2001 From: memoclaw Date: Fri, 13 Mar 2026 18:15:52 +0800 Subject: [PATCH] feat(mcp): enhance MCP server with full capabilities and new tools (#5720) Co-authored-by: Claude Opus 4.6 --- server/router/mcp/README.md | 86 ++++++-- server/router/mcp/mcp.go | 13 +- server/router/mcp/prompts.go | 126 +++++++++++- server/router/mcp/tools_attachment.go | 272 ++++++++++++++++++++++++++ server/router/mcp/tools_reaction.go | 171 ++++++++++++++++ server/router/mcp/tools_relation.go | 211 ++++++++++++++++++++ server/server.go | 2 +- 7 files changed, 856 insertions(+), 25 deletions(-) create mode 100644 server/router/mcp/tools_attachment.go create mode 100644 server/router/mcp/tools_reaction.go create mode 100644 server/router/mcp/tools_relation.go diff --git a/server/router/mcp/README.md b/server/router/mcp/README.md index 8b436851f..86cb16b1e 100644 --- a/server/router/mcp/README.md +++ b/server/router/mcp/README.md @@ -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 ``` -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/` (e.g. `memos/abc123`) +- Attachments: `attachments/` (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) | diff --git a/server/router/mcp/mcp.go b/server/router/mcp/mcp.go index f7fc77218..dc499487c 100644 --- a/server/router/mcp/mcp.go +++ b/server/router/mcp/mcp.go @@ -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) diff --git a/server/router/mcp/prompts.go b/server/router/mcp/prompts.go index 2e05ccc91..085fdc0d5 100644 --- a/server/router/mcp/prompts.go +++ b/server/router/mcp/prompts.go @@ -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/) 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/) 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/) 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/ (first line of content as preview) + - Suggested tags: #tag1, #tag2 + - Related to: memo://memos/ (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 +} diff --git a/server/router/mcp/tools_attachment.go b/server/router/mcp/tools_attachment.go new file mode 100644 index 000000000..e832e5b91 --- /dev/null +++ b/server/router/mcp/tools_attachment.go @@ -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/", 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 (1–100, 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 +} diff --git a/server/router/mcp/tools_reaction.go b/server/router/mcp/tools_reaction.go new file mode 100644 index 000000000..0abafbbca --- /dev/null +++ b/server/router/mcp/tools_reaction.go @@ -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 +} diff --git a/server/router/mcp/tools_relation.go b/server/router/mcp/tools_relation.go new file mode 100644 index 000000000..773f63eb3 --- /dev/null +++ b/server/router/mcp/tools_relation.go @@ -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 +} diff --git a/server/server.go b/server/server.go index 593b73832..dd3247def 100644 --- a/server/server.go +++ b/server/server.go @@ -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