Unlocking LLM Tool-Calling with Go and MCP Explained in 10 Minutes

In this article, I'm going to show you how you can give tool-calling abilities to Claude Desktop or any other MCP-enabled application using Go.

What is the Model Context Protocol (MCP)?

The Model Context Protocol, or MCP, was created by Anthropic. It's a protocol that defines a standardized way to create tool-calling abilities for different LLM applications. You can think of it as a USBC for LLMs. If you need to give your LLM some sort of ability, like searching the web or interacting with an API, you can use MCP to write tools to give your LLM applications this ability.

When Anthropic launched MCP, they provided a TypeScript and Python SDK. As a Go enthusiast, I decided to write a Go SDK. In this article, we're going to write an MCP server in Go that will give an LLM access to an isolated Python execution environment.

This is basically like God Mode for an LLM. If our LLM needs access to outside data or some sort of real-time information, it simply needs to write some Python code, pass it to this tool that we'll define, and the tool will execute the Python code and return the result to the LLM. The LLM can then use this context to give you better insights or information.

Building the Go MCP Server

First, I've created a brand new directory called python-mcp-server. We'll initialize a Go module here.

go mod init python-mcp-server

Now, let's edit our main.go file.

Setting Up main.go

We'll set the package to main and import several dependencies. We'll need context, flag, format, log, os, os/exec, path, and strings. We will also import the mcp and server packages from the Go MCP SDK.

package main

import (
    "context"
    "flag"
    "fmt"
    "log"
    "os"
    "os/exec"
    "path"
    "strings"

    "github.com/edwardzjl/mcp/pkg/mcp"
    "github.com/edwardzjl/mcp/pkg/server"
)

Next, let's create the main function. The first thing we want to do is parse some command-line flags. The sse mode flag allows you to run your MCP server as an HTTP server in Server-Side Events (SSE) mode. Currently, there are no official clients that can access MCP servers via HTTP, but the SDK allows for this functionality, and it's likely that future clients and hosts will support it.

To create an MCP server, you simply call the NewMCPServer function.

func main() {
    sse := flag.Bool("sse", false, "run in sse mode")
    flag.Parse()

    mcpServer := server.NewMCPServer()

    // ... tool definition and server execution logic will go here
}

Defining the Python Execution Tool

Right now, the server has no tool-calling abilities because we haven't defined any tools. The next step is to define a tool with a name, a description, and any arguments that this tool is supposed to take. This allows the LLM to know which arguments to pass and when to actually use the tool.

Here is the tool's description:

Execute Python code in an isolated environment. Playwright and a headless browser are available for web scraping. Use this tool when you need real-time information. Only output printed to standard out or standard error is returned, so always use print statements. Please note: all code is run in an ephemeral container, so modules and code do not persist.

When you give descriptions to your tools, you want to give as much context as possible to the LLM so it knows when and when not to use the tool, and how to use it.

To define the arguments, the SDK has helper functions. We'll use WithString to define a required string argument named code and another optional string argument named modules for any external Python packages.

    pythonTool := server.NewTool("execute_python").
        WithDescription("Execute Python code in an isolated environment. Playwright and headless browser are available for web scraping. Use this tool when you need real-time information. Only output printed to standard out or standard error is returned, so always use print statements. Please note all code is run in an ephemeral container so modules and code do not persist.").
        WithArg(server.NewStringArg("code").
            WithDescription("The Python code to execute.").
            WithRequired(true)).
        WithArg(server.NewStringArg("modules").
            WithDescription("A comma-separated list of Python modules to install before execution (e.g., 'requests,beautifulsoup4')."))

Next, we need to add the tool to our previously defined MCP server using the AddTool function. We pass the pythonTool variable and a handler function, handlePythonExecution, which we will define later. This tells the MCP server that whenever a request is made to run the execute_python tool, it should execute the logic in our handler.

    mcpServer.AddTool(pythonTool, handlePythonExecution)

Running the Server

The last part of our main function is to actually run the server. If the user starts the server in SSE mode, we'll start an HTTP server. Otherwise, we'll run it as a normal standard I/O server.

    if *sse {
        fmt.Println("Starting server on localhost:8080")
        if err := mcpServer.ListenAndServe("localhost:8080"); err != nil {
            log.Fatalf("failed to run http server: %v", err)
        }
    } else {
        if err := mcpServer.Run(); err != nil {
            log.Fatalf("failed to run stdio server: %v", err)
        }
    }

Implementing the Execution Handler

Now, let's define our handlePythonExecution function. This function will take a context and a request of type mcp.ToolCallRequest.

Note: The code for this specific tool is adapted from a Go project by GitHub user Nate-XCVI, who created an agent framework with numerous predefined tools. By adapting this tool, we can plug it into various LLMs and clients that support the MCP protocol.

First, we need to grab the code argument from the list of arguments. If it doesn't exist, we return an error. Then, we grab any optional modules.

func handlePythonExecution(ctx context.Context, req mcp.ToolCallRequest) (map[string]any, error) {
    code, ok := req.Arguments["code"].(string)
    if !ok {
        return nil, fmt.Errorf("code argument is required")
    }

    var modulesToInstall []string
    if modules, ok := req.Arguments["modules"].(string); ok && modules != "" {
        modulesToInstall = strings.Split(modules, ",")
    }
// ... execution logic continues

Next, we use the os package to create a temporary directory. We want to use an isolated environment and not touch anything on our main machine. We'll defer its removal to clean up afterward.

    tempDir, err := os.MkdirTemp("", "python-exec-")
    if err != nil {
        return nil, fmt.Errorf("failed to create temp dir: %w", err)
    }
    defer os.RemoveAll(tempDir)

Now for the fun part: we take the code that the LLM passed to the tool and write it to a script.py file inside our temporary directory.

    scriptPath := path.Join(tempDir, "script.py")
    if err := os.WriteFile(scriptPath, []byte(code), 0644); err != nil {
        return nil, fmt.Errorf("failed to write script: %w", err)
    }

The next set of lines sets up the command arguments to be passed to Docker, which we'll use to run our Python script in an isolated environment. We use docker run, ensure the container is removed after execution (--rm), and mount our temporary directory to /app inside the container. We use a pre-built Docker container that has Python and Playwright installed.

    var cmdArgs []string
    cmdArgs = append(cmdArgs, "run", "--rm", "-v", fmt.Sprintf("%s:/app", tempDir), "mcr.microsoft.com/playwright/python:v1.44.0-jammy")

    shellCmds := []string{}
    if len(modulesToInstall) > 0 {
        installCmd := fmt.Sprintf("python3 -m pip install %s", strings.Join(modulesToInstall, " "))
        shellCmds = append(shellCmds, installCmd)
    }
    shellCmds = append(shellCmds, "python3 /app/script.py")

    cmdArgs = append(cmdArgs, "sh", "-c", strings.Join(shellCmds, " && "))

The final step is to run this command using the exec package from Go's standard library and return the output.

    cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
    output, err := cmd.CombinedOutput()
    if err != nil {
        return nil, fmt.Errorf("failed to execute script: %v\nOutput: %s", err, string(output))
    }

    return map[string]any{"output": string(output)}, nil
}

Compiling and Installing the Tool

Before we can compile this, we need to pull in the external dependencies by running go mod tidy.

go mod tidy

To make this tool available anywhere on our machine, we can run go install.

go install

Putting It All to the Test

Now that the python-mcp-server is installed, we can configure a host application to use it. Here is an example configuration file for an MCP host:

servers:
  - name: python_repl
    command: python-mcp-server

Let's run the host with Anthropic's Claude 3.5 Sonnet model. You can see that it initialized the python_repl server.

Now, let's ask Claude a question:

User: Can you tell me the top 10 articles on Hacker News?

The LLM will think for a bit and then use the python_repl tool to get that external information. Initially, it might try using Playwright. If that fails (perhaps due to network configurations), a smart LLM will try again with a different approach.

In this case, it retries with the requests and beautifulsoup4 libraries.

# LLM-generated code
import requests
from bs4 import BeautifulSoup

url = "https://news.ycombinator.com/"
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')

articles = []
for item in soup.select('.athing'):
    title_element = item.select_one('.titleline > a')
    if title_element:
        title = title_element.get_text()
        articles.append(title)

for i, title in enumerate(articles[:10], 1):
    print(f"{i}. {title}")

It successfully grabs the articles from Hacker News. Comparing the output with the live website, we can see the results match:

  1. We use our own hardware
  2. The Essays of Michel de Montaigne
  3. Slow deployment causes meetings
  4. Murder Mystery City
  5. Roads ... and so on.

Let's ask a follow-up question:

User: Can you tell me more about number seven?

The LLM first tries to fetch more information about the article. It might find that the rankings have changed and will be smart enough to rewrite the script to get the correct article. If it runs into issues scraping the full content, it might summarize the article instead.

As you can see, you can do quite a bit with just giving your LLM the ability to write and execute code on the fly. However, it's not a perfect solution, and sometimes the tool and the LLM can run into issues. In the future, one could explore creating more specialized agents using MCP that make very specific calls in very specific situations, rather than relying on a magical, general-purpose Python instance.

And that's it! That's how you can create a very simple yet powerful MCP server using Go. If you were inspired by this article, it would be great to see what kinds of tools and agents you build.