Architecture

System Context

C4Context title System Context โ€” Remora within joel.holmes.haus Person(operator, "Operator", "Asks Claude questions that need live platform data") Boundary(platform, "joel.holmes.haus Platform") { System(claude, "Claude Desktop / Claude Code", "MCP client โ€” spawns Remora over stdio, or connects to it over SSE") System(remora, "Remora", "Config-driven MCP server โ€” turns a YAML file into MCP tools backed by REST, GraphQL, and ConnectRPC services") System(shrike, "Shrike", "Search โ€” ConnectRPC SearchService (search_knowledge, get_entity, get_index_stats)") System(greyseal, "Grey Seal", "RAG conversations โ€” ConnectRPC ConversationService / ResourceService") } System_Ext(other, "Other REST / GraphQL / ConnectRPC services", "Anything reachable over HTTP can be wired in via config โ€” no code change") Rel(operator, claude, "Asks questions") Rel(claude, remora, "MCP: ListTools / CallTool") Rel(remora, shrike, "JSON-over-HTTP (ConnectRPC), per tools/shrike.yaml") Rel(remora, greyseal, "JSON-over-HTTP (ConnectRPC), per tools/grey-seal.yaml") Rel(remora, other, "REST or GraphQL, per config")

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.

graph TD CLI["cmd/remora\nCobra root command + validate subcommand"] Config["lib/config\nLoad() ยท Validate()"] MCP["mark3labs/mcp-go\nserver.MCPServer"] Registry["lib/registry\nRegistry.RegisterAll() ยท dispatch()"] RESTD["backend.RESTDispatcher"] GQLD["backend.GraphQLDispatcher"] GRPCD["backend.GRPCDispatcher"] Mapping["backend.buildRequestBody / applyAuth\n(lib/backend/mapping.go, grpc.go)"] CLI -->|"--config path (default remora.yaml)"| Config Config -->|"Config{Transport, SSEPort, Tools}"| CLI CLI -->|"server.NewMCPServer + reg.RegisterAll"| Registry Registry -->|"mcp.NewTool + s.AddTool per config.Tool"| MCP MCP -->|"CallToolRequest"| Registry Registry -->|"backend.type == rest"| RESTD Registry -->|"backend.type == graphql"| GQLD Registry -->|"backend.type == grpc"| GRPCD RESTD --> Mapping GQLD --> Mapping GRPCD --> Mapping RESTD -->|"HTTP"| RESTSvc[("REST backends")] GQLD -->|"HTTP POST"| GQLSvc[("GraphQL backends")] GRPCD -->|"HTTP POST /{service}/{method}"| ConnectSvc[("ConnectRPC backends\ne.g. shrike:9000, grey-seal:9000")]

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:

  1. Builds []mcp.ToolOption from tool.Input โ€” one mcp.With{String,Integer,Number,Boolean}(name, ...) per input property, defaulting to string for unrecognized types, with mcp.Description(...) and mcp.Required() attached per field.
  2. Calls mcp.NewTool(tool.Name, opts...) and s.AddTool(t, handler), where handler is a closure capturing that tool’s config (captured := tool) and forwarding to Registry.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:

graph LR subgraph stdio["transport: stdio (default)"] A["Claude Desktop / Claude Code"] -->|"spawns as subprocess,\nJSON-RPC over stdin/stdout"| B["remora --config remora.yaml\nserver.ServeStdio(s)"] end subgraph http["transport: http (Streamable HTTP)"] C["Any MCP client on the network"] -->|"HTTP POST :port{http_path} (default :8090/mcp)"| D["remora --config remora.yaml\nserver.NewStreamableHTTPServer(s, WithStateLess(true)).Start(addr)"] end
  • 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 launch remora as a child process per remora.example.yaml’s documented mcpServers config.
  • 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 (POST for requests, optional GET for 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, so WithStateLess(true) skips the session bookkeeping entirely. Clients: claude mcp add --transport http remora http://<host>:8090/mcp, or an in-cluster Go client via client.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_path tune 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)

  • HTTPMethod is upper-cased; defaults to GET.
  • For any method other than GET/DELETE, the body is built via buildRequestBody(arguments, backend.RequestMapping) and sent with Content-Type: application/json.
  • GET/DELETE requests carry no body (arguments are expected to already be baked into backend.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": {...}}.
  • variables is built from backend.Variables: each entry is either a literal or, if it starts with $., a gjson path 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:

TypeEffect
bearerAuthorization: Bearer <token>
api_keyHeader named by Auth.Header (default X-Api-Key) set to <token>
basicreq.SetBasicAuth(auth.TokenEnv, token)

Note: the basic case passes auth.TokenEnv (the env var name) as the username argument to SetBasicAuth, 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)

PackageRole
github.com/mark3labs/mcp-goMCP server, tool registration, stdio and SSE transports
github.com/spf13/cobraCLI (remora, remora validate)
gopkg.in/yaml.v3Config parsing
github.com/tidwall/gjson$.-path evaluation for request_mapping / GraphQL variables
go.uber.org/zapStructured 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.