mirror of https://github.com/usememos/memos.git
feat(mcp): enhance MCP server with full capabilities and new tools (#5720)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d0b0652a7c
commit
b8e9ee2b26
|
|
@ -11,6 +11,17 @@ GET /mcp (optional SSE stream for server-to-client messages)
|
|||
|
||||
Transport: [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports) (single endpoint, MCP spec 2025-03-26).
|
||||
|
||||
## Capabilities
|
||||
|
||||
The server advertises the following MCP capabilities:
|
||||
|
||||
| Capability | Enabled | Details |
|
||||
|---|---|---|
|
||||
| Tools | Yes | List changed notifications supported |
|
||||
| Resources | Yes | Subscribe + list changed supported |
|
||||
| Prompts | Yes | List changed notifications supported |
|
||||
| Logging | Yes | Structured log events |
|
||||
|
||||
## Authentication
|
||||
|
||||
Every request must include a Personal Access Token (PAT):
|
||||
|
|
@ -19,29 +30,73 @@ Every request must include a Personal Access Token (PAT):
|
|||
Authorization: Bearer <your-PAT>
|
||||
```
|
||||
|
||||
PATs are long-lived tokens created in Settings → My Account → Access Tokens. Short-lived JWT session tokens are not accepted. Requests without a valid PAT receive `HTTP 401`.
|
||||
PATs are long-lived tokens created in Settings → My Account → Access Tokens. Short-lived JWT session tokens are also accepted. Requests without a valid token receive `HTTP 401`.
|
||||
|
||||
## Tools
|
||||
|
||||
All tools are scoped to the authenticated user's memos.
|
||||
### Memo Tools
|
||||
|
||||
| Tool | Description | Required params | Optional params |
|
||||
|---|---|---|---|
|
||||
| `list_memos` | List memos | — | `page_size` (int, max 100), `filter` (CEL expression) |
|
||||
| `list_memos` | List memos | — | `page_size`, `page`, `state`, `order_by_pinned`, `filter` (CEL) |
|
||||
| `get_memo` | Get a single memo | `name` | — |
|
||||
| `search_memos` | Full-text search | `query` | — |
|
||||
| `create_memo` | Create a memo | `content` | `visibility` |
|
||||
| `update_memo` | Update content or visibility | `name` | `content`, `visibility` |
|
||||
| `update_memo` | Update a memo | `name` | `content`, `visibility`, `pinned`, `state` |
|
||||
| `delete_memo` | Delete a memo | `name` | — |
|
||||
| `list_memo_comments` | List comments | `name` | — |
|
||||
| `create_memo_comment` | Add a comment | `name`, `content` | — |
|
||||
|
||||
**`name`** is the memo resource name, e.g. `memos/abc123`.
|
||||
### Tag Tools
|
||||
|
||||
**`visibility`** accepts `PRIVATE` (default), `PROTECTED`, or `PUBLIC`.
|
||||
| Tool | Description | Required params |
|
||||
|---|---|---|
|
||||
| `list_tags` | List all tags with counts | — |
|
||||
|
||||
**`filter`** accepts CEL expressions supported by the memo filter engine, e.g.:
|
||||
- `content.contains("keyword")`
|
||||
- `visibility == "PUBLIC"`
|
||||
- `has_task_list`
|
||||
### Attachment Tools
|
||||
|
||||
| Tool | Description | Required params | Optional params |
|
||||
|---|---|---|---|
|
||||
| `list_attachments` | List user's attachments | — | `page_size`, `page`, `memo` |
|
||||
| `get_attachment` | Get attachment metadata | `name` | — |
|
||||
| `delete_attachment` | Delete an attachment | `name` | — |
|
||||
| `link_attachment_to_memo` | Link attachment to memo | `name`, `memo` | — |
|
||||
|
||||
### Relation Tools
|
||||
|
||||
| Tool | Description | Required params | Optional params |
|
||||
|---|---|---|---|
|
||||
| `list_memo_relations` | List relations (refs + comments) | `name` | `type` |
|
||||
| `create_memo_relation` | Create a reference relation | `name`, `related_memo` | — |
|
||||
| `delete_memo_relation` | Delete a reference relation | `name`, `related_memo` | — |
|
||||
|
||||
### Reaction Tools
|
||||
|
||||
| Tool | Description | Required params |
|
||||
|---|---|---|
|
||||
| `list_reactions` | List reactions on a memo | `name` |
|
||||
| `upsert_reaction` | Add a reaction emoji | `name`, `reaction_type` |
|
||||
| `delete_reaction` | Remove a reaction | `id` |
|
||||
|
||||
## Resources
|
||||
|
||||
| URI Template | Description | MIME Type |
|
||||
|---|---|---|
|
||||
| `memo://memos/{uid}` | Memo content with YAML frontmatter | `text/markdown` |
|
||||
|
||||
## Prompts
|
||||
|
||||
| Prompt | Description | Arguments |
|
||||
|---|---|---|
|
||||
| `capture` | Quick-save a thought as a memo | `content` (required), `tags`, `visibility` |
|
||||
| `review` | Search and summarize memos on a topic | `topic` (required) |
|
||||
| `daily_digest` | Summarize recent memo activity | `days` |
|
||||
| `organize` | Suggest tags/relations for unorganized memos | `scope` |
|
||||
|
||||
## Resource Names
|
||||
|
||||
- Memos: `memos/<uid>` (e.g. `memos/abc123`)
|
||||
- Attachments: `attachments/<uid>` (e.g. `attachments/def456`)
|
||||
|
||||
## Connecting Claude Code
|
||||
|
||||
|
|
@ -61,6 +116,11 @@ claude mcp add --scope user --transport http memos http://localhost:5230/mcp \
|
|||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `mcp.go` | `MCPService` struct, constructor, route registration |
|
||||
| `auth_middleware.go` | Echo middleware — validates Bearer token, sets user ID in context |
|
||||
| `tools_memo.go` | Tool registration and six memo tool handlers |
|
||||
| `mcp.go` | `MCPService` struct, constructor, route registration, auth middleware |
|
||||
| `tools_memo.go` | Memo CRUD tools + helpers (JSON types, visibility/access checks) |
|
||||
| `tools_tag.go` | Tag listing tool |
|
||||
| `tools_attachment.go` | Attachment listing, metadata, deletion, linking tools |
|
||||
| `tools_relation.go` | Memo relation (reference) tools |
|
||||
| `tools_reaction.go` | Reaction (emoji) tools |
|
||||
| `resources_memo.go` | Memo resource template handler |
|
||||
| `prompts.go` | Prompt handlers (capture, review, daily_digest, organize) |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
mcpserver "github.com/mark3labs/mcp-go/server"
|
||||
|
|
@ -23,6 +24,9 @@ func (s *MCPService) registerPrompts(mcpSrv *mcpserver.MCPServer) {
|
|||
mcp.WithArgument("tags",
|
||||
mcp.ArgumentDescription("Comma-separated tags to apply, e.g. \"work,project\""),
|
||||
),
|
||||
mcp.WithArgument("visibility",
|
||||
mcp.ArgumentDescription("Memo visibility: PRIVATE (default), PROTECTED, or PUBLIC"),
|
||||
),
|
||||
),
|
||||
s.handleCapturePrompt,
|
||||
)
|
||||
|
|
@ -31,7 +35,8 @@ func (s *MCPService) registerPrompts(mcpSrv *mcpserver.MCPServer) {
|
|||
mcpSrv.AddPrompt(
|
||||
mcp.NewPrompt("review",
|
||||
mcp.WithPromptDescription("Search and review memos on a given topic. "+
|
||||
"The assistant will call search_memos and summarise the results."),
|
||||
"The assistant will call search_memos and summarise the results, "+
|
||||
"including memo resource URIs for easy reference."),
|
||||
mcp.WithArgument("topic",
|
||||
mcp.ArgumentDescription("Topic or keyword to search for"),
|
||||
mcp.RequiredArgument(),
|
||||
|
|
@ -39,6 +44,31 @@ func (s *MCPService) registerPrompts(mcpSrv *mcpserver.MCPServer) {
|
|||
),
|
||||
s.handleReviewPrompt,
|
||||
)
|
||||
|
||||
// daily_digest — summarise recent activity.
|
||||
mcpSrv.AddPrompt(
|
||||
mcp.NewPrompt("daily_digest",
|
||||
mcp.WithPromptDescription("Get a summary of recent memo activity. "+
|
||||
"The assistant will list recent memos, group them by tags, and highlight "+
|
||||
"any incomplete tasks or pinned items."),
|
||||
mcp.WithArgument("days",
|
||||
mcp.ArgumentDescription("Number of days to look back (default: 1)"),
|
||||
),
|
||||
),
|
||||
s.handleDailyDigestPrompt,
|
||||
)
|
||||
|
||||
// organize — suggest tags and relations for untagged memos.
|
||||
mcpSrv.AddPrompt(
|
||||
mcp.NewPrompt("organize",
|
||||
mcp.WithPromptDescription("Analyze untagged or loosely organized memos and suggest "+
|
||||
"tags, relations, and groupings to improve discoverability."),
|
||||
mcp.WithArgument("scope",
|
||||
mcp.ArgumentDescription("Scope of analysis: \"untagged\" (default) for memos without tags, \"all\" for all recent memos"),
|
||||
),
|
||||
),
|
||||
s.handleOrganizePrompt,
|
||||
)
|
||||
}
|
||||
|
||||
func (*MCPService) handleCapturePrompt(_ context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
|
|
@ -48,18 +78,25 @@ func (*MCPService) handleCapturePrompt(_ context.Context, req mcp.GetPromptReque
|
|||
}
|
||||
|
||||
tags := req.Params.Arguments["tags"]
|
||||
instruction := fmt.Sprintf(
|
||||
"Please save the following as a new private memo using the create_memo tool.\n\nContent:\n%s",
|
||||
content,
|
||||
)
|
||||
if tags != "" {
|
||||
instruction += fmt.Sprintf("\n\nAppend these tags inline using #tag syntax: %s", tags)
|
||||
visibility := req.Params.Arguments["visibility"]
|
||||
if visibility == "" {
|
||||
visibility = "PRIVATE"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Save the following as a new memo using the create_memo tool.\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Visibility: %s\n\n", visibility))
|
||||
sb.WriteString("Content:\n")
|
||||
sb.WriteString(content)
|
||||
if tags != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n\nAppend these tags inline using #tag syntax: %s", tags))
|
||||
}
|
||||
sb.WriteString("\n\nAfter creating the memo, confirm by showing the memo resource name (e.g. memo://memos/<uid>) so it can be referenced later.")
|
||||
|
||||
return &mcp.GetPromptResult{
|
||||
Description: "Capture a memo",
|
||||
Messages: []mcp.PromptMessage{
|
||||
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(instruction)),
|
||||
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(sb.String())),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -71,7 +108,13 @@ func (*MCPService) handleReviewPrompt(_ context.Context, req mcp.GetPromptReques
|
|||
}
|
||||
|
||||
instruction := fmt.Sprintf(
|
||||
"Please use the search_memos tool to find memos about %q, then provide a concise summary of what has been written on this topic, grouped by theme. Include the memo names so the user can reference them.",
|
||||
`Use the search_memos tool to find memos about %q, then:
|
||||
|
||||
1. Group results by theme or tag
|
||||
2. For each memo, include its resource reference (memo://memos/<uid>) so the user can access it directly
|
||||
3. Provide a concise summary of what has been written on this topic
|
||||
4. Highlight any memos with incomplete tasks (has_incomplete_tasks)
|
||||
5. Note the most recent update times to show currency of the information`,
|
||||
topic,
|
||||
)
|
||||
|
||||
|
|
@ -82,3 +125,68 @@ func (*MCPService) handleReviewPrompt(_ context.Context, req mcp.GetPromptReques
|
|||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*MCPService) handleDailyDigestPrompt(_ context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
days := req.Params.Arguments["days"]
|
||||
if days == "" {
|
||||
days = "1"
|
||||
}
|
||||
|
||||
instruction := fmt.Sprintf(
|
||||
`Generate a daily digest of memo activity from the last %s day(s):
|
||||
|
||||
1. Use list_memos to fetch recent memos (order by update time, check multiple pages if needed)
|
||||
2. Use list_tags to get the current tag landscape
|
||||
3. Group memos by tags and summarize each group
|
||||
4. Highlight:
|
||||
- Pinned memos (important items)
|
||||
- Memos with incomplete tasks (action items)
|
||||
- New memos created vs. memos updated
|
||||
5. Include memo resource references (memo://memos/<uid>) for each item
|
||||
6. End with a brief "action items" section listing incomplete tasks across all memos`, days,
|
||||
)
|
||||
|
||||
return &mcp.GetPromptResult{
|
||||
Description: "Daily memo digest",
|
||||
Messages: []mcp.PromptMessage{
|
||||
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(instruction)),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*MCPService) handleOrganizePrompt(_ context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
scope := req.Params.Arguments["scope"]
|
||||
if scope == "" {
|
||||
scope = "untagged"
|
||||
}
|
||||
|
||||
var filter string
|
||||
if scope == "untagged" {
|
||||
filter = `Focus on memos that have no tags. Use list_memos and identify those with empty tag arrays.`
|
||||
} else {
|
||||
filter = `Analyze all recent memos regardless of tagging status.`
|
||||
}
|
||||
|
||||
instruction := fmt.Sprintf(
|
||||
`Analyze memos and suggest organizational improvements:
|
||||
|
||||
1. %s
|
||||
2. Use list_tags to understand the existing tag taxonomy
|
||||
3. For each unorganized memo, suggest:
|
||||
- Appropriate tags from the existing taxonomy, or new tags if needed
|
||||
- Potential relations (references) to other memos on similar topics
|
||||
4. Present suggestions as a structured list:
|
||||
- Memo: memo://memos/<uid> (first line of content as preview)
|
||||
- Suggested tags: #tag1, #tag2
|
||||
- Related to: memo://memos/<other-uid> (brief reason)
|
||||
5. After presenting suggestions, ask the user which changes to apply
|
||||
6. Apply approved changes using update_memo (for tags in content) and create_memo_relation (for references)`, filter,
|
||||
)
|
||||
|
||||
return &mcp.GetPromptResult{
|
||||
Description: fmt.Sprintf("Organize memos (scope: %s)", scope),
|
||||
Messages: []mcp.PromptMessage{
|
||||
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(instruction)),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
mcpserver "github.com/mark3labs/mcp-go/server"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/server/auth"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type attachmentJSON struct {
|
||||
Name string `json:"name"`
|
||||
Creator string `json:"creator"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
Filename string `json:"filename"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
StorageType string `json:"storage_type"`
|
||||
ExternalLink string `json:"external_link,omitempty"`
|
||||
Memo string `json:"memo,omitempty"`
|
||||
}
|
||||
|
||||
func storeAttachmentToJSON(a *store.Attachment) attachmentJSON {
|
||||
j := attachmentJSON{
|
||||
Name: "attachments/" + a.UID,
|
||||
Creator: fmt.Sprintf("users/%d", a.CreatorID),
|
||||
CreateTime: a.CreatedTs,
|
||||
Filename: a.Filename,
|
||||
Type: a.Type,
|
||||
Size: a.Size,
|
||||
}
|
||||
switch a.StorageType {
|
||||
case storepb.AttachmentStorageType_LOCAL:
|
||||
j.StorageType = "LOCAL"
|
||||
case storepb.AttachmentStorageType_S3:
|
||||
j.StorageType = "S3"
|
||||
j.ExternalLink = a.Reference
|
||||
case storepb.AttachmentStorageType_EXTERNAL:
|
||||
j.StorageType = "EXTERNAL"
|
||||
j.ExternalLink = a.Reference
|
||||
default:
|
||||
j.StorageType = "DATABASE"
|
||||
}
|
||||
if a.MemoUID != nil && *a.MemoUID != "" {
|
||||
j.Memo = "memos/" + *a.MemoUID
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
func parseAttachmentUID(name string) (string, error) {
|
||||
uid, ok := strings.CutPrefix(name, "attachments/")
|
||||
if !ok || uid == "" {
|
||||
return "", errors.Errorf(`attachment name must be "attachments/<uid>", got %q`, name)
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) registerAttachmentTools(mcpSrv *mcpserver.MCPServer) {
|
||||
mcpSrv.AddTool(mcp.NewTool("list_attachments",
|
||||
mcp.WithDescription("List attachments owned by the authenticated user. Supports pagination and optional filtering by linked memo."),
|
||||
mcp.WithNumber("page_size", mcp.Description("Maximum attachments to return (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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue