mirror of https://github.com/usememos/memos
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.pull/5661/head
parent
71263736b0
commit
47d9414702
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue