memos/server/router/mcp
johnnyjoygh 8fa2ff4423 fix(mcp): allow reverse-proxied instances to serve /mcp
The go-sdk Streamable HTTP handler enables DNS-rebinding protection that
rejects any request whose Host header is non-loopback while the server is
bound to a loopback address. memos is commonly run bound to loopback behind a
reverse proxy (e.g. the public demo), so every /mcp request was rejected with
"403 Forbidden: invalid Host header" before authentication ran.

Disable the SDK's localhost protection and rely on memos' own Origin/Host
allowlist (isAllowedMCPOrigin) for CSRF / DNS-rebinding protection. Add a
regression test covering the proxied shape and confirming disallowed origins
are still rejected.
2026-06-21 22:20:00 +08:00
..
adapter.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
adapter_test.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
catalog.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
catalog_test.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
openapi.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
openapi_test.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
origin.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
README.md docs: add README for mcp 2026-06-09 23:54:58 +08:00
result.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
service.go fix(mcp): allow reverse-proxied instances to serve /mcp 2026-06-21 22:20:00 +08:00
service_test.go fix(mcp): allow reverse-proxied instances to serve /mcp 2026-06-21 22:20:00 +08:00
validation.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
validation_test.go feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00

MCP Server

This package serves an OpenAPI-driven Model Context Protocol (MCP) endpoint at /mcp. It exposes a curated, memo-focused toolset over the Streamable HTTP transport using the official github.com/modelcontextprotocol/go-sdk.

The core design principle: tool calls execute in-process against the existing REST API. The package owns no store or service logic of its own. Each tool is derived from an operation in the generated OpenAPI document (proto/gen/openapi.yaml, embedded via proto.OpenAPIYAML()), and a tool call is translated into the matching /api/v1/... HTTP request and run against the same Echo server that serves the public API. This keeps OpenAPI as the single source of truth and reuses the API's authentication and authorization as-is.

Integration

server.NewServer calls mcp.NewMCPService after registering the API, file, RSS, and gRPC-gateway routes, passing the same Echo server:

mcpService, err := mcp.NewMCPService(profile, echoServer)
if err != nil {
    return nil, errors.Wrap(err, "failed to create MCP service")
}
mcpService.RegisterRoutes(echoServer)

The service advertises the tools capability only — no prompts, no resources.

Startup flow

NewMCPService (service.go) wires everything up at construction time and fails fast on any inconsistency:

  1. loadMCPServiceOpenAPISpec parses the embedded proto.OpenAPIYAML() bytes into an openAPISpec.
  2. buildOperationRegistry (openapi.go) indexes every operation by operationId, recording method, path, resolved request-body schema, and resolved 200 response schema.
  3. buildCuratedTools (catalog.go) selects the allowlisted operation IDs and converts each into an *sdkmcp.Tool plus a registeredOperation. Missing IDs or duplicate tool names are construction errors.
  4. Each tool is registered with server.AddTool(tool, newMCPToolHandler(...)).
  5. sdkmcp.NewStreamableHTTPHandler wraps the server in stateless, JSON-response mode (no SSE, no session tracking).

Request flow

RegisterRoutes binds echoServer.Any("/mcp", ...). Each request:

  1. isAllowedMCPOrigin (origin.go) rejects disallowed cross-origin browser requests with 403.
  2. The SDK streamable handler dispatches the MCP message.
  3. On a tools/call request, newMCPToolHandler (service.go) decodes the JSON arguments into a map.
  4. validateToolArguments (validation.go) checks them against the tool's input schema.
  5. The caller's Authorization header is read from the request (request.Extra.Header on the SDK's *sdkmcp.CallToolRequest).
  6. apiAdapter.execute (adapter.go) builds the API request (buildAPIRequest: path-parameter substitution, query encoding, JSON body), forwards the bearer token, and runs it against the Echo server through an httptest.ResponseRecorder.
  7. The recorder body is decoded; a non-2xx status becomes a tool error (newToolErrorResult), otherwise the value is wrapped by newStructuredToolResult.

Schema resolution

MCP tool schemas must be self-contained JSON Schema, but the OpenAPI components use $ref. openapi.go resolves these into local definitions:

  • Top-level inlining. The request-body and 200-response schemas for an operation are resolved with inlineRef = true, so the outermost $ref is expanded in place (resolveSchemaRef → resolveSchemaValue → resolveSchemaMap).
  • Nested refs become $defs. Any $ref encountered below the top level is rewritten to a local #/$defs/<Name> pointer, and the referenced component is collected into a $defs map (addSchemaDef).
  • Cycle safety. Recursive component schemas are handled by seeding defs[name] with a placeholder and tracking resolving[name] before recursing, so a schema that references itself terminates (addSchemaDef).

catalog.go then assembles the per-tool input schema in inputSchemaForOperation:

  • Path and query parameters become top-level properties; any required parameter stays in required.
  • A request body becomes a single body property; a required body adds body to required. Body $defs are lifted to the schema's top-level $defs.
  • The schema sets "additionalProperties": false.

The output schema is the operation's 200 application/json schema. When a 200 response has no JSON body, the fallback is:

{ "type": "object", "properties": { "ok": { "type": "boolean" } } }

Endpoint, transport & auth

  • Endpoint: POST /mcp (the SDK may also use GET/DELETE on the same path for the Streamable HTTP transport).
  • Transport: Streamable HTTP, stateless, JSON responses.
  • Auth: the caller's Authorization: Bearer <token> header is forwarded to the in-process API request. Mutating tools therefore require a valid token (personal access token or access token); public reads may work without one, exactly as the REST API allows.
  • Origin safety: isAllowedMCPOrigin allows a request when the Origin header is absent (desktop clients commonly omit it), when its host matches the request Host header (host comparison only — scheme is not checked), or when it matches the configured profile.InstanceURL. Anything else gets 403. This guards against DNS-rebinding from browsers.

Connecting a client

Point any Streamable HTTP MCP client at https://<your-instance>/mcp and supply a personal access token as a bearer credential. Example client config:

{
  "mcpServers": {
    "memos": {
      "type": "http",
      "url": "https://<your-instance>/mcp",
      "headers": {
        "Authorization": "Bearer <your-personal-access-token>"
      }
    }
  }
}

Tool surface

The first version exposes a curated allowlist (curatedOperationIDs in catalog.go), all memo- and attachment-focused:

OpenAPI operation MCP tool
MemoService_ListMemos memo_list_memos
MemoService_CreateMemo memo_create_memo
MemoService_GetMemo memo_get_memo
MemoService_UpdateMemo memo_update_memo
MemoService_DeleteMemo memo_delete_memo
MemoService_ListMemoComments memo_list_memo_comments
MemoService_CreateMemoComment memo_create_memo_comment
MemoService_ListMemoAttachments memo_list_memo_attachments
MemoService_SetMemoAttachments memo_set_memo_attachments
MemoService_ListMemoReactions memo_list_memo_reactions
MemoService_UpsertMemoReaction memo_upsert_memo_reaction
MemoService_DeleteMemoReaction memo_delete_memo_reaction
MemoService_ListMemoRelations memo_list_memo_relations
MemoService_SetMemoRelations memo_set_memo_relations
AttachmentService_ListAttachments attachment_list_attachments
AttachmentService_GetAttachment attachment_get_attachment
AttachmentService_DeleteAttachment attachment_delete_attachment

Naming rule (toolNameFromOperationID): drop the Service suffix from the subject and convert both subject and method from camelCase to snake_case, joined by _. So MemoService_ListMemos → memo_list_memos.

Annotations (annotationsForMethod) are derived from the HTTP method:

Method ReadOnly Destructive Idempotent
GET true false true
DELETE false true true
other (POST, PATCH, …) false false false

OpenWorldHint is false for all tools. Annotations are client hints; they do not replace API authorization.

Result shape. Every result carries object-shaped structuredContent (normalizeStructuredContent in result.go):

  • a JSON object is returned unchanged;
  • an empty response becomes { "ok": true };
  • a bare array becomes { "result": [...] };
  • a scalar becomes { "result": value }.

This is deliberate: it fixes #6022, where collection tools returned a bare array that strict MCP clients reject.

Error handling

Failures are returned as MCP tool errors (CallToolResult with IsError: true and an error.message in structuredContent), not JSON-RPC protocol errors — the handler returns (result, nil):

Failure Result
Arguments are not valid JSON tool error: decode message
Arguments fail schema validation tool error: validation message
Missing required path parameter tool error: missing required path parameter "..."
Missing required request body tool error: missing required request body "body"
API responds non-2xx tool error: "<code> <reason phrase>: <api message>" (e.g. "404 Not Found: ...") (apiErrorMessage)
API response body is not decodable JSON tool error: decode message

Core files

File Responsibility
service.go Constructs the MCP server, registers tools, builds the streamable HTTP handler, and binds the /mcp route.
catalog.go The curated operation allowlist, tool naming, input/output schema assembly, and method-derived annotations.
adapter.go Translates a tool call into an /api/v1/... request and runs it in-process against the Echo server.
openapi.go Parses the OpenAPI spec, builds the operation registry, and resolves $ref schemas into self-contained JSON Schema.
validation.go Validates tool-call arguments against the tool's input schema.
origin.go Origin-header check for browser DNS-rebinding safety.
result.go Normalizes API responses into object-shaped structuredContent and builds error results.

Adding a tool

  1. Add the OpenAPI operationId to curatedOperationIDs in catalog.go.

  2. If the operation is not in the generated OpenAPI, add or adjust the proto/API surface first, then regenerate:

    cd proto && buf generate
    
  3. Extend the tests in catalog_test.go / service_test.go to cover the new tool.

Never hand-edit proto/gen/openapi.yaml or other generated output — change the proto definitions and regenerate.

Testing

go test ./server/router/mcp/...
  • openapi_test.go — spec parsing, registry building, $ref resolution.
  • catalog_test.go — tool selection, naming, schema and annotation building.
  • adapter_test.go — request construction and in-process execution (adapter.go), plus result normalization and error shaping (result.go).
  • validation_test.go — argument validation against input schemas.
  • service_test.go — the origin-header check, plus the end-to-end MCP protocol (initialize, tools/list, tools/call) confirming object-shaped structuredContent.

Design notes

  • Two-layer input validation. validateToolArguments runs a hand-rolled structural check (validateSchemaValue) and then the google/jsonschema-go validator. The first yields friendly messages; the second is the spec-complete backstop.
  • Embedded vs. file load. Production reads the spec from proto.OpenAPIYAML() (loadMCPServiceOpenAPISpec). The path-based loadOpenAPISpec in openapi.go exists for tests.
  • Tools only. The server advertises no prompts or resources in this version.