README
¶
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, 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:
loadMCPServiceOpenAPISpecparses the embeddedproto.OpenAPIYAML()bytes into anopenAPISpec.buildOperationRegistry(openapi.go) indexes every operation byoperationId, recording method, path, resolved request-body schema, and resolved 200 response schema.buildCuratedTools(catalog.go) selects the allowlisted operation IDs and converts each into an*sdkmcp.Toolplus aregisteredOperation. Missing IDs or duplicate tool names are construction errors.- Each tool is registered with
server.AddTool(tool, newMCPToolHandler(...)). sdkmcp.NewStreamableHTTPHandlerwraps the server in stateless, JSON-response mode (no SSE, no session tracking). Stateless mode is also what lets the SDK serve protocol version2026-07-28; older clients still negotiate2025-11-25and earlier through the legacyinitializehandshake. The transport body limit is set to the API-wide limit so attachment uploads are not cut off by the SDK's 4 MiB default.
Request flow
RegisterRoutes binds echoServer.Any("/mcp", ...). Each request:
isAllowedMCPOrigin(origin.go) rejects disallowed cross-origin browser requests with403.- The request body is capped at 256 MiB before the SDK reads it.
- The SDK streamable handler dispatches the MCP message.
- On a
tools/callrequest,newMCPToolHandler(service.go) decodes the JSON arguments into a map. validateToolArguments(validation.go) checks them against the tool's input schema.- The caller's
Authorizationheader is read from the request (request.Extra.Headeron the SDK's*sdkmcp.CallToolRequest). apiAdapter.execute(adapter.go) builds the API request (buildAPIRequest: path-parameter substitution, query encoding, JSON body), forwards the bearer token, presents the caller's resolved client address as the request peer, and runs it against the Echo server through anhttptest.ResponseRecorder.- The recorder body is decoded; a non-2xx status becomes a tool error
(
newToolErrorResult), otherwise the value is wrapped bynewStructuredToolResult.
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$refis expanded in place (resolveSchemaRef→resolveSchemaValue→resolveSchemaMap). - Nested refs become
$defs. Any$refencountered below the top level is rewritten to a local#/$defs/<Name>pointer, and the referenced component is collected into a$defsmap (addSchemaDef). - Cycle safety. Recursive component schemas are handled by seeding
defs[name]with a placeholder and trackingresolving[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
bodyproperty; a required body addsbodytorequired. Body$defsare lifted to the schema's top-level$defs. - Per-operation overrides relax resource-level requirements for create and
partial-update bodies and remove fields already supplied by a path binding
from
body: "*"schemas. Memo and space updates may omitupdateMaskso the REST gateway can infer it from the fields present in the request body. Abody: "*"binding whose only field is path-bound (accepting or declining a space invitation) has itsbodymade optional instead of demanding{}. - 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 useGET/DELETEon the same path for the Streamable HTTP transport). - Transport: Streamable HTTP, stateless, JSON responses.
- Protocol versions:
2026-07-28down to2024-11-05. Clients on2026-07-28skipinitialize, callserver/discover, carry_meta.io.modelcontextprotocol/protocolVersionon every request, and must send theMcp-Protocol-Version,Mcp-Method, and (fortools/call)Mcp-Nameheaders. - Capabilities: tools only, without
listChanged— the catalog is fixed at startup, sotools/listandserver/discoveradvertise a 24-hourttlMsinstead of change notifications. - Request size: request bodies are limited to 256 MiB before SDK dispatch.
- 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. - Client address: the
/mcprequest passes through the same client-address middleware as every other route, which resolves the caller behind trusted proxies. The adapter reads that resolved address from the tool handler's context and sets it as the in-process request's peer, so the middleware's second pass resolves to the same value. Without this every tool call would presenthttptest's placeholder peer (192.0.2.1), and all anonymous MCP callers would share one rate-limit bucket. - Origin safety:
isAllowedMCPOriginallows a request when theOriginheader is absent (desktop clients commonly omit it), when its host matches the requestHostheader (host comparison only — scheme is not checked), or when it matches the configuredprofile.InstanceURL. Anything else gets403. 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 server exposes a curated allowlist (curatedOperationIDs in catalog.go),
centered on memos and attachments, plus the space service (memos carry a
placement and a SPACE audience, so agents must be able to discover and
administer spaces and answer invitations) and two read-only orientation tools:
user_list_memo_views (surfaces a user's named CEL filters for reuse with
memo_list_memos) and auth_get_current_user (a "whoami" so an agent can
resolve its own user — the single allowed auth/identity operation):
| 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_CreateAttachment |
attachment_create_attachment |
AttachmentService_GetAttachment |
attachment_get_attachment |
AttachmentService_DeleteAttachment |
attachment_delete_attachment |
UserService_ListMemoViews |
user_list_memo_views |
AuthService_GetCurrentUser |
auth_get_current_user |
SpaceService_ListSpaces |
space_list_spaces |
SpaceService_GetSpace |
space_get_space |
SpaceService_CreateSpace |
space_create_space |
SpaceService_UpdateSpace |
space_update_space |
SpaceService_DeleteSpace |
space_delete_space |
SpaceService_ListSpaceMembers |
space_list_space_members |
SpaceService_GetSpaceMember |
space_get_space_member |
SpaceService_UpdateSpaceMember |
space_update_space_member |
SpaceService_DeleteSpaceMember |
space_delete_space_member |
SpaceService_ListSpaceInvitations |
space_list_space_invitations |
SpaceService_ListUserSpaceInvitations |
space_list_user_space_invitations |
SpaceService_GetSpaceInvitation |
space_get_space_invitation |
SpaceService_CreateSpaceInvitation |
space_create_space_invitation |
SpaceService_AcceptSpaceInvitation |
space_accept_space_invitation |
SpaceService_DeclineSpaceInvitation |
space_decline_space_invitation |
SpaceService_DeleteSpaceInvitation |
space_delete_space_invitation |
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 (annotationsForOperation) start from the HTTP method:
| Method | ReadOnly | Destructive | Idempotent |
|---|---|---|---|
| GET | true | false | true |
| DELETE | false | true | true |
| other (POST, PATCH, …) | false | false | false |
Per-operation overrides then correct cases the method heuristic gets wrong.
MemoService_SetMemoAttachments and MemoService_SetMemoRelations are PATCH
but declaratively replace the full set on a memo, so they report both
IdempotentHint: true and DestructiveHint: true. MemoService_UpdateMemo,
SpaceService_UpdateSpace, and SpaceService_UpdateSpaceMember also report
DestructiveHint: true because they overwrite existing fields (a member's role
included). SpaceService_DeleteSpace is destructive by method, and the API
also deletes every memo placed in the space.
OpenWorldHint is false for all tools. Annotations are client hints; they do
not replace API authorization.
Result shape. Every successful 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.
Inside that envelope the API's JSON is passed through verbatim, so the gateway's
own encoding is part of the tool contract: whatever it emits is validated against
the output schema resolved from the same OpenAPI spec. grpc-gateway's stock
marshaler emits null for unset message fields, which no schema declares as
nullable — RegisterGateway therefore installs a marshaler that omits them
(newGatewayMarshaler in server/api/v1/v1.go). That fixes
#6139, where "motionMedia": null
failed every tool call returning an attachment.
Error handling
Failures are returned as MCP tool errors (CallToolResult with IsError: true
and a text content block), not JSON-RPC protocol errors — the handler returns
(result, nil). Error results omit structuredContent so strict clients do not
validate an error payload against the tool's success-only output schema:
| 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
-
Add the OpenAPI
operationIdtocuratedOperationIDsincatalog.go. -
If the operation is not in the generated OpenAPI, add or adjust the proto/API surface first, then regenerate:
cd proto && buf generate -
Extend the tests in
catalog_test.go/service_test.goto 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/mcp/...
openapi_test.go— spec parsing, registry building,$refresolution.catalog_test.go— tool selection, naming, schema and annotation building.adapter_test.go— request construction and in-process execution (adapter.go), including the client-address peer, plus result normalization and error shaping (result.go).validation_test.go— argument validation against input schemas.service_test.go— the origin-header check, the stateless2026-07-28flow (server/discover,tools/list,tools/callwith MCP headers), the request body limit, client-address forwarding through the in-process hop, plus the legacy end-to-end MCP protocol (initialize,tools/list,tools/call) confirming object-shapedstructuredContent.
Design notes
- Two-layer input validation.
validateToolArgumentsruns a hand-rolled structural check (validateSchemaValue) and then thegoogle/jsonschema-govalidator. 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-basedloadOpenAPISpecinopenapi.goexists for tests. - Tools only. The server advertises no prompts or resources in this version.
Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type MCPService ¶
type MCPService struct {
// contains filtered or unexported fields
}
MCPService serves the OpenAPI-driven MCP endpoint.
func NewMCPService ¶
NewMCPService creates an MCP service backed by the in-process API routes.
func (*MCPService) RegisterRoutes ¶
func (s *MCPService) RegisterRoutes(echoServer *echo.Echo)
RegisterRoutes registers the streamable HTTP MCP endpoint.