Prasanth Janardhanan

Model Context Protocol (MCP): Lets Implement an MCP server in Go

Model Context Protocol (MCP) is rapidly transforming how we interact with computers by enabling natural language instructions to handle complex tasks. As we stand at the beginning of this revolution, we’re witnessing fast-paced development in MCP tools and components.

While a detailed introduction to MCP was covered in our previous post, here’s a quick refresher: MCP servers expose various capabilities (like resources, tools, and prompts) to clients. The clients can then use these capabilities in conjunction with Large Language Models (LLMs) to perform tasks. The protocol standardizes how these components communicate, making it easy to build interoperable AI-powered applications.

Let’s embark on creating an MCP server in Go. Why Go? There are several compelling reasons:

Lets build an image generation server that connects to Stable Diffusion APIs.

Once ready, this is what we can do with this MCP server + Claude Desktop:

generate a garden image prompt

Result: garden image generated

Source Repository:

primitive-go-mcp-server

Get Started

Before diving into the implementation, let’s understand the networking aspects. MCP currently supports multiple transport protocols, but we’ll focus on stdio since it’s currently supported by Claude Desktop. The protocol uses JSON-RPC 2.0 for message exchange, providing a standardized way to handle requests, responses, and notifications.

JSON-RPC 2.0 in MCP context provides:

Let’s create an image generator that takes a prompt and uses Stable Diffusion APIs to generate images. The first step is handling the initialization request that establishes the connection between the client and server.

The initialization request

When a client connects to our MCP server, the first interaction is an initialization request. This request establishes the protocol version and capabilities. Here’s how we implement it in Go:

func main() {
	decoder := json.NewDecoder(os.Stdin)
	encoder := json.NewEncoder(os.Stdout)

	var req map[string]interface{}
	if err := decoder.Decode(&req); err != nil {
		log.Printf("Error decoding request: %v", err)
		return
	}

	log.Printf("Received request: %v", PrettyJSON(req))

	method, hasMethod := req["method"].(string)
	id, hasId := req["id"]
	var response map[string]interface{}

	if hasMethod && hasId {
		switch method {
		case "initialize":
			response = map[string]interface{}{
				"jsonrpc": "2.0",
				"id":      id,
				"result": map[string]interface{}{
					"protocolVersion": "2024-11-05",
					"serverInfo": map[string]interface{}{
						"name":    "imagegen-go",
						"version": "1.0.0",
					},
					"capabilities": map[string]interface{}{
						"tools": map[string]interface{}{},
					},
				},
			}
		}

	}

	if response != nil {
		log.Printf("Sending response: %v", PrettyJSON(response))

		err := encoder.Encode(response)

		if err != nil {
			log.Printf("Error encoding response: %v", err)
		}
	}

}

Let’s break down this initialization code:

Stream Setup

Request Handling

Method Dispatch**

Response Structure

The PrettyJSON helper function (not shown above) helps with logging formatted JSON for debugging:

Building and Configuring the Server

Build the Binary

First, let’s compile our Go server into a binary.

go build -o ./bin/imagegen-go ./main

Configure Claude Desktop

Since Claude Desktop is currently the main client available for testing MCP servers, we’ll need to set it up:

Install Claude Desktop

Update Configuration

The Claude Desktop configuration. For mac the file is located at:

~/Library/Application Support/Claude/claude_desktop_config.json

If this file doesn’t exist, you’ll need to create it. Edit the file to point to your MCP server:

{
    "mcpServers": {
    "imagegen-go": {
        "command": "/absolute/path/to/your/binary/bin/imagegen-go"
    }
    }
}

Replace /absolute/path/to/your/binary with the actual path to your compiled binary. For example:

{
    "mcpServers": {
    "imagegen-go": {
        "command": "/Users/username/Projects/mcp/myservers/imagegen-go/bin/imagegen-go"
    }
    }
}

Restart Claude Desktop

At this point, you might see an error about the imagegen-go MCP server - this is expected since we haven’t implemented all the required functionality yet. The important thing is that we can still see the server logs to verify the initialization request is being received.

Check the Log File

Since we’ve added logging to our server, we can inspect the protocol messages. When you start Claude desktop with this configuration, you should see something like this in the logs:

2025/01/02 05:11:34 Received request: {
    "id": 0,
    "jsonrpc": "2.0",
    "method": "initialize",
    "params": {
        "capabilities": {},
        "clientInfo": {
            "name": "claude-ai",
            "version": "0.1.0"
        },
        "protocolVersion": "2024-11-05"
    }
}
2025/01/02 05:11:34 Sending response: {
    "id": 0,
    "jsonrpc": "2.0",
    "result": {
        "capabilities": {
            "tools": {}
        },
        "protocolVersion": "2024-11-05",
        "serverInfo": {
            "name": "imagegen-go",
            "version": "1.0.0"
        }
    }
}

In the response, this part is particularly important:

"capabilities": {
    "tools": {}
}

This tells the client (Claude Desktop) that our server implements the “tools” capability. In MCP, servers can declare three main capabilities:

By including “tools” in our capabilities, we’re indicating that our server will provide executable functions - in this case, for image generation.

Initialization updates.

Now that we know we are getting the initialization request right, lets reorganize a bit. Use complete structs to parse (rather than generic map[string]) Then we have to process the requests in a loop.

First added struct definitions:

// JSON-RPC 2.0 base types
type JSONRPCRequest struct {
	JSONRPC string      `json:"jsonrpc"`
	ID      interface{} `json:"id"`
	Method  string      `json:"method"`
	Params  interface{} `json:"params,omitempty"`
}

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

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

// MCP specific types for initialize
type InitializeResult struct {
	ProtocolVersion string       `json:"protocolVersion"`
	ServerInfo      ServerInfo   `json:"serverInfo"`
	Capabilities    Capabilities `json:"capabilities"`
}

Then updated the main function:

func main() {
	decoder := json.NewDecoder(os.Stdin)
	encoder := json.NewEncoder(os.Stdout)
	// Set up logging to stderr
	log.SetOutput(os.Stderr)
	log.SetFlags(log.LstdFlags | log.Lmicroseconds)

	log.Printf("Starting imagegen-go MCP server ...")

	for {
		var request JSONRPCRequest
		if err := decoder.Decode(&request); err != nil {
			log.Printf("Error decoding request: %v", err)
			sendError(encoder, nil, ParseError, "Failed to parse JSON")
			break
		}

		log.Printf("Received request: %v", PrettyJSON(request))

		if request.JSONRPC != "2.0" {
			sendError(encoder, request.ID, InvalidRequest, "Only JSON-RPC 2.0 is supported")
			continue
		}

		var response interface{}

		switch request.Method {
		case "initialize":
			response = JSONRPCResponse{
				JSONRPC: "2.0",
				ID:      request.ID,
				Result: InitializeResult{
					ProtocolVersion: "2024-11-05",
					ServerInfo: ServerInfo{
						Name:    "imagegen-go",
						Version: "1.0.0",
					},
					Capabilities: Capabilities{
						Tools: map[string]interface{}{},
					},
				},
			}

		case "notifications/initialized", "initialized":
			log.Printf("Server initialized successfully")
			continue // Skip sending response for notifications

		
		default:
			sendError(encoder, request.ID, MethodNotFound, "Method not implemented")
			continue
		}

		if response != nil {
			log.Printf("Sending response: %v", PrettyJSON(response))
			sendResponse(encoder, response)
		}
	}

	log.Printf("imagegen-go MCP server out of loop...")
}

Persistent Connection

Protocol Handshake

