4cddfcf2f3
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
118 lines
3.5 KiB
Go
118 lines
3.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/pkg/errors"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/usememos/memos/internal/profile"
|
|
memosproto "github.com/usememos/memos/proto"
|
|
)
|
|
|
|
// MCPService serves the OpenAPI-driven MCP endpoint.
|
|
type MCPService struct {
|
|
profile *profile.Profile
|
|
|
|
operationsByTool map[string]*registeredOperation
|
|
handler http.Handler
|
|
}
|
|
|
|
// NewMCPService creates an MCP service backed by the in-process API routes.
|
|
func NewMCPService(profile *profile.Profile, echoServer *echo.Echo) (*MCPService, error) {
|
|
spec, err := loadMCPServiceOpenAPISpec()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
registry, err := buildOperationRegistry(spec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tools, operationsByTool, err := buildCuratedTools(registry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
version := "dev"
|
|
if profile != nil && profile.Version != "" {
|
|
version = profile.Version
|
|
}
|
|
server := sdkmcp.NewServer(&sdkmcp.Implementation{
|
|
Name: "memos",
|
|
Version: version,
|
|
}, nil)
|
|
|
|
adapter := newAPIAdapter(echoServer)
|
|
for _, tool := range tools {
|
|
operation := operationsByTool[tool.Name]
|
|
server.AddTool(tool, newMCPToolHandler(adapter, operation))
|
|
}
|
|
|
|
handler := sdkmcp.NewStreamableHTTPHandler(func(*http.Request) *sdkmcp.Server {
|
|
return server
|
|
}, &sdkmcp.StreamableHTTPOptions{
|
|
Stateless: true,
|
|
JSONResponse: true,
|
|
// memos is typically served behind a reverse proxy with the app bound to a
|
|
// loopback address while the public Host header is a real domain. The SDK's
|
|
// DNS-rebinding guard treats that shape as an attack and rejects every
|
|
// request with 403 ("invalid Host header"). Disable it and rely on memos'
|
|
// own Origin/Host allowlist (see RegisterRoutes -> isAllowedMCPOrigin) for
|
|
// CSRF / DNS-rebinding protection instead.
|
|
DisableLocalhostProtection: true,
|
|
})
|
|
|
|
return &MCPService{
|
|
profile: profile,
|
|
operationsByTool: operationsByTool,
|
|
handler: handler,
|
|
}, nil
|
|
}
|
|
|
|
func loadMCPServiceOpenAPISpec() (*openAPISpec, error) {
|
|
spec := &openAPISpec{}
|
|
if err := yaml.Unmarshal(memosproto.OpenAPIYAML(), spec); err != nil {
|
|
return nil, errors.Wrap(err, "failed to parse embedded OpenAPI spec")
|
|
}
|
|
if spec.Paths == nil {
|
|
return nil, errors.New("embedded OpenAPI spec has no paths")
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
func newMCPToolHandler(adapter *apiAdapter, operation *registeredOperation) sdkmcp.ToolHandler {
|
|
return func(ctx context.Context, request *sdkmcp.CallToolRequest) (*sdkmcp.CallToolResult, error) {
|
|
arguments := map[string]any{}
|
|
if request.Params != nil && len(request.Params.Arguments) > 0 {
|
|
if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil {
|
|
return newToolErrorResult(errors.Wrap(err, "failed to decode MCP tool arguments").Error()), nil
|
|
}
|
|
}
|
|
if err := validateToolArguments(operation.InputSchema, arguments); err != nil {
|
|
return newToolErrorResult(err.Error()), nil
|
|
}
|
|
|
|
authorization := ""
|
|
if request.Extra != nil {
|
|
authorization = request.Extra.Header.Get("Authorization")
|
|
}
|
|
return adapter.execute(ctx, operation.Operation, arguments, authorization)
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers the streamable HTTP MCP endpoint.
|
|
func (s *MCPService) RegisterRoutes(echoServer *echo.Echo) {
|
|
echoServer.Any("/mcp", func(c *echo.Context) error {
|
|
request := c.Request()
|
|
if !isAllowedMCPOrigin(request.Host, request.Header.Get("Origin"), s.profile) {
|
|
return c.NoContent(http.StatusForbidden)
|
|
}
|
|
s.handler.ServeHTTP(c.Response(), request)
|
|
return nil
|
|
})
|
|
}
|