44 KiB
OpenAPI-Driven MCP Support Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a new standard MCP Streamable HTTP endpoint whose tools are derived from the generated proto/gen/openapi.yaml and execute through the existing Memos API contract.
Architecture: A new server/router/mcp package parses generated OpenAPI into an operation registry, selects a curated memo-focused operation allowlist, converts those operations into MCP tools using the official github.com/modelcontextprotocol/go-sdk, and dispatches tool calls to the existing Echo API handler in-process. The package owns tool schema generation, argument-to-HTTP mapping, result normalization, origin checks, and MCP route registration; API authorization remains enforced by the existing API middleware.
Tech Stack: Go 1.26.2 · Echo v5 · gRPC-Gateway JSON API · official MCP Go SDK github.com/modelcontextprotocol/go-sdk · gopkg.in/yaml.v3 for generated OpenAPI parsing · testify/require for tests.
File Structure
Create:
server/router/mcp/openapi.go— OpenAPI YAML loading, schema representation,$refresolution, curated operation lookup.server/router/mcp/catalog.go— curated operation IDs, tool naming, method-derived annotations, OpenAPI operation to MCP tool conversion.server/router/mcp/adapter.go— MCP arguments to HTTP path/query/body mapping, in-process API request execution, response decoding.server/router/mcp/result.go— object-shaped structured result normalization and tool error helpers.server/router/mcp/origin.go— same-origin and configured instance URL origin checks for/mcp.server/router/mcp/service.go—MCPService, official SDK server setup, tool registration, route registration.server/router/mcp/openapi_test.go— OpenAPI parser and curated operation tests.server/router/mcp/catalog_test.go— tool name, schema, annotation, and exclusion tests.server/router/mcp/adapter_test.go— path/query/body/result mapping tests against an Echo test server.server/router/mcp/service_test.go— MCP initialize/tools/list/tools/call protocol tests.
Modify:
go.mod— addgithub.com/modelcontextprotocol/go-sdkin Task 3 whenserver/router/mcp/catalog_test.gofirst imports it.go.sum— updated bygo get/go mod tidyin Task 3.server/server.go— importserver/router/mcpand register the new MCP service after API routes are registered.
Task 0: Commit this plan
Files:
-
Add:
docs/superpowers/plans/2026-06-08-openapi-mcp-support.md -
Step 1: Commit the plan document on its own
git add docs/superpowers/plans/2026-06-08-openapi-mcp-support.md
git commit -m "docs(mcp): plan OpenAPI-driven MCP support"
Expected: a docs-only commit that keeps the implementation plan separate from feature commits.
Task 1: Removed standalone MCP SDK dependency task
Files:
-
None
-
Step 1: Skip standalone dependency addition
Do not add github.com/modelcontextprotocol/go-sdk as a standalone dependency. go mod tidy -go=1.26.2 removes unused library dependencies when no Go source imports them yet, and this SDK must not be kept as a Go tool dependency. Add the SDK in Task 3 when catalog tests and implementation first import github.com/modelcontextprotocol/go-sdk/mcp.
go mod tidy -go=1.26.2
Expected: command succeeds. github.com/modelcontextprotocol/go-sdk is not present in go.mod or go.sum; generated or unrelated files do not change.
- Step 2: Verify existing server packages
go test ./server/...
Expected: PASS for existing server packages before MCP files are added.
- Step 3: Commit plan correction if needed
git add docs/superpowers/plans/2026-06-08-openapi-mcp-support.md go.mod go.sum
git commit -m "docs(mcp): fold SDK dependency into implementation task"
Task 2: Build the OpenAPI operation registry
Files:
-
Create:
server/router/mcp/openapi.go -
Test:
server/router/mcp/openapi_test.go -
Step 1: Write failing tests for loading curated operations
Create server/router/mcp/openapi_test.go:
package mcp
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestLoadOpenAPIOperationsIncludesCuratedIDs(t *testing.T) {
spec, err := loadOpenAPISpec("../../../proto/gen/openapi.yaml")
require.NoError(t, err)
registry, err := buildOperationRegistry(spec)
require.NoError(t, err)
curatedIDs := []string{
"MemoService_ListMemos",
"MemoService_CreateMemo",
"MemoService_GetMemo",
"MemoService_UpdateMemo",
"MemoService_DeleteMemo",
"MemoService_ListMemoComments",
"MemoService_CreateMemoComment",
"MemoService_ListMemoAttachments",
"MemoService_SetMemoAttachments",
"MemoService_ListMemoReactions",
"MemoService_UpsertMemoReaction",
"MemoService_DeleteMemoReaction",
"MemoService_ListMemoRelations",
"MemoService_SetMemoRelations",
"AttachmentService_ListAttachments",
"AttachmentService_GetAttachment",
"AttachmentService_DeleteAttachment",
}
for _, operationID := range curatedIDs {
operation, ok := registry[operationID]
require.True(t, ok, "missing curated operation %s", operationID)
require.NotEmpty(t, operation.Method, operationID)
require.NotEmpty(t, operation.Path, operationID)
require.NotEmpty(t, operation.Description, operationID)
require.NotNil(t, operation.ResponseSchema, operationID)
}
createMemo := registry["MemoService_CreateMemo"]
require.NotNil(t, createMemo.RequestBodySchema)
require.Equal(t, "object", createMemo.RequestBodySchema["type"])
}
func TestBuildOperationRegistryRejectsDuplicateOperationIDs(t *testing.T) {
spec := &openAPISpec{
Paths: map[string]map[string]*openAPIOperation{
"/a": {
"get": {OperationID: "MemoService_GetMemo", Description: "first"},
},
"/b": {
"get": {OperationID: "MemoService_GetMemo", Description: "second"},
},
},
}
_, err := buildOperationRegistry(spec)
require.ErrorContains(t, err, "duplicate OpenAPI operationId")
}
func TestResolveSchemaRef(t *testing.T) {
spec := &openAPISpec{
Components: openAPIComponents{
Schemas: map[string]jsonSchema{
"ListMemosResponse": {
"type": "object",
"properties": map[string]any{
"memos": map[string]any{"type": "array"},
},
},
},
},
}
schema, err := resolveSchemaRef(spec, jsonSchema{"$ref": "#/components/schemas/ListMemosResponse"})
require.NoError(t, err)
require.Equal(t, "object", schema["type"])
require.Contains(t, schema["properties"], "memos")
}
- Step 2: Run tests and verify they fail
go test ./server/router/mcp/... -run 'TestLoadOpenAPIOperationsIncludesCuratedIDs|TestBuildOperationRegistryRejectsDuplicateOperationIDs|TestResolveSchemaRef'
Expected: FAIL because server/router/mcp files and symbols do not exist.
- Step 3: Implement
openapi.go
Create server/router/mcp/openapi.go:
package mcp
import (
"os"
"strings"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
type jsonSchema map[string]any
type openAPISpec struct {
OpenAPI string `yaml:"openapi"`
Paths map[string]map[string]*openAPIOperation `yaml:"paths"`
Components openAPIComponents `yaml:"components"`
}
type openAPIComponents struct {
Schemas map[string]jsonSchema `yaml:"schemas"`
}
type openAPIOperation struct {
OperationID string `yaml:"operationId"`
Description string `yaml:"description"`
Parameters []openAPIParameter `yaml:"parameters"`
RequestBody *openAPIRequestBody `yaml:"requestBody"`
Responses map[string]openAPIResponse `yaml:"responses"`
Method string `yaml:"-"`
Path string `yaml:"-"`
ResponseSchema jsonSchema `yaml:"-"`
RequestBodySchema jsonSchema `yaml:"-"`
}
type openAPIParameter struct {
Name string `yaml:"name"`
In string `yaml:"in"`
Description string `yaml:"description"`
Required bool `yaml:"required"`
Schema jsonSchema `yaml:"schema"`
}
type openAPIRequestBody struct {
Required bool `yaml:"required"`
Content map[string]openAPIMediaType `yaml:"content"`
}
type openAPIResponse struct {
Description string `yaml:"description"`
Content map[string]openAPIMediaType `yaml:"content"`
}
type openAPIMediaType struct {
Schema jsonSchema `yaml:"schema"`
}
func loadOpenAPISpec(path string) (*openAPISpec, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, errors.Wrap(err, "failed to read OpenAPI spec")
}
spec := &openAPISpec{}
if err := yaml.Unmarshal(data, spec); err != nil {
return nil, errors.Wrap(err, "failed to parse OpenAPI spec")
}
if spec.Paths == nil {
return nil, errors.New("OpenAPI spec has no paths")
}
return spec, nil
}
func buildOperationRegistry(spec *openAPISpec) (map[string]*openAPIOperation, error) {
registry := map[string]*openAPIOperation{}
for path, pathItem := range spec.Paths {
for method, operation := range pathItem {
if operation == nil || operation.OperationID == "" {
continue
}
if _, exists := registry[operation.OperationID]; exists {
return nil, errors.Errorf("duplicate OpenAPI operationId %q", operation.OperationID)
}
operation.Method = strings.ToUpper(method)
operation.Path = path
responseSchema, err := operationSuccessResponseSchema(spec, operation)
if err != nil {
return nil, errors.Wrapf(err, "failed to resolve response schema for %s", operation.OperationID)
}
operation.ResponseSchema = responseSchema
requestBodySchema, err := operationRequestBodySchema(spec, operation)
if err != nil {
return nil, errors.Wrapf(err, "failed to resolve request body schema for %s", operation.OperationID)
}
operation.RequestBodySchema = requestBodySchema
registry[operation.OperationID] = operation
}
}
return registry, nil
}
func operationSuccessResponseSchema(spec *openAPISpec, operation *openAPIOperation) (jsonSchema, error) {
response, ok := operation.Responses["200"]
if !ok || response.Content == nil {
return okSchema(), nil
}
mediaType, ok := response.Content["application/json"]
if !ok || mediaType.Schema == nil {
return okSchema(), nil
}
return resolveSchemaRef(spec, mediaType.Schema)
}
func operationRequestBodySchema(spec *openAPISpec, operation *openAPIOperation) (jsonSchema, error) {
if operation.RequestBody == nil {
return nil, nil
}
mediaType, ok := operation.RequestBody.Content["application/json"]
if !ok || mediaType.Schema == nil {
return jsonSchema{"type": "object"}, nil
}
return resolveSchemaRef(spec, mediaType.Schema)
}
func resolveSchemaRef(spec *openAPISpec, schema jsonSchema) (jsonSchema, error) {
ref, ok := schema["$ref"].(string)
if !ok || ref == "" {
return schema, nil
}
const prefix = "#/components/schemas/"
if !strings.HasPrefix(ref, prefix) {
return nil, errors.Errorf("unsupported schema ref %q", ref)
}
name := strings.TrimPrefix(ref, prefix)
resolved, ok := spec.Components.Schemas[name]
if !ok {
return nil, errors.Errorf("schema ref %q not found", ref)
}
return resolved, nil
}
func okSchema() jsonSchema {
return jsonSchema{
"type": "object",
"properties": map[string]any{
"ok": map[string]any{"type": "boolean"},
},
}
}
- Step 4: Run tests and verify parser tests pass
go test ./server/router/mcp/... -run 'TestLoadOpenAPIOperationsIncludesCuratedIDs|TestBuildOperationRegistryRejectsDuplicateOperationIDs|TestResolveSchemaRef'
Expected: PASS.
- Step 5: Commit
git add server/router/mcp/openapi.go server/router/mcp/openapi_test.go
git commit -m "feat(mcp): parse generated OpenAPI operations"
Task 3: Convert curated OpenAPI operations into MCP tool definitions
Files:
-
Modify:
go.mod -
Modify:
go.sum -
Create:
server/router/mcp/catalog.go -
Test:
server/router/mcp/catalog_test.go -
Step 1: Write failing catalog tests
Create server/router/mcp/catalog_test.go:
package mcp
import (
"encoding/json"
"testing"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"
)
func TestCuratedOperationIDsStayMemoFocused(t *testing.T) {
for _, operationID := range curatedOperationIDs {
require.NotContains(t, operationID, "AuthService_")
require.NotContains(t, operationID, "IdentityProviderService_")
require.NotContains(t, operationID, "InstanceService_")
require.NotContains(t, operationID, "PersonalAccessToken")
require.NotContains(t, operationID, "Webhook")
require.NotContains(t, operationID, "Share")
require.NotContains(t, operationID, "BatchDelete")
require.NotContains(t, operationID, "Transcribe")
}
}
func TestToolNameFromOperationID(t *testing.T) {
require.Equal(t, "memo_list_memos", toolNameFromOperationID("MemoService_ListMemos"))
require.Equal(t, "attachment_get_attachment", toolNameFromOperationID("AttachmentService_GetAttachment"))
}
func TestBuildToolFromOperationIncludesSchemasAndMetadata(t *testing.T) {
spec, err := loadOpenAPISpec("../../../proto/gen/openapi.yaml")
require.NoError(t, err)
registry, err := buildOperationRegistry(spec)
require.NoError(t, err)
tool, operation := buildToolFromOperation(registry["MemoService_ListMemos"])
require.Equal(t, "memo_list_memos", tool.Name)
require.Equal(t, "MemoService_ListMemos", operation.OperationID)
require.NotEmpty(t, tool.Description)
require.NotNil(t, tool.InputSchema)
require.NotNil(t, tool.OutputSchema)
require.NotNil(t, tool.Annotations)
require.True(t, tool.Annotations.ReadOnlyHint)
inputBytes, err := json.Marshal(tool.InputSchema)
require.NoError(t, err)
require.Contains(t, string(inputBytes), `"pageSize"`)
outputBytes, err := json.Marshal(tool.OutputSchema)
require.NoError(t, err)
require.Contains(t, string(outputBytes), `"memos"`)
}
func TestBuildCuratedToolsHasUniqueNames(t *testing.T) {
spec, err := loadOpenAPISpec("../../../proto/gen/openapi.yaml")
require.NoError(t, err)
registry, err := buildOperationRegistry(spec)
require.NoError(t, err)
tools, operations, err := buildCuratedTools(registry)
require.NoError(t, err)
require.Len(t, tools, len(curatedOperationIDs))
require.Len(t, operations, len(curatedOperationIDs))
names := map[string]struct{}{}
for _, tool := range tools {
require.IsType(t, &sdkmcp.Tool{}, tool)
require.NotEmpty(t, tool.Name)
require.NotContains(t, names, tool.Name)
names[tool.Name] = struct{}{}
}
}
- Step 2: Add the official Go MCP SDK once tests import it
go get github.com/modelcontextprotocol/go-sdk@v1.6.1
Expected: go.mod gains github.com/modelcontextprotocol/go-sdk v1.6.1 and go.sum gains its checksums. This SDK is the official Go SDK and exposes mcp.NewStreamableHTTPHandler, mcp.Server.AddTool, raw JSON schemas, tool annotations, and object-shaped CallToolResult.StructuredContent. Do not add it with go get -tool.
- Step 3: Run tests and verify they fail
go test ./server/router/mcp/... -run 'TestCuratedOperationIDsStayMemoFocused|TestToolNameFromOperationID|TestBuildToolFromOperationIncludesSchemasAndMetadata|TestBuildCuratedToolsHasUniqueNames'
Expected: FAIL because catalog.go symbols do not exist.
- Step 4: Implement
catalog.go
Create server/router/mcp/catalog.go:
package mcp
import (
"regexp"
"strings"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/pkg/errors"
)
var curatedOperationIDs = []string{
"MemoService_ListMemos",
"MemoService_CreateMemo",
"MemoService_GetMemo",
"MemoService_UpdateMemo",
"MemoService_DeleteMemo",
"MemoService_ListMemoComments",
"MemoService_CreateMemoComment",
"MemoService_ListMemoAttachments",
"MemoService_SetMemoAttachments",
"MemoService_ListMemoReactions",
"MemoService_UpsertMemoReaction",
"MemoService_DeleteMemoReaction",
"MemoService_ListMemoRelations",
"MemoService_SetMemoRelations",
"AttachmentService_ListAttachments",
"AttachmentService_GetAttachment",
"AttachmentService_DeleteAttachment",
}
type registeredOperation struct {
ToolName string
Operation *openAPIOperation
}
var wordBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
func buildCuratedTools(registry map[string]*openAPIOperation) ([]*sdkmcp.Tool, map[string]*registeredOperation, error) {
tools := make([]*sdkmcp.Tool, 0, len(curatedOperationIDs))
operations := map[string]*registeredOperation{}
for _, operationID := range curatedOperationIDs {
operation, ok := registry[operationID]
if !ok {
return nil, nil, errors.Errorf("curated OpenAPI operation %q not found", operationID)
}
tool, registered := buildToolFromOperation(operation)
if _, exists := operations[tool.Name]; exists {
return nil, nil, errors.Errorf("duplicate MCP tool name %q", tool.Name)
}
tools = append(tools, tool)
operations[tool.Name] = registered
}
return tools, operations, nil
}
func buildToolFromOperation(operation *openAPIOperation) (*sdkmcp.Tool, *registeredOperation) {
name := toolNameFromOperationID(operation.OperationID)
tool := &sdkmcp.Tool{
Name: name,
Title: titleFromToolName(name),
Description: operation.Description,
InputSchema: inputSchemaForOperation(operation),
OutputSchema: operation.ResponseSchema,
Annotations: annotationsForMethod(operation.Method, name),
}
return tool, ®isteredOperation{ToolName: name, Operation: operation}
}
func toolNameFromOperationID(operationID string) string {
service, method, ok := strings.Cut(operationID, "_")
if !ok {
return strings.ToLower(operationID)
}
service = strings.TrimSuffix(service, "Service")
return camelToSnake(service) + "_" + camelToSnake(method)
}
func camelToSnake(value string) string {
return strings.ToLower(wordBoundary.ReplaceAllString(value, `${1}_${2}`))
}
func titleFromToolName(name string) string {
parts := strings.Split(name, "_")
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
return strings.Join(parts, " ")
}
func inputSchemaForOperation(operation *openAPIOperation) jsonSchema {
properties := map[string]any{}
required := []string{}
for _, parameter := range operation.Parameters {
schema := cloneSchema(parameter.Schema)
if parameter.Description != "" {
schema["description"] = parameter.Description
}
properties[parameter.Name] = schema
if parameter.Required {
required = append(required, parameter.Name)
}
}
if operation.RequestBody != nil {
bodySchema := requestBodySchema(operation)
properties["body"] = bodySchema
if operation.RequestBody.Required {
required = append(required, "body")
}
}
schema := jsonSchema{
"type": "object",
"properties": properties,
"additionalProperties": false,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
func requestBodySchema(operation *openAPIOperation) jsonSchema {
if operation.RequestBodySchema == nil {
return jsonSchema{"type": "object"}
}
return cloneSchema(operation.RequestBodySchema)
}
func cloneSchema(schema jsonSchema) jsonSchema {
clone := jsonSchema{}
for key, value := range schema {
clone[key] = value
}
return clone
}
func annotationsForMethod(method string, name string) *sdkmcp.ToolAnnotations {
openWorld := false
destructive := false
switch strings.ToUpper(method) {
case "GET":
return &sdkmcp.ToolAnnotations{
Title: titleFromToolName(name),
ReadOnlyHint: true,
DestructiveHint: &destructive,
IdempotentHint: true,
OpenWorldHint: &openWorld,
}
case "DELETE":
destructive = true
return &sdkmcp.ToolAnnotations{
Title: titleFromToolName(name),
ReadOnlyHint: false,
DestructiveHint: &destructive,
IdempotentHint: true,
OpenWorldHint: &openWorld,
}
default:
return &sdkmcp.ToolAnnotations{
Title: titleFromToolName(name),
ReadOnlyHint: false,
DestructiveHint: &destructive,
IdempotentHint: false,
OpenWorldHint: &openWorld,
}
}
}
- Step 5: Tidy with the repository Go version
go mod tidy -go=1.26.2
Expected: command succeeds. github.com/modelcontextprotocol/go-sdk remains in go.mod because catalog_test.go and catalog.go import github.com/modelcontextprotocol/go-sdk/mcp.
- Step 6: Run catalog tests
go test ./server/router/mcp/... -run 'TestCuratedOperationIDsStayMemoFocused|TestToolNameFromOperationID|TestBuildToolFromOperationIncludesSchemasAndMetadata|TestBuildCuratedToolsHasUniqueNames'
Expected: PASS.
- Step 7: Commit
git add go.mod go.sum server/router/mcp/catalog.go server/router/mcp/catalog_test.go
git commit -m "feat(mcp): derive tools from curated OpenAPI operations"
Task 4: Implement result normalization and tool error helpers
Files:
-
Create:
server/router/mcp/result.go -
Test:
server/router/mcp/adapter_test.go -
Step 1: Write failing result tests
Create server/router/mcp/adapter_test.go with the result-focused tests first:
package mcp
import (
"encoding/json"
"testing"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"
)
func TestNormalizeStructuredContentKeepsObjects(t *testing.T) {
result := normalizeStructuredContent(map[string]any{"memos": []any{map[string]any{"name": "memos/a"}}})
require.Equal(t, map[string]any{"memos": []any{map[string]any{"name": "memos/a"}}}, result)
}
func TestNormalizeStructuredContentWrapsArrays(t *testing.T) {
result := normalizeStructuredContent([]any{map[string]any{"tag": "work"}})
require.Equal(t, map[string]any{"result": []any{map[string]any{"tag": "work"}}}, result)
}
func TestNormalizeStructuredContentUsesOKForNil(t *testing.T) {
result := normalizeStructuredContent(nil)
require.Equal(t, map[string]any{"ok": true}, result)
}
func TestNewStructuredToolResultUsesObjectStructuredContent(t *testing.T) {
result, err := newStructuredToolResult([]any{"one"})
require.NoError(t, err)
require.IsType(t, map[string]any{}, result.StructuredContent)
require.NotEmpty(t, result.Content)
text, ok := result.Content[0].(*sdkmcp.TextContent)
require.True(t, ok)
require.JSONEq(t, `{"result":["one"]}`, text.Text)
}
func TestNewToolErrorResult(t *testing.T) {
result := newToolErrorResult("resource not found")
require.True(t, result.IsError)
require.NotEmpty(t, result.Content)
text, ok := result.Content[0].(*sdkmcp.TextContent)
require.True(t, ok)
require.Equal(t, "resource not found", text.Text)
}
func TestDecodeJSONValue(t *testing.T) {
value, err := decodeJSONValue([]byte(`{"ok":true}`))
require.NoError(t, err)
require.Equal(t, map[string]any{"ok": true}, value)
value, err = decodeJSONValue([]byte{})
require.NoError(t, err)
require.Nil(t, value)
_, err = decodeJSONValue([]byte(`{`))
require.Error(t, err)
require.True(t, errorsIsJSONSyntax(err), "wrapped syntax errors should remain inspectable")
}
func errorsIsJSONSyntax(err error) bool {
var syntaxError *json.SyntaxError
return err != nil && errors.As(err, &syntaxError)
}
Add these imports to the test file when the compiler asks for them:
import "errors"
- Step 2: Run tests and verify they fail
go test ./server/router/mcp/... -run 'TestNormalizeStructuredContent|TestNewStructuredToolResult|TestNewToolErrorResult|TestDecodeJSONValue'
Expected: FAIL because result helpers do not exist.
- Step 3: Implement
result.go
Create server/router/mcp/result.go:
package mcp
import (
"bytes"
"encoding/json"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/pkg/errors"
)
func normalizeStructuredContent(value any) map[string]any {
switch typed := value.(type) {
case nil:
return map[string]any{"ok": true}
case map[string]any:
return typed
case []any:
return map[string]any{"result": typed}
default:
return map[string]any{"result": typed}
}
}
func newStructuredToolResult(value any) (*sdkmcp.CallToolResult, error) {
structured := normalizeStructuredContent(value)
text, err := json.Marshal(structured)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal MCP structured result")
}
return &sdkmcp.CallToolResult{
Content: []sdkmcp.Content{
&sdkmcp.TextContent{Text: string(text)},
},
StructuredContent: structured,
}, nil
}
func newToolErrorResult(message string) *sdkmcp.CallToolResult {
return &sdkmcp.CallToolResult{
Content: []sdkmcp.Content{
&sdkmcp.TextContent{Text: message},
},
IsError: true,
}
}
func decodeJSONValue(data []byte) (any, error) {
if len(bytes.TrimSpace(data)) == 0 {
return nil, nil
}
var value any
if err := json.Unmarshal(data, &value); err != nil {
return nil, errors.Wrap(err, "failed to decode API JSON response")
}
return value, nil
}
- Step 4: Run result tests
go test ./server/router/mcp/... -run 'TestNormalizeStructuredContent|TestNewStructuredToolResult|TestNewToolErrorResult|TestDecodeJSONValue'
Expected: PASS.
- Step 5: Commit
git add server/router/mcp/result.go server/router/mcp/adapter_test.go
git commit -m "feat(mcp): normalize structured tool results"
Task 5: Implement the OpenAPI HTTP adapter
Files:
-
Create:
server/router/mcp/adapter.go -
Modify:
server/router/mcp/adapter_test.go -
Step 1: Add failing adapter tests
Append to server/router/mcp/adapter_test.go:
func TestBuildAPIRequestMapsPathQueryAndBody(t *testing.T) {
operation := &openAPIOperation{
Method: "PATCH",
Path: "/api/v1/memos/{memo}",
Parameters: []openAPIParameter{
{Name: "memo", In: "path", Required: true, Schema: jsonSchema{"type": "string"}},
{Name: "updateMask", In: "query", Schema: jsonSchema{"type": "string"}},
},
RequestBody: &openAPIRequestBody{Required: true},
}
arguments := map[string]any{
"memo": "abc123",
"updateMask": "content",
"body": map[string]any{
"memo": map[string]any{
"name": "memos/abc123",
"content": "updated",
},
},
}
req, err := buildAPIRequest(context.Background(), operation, arguments, "Bearer pat")
require.NoError(t, err)
require.Equal(t, "PATCH", req.Method)
require.Equal(t, "/api/v1/memos/abc123", req.URL.Path)
require.Equal(t, "content", req.URL.Query().Get("updateMask"))
require.Equal(t, "Bearer pat", req.Header.Get("Authorization"))
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.JSONEq(t, `{"memo":{"name":"memos/abc123","content":"updated"}}`, string(body))
}
func TestBuildAPIRequestRequiresPathParameters(t *testing.T) {
operation := &openAPIOperation{
Method: "GET",
Path: "/api/v1/memos/{memo}",
Parameters: []openAPIParameter{{Name: "memo", In: "path", Required: true}},
}
_, err := buildAPIRequest(context.Background(), operation, map[string]any{}, "")
require.ErrorContains(t, err, `missing required path parameter "memo"`)
}
func TestExecuteOperationReturnsObjectStructuredContent(t *testing.T) {
echoServer := echo.New()
echoServer.GET("/api/v1/memos", func(c *echo.Context) error {
require.Equal(t, "Bearer token", c.Request().Header.Get("Authorization"))
return c.JSON(http.StatusOK, map[string]any{
"memos": []any{map[string]any{"name": "memos/abc123"}},
})
})
operation := &openAPIOperation{
Method: "GET",
Path: "/api/v1/memos",
}
adapter := newAPIAdapter(echoServer)
result, err := adapter.execute(context.Background(), operation, map[string]any{}, "Bearer token")
require.NoError(t, err)
require.False(t, result.IsError)
require.Equal(t, map[string]any{
"memos": []any{map[string]any{"name": "memos/abc123"}},
}, result.StructuredContent)
}
func TestExecuteOperationConvertsAPIErrorsToToolErrors(t *testing.T) {
echoServer := echo.New()
echoServer.GET("/api/v1/memos/:memo", func(c *echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]any{"message": "missing memo"})
})
operation := &openAPIOperation{
Method: "GET",
Path: "/api/v1/memos/{memo}",
Parameters: []openAPIParameter{{Name: "memo", In: "path", Required: true}},
}
adapter := newAPIAdapter(echoServer)
result, err := adapter.execute(context.Background(), operation, map[string]any{"memo": "missing"}, "")
require.NoError(t, err)
require.True(t, result.IsError)
text := result.Content[0].(*sdkmcp.TextContent)
require.Contains(t, text.Text, "404")
require.Contains(t, text.Text, "missing memo")
}
Add these imports to server/router/mcp/adapter_test.go:
import (
"context"
"io"
"net/http"
"github.com/labstack/echo/v5"
)
- Step 2: Run tests and verify they fail
go test ./server/router/mcp/... -run 'TestBuildAPIRequest|TestExecuteOperation'
Expected: FAIL because adapter.go symbols do not exist.
- Step 3: Implement
adapter.go
Create server/router/mcp/adapter.go:
package mcp
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"github.com/labstack/echo/v5"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/pkg/errors"
)
type apiAdapter struct {
echoServer *echo.Echo
}
func newAPIAdapter(echoServer *echo.Echo) *apiAdapter {
return &apiAdapter{echoServer: echoServer}
}
func (a *apiAdapter) execute(ctx context.Context, operation *openAPIOperation, arguments map[string]any, authorization string) (*sdkmcp.CallToolResult, error) {
req, err := buildAPIRequest(ctx, operation, arguments, authorization)
if err != nil {
return newToolErrorResult(err.Error()), nil
}
recorder := httptest.NewRecorder()
a.echoServer.ServeHTTP(recorder, req)
value, err := decodeJSONValue(recorder.Body.Bytes())
if err != nil {
return newToolErrorResult(err.Error()), nil
}
if recorder.Code < http.StatusOK || recorder.Code >= http.StatusMultipleChoices {
return newToolErrorResult(apiErrorMessage(recorder.Code, value)), nil
}
return newStructuredToolResult(value)
}
func buildAPIRequest(ctx context.Context, operation *openAPIOperation, arguments map[string]any, authorization string) (*http.Request, error) {
path, err := substitutePathParameters(operation, arguments)
if err != nil {
return nil, err
}
query := url.Values{}
for _, parameter := range operation.Parameters {
if parameter.In != "query" {
continue
}
value, ok := arguments[parameter.Name]
if !ok || value == nil {
continue
}
query.Set(parameter.Name, valueToString(value))
}
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
var body io.Reader
if operation.RequestBody != nil {
bodyValue := arguments["body"]
if bodyValue == nil {
if operation.RequestBody.Required {
return nil, errors.New(`missing required request body "body"`)
}
bodyValue = map[string]any{}
}
data, err := json.Marshal(bodyValue)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal request body")
}
body = bytes.NewReader(data)
}
req := httptest.NewRequest(operation.Method, path, body).WithContext(ctx)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if authorization != "" {
req.Header.Set("Authorization", authorization)
}
return req, nil
}
func substitutePathParameters(operation *openAPIOperation, arguments map[string]any) (string, error) {
path := operation.Path
for _, parameter := range operation.Parameters {
if parameter.In != "path" {
continue
}
value, ok := arguments[parameter.Name]
if !ok || value == nil || valueToString(value) == "" {
return "", errors.Errorf(`missing required path parameter "%s"`, parameter.Name)
}
encoded := url.PathEscape(valueToString(value))
path = strings.ReplaceAll(path, "{"+parameter.Name+"}", encoded)
}
return path, nil
}
func valueToString(value any) string {
switch typed := value.(type) {
case string:
return typed
case bool:
if typed {
return "true"
}
return "false"
default:
return strings.TrimSpace(strings.Trim(jsonNumberString(typed), `"`))
}
}
func jsonNumberString(value any) string {
data, err := json.Marshal(value)
if err != nil {
return ""
}
return string(data)
}
func apiErrorMessage(statusCode int, value any) string {
message := ""
if object, ok := value.(map[string]any); ok {
for _, key := range []string{"message", "error"} {
if raw, ok := object[key].(string); ok && raw != "" {
message = raw
break
}
}
}
if message == "" {
return http.StatusText(statusCode)
}
return errors.Errorf("%d %s: %s", statusCode, http.StatusText(statusCode), message).Error()
}
- Step 4: Run adapter tests
go test ./server/router/mcp/... -run 'TestBuildAPIRequest|TestExecuteOperation|TestNormalizeStructuredContent|TestNewStructuredToolResult|TestNewToolErrorResult|TestDecodeJSONValue'
Expected: PASS.
- Step 5: Commit
git add server/router/mcp/adapter.go server/router/mcp/adapter_test.go
git commit -m "feat(mcp): execute tools through API routes"
Task 6: Add origin checks and MCP service registration
Files:
-
Create:
server/router/mcp/origin.go -
Create:
server/router/mcp/service.go -
Create:
server/router/mcp/service_test.go -
Modify:
server/server.go -
Step 1: Write failing service and origin tests
Create server/router/mcp/service_test.go:
package mcp
import (
"context"
"testing"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/profile"
)
func TestIsAllowedMCPOrigin(t *testing.T) {
p := &profile.Profile{InstanceURL: "https://notes.example.com"}
require.True(t, isAllowedMCPOrigin(p, "localhost:5230", ""))
require.True(t, isAllowedMCPOrigin(p, "localhost:5230", "http://localhost:5230"))
require.True(t, isAllowedMCPOrigin(p, "localhost:5230", "https://notes.example.com"))
require.False(t, isAllowedMCPOrigin(p, "localhost:5230", "https://evil.example.com"))
}
func TestNewMCPServiceRegistersCuratedTools(t *testing.T) {
echoServer := echo.New()
service, err := NewMCPService(context.Background(), &profile.Profile{}, echoServer, "../../../proto/gen/openapi.yaml")
require.NoError(t, err)
require.NotNil(t, service)
require.Len(t, service.operationsByTool, len(curatedOperationIDs))
require.NotNil(t, service.handler)
}
- Step 2: Run tests and verify they fail
go test ./server/router/mcp/... -run 'TestIsAllowedMCPOrigin|TestNewMCPServiceRegistersCuratedTools'
Expected: FAIL because service and origin files do not exist.
- Step 3: Implement
origin.go
Create server/router/mcp/origin.go:
package mcp
import (
"net/url"
"strings"
"github.com/usememos/memos/internal/profile"
)
func isAllowedMCPOrigin(profile *profile.Profile, requestHost string, origin string) bool {
if origin == "" {
return true
}
originURL, err := url.Parse(origin)
if err != nil || originURL.Scheme == "" || originURL.Host == "" {
return false
}
if strings.EqualFold(originURL.Host, requestHost) {
return true
}
if profile == nil || profile.InstanceURL == "" {
return false
}
instanceURL, err := url.Parse(profile.InstanceURL)
if err != nil || instanceURL.Scheme == "" || instanceURL.Host == "" {
return false
}
return strings.EqualFold(originURL.Scheme, instanceURL.Scheme) && strings.EqualFold(originURL.Host, instanceURL.Host)
}
- Step 4: Implement
service.go
Create server/router/mcp/service.go:
package mcp
import (
"bytes"
"context"
"net/http"
"github.com/labstack/echo/v5"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/profile"
)
type MCPService struct {
profile *profile.Profile
echoServer *echo.Echo
adapter *apiAdapter
server *sdkmcp.Server
handler http.Handler
operationsByTool map[string]*registeredOperation
}
func NewMCPService(ctx context.Context, profile *profile.Profile, echoServer *echo.Echo, openAPIPath string) (*MCPService, error) {
spec, err := loadOpenAPISpec(openAPIPath)
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
}
sdkServer := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "memos", Version: "1.0.0"}, nil)
service := &MCPService{
profile: profile,
echoServer: echoServer,
adapter: newAPIAdapter(echoServer),
server: sdkServer,
operationsByTool: operationsByTool,
}
for _, tool := range tools {
registered := operationsByTool[tool.Name]
sdkServer.AddTool(tool, service.handlerForOperation(registered.Operation))
}
service.handler = sdkmcp.NewStreamableHTTPHandler(func(*http.Request) *sdkmcp.Server {
return sdkServer
}, &sdkmcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})
_ = ctx
return service, nil
}
func (s *MCPService) RegisterRoutes(echoServer *echo.Echo) {
echoServer.Any("/mcp", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAllowedMCPOrigin(s.profile, r.Host, r.Header.Get("Origin")) {
http.Error(w, `{"message":"invalid origin"}`, http.StatusForbidden)
return
}
s.handler.ServeHTTP(w, r)
})))
}
func (s *MCPService) handlerForOperation(operation *openAPIOperation) sdkmcp.ToolHandler {
return func(ctx context.Context, request *sdkmcp.CallToolRequest) (*sdkmcp.CallToolResult, error) {
arguments, err := callArguments(request)
if err != nil {
return newToolErrorResult(err.Error()), nil
}
authorization := ""
if request != nil && request.Extra != nil {
authorization = request.Extra.Header.Get("Authorization")
}
return s.adapter.execute(ctx, operation, arguments, authorization)
}
}
func callArguments(request *sdkmcp.CallToolRequest) (map[string]any, error) {
if request == nil || request.Params == nil || request.Params.Arguments == nil {
return map[string]any{}, nil
}
var arguments map[string]any
data := request.Params.Arguments
if len(bytes.TrimSpace(data)) == 0 {
return map[string]any{}, nil
}
if err := json.Unmarshal(data, &arguments); err != nil {
return nil, errors.Wrap(err, "failed to decode MCP tool arguments")
}
return arguments, nil
}
Add this missing import in service.go:
import "encoding/json"
- Step 5: Wire the service into
server/server.go
Modify server/server.go imports to include:
"github.com/usememos/memos/server/router/mcp"
After successful API gateway registration, add:
mcpService, err := mcp.NewMCPService(ctx, s.Profile, echoServer, "proto/gen/openapi.yaml")
if err != nil {
return nil, errors.Wrap(err, "failed to register MCP service")
}
mcpService.RegisterRoutes(echoServer)
This placement keeps /api/v1/... routes available before the MCP adapter dispatches in-process API requests.
- Step 6: Run service tests
go test ./server/router/mcp/... -run 'TestIsAllowedMCPOrigin|TestNewMCPServiceRegistersCuratedTools'
Expected: PASS.
- Step 7: Run server package tests
go test ./server/...
Expected: PASS.
- Step 8: Commit
git add server/router/mcp/origin.go server/router/mcp/service.go server/router/mcp/service_test.go server/server.go
git commit -m "feat(mcp): register OpenAPI-driven MCP endpoint"
Task 7: Add MCP protocol coverage for tools/list and tools/call
Files:
-
Modify:
server/router/mcp/service_test.go -
Step 1: Add protocol tests for initialize and tools/list
Append to server/router/mcp/service_test.go:
func TestMCPProtocolListsCuratedToolsOnly(t *testing.T) {
echoServer := echo.New()
service, err := NewMCPService(context.Background(), &profile.Profile{}, echoServer, "../../../proto/gen/openapi.yaml")
require.NoError(t, err)
service.RegisterRoutes(echoServer)
sessionID := initializeMCP(t, echoServer)
response := postMCP(t, echoServer, sessionID, map[string]any{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
})
result := response["result"].(map[string]any)
tools := result["tools"].([]any)
require.Len(t, tools, len(curatedOperationIDs))
names := map[string]struct{}{}
for _, rawTool := range tools {
tool := rawTool.(map[string]any)
name := tool["name"].(string)
names[name] = struct{}{}
require.Contains(t, tool, "inputSchema")
require.Contains(t, tool, "outputSchema")
}
require.Contains(t, names, "memo_list_memos")
require.Contains(t, names, "memo_create_memo")
require.NotContains(t, names, "auth_sign_in")
require.NotContains(t, names, "user_create_user")
}
Add helper functions to the same file:
func initializeMCP(t *testing.T, echoServer *echo.Echo) string {
t.Helper()
response := postMCP(t, echoServer, "", map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": map[string]any{
"protocolVersion": "2025-06-18",
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "memos-test",
"version": "1.0.0",
},
},
})
require.NotNil(t, response["result"])
return ""
}
func postMCP(t *testing.T, echoServer *echo.Echo, sessionID string, payload map[string]any) map[string]any {
t.Helper()
data, err := json.Marshal(payload)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if sessionID != "" {
req.Header.Set("Mcp-Session-Id", sessionID)
}
recorder := httptest.NewRecorder()
echoServer.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
var response map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
return response
}
Add these imports:
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
)
- Step 2: Run protocol list test
go test ./server/router/mcp/... -run TestMCPProtocolListsCuratedToolsOnly
Expected: PASS.
- Step 3: Add tools/call test that proves collection results are object-shaped
Append to server/router/mcp/service_test.go:
func TestMCPToolCallReturnsObjectStructuredContent(t *testing.T) {
echoServer := echo.New()
echoServer.GET("/api/v1/memos", func(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]any{
"memos": []any{map[string]any{"name": "memos/abc123"}},
})
})
service, err := NewMCPService(context.Background(), &profile.Profile{}, echoServer, "../../../proto/gen/openapi.yaml")
require.NoError(t, err)
service.RegisterRoutes(echoServer)
_ = initializeMCP(t, echoServer)
response := postMCP(t, echoServer, "", map[string]any{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": map[string]any{
"name": "memo_list_memos",
"arguments": map[string]any{
"pageSize": 1,
},
},
})
result := response["result"].(map[string]any)
structured := result["structuredContent"].(map[string]any)
require.Contains(t, structured, "memos")
require.NotContains(t, structured, "result")
}
- Step 4: Run tools/call test
go test ./server/router/mcp/... -run TestMCPToolCallReturnsObjectStructuredContent
Expected: PASS using stateless Streamable HTTP and JSON responses.
- Step 5: Commit
git add server/router/mcp/service_test.go server/router/mcp/service.go
git commit -m "test(mcp): cover MCP tool discovery and calls"
Task 8: Final verification and cleanup
Files:
-
Review all files changed in this plan.
-
Step 1: Run package tests for MCP and related server behavior
go test ./server/router/mcp/... ./server/router/api/v1/... ./server/...
Expected: PASS.
- Step 2: Run repository Go tests if time allows
go test ./...
Expected: PASS. If this fails in unrelated DB/Testcontainers packages due to local environment constraints, capture the exact failure and keep the narrower command from Step 1 as the required verification.
- Step 3: Run tidy check
go mod tidy -go=1.26.2
git diff -- go.mod go.sum
Expected: no diff after tidy, or only dependency changes already produced by Task 3.
- Step 4: Inspect final diff
git diff --stat
git diff -- server/router/mcp server/server.go go.mod go.sum
Expected: changes are limited to the MCP package, server route registration, and dependency files.
- Step 5: Commit any final fixes
git add server/router/mcp server/server.go go.mod go.sum
git commit -m "fix(mcp): complete OpenAPI-driven MCP verification"
Expected: create this commit only if Step 1 through Step 4 required additional fixes after the prior task commits. If there are no remaining changes, skip this commit.