mirror of https://github.com/usememos/memos.git
feat: add MCP server with PAT authentication
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.
This commit is contained in:
parent
71263736b0
commit
47d9414702
7
go.mod
7
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
|
||||
|
|
|
|||
15
go.sum
15
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=
|
||||
|
|
|
|||
|
|
@ -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 <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`.
|
||||
|
||||
## 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 <your-PAT>"
|
||||
```
|
||||
|
||||
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 <your-PAT>"
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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/<uid>"`), 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/<uid>"`), 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/<uid>"`), 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue