MCP is mostly used for tool calling with LLMs. Therefore, before we dive into MCP, let's first look at how tool calling works with a simple example.
All the following examples use Google's gemini-flash-lite-latest model via the Go GenAI SDK.
Tool Calling ¶
Tool calling allows LLMs to invoke external functions (tools) to perform specific tasks, such as fetching data from an API, performing calculations, or interacting with other services. The LLM decides when to call a tool based on the conversation context.
It's important to note that it's never the LLM itself that executes the tool; rather, it generates a tool call response. After receiving such a special response, the client extracts the tool call details and invokes the corresponding tool. The client then sends the tool's output back to the LLM as part of the conversation, allowing the LLM to continue generating responses based on the new information. It's important that the call to the LLM with the tool call response contains the full conversation history, including the original user prompt and any previous messages. LLMs are stateless and need the full history in the request to maintain context.
Here's an overview of the tool calling flow:
Note that the LLM can decide to call the tool multiple times in a conversation, and it can also choose not to call the tool at all if it deems it unnecessary..
LLMs need to know what tools are available and how to call them. The initial request the application sends to the LLM includes the tool declarations for all available tools. The tool declaration specifies the tool's name, description, and input parameters. Especially the description is important, as it helps the LLM understand when and how to use the tool.
With the Go GenAI SDK, a tool declaration looks like this:
weatherFunc := &genai.FunctionDeclaration{
Name: "get_weather",
Description: "Get current weather information for a location. You can provide either latitude/longitude coordinates OR city/country ISO-3166-1 alpha2 code. If you know the coordinates, provide them directly. If you only have the city name, you must provide the city name together with the country ISO-3166-1 alpha2 code.",
Parameters: &genai.Schema{
Type: genai.TypeObject,
Properties: map[string]*genai.Schema{
"latitude": {
Type: genai.TypeNumber,
Description: "Latitude coordinate (optional if city is provided)",
},
"longitude": {
Type: genai.TypeNumber,
Description: "Longitude coordinate (optional if city is provided)",
},
"city": {
Type: genai.TypeString,
Description: "City name (optional if latitude/longitude provided)",
},
"country": {
Type: genai.TypeString,
Description: "Country ISO-3166-1 alpha2 code (optional if latitude/longitude provided, mandatory if city name is provided)",
},
},
},
}
config := &genai.GenerateContentConfig{
Tools: []*genai.Tool{{
FunctionDeclarations: []*genai.FunctionDeclaration{weatherFunc},
}},
Temperature: genai.Ptr[float32](0.3),
}
The application then sends this configuration together with the user prompt to the LLM.
result, err := client.Models.GenerateContent(
ctx,
GEMINI_MODEL,
userMessage,
config,
)
The client then processes the LLM's response, checking if it contains tool calls. When a LLM supports parallel tool calls, there may be multiple tool calls in the response. In this example gemini-flash-lite-latest supports parallel tool calls, so the response to the initial request might contain multiple tool calls.
var functionCalls []*genai.Part
for _, part := range candidate.Content.Parts {
if part.FunctionCall != nil {
functionCalls = append(functionCalls, part)
}
}
If there are tool calls in the response, the application iterates over each tool call to see which tool the LLM wants to invoke.
The program extracts the function name and arguments, executes the corresponding tool. In this example there is only one tool, the weather tool, so the code checks if the tool call name matches get_weather. If it does, it extracts the arguments and calls the local weather function:
if part.FunctionCall.Name != "get_weather" {
fmt.Println("Unknown function call:", part.FunctionCall.Name)
continue
}
args := internal.WeatherFunctionArgs{}
if lat, ok := part.FunctionCall.Args["latitude"].(float64); ok {
args.Latitude = &lat
}
if lon, ok := part.FunctionCall.Args["longitude"].(float64); ok {
args.Longitude = &lon
}
if city, ok := part.FunctionCall.Args["city"].(string); ok {
args.City = &city
}
if country, ok := part.FunctionCall.Args["country"].(string); ok {
args.Country = &country
}
weatherData, err := internal.ExecuteWeatherFunction(args)
if err != nil {
return fmt.Errorf("function execution failed: %w", err)
}
weatherResponse := map[string]any{
"temperature": weatherData.Temperature,
"wind_speed": weatherData.WindSpeed,
"wind_direction": weatherData.WindDirection,
"weather_description": weatherData.WeatherDescription,
"time": weatherData.Time,
}
functionResponseParts = append(functionResponseParts, &genai.Part{
FunctionResponse: &genai.FunctionResponse{
Name: part.FunctionCall.Name,
Response: weatherResponse,
},
})
Note: This example calls the free, open-access API from Open-Meteo, which is perfect for learning and experimentation. However, be aware that the free tier has rate limits (10,000 requests per day) and is not suitable for production use. For commercial applications, check out their pricing page for paid plans with higher limits and SLA guarantees.
After calling all requested tools, the application sends the tool responses back to the LLM as part of the conversation history, allowing the LLM to generate an answer based on the new information.
conversationHistory = append(conversationHistory, candidate.Content)
conversationHistory = append(conversationHistory, &genai.Content{
Parts: functionResponseParts,
})
nextResult, err := client.Models.GenerateContent(
ctx,
GEMINI_MODEL,
conversationHistory,
config,
)
The response of this LLM request can be another tool call response if the LLM needs to call more tools based on the new information. In that case, the application repeats the process: it extracts the tool calls, executes the corresponding tools, and sends the results back to the LLM. It continues this loop until the LLM generates a final natural language response without any further tool calls. Make sure to implement a limit to avoid infinite loops. In this example the loop will run up to 3 times before giving up.
What is Model Context Protocol (MCP)? ¶
The tool call flow is a way to extend the capabilities of LLMs. The problem with the code above is that the weather tool is embedded directly in the Go code. If this is a special tool that only makes sense together with this specific application and prompt, then this is fine. However, if the tool is more generic and could be useful in other applications, we have a problem.
We could extract the weather tool into a separate Go module and share it that way. But that only works for other Go applications. What if we want to use the same weather tool in a Python application, a JavaScript application, or a Java application?
Model Context Protocol (MCP) helps us solve this problem. The Model Context Protocol (MCP) is an open standard that enables seamless integration between LLM client applications and external data sources and tools. Developed by Anthropic, MCP provides a universal protocol that allows LLM clients to discover and invoke capabilities exposed by MCP servers. MCP allows us to write tools in any programming language and expose them via MCP servers. These MCP servers can then be consumed by any MCP client application, regardless of the programming language.
MCP introduces a client-server architecture where MCP servers expose tools, resources, and prompts through a standardized protocol. MCP clients can dynamically discover these capabilities at runtime and invoke them as needed. The protocol supports two main transport layers: Standard I/O (stdio) for subprocess communication and Streamable HTTP for network-based communication.
For the following examples, we will use the Go SDK for MCP. A complete SDK for building both MCP servers and clients in Go. You can find SDKs for other languages at MCP SDKs.
MCP Architecture ¶
When using tool calling with MCP, the code of the tool is no longer embedded in the client application. Instead, the tool is hosted on an MCP server, which the client application connects to. The MCP server exposes one or multiple tools via the MCP protocol. The client application discovers the available tools at runtime, sends them as tool definitions to the LLM, and when the LLM generates a tool call response, the client forwards the tool call to the MCP server for execution. The MCP server executes the tool and returns the result to the client, which then sends it back to the LLM.
MCP Transport ¶
There are two main transport protocols supported by MCP:
Standard I/O (stdio) ¶
The client launches the MCP server as a subprocess. The server reads JSON-RPC messages from its standard input (stdin) and sends messages to its standard output (stdout). When you start multiple clients, each client starts its own MCP server process.
Streamable HTTP ¶
With the Streamable HTTP transport, the server operates as an independent process that can handle multiple client connections. Only one instance of the server needs to run at any given time, and multiple clients can connect to it over HTTP.
Building an MCP Server in Go ¶
In this section, we will build a simple MCP server in Go that exposes the weather tool we used in the previous example. To create an MCP server, an application first initializes the server with metadata such as name, version, and instructions.
server := mcp.NewServer(&mcp.Implementation{
Name: "weather-server",
Version: "1.0.0",
}, &mcp.ServerOptions{
Instructions: "Weather information server. Provides current weather data for locations using latitude/longitude coordinates or city/country codes.",
})
Next, it defines the input and output schemas for the tool using Go structs. The Go SDK uses struct tags to generate JSON schemas automatically.
type WeatherToolInput struct {
Latitude *float64 `json:"latitude,omitempty" jsonschema:"Latitude coordinate (optional if city is provided)"`
Longitude *float64 `json:"longitude,omitempty" jsonschema:"Longitude coordinate (optional if city is provided)"`
City *string `json:"city,omitempty" jsonschema:"City name (optional if latitude/longitude provided)"`
Country *string `json:"country,omitempty" jsonschema:"Country ISO-3166-1 alpha2 code (optional if latitude/longitude provided, mandatory if city name is provided)"`
}
type WeatherToolOutput struct {
Temperature float64 `json:"temperature" jsonschema:"Temperature in Celsius"`
WindSpeed float64 `json:"wind_speed" jsonschema:"Wind speed in km/h"`
WindDirection int `json:"wind_direction" jsonschema:"Wind direction in degrees"`
WeatherDescription string `json:"weather_description" jsonschema:"Human-readable weather description"`
Time string `json:"time" jsonschema:"Time of the weather observation"`
}
The application then registers the tool with the server, providing the tool's name, description, and handler function. As before the description is important to help LLMs understand when and how to use the tool.
mcp.AddTool(server, &mcp.Tool{
Name: "get_weather",
Description: "Get current weather information for a location. You can provide either latitude/longitude coordinates OR city/country ISO-3166-1 alpha2 code. If you know the coordinates, provide them directly. If you only have the city name, you must provide the city name together with the country ISO-3166-1 alpha2 code.",
}, handleWeatherTool)
The handler function implements the tool's logic. It receives the input parameters, executes the tool, and returns the structured output.
func handleWeatherTool(
ctx context.Context,
req *mcp.CallToolRequest,
input WeatherToolInput,
) (*mcp.CallToolResult, WeatherToolOutput, error) {
args := internal.WeatherFunctionArgs{
Latitude: input.Latitude,
Longitude: input.Longitude,
City: input.City,
Country: input.Country,
}
result, err := internal.ExecuteWeatherFunction(args)
if err != nil {
return nil, WeatherToolOutput{}, err
}
output := WeatherToolOutput{
Temperature: result.Temperature,
WindSpeed: result.WindSpeed,
WindDirection: result.WindDirection,
WeatherDescription: result.WeatherDescription,
Time: result.Time,
}
return nil, output, nil
}
Finally, the application starts the MCP server using the Streamable HTTP transport. The server listens for incoming client connections and handles tool invocation requests.
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, &mcp.StreamableHTTPOptions{
Stateless: false,
})
addr := ":8080"
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
Testing with the MCP Inspector ¶
Before writing a client, you can test your MCP server using the official inspector tool. Start it with the following command:
npx @modelcontextprotocol/inspector
The inspector opens a web interface in your browser. You can connect it to your MCP server by specifying the server's address (e.g., http://localhost:8080 for connecting to this example server).
The inspector provides a web interface to:
- Connect to your MCP server
- Browse available tools, resources, and prompts
- Test tool invocations with different parameters
- View responses in real-time
You find more information about the MCP Inspector in the official documentation.
Building an MCP Client in Go ¶
In this section, we take a look at how to build an MCP client in Go that connects to the weather MCP server we just built.
The client application first initializes the MCP client and connects to the MCP server over Streamable HTTP. Endpoint specifies the URL of the MCP server.
client := mcp.NewClient(&mcp.Implementation{
Name: "weather-client",
Version: "1.0.0",
}, nil)
transport := &mcp.StreamableClientTransport{
Endpoint: "http://localhost:8080",
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
session, err := client.Connect(ctx, transport, nil)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer session.Close()
The client then can fetch the available tools from the MCP server using the ListTools() method.
result, err := session.ListTools(ctx, nil)
if err != nil {
log.Printf("Failed to list tools: %v", err)
return
}
fmt.Printf("Found %d tool(s):\n", len(result.Tools))
for i, tool := range result.Tools {
fmt.Printf(" %d. Name: %s\n", i+1, tool.Name)
fmt.Printf(" Description: %s\n", tool.Description)
if tool.InputSchema != nil {
schema, _ := json.MarshalIndent(tool.InputSchema, " ", " ")
fmt.Printf(" Input Schema: %s\n", string(schema))
}
}
To call a tool, the client uses the CallTool() method, providing the tool name and input arguments.
result1, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "get_weather",
Arguments: map[string]any{
"latitude": 47.3769,
"longitude": 8.5417,
},
})
if err != nil {
log.Printf("Failed to call tool: %v", err)
} else {
displayToolResult(result1)
}
Integrating MCP with LLM Tool Calling ¶
Now we have all the pieces together to rewrite our initial weather tool calling example using the MCP server we built.
The client application first initializes the Gemini client and connects to the MCP server over Streamable HTTP.
genaiClient, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: apiKey,
Backend: genai.BackendGeminiAPI,
})
if err != nil {
log.Fatalf("Failed to create Gen AI client: %v", err)
}
mcpClient := mcp.NewClient(&mcp.Implementation{
Name: "weather-client-tool",
Version: "1.0.0",
}, nil)
transport := &mcp.StreamableClientTransport{
Endpoint: "http://localhost:8080",
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
session, err := mcpClient.Connect(ctx, transport, nil)
Next, the application fetches the available tools from the MCP server and converts them to Gemini function declarations.
toolsResult, err := session.ListTools(ctx, nil)
if err != nil {
log.Fatalf("Failed to list tools: %v", err)
}
if len(toolsResult.Tools) == 0 {
log.Fatal("No tools available on MCP server")
}
var functionDeclarations []*genai.FunctionDeclaration
for _, tool := range toolsResult.Tools {
funcDecl := convertMCPToolToGeminiFunction(*tool)
functionDeclarations = append(functionDeclarations, funcDecl)
}
config := &genai.GenerateContentConfig{
Tools: []*genai.Tool{{
FunctionDeclarations: functionDeclarations,
}},
Temperature: genai.Ptr[float32](0.3),
}
The conversion function maps MCP's JSON schema to Gemini's schema format.
func convertMCPToolToGeminiFunction(tool mcp.Tool) *genai.FunctionDeclaration {
funcDecl := &genai.FunctionDeclaration{
Name: tool.Name,
Description: tool.Description,
}
if tool.InputSchema != nil {
if schema, ok := tool.InputSchema.(map[string]any); ok {
funcDecl.Parameters = convertJSONSchemaToGemini(schema)
}
}
return funcDecl
}
When the LLM sends back a tool call response, the application forwards the tool call to the MCP server for execution. After receiving the tool response from the MCP server, the application sends it back to the LLM, including the full conversation history.
const maxLoops = 3
conversationHistory := slices.Clone(userMessage)
currentResult := result
for loop := range maxLoops {
hasToolCalls := false
for _, candidate := range currentResult.Candidates {
if candidate.Content == nil {
continue
}
var functionCalls []*genai.Part
for _, part := range candidate.Content.Parts {
if part.FunctionCall != nil {
functionCalls = append(functionCalls, part)
}
}
if len(functionCalls) > 0 {
hasToolCalls = true
var functionResponseParts []*genai.Part
for _, part := range functionCalls {
fmt.Printf("Calling tool (loop %d): %s\n", loop+1, part.FunctionCall.Name)
if part.FunctionCall.Name != "get_weather" {
fmt.Println("Unknown function call:", part.FunctionCall.Name)
continue
}
toolResult, err := mcpSession.CallTool(ctx, &mcp.CallToolParams{
Name: part.FunctionCall.Name,
Arguments: part.FunctionCall.Args,
})
if err != nil {
return fmt.Errorf("MCP tool call failed: %w", err)
}
var responseData map[string]any
if toolResult.StructuredContent != nil {
if structured, ok := toolResult.StructuredContent.(map[string]any); ok {
responseData = structured
}
} else {
responseData = make(map[string]any)
for _, content := range toolResult.Content {
if text, ok := content.(*mcp.TextContent); ok {
responseData["result"] = text.Text
}
}
}
functionResponseParts = append(functionResponseParts, &genai.Part{
FunctionResponse: &genai.FunctionResponse{
Name: part.FunctionCall.Name,
Response: responseData,
},
})
}
conversationHistory = append(conversationHistory, candidate.Content)
conversationHistory = append(conversationHistory, &genai.Content{
Parts: functionResponseParts,
})
nextResult, err := genaiClient.Models.GenerateContent(
ctx,
GEMINI_MODEL,
conversationHistory,
config,
)
if err != nil {
return fmt.Errorf("generation failed at loop %d: %w", loop+1, err)
}
currentResult = nextResult
break
}
}
if !hasToolCalls {
fmt.Printf("Assistant: %s\n", currentResult.Text())
return nil
}
}
Key benefits of using MCP for tool calling:
- Interoperability: The weather tool can now be used by any MCP client, regardless of language
- Discoverability: Tools are discovered dynamically via
ListTools() - Maintainability: Bug fixes to the tool only need to be deployed to the MCP server
- Reusability: The same server can serve multiple clients simultaneously
Advanced Example: Browser Automation with Playwright MCP ¶
In the previous example we have seen how to build the MCP client and MCP server in Go. But MCP is language-agnostic. You can build MCP servers in any programming language and consume them from any MCP client, regardless of the language. To demonstrate MCP's interoperability, here's an example that uses the official Playwright MCP server. The Playwright MCP server is implemented in TypeScript. Thanks to MCP we can consume it from our Go application seamlessly.
The official Playwright MCP server is distributed as a Docker container. The Go application connects to it using the stdio transport.
mcpClient := mcp.NewClient(&mcp.Implementation{
Name: "playwright-client",
Version: "1.0.0",
}, nil)
cmd := exec.Command("docker", "run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp")
transport := &mcp.CommandTransport{
Command: cmd,
}
session, err := mcpClient.Connect(ctx, transport, nil)
Once connected, the application calls ListTools to get a list of available Playwright tools.
toolsResult, err := session.ListTools(ctx, nil)
if err != nil {
log.Fatalf("Failed to list tools: %v", err)
}
if len(toolsResult.Tools) == 0 {
log.Fatal("No tools available on Playwright MCP server")
}
fmt.Println("Available Playwright tools:")
for _, tool := range toolsResult.Tools {
fmt.Printf(" - %s: %s\n", tool.Name, tool.Description)
}
fmt.Println()
The Playwright server provides a large list of tools for browser automation, including:
browser_navigate: Navigate to a URLbrowser_click: Perform click on a web pagebrowser_take_screenshot: Capture screenshotsbrowser_evaluate: Evaluate JavaScript expression on page or element
You can find the full list of available tools in the Playwright MCP repository.
After receiving the list of tools, the application converts them to Gemini function declarations.
var functionDeclarations []*genai.FunctionDeclaration
for _, tool := range toolsResult.Tools {
funcDecl := convertMCPToolToGeminiFunction(*tool)
functionDeclarations = append(functionDeclarations, funcDecl)
}
config := &genai.GenerateContentConfig{
Tools: []*genai.Tool{{
FunctionDeclarations: functionDeclarations,
}},
Temperature: genai.Ptr[float32](0.3),
}
And then sends a user prompt including the tool declarations to the LLM.
result, err := genaiClient.Models.GenerateContent(
ctx,
GEMINI_MODEL,
userMessage,
config,
)
In this example the LLM often sends another tool call response after receiving the response to the first tool call. To handle this, the application implements a loop that continues processing tool calls until the LLM no longer sends any tool call responses. But to avoid infinite loops, the code limits the number of iterations to a maximum of 3. This number is arbitrary and can be adjusted based on the expected complexity of the tasks.
You find the implementation of the processing loop here.
This example calls the tools browser_navigate and browser_snapshot from the Playwright MCP server to navigate to a website and take a screenshot.
Topics Not Covered ¶
In this blog post, I focused on the core concepts of MCP and how to use it for tool calling with LLMs. However, MCP supports additional features that I did not cover. Here's a brief overview:
Prompts ¶
MCP servers can expose prompt templates that clients can discover and use. Prompts are reusable templates with parameters that help standardize how LLMs are instructed for specific tasks. This is useful for sharing best practices across teams and applications.
Learn more: https://modelcontextprotocol.io/docs/concepts/prompts
Resources ¶
Resources provide a way for MCP servers to expose data sources (files, database records, API responses) that LLMs can access for context. Unlike tools which perform actions, resources are read-only data that enriches the LLM's understanding.
Learn more: https://modelcontextprotocol.io/docs/concepts/resources
Authentication and Security ¶
Production MCP servers, especially when exposed over HTTP, require proper authentication and authorization. The protocol supports various authentication mechanisms, and the Go SDK provides hooks for implementing custom authentication handlers. Consider security best practices when exposing MCP servers over the network.
Learn more: https://modelcontextprotocol.io/docs/concepts/security
Resources ¶
- MCP Homepage: https://modelcontextprotocol.io/
- Go SDK: https://github.com/modelcontextprotocol/go-sdk
- All MCP SDKs: https://modelcontextprotocol.io/docs/sdk
- MCP Specification: https://spec.modelcontextprotocol.io/
Conclusion ¶
This concludes this tour of the Model Context Protocol (MCP) using Go. We explored how MCP enables seamless integration between LLM client applications and external tools through a standardized protocol.
With the Model Context Protocol we can build tools as independent MCP servers that can be consumed by any MCP client application. This decouples tool implementation from client applications, enabling the reuse of tools across different languages and platforms.