switch request.Method {
case "initialize":
    response = JSONRPCResponse{
        JSONRPC: "2.0",
        ID:      request.ID,
        Result: InitializeResult{...},
    }
case "notifications/initialized", "initialized":
    log.Printf("Server initialized successfully")
    continue  // Skip sending response for notifications

Connection Lifecycle

The loop allows the server to:

  1. Complete the initialization handshake
  2. Stay alive to handle subsequent calls
  3. Maintain state across multiple requests
  4. Properly handle disconnection through the break statement

List the tools

Now, let’s handle tools/list request from the client.

case "tools/list":
    toolSchema := json.RawMessage(`{
        "type": "object",
        "properties": {
            "prompt": {
                "type": "string",
                "description": "Description of the image to generate"
            },
            "width": {
                "type": "number",
                "description": "Width of the image in pixels",
                "default": 512
            },
            "height": {
                "type": "number",
                "description": "Height of the image in pixels",
                "default": 512
            },
            "destination": {
                "type": "string",
                "description": "Path where the generated image should be saved"
            }
        },
        "required": ["prompt", "destination"]
    }`)

    response = JSONRPCResponse{
        JSONRPC: "2.0",
        ID:      request.ID,
        Result: ListToolsResult{
            Tools: []Tool{
                {
                    Name:        "generate-image",
                    Description: "Generate an image using Stable Diffusion based on a text prompt",
                    InputSchema: toolSchema,
                },
            },
        },
    }

Let’s break down how this works:

Tool Definition

Each tool needs three key pieces of information: - Name: The identifier for the tool (“generate-image”) - Description: Human-readable explanation of what the tool does - InputSchema: JSON Schema defining the expected input parameters

Input Schema

Response Structure

When a client like Claude receives this response, it understands:

This enables Claude to:

  1. Know when to use this tool (when the user wants to generate images)
  2. Validate inputs before making the call (ensuring required fields are present)
  3. Provide meaningful feedback to users about what information is needed

For “resources/list” and “prompts/list” we just return empty arrays since we are not implementing it at the moment.

Lets now restart Claude Desktop and check the log.

Log After adding tools/list

2025/01/02 11:47:30.511413 Starting imagegen-go MCP server ...
2025/01/02 11:47:30.512904 Received request: {
    "jsonrpc": "2.0",
    "id": 0,
    "method": "initialize",
    "params": {
        "capabilities": {},
        "clientInfo": {
            "name": "claude-ai",
            "version": "0.1.0"
        },
        "protocolVersion": "2024-11-05"
    }
}
2025/01/02 11:47:30.512932 Sending response: {
    "jsonrpc": "2.0",
    "id": 0,
    "result": {
        "protocolVersion": "2024-11-05",
        "serverInfo": {
            "name": "imagegen-go",
            "version": "1.0.0"
        },
        "capabilities": {
            "tools": {}
        }
    }
}
2025/01/02 11:47:30.514176 Received request: {
    "jsonrpc": "2.0",
    "id": null,
    "method": "notifications/initialized"
}
2025/01/02 11:47:30.514185 Server initialized successfully
2025/01/02 11:47:30.515453 Received request: {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list",
    "params": {}
}
2025/01/02 11:47:30.515554 Sending response: {
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "tools": [
            {
                "name": "generate-image",
                "description": "Generate an image using Stable Diffusion based on a text prompt",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "prompt": {
                            "type": "string",
                            "description": "Description of the image to generate"
                        },
                        "width": {
                            "type": "number",
                            "description": "Width of the image in pixels",
                            "default": 512
                        },
                        "height": {
                            "type": "number",
                            "description": "Height of the image in pixels",
                            "default": 512
                        },
                        "destination": {
                            "type": "string",
                            "description": "Path where the generated image should be saved"
                        }
                    },
                    "required": [
                        "prompt",
                        "destination"
                    ]
                }
            }
        ]
    }
}
2025/01/02 11:47:31.680344 Received request: {
    "jsonrpc": "2.0",
    "id": 2,
    "method": "resources/list",
    "params": {}
}
2025/01/02 11:47:31.680520 Sending response: {
    "jsonrpc": "2.0",
    "id": 2,
    "result": {
        "resources": []
    }
}
2025/01/02 11:47:31.709596 Received request: {
    "jsonrpc": "2.0",
    "id": 3,
    "method": "prompts/list",
    "params": {}
}
2025/01/02 11:47:31.709670 Sending response: {
    "jsonrpc": "2.0",
    "id": 3,
    "result": {
        "prompts": []
    }
}
2025/01/02 11:47:32.050237 Received request: {
    "jsonrpc": "2.0",
    "id": 4,
    "method": "resources/list",
    "params": {}
}
2025/01/02 11:47:32.050267 Sending response: {
    "jsonrpc": "2.0",
    "id": 4,
    "result": {
        "resources": []
    }
}
2025/01/02 11:47:32.050830 Received request: {
    "jsonrpc": "2.0",
    "id": 5,
    "method": "tools/list",
    "params": {}
}

After the initialization sequence, it sends the tools/list request and we respond with our tool and parameter details.

However, the client is sending “prompts/list” and “resources/list” again and again although we already said we don’t have any.

The “tools/call” Implementation

Now let’s implement the core functionality of our server: image generation. The implementation follows a straightforward flow - collect parameters from the request, call the OpenAI API to generate the image, and save it to a specified location.

Here’s our implementation of the tools/call handler:

case "tools/call":
    log.Printf("Handling tools/call request")
    params, ok := request.Params.(map[string]interface{})
    if !ok {
        log.Printf("Error: Invalid parameters type: %T", request.Params)
        sendError(encoder, request.ID, InvalidParams, "Invalid parameters")
        continue
    }

    toolName, ok := params["name"].(string)
    if !ok {
        log.Printf("Error: Tool name not found or invalid type: %T", params["name"])
        sendError(encoder, request.ID, InvalidParams, "Invalid tool name")
        continue
    }
    log.Printf("Tool requested: %s", toolName)

    if toolName != "generate-image" {
        log.Printf("Error: Unknown tool requested: %s", toolName)
        sendError(encoder, request.ID, MethodNotFound, "Unknown tool")
        continue
    }

    args, ok := params["arguments"].(map[string]interface{})
    if !ok {
        log.Printf("Error: Invalid arguments type: %T", params["arguments"])
        sendError(encoder, request.ID, InvalidParams, "Invalid arguments")
        continue
    }
    log.Printf("Received arguments: %v", PrettyJSON(args))

    prompt, ok := args["prompt"].(string)
    if !ok || prompt == "" {
        log.Printf("Error: Invalid or empty prompt: %v", args["prompt"])
        sendError(encoder, request.ID, InvalidParams, "Prompt is required")
        continue
    }
    log.Printf("Processing prompt: %s", prompt)

    // Get destination path or use filename from sanitized prompt
    var destPath string
    if dest, ok := args["destination"].(string); ok && dest != "" {
        destPath = dest
        log.Printf("Using provided destination path: %s", destPath)
    } else {
        // Create filename from sanitized prompt
        sanitized := sanitizeFilename(prompt)
        defaultPath := os.Getenv("DEFAULT_DOWNLOAD_PATH")
        if defaultPath == "" {
            homeDir, err := os.UserHomeDir()
            if err != nil {
                log.Printf("Error getting user home directory: %v", err)
                sendError(encoder, request.ID, InternalError, "Could not determine default path")
                continue
            }
            defaultPath = filepath.Join(homeDir, "Downloads")
        }
        destPath = filepath.Join(defaultPath, sanitized+".webp")
        log.Printf("Using generated destination path: %s", destPath)
    }

    // Generate unique filename with error handling
    fullPath, err := generateUniqueFilename(destPath, prompt)
    if err != nil {
        log.Printf("Error generating unique filename: %v", err)
        sendError(encoder, request.ID, InternalError, fmt.Sprintf("Error generating filename: %v", err))
        continue
    }
    log.Printf("Generated unique filename: %s", fullPath)

    // Get dimensions with more detailed logging
    width := 1920
    height := 1080
    if w, ok := args["width"].(float64); ok {
        width = int(w)
        log.Printf("Using provided width: %d", width)
    } else {
        log.Printf("Using default width: %d", width)
    }
    if h, ok := args["height"].(float64); ok {
        height = int(h)
        log.Printf("Using provided height: %d", height)
    } else {
        log.Printf("Using default height: %d", height)
    }

    // Generate image
    log.Printf("Starting image generation with OpenAI...")
    imageURL, err := openai.GenerateImage(prompt, width, height)
    if err != nil {
        log.Printf("Error generating image: %v", err)
        sendError(encoder, request.ID, InternalError, fmt.Sprintf("Error generating image: %v", err))
        continue
    }
    log.Printf("Successfully generated image URL: %s", imageURL)

    // Download image
    log.Printf("Starting image download...")
    if err := openai.DownloadImage(imageURL, fullPath); err != nil {
        log.Printf("Error downloading image: %v", err)
        sendError(encoder, request.ID, InternalError, fmt.Sprintf("Error saving image: %v", err))
        continue
    }
    log.Printf("Successfully downloaded image to: %s", fullPath)

    response = JSONRPCResponse{
        JSONRPC: "2.0",
        ID:      request.ID,
        Result: CallToolResult{
            Content: []ToolContent{
                {
                    Type: "text",
                    Text: fmt.Sprintf("Image generated and saved to: %s", fullPath),
                },
            },
        },
    }
    log.Printf("Sending successful response for image generation")

Let’s understand what’s happening in this implementation:

Parameter Validation

Path Management

Image Generation

Configuration Updates

To use this functionality, we need to update our Claude Desktop configuration with the required environment variables. Modify your claude_desktop_config.json to include:

{
  "mcpServers": {
   "imagegen-go": {
      "command": "/Users/user/imagegen-go/bin/imagegen-go",
      "env": {
        "OPENAI_API_KEY": "xxxx",
        "DEFAULT_DOWNLOAD_PATH":"/Users/user/Downloads"
      }
    }
  }
}

This configuration provides two essential environment variables:

The server will use these settings to authenticate with OpenAI and determine where to save generated images when no specific destination is provided in the request.

Interesting observations

The image dimensions are optional. However when the prompt does not include the dimensions, claude seem to insert the dimensions on its own

Generate image with simple prompt

Then I updated the descriptions of the parameters and mentioned that the parameter is optional. May be it would have worked? The next call didn’t have dimension parameters:

No dimention parameters

I had a talk with claude itself about the behaviour:

Dimension discussion

Timeout and cancelled notifications

The API call to generate the image and the image download takes some time. This can cause the client to have timeout and sends a “cancelled” notification. Initially, this call was not handled and resulted in responding with error.

case "cancelled":
    if params, ok := request.Params.(map[string]interface{}); ok {
        log.Printf("Received cancellation notification for request ID: %v, reason: %v",
            params["requestId"], params["reason"])
    } else {
        log.Printf("Received cancellation notification with invalid params")
    }
    continue // Skip sending response for notifications

The real fix would be to keep being responsive by handling the long running request in another goroutine.

Surprises

I discovered something fascinating about Claude’s image generation capabilities - it’s quite the artist when it comes to enhancing prompts! Even when I toss a basic description its way, Claude knows exactly how to jazz it up for better results.

Here’s a perfect example. I gave Claude this simple prompt:

Can you generate an image of a riverside home in cinematic style?

And look at how Claude transformed it when talking to DALL-E:

{
  `prompt`: `A cinematic shot of a beautiful riverside home, golden hour lighting, reflections in the calm water, architectural photography style, high end real estate photography, high detail, photorealistic, artful composition, dramatic lighting`
}

The result? Judge for yourself: improved prompt

This opens up some really cool possibilities - you could feed Claude an entire story and have it generate images for each scene, adding all those professional photography touches automatically!

Claude understands OpenAI’s content filters!

Here’s something that really made me laugh - Claude actually understands OpenAI’s content filters! In one attempt, OpenAI’s filter blocked Claude’s first prompt because it contained the word “whip”. Without missing a beat, Claude figured out what triggered the filter, rewrote the prompt, and got the image generated successfully.

Auto-corrects

Through this article, we’ve built a complete MCP server in Go that leverages OpenAI’s image generation capabilities. But more importantly, we’ve uncovered some fascinating insights about how MCP enables natural interaction between LLMs and external tools.

What makes this implementation particularly interesting is how it demonstrates the evolving relationship between different AI components:

The server implementation shows how to handle various edge cases gracefully, from timeouts to content filter issues. Claude’s ability to automatically work around content filters shows how LLMs can adapt to system constraints.

While our implementation focused on stdio transport and basic image generation, it demonstrates the core principles of MCP: standardized communication between AI models and tools.

Looking ahead, there are several ways this server could be enhanced:

The source code for this implementation is available on GitHub, and I encourage you to experiment with it.

MCP is opening up new possibilities for how we interact with AI systems, and I’m excited to see what the community builds next!