From 47d9414702dc18966af385352b960bfe451511b7 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 24 Feb 2026 22:54:51 +0800 Subject: [PATCH] feat: add MCP server with PAT authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embeds a Model Context Protocol (MCP) server into the Memos HTTP process, exposing memo operations as MCP tools at POST/GET /mcp using Streamable HTTP transport. Authentication is PAT-only — requests without a valid personal access token receive HTTP 401. Six tools are exposed: list_memos, get_memo, create_memo, update_memo, delete_memo, and search_memos, all scoped to the authenticated user. --- go.mod | 7 + go.sum | 15 ++ server/router/mcp/README.md | 66 ++++++ server/router/mcp/auth_middleware.go | 35 +++ server/router/mcp/mcp.go | 31 +++ server/router/mcp/tools_memo.go | 318 +++++++++++++++++++++++++++ server/server.go | 5 + 7 files changed, 477 insertions(+) create mode 100644 server/router/mcp/README.md create mode 100644 server/router/mcp/auth_middleware.go create mode 100644 server/router/mcp/mcp.go create mode 100644 server/router/mcp/tools_memo.go diff --git a/go.mod b/go.mod index 1205ad88f..bbfe4603d 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/labstack/echo/v5 v5.0.3 github.com/lib/pq v1.10.9 github.com/lithammer/shortuuid/v4 v4.2.0 + github.com/mark3labs/mcp-go v0.44.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.20.1 @@ -44,6 +45,8 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -62,9 +65,11 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect @@ -90,6 +95,8 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect diff --git a/go.sum b/go.sum index 527ee36bc..0f927b847 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,10 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47 github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -121,6 +125,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -131,6 +137,7 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -147,6 +154,10 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= +github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -233,6 +244,10 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/server/router/mcp/README.md b/server/router/mcp/README.md new file mode 100644 index 000000000..8b436851f --- /dev/null +++ b/server/router/mcp/README.md @@ -0,0 +1,66 @@ +# MCP Server + +This package implements a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server embedded in the Memos HTTP process. It exposes memo operations as MCP tools, making Memos accessible to any MCP-compatible AI client (Claude Desktop, Cursor, Zed, etc.). + +## Endpoint + +``` +POST /mcp (tool calls, initialize) +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). + +## Authentication + +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`. + +## Tools + +All tools are scoped to the authenticated user's memos. + +| Tool | Description | Required params | Optional params | +|---|---|---|---| +| `list_memos` | List memos | — | `page_size` (int, max 100), `filter` (CEL expression) | +| `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` | +| `delete_memo` | Delete a memo | `name` | — | + +**`name`** is the memo resource name, e.g. `memos/abc123`. + +**`visibility`** accepts `PRIVATE` (default), `PROTECTED`, or `PUBLIC`. + +**`filter`** accepts CEL expressions supported by the memo filter engine, e.g.: +- `content.contains("keyword")` +- `visibility == "PUBLIC"` +- `has_task_list` + +## Connecting Claude Code + +```bash +claude mcp add --transport http memos http://localhost:5230/mcp \ + --header "Authorization: Bearer " +``` + +Use `--scope user` to make it available across all projects: + +```bash +claude mcp add --scope user --transport http memos http://localhost:5230/mcp \ + --header "Authorization: Bearer " +``` + +## Package Structure + +| 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 | diff --git a/server/router/mcp/auth_middleware.go b/server/router/mcp/auth_middleware.go new file mode 100644 index 000000000..c0ee94b5f --- /dev/null +++ b/server/router/mcp/auth_middleware.go @@ -0,0 +1,35 @@ +package mcp + +import ( + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v5" + + "github.com/usememos/memos/server/auth" + "github.com/usememos/memos/store" +) + +func newAuthMiddleware(s *store.Store) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + token := auth.ExtractBearerToken(c.Request().Header.Get("Authorization")) + if !strings.HasPrefix(token, auth.PersonalAccessTokenPrefix) { + return c.JSON(http.StatusUnauthorized, map[string]string{"message": "a personal access token is required"}) + } + + result, err := s.GetUserByPATHash(c.Request().Context(), auth.HashPersonalAccessToken(token)) + if err != nil || result == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"message": "invalid or expired personal access token"}) + } + if result.PAT.ExpiresAt != nil && result.PAT.ExpiresAt.AsTime().Before(time.Now()) { + return c.JSON(http.StatusUnauthorized, map[string]string{"message": "invalid or expired personal access token"}) + } + + ctx := auth.SetUserInContext(c.Request().Context(), result.User, result.PAT.GetTokenId()) + c.SetRequest(c.Request().WithContext(ctx)) + return next(c) + } + } +} diff --git a/server/router/mcp/mcp.go b/server/router/mcp/mcp.go new file mode 100644 index 000000000..f8bd114e9 --- /dev/null +++ b/server/router/mcp/mcp.go @@ -0,0 +1,31 @@ +package mcp + +import ( + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/usememos/memos/store" +) + +type MCPService struct { + store *store.Store +} + +func NewMCPService(store *store.Store) *MCPService { + return &MCPService{store: store} +} + +func (s *MCPService) RegisterRoutes(echoServer *echo.Echo) { + mcpSrv := mcpserver.NewMCPServer("Memos", "1.0.0", mcpserver.WithToolCapabilities(false)) + s.registerMemoTools(mcpSrv) + + httpHandler := mcpserver.NewStreamableHTTPServer(mcpSrv) + + mcpGroup := echoServer.Group("") + mcpGroup.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: []string{"*"}, + })) + mcpGroup.Use(newAuthMiddleware(s.store)) + mcpGroup.Any("/mcp", echo.WrapHandler(httpHandler)) +} diff --git a/server/router/mcp/tools_memo.go b/server/router/mcp/tools_memo.go new file mode 100644 index 000000000..556c4f9a1 --- /dev/null +++ b/server/router/mcp/tools_memo.go @@ -0,0 +1,318 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/lithammer/shortuuid/v4" + "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/usememos/memos/server/auth" + "github.com/usememos/memos/store" +) + +func extractUserID(ctx context.Context) (int32, error) { + id := auth.GetUserID(ctx) + if id == 0 { + return 0, errors.New("unauthenticated") + } + return id, nil +} + +func marshalJSON(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func (s *MCPService) registerMemoTools(mcpSrv *mcpserver.MCPServer) { + listTool := mcp.NewTool("list_memos", + mcp.WithDescription("List the authenticated user's memos"), + mcp.WithNumber("page_size", mcp.Description("Max memos to return, default 20")), + mcp.WithString("filter", mcp.Description(`CEL filter expression, e.g. content.contains("keyword")`)), + ) + mcpSrv.AddTool(listTool, s.handleListMemos) + + getTool := mcp.NewTool("get_memo", + mcp.WithDescription("Get a single memo by resource name"), + mcp.WithString("name", mcp.Required(), mcp.Description(`Memo resource name, e.g. "memos/abc123"`)), + ) + mcpSrv.AddTool(getTool, s.handleGetMemo) + + createTool := mcp.NewTool("create_memo", + mcp.WithDescription("Create a new memo"), + mcp.WithString("content", mcp.Required(), mcp.Description("Memo content")), + mcp.WithString("visibility", + mcp.Enum("PRIVATE", "PROTECTED", "PUBLIC"), + mcp.Description("Visibility: PRIVATE (default), PROTECTED, or PUBLIC"), + ), + ) + mcpSrv.AddTool(createTool, s.handleCreateMemo) + + updateTool := mcp.NewTool("update_memo", + mcp.WithDescription("Update a memo's content or visibility"), + mcp.WithString("name", mcp.Required(), mcp.Description(`Memo resource name, e.g. "memos/abc123"`)), + mcp.WithString("content", mcp.Description("New content (omit to leave unchanged)")), + mcp.WithString("visibility", + mcp.Enum("PRIVATE", "PROTECTED", "PUBLIC"), + mcp.Description("New visibility (omit to leave unchanged)"), + ), + ) + mcpSrv.AddTool(updateTool, s.handleUpdateMemo) + + deleteTool := mcp.NewTool("delete_memo", + mcp.WithDescription("Delete a memo"), + mcp.WithString("name", mcp.Required(), mcp.Description(`Memo resource name, e.g. "memos/abc123"`)), + ) + mcpSrv.AddTool(deleteTool, s.handleDeleteMemo) + + searchTool := mcp.NewTool("search_memos", + mcp.WithDescription("Search memo content using a text query"), + mcp.WithString("query", mcp.Required(), mcp.Description("Text to search in memo content")), + ) + mcpSrv.AddTool(searchTool, s.handleSearchMemos) +} + +func (s *MCPService) handleListMemos(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 + } + filterExpr := req.GetString("filter", "") + + rowStatus := store.Normal + limitPlusOne := pageSize + 1 + zero := 0 + find := &store.FindMemo{ + CreatorID: &userID, + ExcludeComments: true, + RowStatus: &rowStatus, + Limit: &limitPlusOne, + Offset: &zero, + } + if filterExpr != "" { + find.Filters = append(find.Filters, filterExpr) + } + + memos, err := s.store.ListMemos(ctx, find) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list memos: %v", err)), nil + } + if len(memos) == limitPlusOne { + memos = memos[:pageSize] + } + + out, err := marshalJSON(memos) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(out), nil +} + +func (s *MCPService) handleGetMemo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + userID, err := extractUserID(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + name := req.GetString("name", "") + if name == "" { + return mcp.NewToolResultError("name is required"), nil + } + uid, found := strings.CutPrefix(name, "memos/") + if !found || uid == "" { + return mcp.NewToolResultError(`name must be in the format "memos/"`), 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 memo.Visibility == store.Private && memo.CreatorID != userID { + return mcp.NewToolResultError("permission denied"), nil + } + + out, err := marshalJSON(memo) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(out), nil +} + +func (s *MCPService) handleCreateMemo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + userID, err := extractUserID(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + content := req.GetString("content", "") + if content == "" { + return mcp.NewToolResultError("content is required"), nil + } + + visibility := req.GetString("visibility", "PRIVATE") + switch visibility { + case "PRIVATE", "PROTECTED", "PUBLIC": + default: + return mcp.NewToolResultError("visibility must be PRIVATE, PROTECTED, or PUBLIC"), nil + } + + create := &store.Memo{ + UID: shortuuid.New(), + CreatorID: userID, + Content: content, + Visibility: store.Visibility(visibility), + } + memo, err := s.store.CreateMemo(ctx, create) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to create memo: %v", err)), nil + } + + out, err := marshalJSON(memo) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(out), nil +} + +func (s *MCPService) handleUpdateMemo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + userID, err := extractUserID(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + name := req.GetString("name", "") + if name == "" { + return mcp.NewToolResultError("name is required"), nil + } + uid, found := strings.CutPrefix(name, "memos/") + if !found || uid == "" { + return mcp.NewToolResultError(`name must be in the format "memos/"`), 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 memo.CreatorID != userID { + return mcp.NewToolResultError("permission denied"), nil + } + + update := &store.UpdateMemo{ID: memo.ID} + if content := req.GetString("content", ""); content != "" { + update.Content = &content + } + if vis := req.GetString("visibility", ""); vis != "" { + switch vis { + case "PRIVATE", "PROTECTED", "PUBLIC": + default: + return mcp.NewToolResultError("visibility must be PRIVATE, PROTECTED, or PUBLIC"), nil + } + v := store.Visibility(vis) + update.Visibility = &v + } + + if err := s.store.UpdateMemo(ctx, update); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to update memo: %v", err)), nil + } + + updated, err := s.store.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to fetch updated memo: %v", err)), nil + } + + out, err := marshalJSON(updated) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(out), nil +} + +func (s *MCPService) handleDeleteMemo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + userID, err := extractUserID(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + name := req.GetString("name", "") + if name == "" { + return mcp.NewToolResultError("name is required"), nil + } + uid, found := strings.CutPrefix(name, "memos/") + if !found || uid == "" { + return mcp.NewToolResultError(`name must be in the format "memos/"`), 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 memo.CreatorID != userID { + return mcp.NewToolResultError("permission denied"), nil + } + + if err := s.store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to delete memo: %v", err)), nil + } + return mcp.NewToolResultText("memo deleted"), nil +} + +func (s *MCPService) handleSearchMemos(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + userID, err := extractUserID(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + query := req.GetString("query", "") + if query == "" { + return mcp.NewToolResultError("query is required"), nil + } + + rowStatus := store.Normal + limit := 50 + zero := 0 + find := &store.FindMemo{ + ExcludeComments: true, + RowStatus: &rowStatus, + Limit: &limit, + Offset: &zero, + Filters: []string{ + fmt.Sprintf("creator_id == %d", userID), + fmt.Sprintf(`content.contains(%q)`, query), + }, + } + + memos, err := s.store.ListMemos(ctx, find) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to search memos: %v", err)), nil + } + + out, err := marshalJSON(memos) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(out), nil +} diff --git a/server/server.go b/server/server.go index af3056764..6d8ca7226 100644 --- a/server/server.go +++ b/server/server.go @@ -19,6 +19,7 @@ import ( apiv1 "github.com/usememos/memos/server/router/api/v1" "github.com/usememos/memos/server/router/fileserver" "github.com/usememos/memos/server/router/frontend" + mcprouter "github.com/usememos/memos/server/router/mcp" "github.com/usememos/memos/server/router/rss" "github.com/usememos/memos/server/runner/s3presign" "github.com/usememos/memos/store" @@ -79,6 +80,10 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store return nil, errors.Wrap(err, "failed to register gRPC gateway") } + // Register MCP server. + mcpService := mcprouter.NewMCPService(s.Store) + mcpService.RegisterRoutes(echoServer) + return s, nil }