Architecture
System Context
Container / Component Diagram
Remora is a single Go binary (cmd/remora). There’s no persistent state, no database, and no background process โ every request is config lookup + one outbound HTTP call.
Config Loading (lib/config/config.go)
config.Load(path) reads and unmarshals the YAML file into a Config{Transport, SSEPort, Include, Tools}. Defaults are applied after unmarshalling: Transport defaults to "stdio", SSEPort defaults to 8090.
Each entry in Include is resolved relative to the directory of the top-level config file (or used as-is if absolute), loaded as a fragment{Tools []Tool}, and its tools are appended to cfg.Tools. This is how remora.example.yaml stays small โ it just points at tools/shrike.yaml and tools/grey-seal.yaml rather than inlining every tool definition:
transport: stdio
include:
- ./tools/shrike.yaml
- ./tools/grey-seal.yaml
A Tool has a Name, Description, a map of Input properties (type, description, required), a Backend, and an Output hint ("json" | "text", informational only โ the dispatch path always returns the raw backend response text).
config.Validate() (lib/config/validate.go) runs after load and enforces: Transport is stdio or sse; every tool has a non-empty, unique Name and a non-empty Description; and the Backend has the fields its Type requires โ grpc needs address/service/method, rest needs url, graphql needs url/query. This is what backs the remora validate subcommand (see below) and what runServe calls before starting the server, so a broken config fails immediately rather than after the process starts accepting MCP calls.
Tool Registration (lib/registry/registry.go)
Registry.RegisterAll(s *server.MCPServer, tools []config.Tool) walks the resolved tool list and, per tool:
- Builds
[]mcp.ToolOptionfromtool.Inputโ onemcp.With{String,Integer,Number,Boolean}(name, ...)per input property, defaulting to string for unrecognized types, withmcp.Description(...)andmcp.Required()attached per field. - Calls
mcp.NewTool(tool.Name, opts...)ands.AddTool(t, handler), wherehandleris a closure capturing that tool’s config (captured := tool) and forwarding toRegistry.dispatch.
Registry.dispatch pulls req.GetArguments() and switches on tool.Backend.Type to call the matching dispatcher (grpc โ GRPCDispatcher, rest โ RESTDispatcher, graphql โ GraphQLDispatcher). An unknown backend type or a dispatch error is turned into a {"error": "..."} JSON text result rather than an MCP-level error โ the tool call always “succeeds” from the protocol’s point of view, with the failure surfaced in the payload for the LLM to read.
Transport Layer (cmd/remora/main.go)
runServe builds the server.MCPServer and registry, then branches on cfg.Transport:
- stdio (default):
server.ServeStdio(s)blocks, reading MCP JSON-RPC requests from stdin and writing responses to stdout. This is the mode Claude Desktop and Claude Code use โ they launchremoraas a child process perremora.example.yaml’s documentedmcpServersconfig. - http (Streamable HTTP โ the current MCP remote transport, spec rev 2025-03-26):
server.NewStreamableHTTPServer(s, server.WithEndpointPath(cfg.HTTPPath), server.WithStateLess(true)).Start(":{port}"). A single endpoint (POSTfor requests, optionalGETfor the server stream); session is a header (Mcp-Session-Id), so there is no “advertise the message endpoint” handshake and it works unchanged behind a proxy. Remora holds no session state, soWithStateLess(true)skips the session bookkeeping entirely. Clients:claude mcp add --transport http remora http://<host>:8090/mcp, or an in-cluster Go client viaclient.NewStreamableHttpClient("http://remora:8090/mcp"). - sse (legacy โ deprecated in MCP spec 2025-03-26, kept for older clients):
server.NewSSEServer(s, server.WithBaseURL(...), server.WithUseFullURLForMessageEndpoint(false))on:{port}.WithUseFullURLForMessageEndpoint(false)advertises the message endpoint as a relative path so it resolves against the client’s URL;sse_base_url/sse_base_pathtune it further for a proxied deployment.
Backend Dispatch (lib/backend/)
All three dispatchers share the same shape: build a request from the tool’s MCP call arguments, apply auth, make one HTTP call, and return the raw response body as the tool result string โ status codes are not inspected, and transport-level errors (e.g. connection refused) are caught and turned into {"error": "..."} JSON rather than a Go error, so the MCP call still returns a result.
REST (rest.go)
HTTPMethodis upper-cased; defaults toGET.- For any method other than
GET/DELETE, the body is built viabuildRequestBody(arguments, backend.RequestMapping)and sent withContent-Type: application/json. GET/DELETErequests carry no body (arguments are expected to already be baked intobackend.URL, e.g. via path templating done at config-authoring time โ there’s no query-string builder).
GraphQL (graphql.go)
- Always POSTs
{"query": backend.Query, "variables": {...}}. variablesis built frombackend.Variables: each entry is either a literal or, if it starts with$., agjsonpath evaluated against{"input": arguments}.
“grpc” โ actually ConnectRPC over JSON (grpc.go)
Despite the config key, there’s no real gRPC client and no generated stubs. GRPCDispatcher builds a JSON body the same way REST does (via buildRequestBody), then POSTs it to http://{backend.Address}/{backend.Service}/{backend.Method} with Content-Type: application/json and Connect-Protocol-Version: 1 โ the two headers ConnectRPC needs to accept a JSON-encoded unary call without protobuf codegen on either side. This is why adding a new backend RPC to a tool config is a YAML change, not a buf generate + vendored client change.
Request Mapping (mapping.go)
buildRequestBody(arguments, mapping) is shared by the REST and gRPC dispatchers (GraphQL has its own inline version for variables). If mapping is empty, the MCP call arguments are marshalled as-is. Otherwise, each destination field’s expression is evaluated: a $.-prefixed expression is a gjson path resolved against {"input": arguments} (e.g. "$.input.query" pulls the query MCP argument); anything else is used as a literal value. This lets a tool config rename fields, inject constants, or reshape the payload the backend expects without any Go code โ see tools/shrike.yaml’s search_knowledge tool, whose request_mapping renames query/mode/limit straight through, or tools/grey-seal.yaml’s tools doing the same for count/cursor/uuid.
Auth (applyAuth, in grpc.go, used by all three dispatchers)
Backend.Auth is optional. When set, the token is read from the environment variable named by Auth.TokenEnv at call time (never stored in config), and applied per Auth.Type:
| Type | Effect |
|---|---|
bearer | Authorization: Bearer <token> |
api_key | Header named by Auth.Header (default X-Api-Key) set to <token> |
basic | req.SetBasicAuth(auth.TokenEnv, token) |
Note: the
basiccase passesauth.TokenEnv(the env var name) as the username argument toSetBasicAuth, not a separately-configured username โ there’s currently no config field for a basic-auth username. Worth knowing if a tool config needs real basic auth.
validate Subcommand
remora validate --config <path> runs config.Load + cfg.Validate() and prints config OK: N tools on success, without constructing an MCPServer or starting a transport. make validate runs this against remora.example.yaml as a config smoke test.
Project Layout
cmd/remora/
main.go โ Cobra root (serve) + validate subcommand; transport switch
lib/config/
config.go โ Config/Tool/InputProp/Backend/Auth types; Load() + include resolution
validate.go โ Config.Validate()
lib/registry/
registry.go โ Registry: RegisterAll() (config.Tool โ mcp.Tool), dispatch() (route by backend type)
lib/backend/
rest.go โ RESTDispatcher
graphql.go โ GraphQLDispatcher
grpc.go โ GRPCDispatcher (ConnectRPC JSON-over-HTTP) + applyAuth()
mapping.go โ buildRequestBody() (gjson-based field mapping) + errorJSON()
tools/
shrike.yaml โ search_knowledge, get_entity, get_index_stats (grpc โ shrike:9000)
grey-seal.yaml โ list_conversations, get_conversation, create_conversation, list_resources (grpc โ grey-seal:9000)
remora.example.yaml โ reference config for the joel.holmes.haus environment; includes both tool files above
External Dependencies (key)
| Package | Role |
|---|---|
github.com/mark3labs/mcp-go | MCP server, tool registration, stdio and SSE transports |
github.com/spf13/cobra | CLI (remora, remora validate) |
gopkg.in/yaml.v3 | Config parsing |
github.com/tidwall/gjson | $.-path evaluation for request_mapping / GraphQL variables |
go.uber.org/zap | Structured logging |
Deployment
Dockerfile is a two-stage build: golang:1.26-alpine compiles a static (CGO_ENABLED=0) binary, and the runtime stage is alpine:3.21 with ca-certificates (needed since every backend call is outbound HTTPS-capable HTTP) and the binary as ENTRYPOINT. No config is baked into the image โ remora.yaml (or an equivalent) is expected to be mounted or supplied via --config at run time.
.github/workflows/docker-publish.yml gates every PR (go vet, golangci-lint, go test -race, remora validate) and, on merge to main, builds and pushes ghcr.io/holmes89/remora (:latest and :sha-<commit>). The joel.holmes.haus stack pulls that image like every other service โ mounting remora.http.yaml + tools/ from a checkout for the config.