The Applied Go Weekly Newsletter logo

The Applied Go Weekly Newsletter

Subscribe
Archives
August 5, 2025

You're Lucky: Full Moon Tonight! • The Applied Go Weekly Newsletter 2025-08-03

AppliedGoNewsletterHeader640.png

Your weekly source of Go news, tips, and projects

2025-08-03 Newsletter Badge.png

You're Lucky: Full Moon Tonight!

Hi ,

This is the second of four 🌴 "Summer Break" 🌴 issues with reduced content. As you may have guessed when you read the previous issue, the topic for this summer break is "AI", as I want to explore the various ways of using AI and Go together.

As a happy coincidence, Charm_, the makers of Bubble Tea and other popular terminal UI packages, released Crush, an open source AI-assisted coding client for the terminal, similar to Claude Code. I see some of you rolling their eyes in annoyance, vividly remembering how Andrej Karpathy's "vibe coding" tweet lead to helplessly underskilled people creating apps that leak credentials, letting LLMs delete the production database, and whatnot.

But hey, a tool can only be as good as the craftsperson who wields it. A few basic measures help to keep code-executing chatterboxes under control, including using sandboxed environments, manually acknowledging each and every tool use, and meticulously inspecting all code an LLM produces with the inquisitive eyes of a seasoned software developer.

But I digress. Back to Crush! When I first heard of Crush, I decided to use it to generate the code for this newsletter's Spotlight (which, not coincidentally, is about writing an MCP server).

Maybe this was a mistake, as I found myself wrestling with a new LLM coding platform, a protocol still unknown to me, and creative stochastic parrots (a.k.a. LLMs) all at once. But I learned quite a few things about each of them. About MCP, I learned what the minimum viable set of requests is that an MCP server must respond to.

More about this in the Spotlight right here:

Spotlight: How to write an MCP server in Go

At its core, an LLM is a chatbot. It takes text input, mingles it with an enormous pile of knowledge, and returns a text response.

"What if LLMs could do more than that! We need to give them eyes and hands so that they can explore more of our world and even change it"! LLM proponents dreamt of connecting LLMs to tools, and in an attempt to avoid uncontrolled proliferation of APIs and protocols, Anthropic (the makers of the Claude family of LLMs) dashed ahead and released the Model Context Protocol (MCP) to connect LLMs with tools in a unified way.

Since then, the MCP has gained widespread adoption: Models became tools-aware, and LLM client apps turned into hubs that connect LLMs with tools through MCP servers. An MCP server is an app that wraps a tool—that can be as simple as an ls command or as powerful as a web search engine—with a standard API that LLMs know how to use.

With MCP's popularity came Go SDKs: While the official SDK is still a work in progress (as of this writing, it's at v0.2.0 and may observe breaking changes), other SDKs have gained widespread use, including mark3labs/mcp-go, metoro-io/mcp-golang, ThinkInAIXYZ/go-mcp, and (rather new) deepnoodle-ai/dive.

In this Spotlight, I want to explore how a minimal MCP implementation works. I will, therefore, use none of the aforementioned Go modules but rather implement an MCP server with the standard library only. I would, however, invite you to explore the SDKs once you're familiar with the concepts demonstrated here, as the MCP protocol contains many more features (and implementation nuances) than this little demo server.

The MCP server I'm going to walk you through shall take a date and time and return information about the phase of the moon at that date. Specifically, it shall return -

  • The "age" of the moon (in days since new moon)

  • The percentage of illuminated surface

How LLMs talk with an MCP server

The MCProtocol can use three transports: stdio, http, and sse (server-side-events). So you could write a simple CLI tool that reads from stdin and writes to stdout, as about any UNIX command does, and your LLMs could talk to it. (Via an MCP client, of course, that exposes MCP servers to LLMs.)

I decided for writing an HTTP-based MCP server, so that it can either run locally or remotely.

Regardless of the transport used, the content has always the same structure based on the JSON-RPC 2.0 specification.

All messaging is based on request, response, and notification types:

Requests

Requests contain a unique ID (that the server must include in the response), a method to call, and optional parameters, as this schema shows:

{
  jsonrpc: "2.0";
  id: string | number;
  method: string;
  params?: {
    [key: string]: unknown;
  };
}

Responses

Responses contain the requests's unique ID and either a result or an error:

{
  jsonrpc: "2.0";
  id: string | number;
  result?: {
    [key: string]: unknown;
  }
  error?: {
    code: number;
    message: string;
    data?: unknown;
  }
}

Notifications

Notifications are one-way messages that either party (client or server) can send to the other. The receive of a notification must not send a response. (Think "UDP" if you're in networking.)

The minimal set of messages an MCP server implementation needs

I wanted to keep the code minimal, so I needed to know the absolute minimum of message types to process.

As per the Lifecycle documentation, a complete life cycle includes three phases: initialization, operation, and shutdown.

Initialization

In the initialization phase, the MCP server receives an initialize method from the client including the client's capabilities and implementation information. The server responds with its own capabilities and information. Finally, the client sends an initialized notification to conclude the intialization phase.

Operation

During the operation phase, clients can discover and invoke tools through two methods: tools/list and tools/call. These two methods are the bare minimum of methods that an MCP server must implement for the operation phase.

tools/list requests to list all available tools. Our moonphase server lists only one tool: moonphase.

tools/call invokes a specific tool by name and with optional parameters. moonphase takes a datetime value as parameter.

Shutdown

This phase is optional. The client can tell the server that it ends the session by closing the underlying connection. The server can decide to take measures like cleaning up or even exiting. The moonphase server does not do anything when the client closes the connection as other clients might still want to connect.

Security considerations

Security should be taken seriously, and while authorization with OAuth 2.0 is optional, every MCP server should implement a minimal level of security and follow security best practices.

Our moonphase server uses an API key in an X-Api-Token header to provide a bare minimum of security, to keep the code short. Production-grade MCP servers should use OAuth.

A moonphase MCP server

With all the relevant MCP concepts in mind, let's move on to create some code! As this project is AI-related and Crush came out recently, I decided to turn this into an AI-assisted coding experiment.

TL;DR: Claude Sonnet and Kimi K2 know something about MCP but both still do a lot of guesswork, so after a few rounds, I ended up with a spec.md grown quite large, but both LLMs finally did a decent job of implementing a functioning MCP server.

I picked Claude's code and (manually) merged a design idea from Kimi into the code, then tweaked the code a bit until the result looked clean enough.

Here we go:

Types

Let me start with listing the types the code uses. The first three types define the JSON-RPC message structure:

ype JSONRPCRequest struct {
    JSONRPC string         `json:"jsonrpc"`
    ID      any            `json:"id"`
    Method  string         `json:"method"`
    Params  map[string]any `json:"params,omitempty"`
}

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

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

When an MCP client asks the server to list its tools, the server replies with the following data structure:

type Tool struct {
    Name        string      `json:"name"`
    Title       string      `json:"title"`
    Description string      `json:"description"`
    InputSchema InputSchema `json:"inputSchema"`
}

type InputSchema struct {
    Type       string              `json:"type"`
    Properties map[string]Property `json:"properties"`
    Required   []string            `json:"required,omitempty"`
}

type Property struct {
    Type        string `json:"type"`
    Description string `json:"description"`
}

A request to tools/list and tools/call can return multiple tools and call results, respectively.

Call results can contain text, image, or other data. Our moonphase server returns only text content:

type ToolsListResult struct {
    Tools []Tool `json:"tools"`
}

type ToolCallResult struct {
    Content []Content `json:"content"`
    IsError bool      `json:"isError"`
}

type Content struct {
    Type string `json:"type"`
    Text string `json:"text"`
}

Poor man's moon phase calculator

Here is, in all conciseness, the function that (roughly) caluclates the moon phase on a given date:

func calculateMoonPhase(t time.Time) (age float64, illumination int) {
    knownNewMoon := time.Date(2000, 1, 6, 18, 14, 0, 0, time.UTC)
    lunarCycle := 29.53059
    daysSince := t.Sub(knownNewMoon).Hours() / 24.0
    age = math.Mod(daysSince, lunarCycle)
    if age < 0 {
        age += lunarCycle
    }
    phase := (age / lunarCycle) * 2 * math.Pi
    illumination = int(math.Round((1 - math.Cos(phase)) / 2 * 100))
    return age, illumination
}

HTTP handler helpers

Before we define the HTTP handler, let's look at three helper functions.

First, a simple middleware handler function that verifies the API key in the request:

func requireAPIKey(apiKey string, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {

        providedKey := r.Header.Get("X-Api-Token")
        if providedKey != apiKey {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        next(w, r)
    }
}

Then, two functions to reduce repetition when constructing MCP result and error responses:

func mcpResult(id any, result any) JSONRPCResponse {
    return JSONRPCResponse{
        JSONRPC: "2.0",
        ID:      id,
        Result:  result,
    }
}

func mcpError(id any, code int, message string) JSONRPCResponse {
    return JSONRPCResponse{
        JSONRPC: "2.0",
        ID:      id,
        Error: &JSONRPCError{
            Code:    code,
            Message: message,
        },
    }
}

All requests go to /mcp

Nothing unusual so far. Now we come to the HTTP handler. The only endpoint the MCP server exposes is /map, and it only accepts POST requests.

All further "routing" to initialization and tools list and call operations is done within the handler.

Note that in the method switch statement, the handler doesn't return anything in the notifications/initialized case, as required by the spec.

I sprinkled some log statements across this and the following functions so that you can watch the MCP server react to client requests if you try out the code:

func mcpHandler(w http.ResponseWriter, r *http.Request) {

    var req JSONRPCRequest
    var response JSONRPCResponse

    w.Header().Set("Content-Type", "application/json")

    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        log.Println("JSON parse error")
        json.NewEncoder(w).Encode(mcpError(nil, -32700, "Parse error"))
        return
    }

    if req.JSONRPC != "2.0" {
        log.Println("Invalid JSON-RPC version")
        json.NewEncoder(w).Encode(mcpError(req.ID, -32600, "Invalid JSON-RPC version"))
        return
    }

    switch req.Method {
    case "initialize":
        log.Println("initialize")
        response = handleInitialize(req)
    case "notifications/initialized":
        log.Println("Client initialized")
        // don't respond to notifications
        return
    case "tools/list":
        log.Println("tools/list")
        response = handleToolsList(req)
    case "tools/call":
        log.Println("tools/call")
        response = handleToolsCall(req)
    default:
        log.Println("Method", req.Method, "not found")
        response = mcpError(req.ID, -32601, "Method not found: "+req.Method)
    }

    json.NewEncoder(w).Encode(response)
}

Funny side note: Claude and Kimi did not quite know how to implement MCP method calls, so in a first attempt, they created separate endpoints tools/list and tools/call until I clarified that point in the spec.md file I used to describe the project. At least they tried...

The Initialize phase: How to know each other

The MPC server handles initialization by responding to a client request for the method initialize with a data structure that lists the server's capabilities and other information.

Note that there is only a tools capability listed, and this one is apparently empty! This is no error. tools is just a capability, not a list of actual tools (this one comes later). The existence of the tools entry simply tells the client that the server provides tools.

func handleInitialize(req JSONRPCRequest) JSONRPCResponse {
    result := map[string]any{
        "protocolVersion": "2024-11-05",
        "capabilities": map[string]any{
            "tools": map[string]any{},
        },
        "serverInfo": map[string]any{
            "name":    "moonphase-mcp-server",
            "version": "1.0.0",
        },
    }
    return mcpResult(req.ID, result)
}

Show me your tools!

After initialization, the MCP client usually wants to know what tools the server provides. Interesting to see that the Description fields are quite an important part of the tools definition, as they tell the LLM what the tool is about and how to use it.

In "classic" API contexts, the client code couldn't care less about description fields. But here, they are indispensable.

func handleToolsList(req JSONRPCRequest) JSONRPCResponse {
    tools := []Tool{
        {
            Name:        "moonphase",
            Title:       "Moon Phase Calculator",
            Description: "Calculate the phase of the moon for a given date and time",
            InputSchema: InputSchema{
                Type: "object",
                Properties: map[string]Property{
                    "datetime": {
                        Type:        "string",
                        Description: "Date and time in RFC3339 format (optional, defaults to current time)",
                    },
                },
            },
        },
    }

    result := ToolsListResult{Tools: tools}
    return mcpResult(req.ID, result)
}

"Be careful! New moon tonight."

Now comes the most important method: tools/call. Here, we call the moonphase function with the current date and return the moon's age and percentage of illuminated surface.

Most of the code consists of request validation and result construction, built around a call to the moonphase function:

func handleToolsCall(req JSONRPCRequest) JSONRPCResponse {
    name, ok := req.Params["name"].(string)
    if !ok {
        log.Println("Invalid params: missing tool name")
        return mcpError(req.ID, -32602, "Invalid params: missing tool name")
    }

    if name != "moonphase" {
        log.Println("Unknown tool:", name)
        return mcpError(req.ID, -32602, "Unknown tool")
    }

    arguments, _ := req.Params["arguments"].(map[string]any)

    var targetTime time.Time
    if datetimeStr, ok := arguments["datetime"].(string); ok && datetimeStr != "" {
        var err error
        targetTime, err = time.Parse(time.RFC3339, datetimeStr)
        if err != nil {
            log.Println("Invalid datetime format:", datetimeStr)
            return mcpError(req.ID, -32602, "Invalid datetime format, use RFC3339")
        }
    } else {
        targetTime = time.Now()
    }

    log.Println("Calculating moon phase for", targetTime)
    age, illum := calculateMoonPhase(targetTime)

    text := fmt.Sprintf("Moon phase for %s:\nAge: %.1f days\nIllumination: %d%%",
        targetTime.Format("2006-01-02 15:04:05 MST"),
        age,
        illum)

    result := ToolCallResult{
        Content: []Content{
            {
                Type: "text",
                Text: text,
            },
        },
        IsError: false,
    }

    return mcpResult(req.ID, result)
}

Start the engines!

Finally: func main(). All we need to do here is read the API key from the environment, wire up the handlers, and start the server:

func main() {
    apiKey := os.Getenv("MOONPHASE_API_KEY")
    if apiKey == "" {
        log.Fatalln("MOONPHASE_API_KEY not set")
    }
    http.HandleFunc("POST /mcp", requireAPIKey(apiKey, mcpHandler))

    // Return 404 for all other routes and verbs
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/mcp" {
            http.NotFound(w, r)
        }
        if r.Method != "POST" {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    })

    port := "8181"
    log.Printf("MCP server starting on port %s", port)
    log.Fatalln(http.ListenAndServe(":"+port, nil))
}

To quickly test the MCP server, you can run the code right from the source, but ensure to set the MOONPHASE_API_KEY variable, for example:

MOONPHASE_API_KEY=<some-api-key> go run .

I used Crush as an MCP client for testing. If you do too, then create a local crush.json file with the tool configuration (see the Crush repo's Readme for details):

{
  "$schema": "https://charm.land/crush.json",
  "mcp": {
    "moonphase": {
      "type": "http",
      "url": "http://localhost:8181/mcp",
      "headers": {
        "X-Api-Token": "$MOONPHASE_API_KEY"
      }
    }
  }
}

Luckily, crush.json interprets $env vars, so we don't have t o add the literal API key to the config file.

Now, start crush:

MOONPHASE_API_KEY=<some-api-key> crush

In the start screen, you should now see moonphase listed in the MCPs section.

Try to ask the LLM: "What's the current moon phase?"

If it doesnt seem to want to use the tool, be more specific:

"Use the moonphase tool to determine the current phase of the moon."

Eventually, the LLM should invoke the tool (after you confirm the action) and return something like "Waxing Gibbous":

crush_moonphase.png

As the source code consists of more than one file, I published it in a repository.

Have fun playing with this code. And watch out for werewolves!

Happy coding! ʕ◔ϖ◔ʔ

Questions or feedback? Drop me a line. I'd love to hear from you.

Best from Munich, Christoph

Not a subscriber yet?

If you read this newsletter issue online, or if someone forwarded the newsletter to you, subscribe for regular updates to get every new issue earlier than the online version, and more reliable than an occasional forwarding. 

Find the subscription form at the end of this page.

How I can help

If you're looking for more useful content around Go, here are some ways I can help you become a better Gopher (or a Gopher at all):

On AppliedGo.net, I blog about Go projects, algorithms and data structures in Go, and other fun stuff.

Or visit the AppliedGo.com blog and learn about language specifics, Go updates, and programming-related stuff. 

My AppliedGo YouTube channel hosts quick tip and crash course videos that help you get more productive and creative with Go.

Enroll in my Go course for developers that stands out for its intense use of animated graphics for explaining abstract concepts in an intuitive way. Numerous short and concise lectures allow you to schedule your learning flow as you like.

Check it out.


Christoph Berger IT Products and Services
Dachauer Straße 29
Bergkirchen
Germany

Don't miss what's next. Subscribe to The Applied Go Weekly Newsletter:
LinkedIn
Powered by Buttondown, the easiest way to start and grow your newsletter.