Building a JSON-RPC Server in Go: Complete Implementation Guide

Table of Contents

Last week, I posted about JSON-RPC and the Model Context Protocol. Today, we’re going to see an example of how we can create a simple application following the JSON-RPC standard. We’ll build a simple JSON-RPC calculator together, and I hope you’ll enjoy it.

Request Flow Overview

flowchart LR A[Client Request] --> B[Parse JSON] B --> C{Valid?} C -->|No| D[Error Response] C -->|Yes| E{Type?} E -->|Request| F[Match Method] E -->|Notification| G[Execute & End] F --> H{Method Found?} H -->|No| I[Method Not Found] H -->|Yes| J[Execute Method] J --> K[Build Response] I --> K K --> L[Send Response] D --> L L --> M[End] G --> M

Message Parsing and Validation

Let’s recap JSON-RPC message types. We have 3 types of messages: Request, Response, and Notification. For request and response types, we can send them in batches, and responses must be returned in a batch matching the request IDs in any order. We can distinguish between a request and a notification by checking the ID field. If there’s an ID, it’s a request; if not, it’s a notification.

type JSONRPCNotification struct {
	JSONRPC string      `json:"jsonrpc"`
	Method  string      `json:"method"`
	Params  interface{} `json:"params,omitempty"`
}

type JSONRPCRequest struct {
	JSONRPC string      `json:"jsonrpc"`
	Method  string      `json:"method"`
	Params  interface{} `json:"params,omitempty"`
	ID      interface{} `json:"id"`
}

type JSONRPCResponse struct {
	JSONRPC string      `json:"jsonrpc"`
	Result  interface{} `json:"result,omitempty"`
	Error   *JSONRPCError `json:"error,omitempty"`
	ID      interface{} `json:"id"`
}

type JSONRPCError struct {
	Code    int         `json:"code"`
	Message string      `json:"message"`
	Data    interface{} `json:"data,omitempty"`
}

Now let’s look at the parsing function:

// ParseSingleMessage parses a single JSON-RPC message
func ParseSingleMessage(data []byte) (interface{}, error) {
	// Parse once into a raw message that preserves ID field
	var raw struct {
		JSONRPC string          `json:"jsonrpc"`
		Method  string          `json:"method"`
		Params  json.RawMessage `json:"params,omitempty"`
		ID      *json.RawMessage `json:"id,omitempty"` // Pointer to detect presence
	}
	
	if err := json.Unmarshal(data, &raw); err != nil {
		return nil, &JSONRPCError{
			Code:    ParseError,
			Message: "Parse error",
			Data:    err.Error(),
		}
	}
	
	// Validate common fields
	if raw.JSONRPC != JSONRPCVersion {
		return nil, &JSONRPCError{
			Code:    InvalidRequest,
			Message: "Invalid Request",
			Data:    "jsonrpc field must be '2.0'",
		}
	}
	
	if raw.Method == "" {
		return nil, &JSONRPCError{
			Code:    InvalidRequest,
			Message: "Invalid Request",
			Data:    "method field is required",
		}
	}
	
	// Parse params once (same for both request and notification)
	var params interface{}
	if raw.Params != nil {
		if err := json.Unmarshal(raw.Params, &params); err != nil {
			return nil, &JSONRPCError{
				Code:    InvalidRequest,
				Message: "Invalid Request",
				Data:    "Invalid params field",
			}
		}
	}
	
	// Only difference: check ID at the end to determine type
	if raw.ID != nil {
		// It's a request (has ID, expects response)
		var id interface{}
		if err := json.Unmarshal(*raw.ID, &id); err != nil {
			return nil, &JSONRPCError{
				Code:    InvalidRequest,
				Message: "Invalid Request",
				Data:    "Invalid ID field",
			}
		}
		
		return JSONRPCRequest{
			JSONRPC: raw.JSONRPC,
			Method:  raw.Method,
			Params:  params,
			ID:      id,
		}, nil
	}
	
	// No ID = notification (no response expected)
	return JSONRPCNotification{
		JSONRPC: raw.JSONRPC,
		Method:  raw.Method,
		Params:  params,
	}, nil
}

This function converts the byte array into a raw struct, then validates required common fields like the JSON-RPC version and method. Finally, it identifies the message type (request or notification) by checking for the ID field.

Message Type Routing

	switch msg := message.(type) {
	case []interface{}:
		// Batch request
		return s.handleBatchRequest(msg)
	case JSONRPCRequest:
		// Single request
		response := s.handleSingleRequest(msg)
		return json.Marshal(response)
	case JSONRPCNotification:
		// Single notification - no response
		s.handleNotification(msg)
		return nil, nil // No response for notifications
	default:
		// This shouldn't happen if parsing worked correctly
		errorResp := CreateErrorResponse(&JSONRPCError{
			Code:    InvalidRequest,
			Message: "Invalid Request",
			Data:    "Unknown message type",
		}, nil)
		return json.Marshal(errorResp)
	}

In this switch statement, we handle the request based on the message type. We distinguish between a single request, batch request, or notification. For notifications, the handling is simple: just execute the method call without any response.

func (s *JSONRPCServer) handleNotification(notif JSONRPCNotification) {
	log.Printf("Handling notification: %s", notif.Method)
	
	// Call method but ignore any result/error since it's a notification
	_, err := s.callMethod(notif.Method, notif.Params)
	if err != nil {
		log.Printf("Notification error (ignored): %v", err)
	}
}

On the other hand, for single or batch request types, we must return a response like this:

func (s *JSONRPCServer) handleSingleRequest(req JSONRPCRequest) JSONRPCResponse {
	// Route the method call
	result, err := s.callMethod(req.Method, req.Params)
	
	if err != nil {
		// Check if it's already a JSON-RPC error
		if jsonrpcErr, ok := err.(*JSONRPCError); ok {
			return CreateErrorResponse(jsonrpcErr, req.ID)
		}
		
		// Convert regular error to JSON-RPC error
		jsonrpcErr := &JSONRPCError{
			Code:    InternalError,
			Message: "Internal error",
			Data:    err.Error(),
		}
		return CreateErrorResponse(jsonrpcErr, req.ID)
	}
	
	return CreateSuccessResponse(result, req.ID)
}

func CreateSuccessResponse(result interface{}, id interface{}) JSONRPCResponse {
	return JSONRPCResponse{
		JSONRPC: JSONRPCVersion,
		Result:  result,
		ID:      id,
	}
}

Method Execution

Up to this point, everything should be straightforward. In the request handler, we have this line that handles method calls:

result, err := s.callMethod(req.Method, req.Params)

Inside this callMethod function, we accept two parameters: a method name and the params. We handle the method call based on the method name from the request.

func (s *JSONRPCServer) callMethod(method string, params interface{}) (interface{}, error) {

	switch method {
	case "add":
		return s.callCalculatorMethod("Add", params)
	case "subtract":
		return s.callCalculatorMethod("Subtract", params)
	case "multiply":
		return s.callCalculatorMethod("Multiply", params)
	case "divide":
		return s.callCalculatorMethod("Divide", params)
	case "getInfo":
		return s.calculator.GetInfo()
	case "log":
		return s.callNotificationMethod("Log", params)
	default:
		return nil, &JSONRPCError{
			Code:    MethodNotFound,
			Message: "Method not found",
			Data:    fmt.Sprintf("Method '%s' is not available", method),
		}
	}
}

Finally, here’s the inner function that handles method execution:

func (s *JSONRPCServer) callCalculatorMethod(methodName string, params interface{}) (interface{}, error) {
	// Parse parameters
	var calcParams CalculatorParams
	if params != nil {
		paramBytes, err := json.Marshal(params)
		if err != nil {
			return nil, &JSONRPCError{
				Code:    InvalidParams,
				Message: "Invalid params",
				Data:    "Cannot marshal parameters",
			}
		}
		
		if err := json.Unmarshal(paramBytes, &calcParams); err != nil {
			return nil, &JSONRPCError{
				Code:    InvalidParams,
				Message: "Invalid params",
				Data:    "Expected parameters: {\"a\": number, \"b\": number}",
			}
		}
	} else {
		return nil, &JSONRPCError{
			Code:    InvalidParams,
			Message: "Invalid params",
			Data:    "Parameters required: {\"a\": number, \"b\": number}",
		}
	}
	
	// Use reflection to call the method
	calcValue := reflect.ValueOf(s.calculator)
	method := calcValue.MethodByName(methodName)
	if !method.IsValid() {
		return nil, &JSONRPCError{
			Code:    InternalError,
			Message: "Internal error",
			Data:    fmt.Sprintf("Method %s not found on calculator", methodName),
		}
	}
	
	// Call the method
	results := method.Call([]reflect.Value{reflect.ValueOf(calcParams)})
	
	// Handle results (expecting result, error pattern)
	if len(results) != 2 {
		return nil, &JSONRPCError{
			Code:    InternalError,
			Message: "Internal error",
			Data:    "Unexpected return value count",
		}
	}
	
	// Check for error
	if !results[1].IsNil() {
		err := results[1].Interface().(error)
		return nil, err
	}
	
	// Return the result
	return results[0].Interface(), nil
}

In this function, we rely heavily on reflection to execute methods from the reflect package. After method execution completes, we return the response back to the request.

Wrapping Up

Building a JSON-RPC server in Go is surprisingly straightforward once you understand the message flow. The key is proper parsing, validation, and method routing. Go’s reflection makes dynamic method dispatch clean, and the JSON-RPC spec handles the rest.

This pattern works great for microservices, tool integrations, or any situation where you need structured RPC calls over various transports. Plus, understanding this foundation makes working with protocols like MCP much easier.


More Deep Dives You Might Like

Understanding JSON-RPC: The Protocol Behind MCP - Get the conceptual foundation. This post explains why JSON-RPC was chosen for MCP and breaks down the protocol’s design.

The HTTP Request Journey in Go - Compare protocol implementations. See how Go handles HTTP requests with the same internal patterns we used here for JSON-RPC.


Questions or thoughts about this? Hit me up at [email protected].