At GopherCon 2025, I discovered Model Context Protocol (MCP) servers - a powerful way to extend LLMs with external tools and services. Using the official Go-SDK for MCP, developers can skip the protocol boilerplate and stay focused on building what matters. official Go-SDK I decided to build a tool for querying blockchain balances. Why? Because even a simple task like checking an Ethereum account balance can be too complex for most people. By combining the MCP server with the Copilot extension in VSCode, querying a blockchain balance is now as easy as asking a question. Before you start, I highly recommend reading through the official Go-SDK design, as it gives the right context for how the pieces fit together. official Go-SDK design Client-Server Communication The MCP is defined in terms of client-server communication over bidirectional JSON-RPC message connections. Specifically, version 2025-06-18 of the specification defines two transports: stdio: communication with a subprocess over standard in and standard out streamable http: communication over a relatively complicated series of text/event-stream GET and HTTP POST requests stdio: communication with a subprocess over standard in and standard out stdio: streamable http: communication over a relatively complicated series of text/event-stream GET and HTTP POST requests streamable http: For our server, we’ll keep things straightforward and use stdio . In this setup, VSCode will spin up the MCP server as a subprocess, and communicate by reading and writing JSON-RPC messages to std in and std out. stdio Building the MCP Server: Server-Side Implementation With the fundamentals covered, it’s to get hands-on and dive into the code. In this section we’ll build a simple MCP server in Go that exposes a tool for querying Ethereum balances. You’ll see how easy it is to define tools, register them, and handle client requests using the official Go-SDK. An MCP server can be built with a single line: server := mcp.NewServer(&mcp.Implementation{ Name: "web3", Version: "v1.0.0" }, nil) server := mcp.NewServer(&mcp.Implementation{ Name: "web3", Version: "v1.0.0" }, nil) However, this server doesn’t do much as it has no tools. What’s Tool? A tool is basically a feature or action that your server offers to clients, such as Copilot or any LLM. Think of like an API endpoint, but specifically designed to be called through MCP. Each tool does one thing - like getting a balance or sending a transaction and the client can invoke it without knowing the details of how it works under the hood. Furthermore, A server can register one or more tools Each tool has a name, description, and schema for inputs/outputs Clients can discover available tools through the protocol and invoke them dynamically A server can register one or more tools Each tool has a name, description, and schema for inputs/outputs Clients can discover available tools through the protocol and invoke them dynamically Add Tool to MCP Server Here’s what registering a tool looks like with the Go SDK: func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) where the Tool struct is defined as: Tool type Tool struct { Name string `json:"name"` Description string `json:"description"` // A JSON Schema object defining the expected parameters for the tool. InputSchema *jsonschema.Schema `json:"inputSchema"` // An optional JSON Schema object defining the structure of the tool's output // returned in the structuredContent field of a CallToolResult. OutputSchema *jsonschema.Schema `json:"outputSchema,omitempty"` ... } type Tool struct { Name string `json:"name"` Description string `json:"description"` // A JSON Schema object defining the expected parameters for the tool. InputSchema *jsonschema.Schema `json:"inputSchema"` // An optional JSON Schema object defining the structure of the tool's output // returned in the structuredContent field of a CallToolResult. OutputSchema *jsonschema.Schema `json:"outputSchema,omitempty"` ... } The ToolHandlerFor type defines the function that gets called when the tool is called: ToolHandlerFor type ToolHandlerFor[In, Out any] func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error) type ToolHandlerFor[In, Out any] func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error) Most of the time, you don’t need to worry about CallToolRequest or CallToolResult . In fact, it is permissible to return a nil CallToolResult if you only care about returning an output value or an error. Additionally, if the Tool’s InputSchema or OutputSchema are nil, they are inferred from the In and Out types respectively of the Tool Handler. CallToolRequest CallToolResult CallToolResult InputSchema OutputSchema In Out Create Get Balance Tool Let’s define a tool that retrieves the Ethereum balance for a given address. // GetBalanceInput defines the input params for the GetBalance tool. type GetBalanceInput struct { Address string `json:"address" description:"The address to get the balance for"` } // GetBalanceOutput defines the schema for the response of the GetBalance tool. type GetBalanceOutput struct { Address string `json:"address"` Balance string `json:"balance"` } // GetBalance tool retrieves the Ethereum balance for a given address. func GetBalance(ctx context.Context, req *mcp.CallToolRequest, input GetBalanceInput) (*mcp.CallToolResult, GetBalanceOutput, error) { // Parse the input address. Return an error if the address is invalid. address, err := parseAddress(input.Address) if err != nil { return nil, GetBalanceOutput{}, err } weis, err := client.BalanceAt(ctx, address, nil) if err != nil { return nil, GetBalanceOutput{}, fmt.Errorf("failed to get balance: %v", err) } // Convert wei to eth and format the balance string. balanceEth := fmt.Sprintf("%s ETH", convertWeiToEth(weis)) return nil, GetBalanceOutput{ Address: input.Address, Balance: balanceEth, }, nil } // GetBalanceInput defines the input params for the GetBalance tool. type GetBalanceInput struct { Address string `json:"address" description:"The address to get the balance for"` } // GetBalanceOutput defines the schema for the response of the GetBalance tool. type GetBalanceOutput struct { Address string `json:"address"` Balance string `json:"balance"` } // GetBalance tool retrieves the Ethereum balance for a given address. func GetBalance(ctx context.Context, req *mcp.CallToolRequest, input GetBalanceInput) (*mcp.CallToolResult, GetBalanceOutput, error) { // Parse the input address. Return an error if the address is invalid. address, err := parseAddress(input.Address) if err != nil { return nil, GetBalanceOutput{}, err } weis, err := client.BalanceAt(ctx, address, nil) if err != nil { return nil, GetBalanceOutput{}, fmt.Errorf("failed to get balance: %v", err) } // Convert wei to eth and format the balance string. balanceEth := fmt.Sprintf("%s ETH", convertWeiToEth(weis)) return nil, GetBalanceOutput{ Address: input.Address, Balance: balanceEth, }, nil } Finally, register the tool with the server mcp.AddTool(server, &mcp.Tool{ Name: "balanceGetter", Description: "get ethereum balance" }, GetBalance) mcp.AddTool(server, &mcp.Tool{ Name: "balanceGetter", Description: "get ethereum balance" }, GetBalance) That’s it! You now have an MCP server that returns the balance of any Ethereum address. In the next section, we’ll see how VSCode Copilot can act as a client and query this server using natural language. Connecting VSCode Copilot to your MCP Server Now that our server is ready, the next step is hooking it up to VSCode Copilot so we can query it in natural language. Detailed instructions are available here, but the setup is pretty straightforward: here Open the Command Pallete and type "MCP: Add Server” Select “Command (stdio) Run a local command that implements the MCP Protocol” Enter the command that will start the MCP Server go run -C ${path_to_main_file} . Open the Command Pallete and type "MCP: Add Server” "MCP: Add Server” Select “Command (stdio) Run a local command that implements the MCP Protocol” Command (stdio) Run a local command that implements the MCP Protocol” Enter the command that will start the MCP Server go run -C ${path_to_main_file} . go run -C ${path_to_main_file} . These steps will generate .vscode/mcp.json in the root folder of your project that looks something like this: .vscode/mcp.json { "servers": { "my-mcp-server-417429b9": { "type": "stdio", "command": "go", "args": [ "run", "-C", "/Users/abhaar/dev/go/mcp-server/", "." ] } }, "inputs": [] } { "servers": { "my-mcp-server-417429b9": { "type": "stdio", "command": "go", "args": [ "run", "-C", "/Users/abhaar/dev/go/mcp-server/", "." ] } }, "inputs": [] } Once that’s setup, fire up Copilot chat and put it to work! Test I first gave Copilot an invalid address and asked it to fetch its balance. As expected, we got an error because the address we provided was invalid. What’s really neat is that Copilot automatically used the balanceGetter tool on our MCP server to try and find the answer. We didn’t have to instruct it to do so explicitly - it did that on its own. Pretty cool! balanceGetter Let’s give it a valid address this time. Voila! Copilot returns the correct balance for that address as of this writing. Users of this tool can now query the Ethereum balance of any address using natural language - no need to touch RPCs or write code. Complete Code mcp-server/main.go mcp-server/main.go package main import ( "context" "github.com/abhaar/mcp-server/v2/tools/eth" "github.com/modelcontextprotocol/go-sdk/mcp" ) func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() server := mcp.NewServer( &mcp.Implementation{ Name: "getBalance", Version: "1.0.0", }, nil, ) mcp.AddTool(server, &mcp.Tool{Name: "balanceGetter", Description: "get ethereum balance"}, eth.GetBalance) if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { panic(err) } } package main import ( "context" "github.com/abhaar/mcp-server/v2/tools/eth" "github.com/modelcontextprotocol/go-sdk/mcp" ) func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() server := mcp.NewServer( &mcp.Implementation{ Name: "getBalance", Version: "1.0.0", }, nil, ) mcp.AddTool(server, &mcp.Tool{Name: "balanceGetter", Description: "get ethereum balance"}, eth.GetBalance) if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { panic(err) } } mcp-server/tools/eth/balance.go mcp-server/tools/eth/balance.go package eth import ( "context" "fmt" "math/big" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/params" "github.com/modelcontextprotocol/go-sdk/mcp" ) const rpcURL = "https://eth.llamarpc.com" var client *ethclient.Client func init() { c, err := ethclient.Dial(rpcURL) if err != nil { panic(fmt.Sprintf("failed to connect to Ethereum client: %v", err)) } client = c } // GetBalanceInput defines the input params for the GetBalance tool. type GetBalanceInput struct { Address string `json:"address" description:"The address to get the balance for"` } // GetBalanceOutput defines the schema for the response of the GetBalance tool. type GetBalanceOutput struct { Address string `json:"address"` Balance string `json:"balance"` } // GetBalance tool retrieves the Ethereum balance for a given address. func GetBalance(ctx context.Context, req *mcp.CallToolRequest, input GetBalanceInput) (*mcp.CallToolResult, GetBalanceOutput, error) { // Parse the input address. Return an error if the address is invalid. address, err := parseAddress(input.Address) if err != nil { return nil, GetBalanceOutput{}, err } weis, err := client.BalanceAt(ctx, address, nil) if err != nil { return nil, GetBalanceOutput{}, fmt.Errorf("failed to get balance: %v", err) } // Convert wei to eth and format the balance string. balanceEth := fmt.Sprintf("%s ETH", convertWeiToEth(weis)) return nil, GetBalanceOutput{ Address: input.Address, Balance: balanceEth, }, nil } func parseAddress(address string) (common.Address, error) { var zero common.Address if !common.IsHexAddress(address) { return zero, fmt.Errorf("invalid address: %s", address) } return common.HexToAddress(address), nil } // Convert wei to eth (1 eth = 10^18 wei) func convertWeiToEth(weiAmount *big.Int) string { return new(big.Float). Quo(new(big.Float).SetInt(weiAmount), big.NewFloat(params.Ether)). Text('f', 6) } package eth import ( "context" "fmt" "math/big" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/params" "github.com/modelcontextprotocol/go-sdk/mcp" ) const rpcURL = "https://eth.llamarpc.com" var client *ethclient.Client func init() { c, err := ethclient.Dial(rpcURL) if err != nil { panic(fmt.Sprintf("failed to connect to Ethereum client: %v", err)) } client = c } // GetBalanceInput defines the input params for the GetBalance tool. type GetBalanceInput struct { Address string `json:"address" description:"The address to get the balance for"` } // GetBalanceOutput defines the schema for the response of the GetBalance tool. type GetBalanceOutput struct { Address string `json:"address"` Balance string `json:"balance"` } // GetBalance tool retrieves the Ethereum balance for a given address. func GetBalance(ctx context.Context, req *mcp.CallToolRequest, input GetBalanceInput) (*mcp.CallToolResult, GetBalanceOutput, error) { // Parse the input address. Return an error if the address is invalid. address, err := parseAddress(input.Address) if err != nil { return nil, GetBalanceOutput{}, err } weis, err := client.BalanceAt(ctx, address, nil) if err != nil { return nil, GetBalanceOutput{}, fmt.Errorf("failed to get balance: %v", err) } // Convert wei to eth and format the balance string. balanceEth := fmt.Sprintf("%s ETH", convertWeiToEth(weis)) return nil, GetBalanceOutput{ Address: input.Address, Balance: balanceEth, }, nil } func parseAddress(address string) (common.Address, error) { var zero common.Address if !common.IsHexAddress(address) { return zero, fmt.Errorf("invalid address: %s", address) } return common.HexToAddress(address), nil } // Convert wei to eth (1 eth = 10^18 wei) func convertWeiToEth(weiAmount *big.Int) string { return new(big.Float). Quo(new(big.Float).SetInt(weiAmount), big.NewFloat(params.Ether)). Text('f', 6) } Next Steps You’re not limited to just checking balances - your server can expose as many tools as you like for querying different types of blockchain data. If you’re feeling adventurous, you could even hook up a small wallet to the server and let the LLM agent handle instructions in natural language to move funds. The possibilities are endless!