How to Build an MCP Server: A Working Remote Server in Go, Then Production
The tutorial part takes ten minutes. The production part is where every server we have reviewed had the same bugs. This guide does both, with code we ran before publishing and the open-source library we extracted from five production servers.
Oct 16, 2026
To build an MCP server, you register tools (each with a name, a description and an input schema) on an MCP SDK's server object, and serve it over a transport. For a remote server that Claude or ChatGPT can reach by URL, that transport is Streamable HTTP. With the official Go SDK, a working server with one tool is about 50 lines, and you can test it with curl. Below is that server, the exact responses it returns, how to connect it to Claude, and the four things that change before production. We run MCP servers in production with close to two hundred tools across our services, and those four are where every server we reviewed went wrong.
What you are building
An MCP server is a list of tools your system exposes to an AI model, such as "search invoices" or "update a deal", plus a way for clients to call them. The model reads each tool's description and input schema, decides when to call it, and works with the result.
There are two shapes:
- Local (stdio): runs on the user's machine, configured in a JSON file. Fine for developer tools.
- Remote (Streamable HTTP): an HTTPS endpoint. This is what a business system needs, because users connect to it from Claude or ChatGPT by URL, with their own account.
This guide builds the remote kind.
Step 1: a server with one tool
This uses the official Go SDK (v1.6.1, the version our CRM runs). Create a module, add the SDK, and save this as main.go:
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type SearchInvoicesArgs struct {
Customer string `json:"customer" jsonschema:"customer name or part of it"`
}
type Invoice struct {
Number string `json:"number"`
Amount float64 `json:"amount"`
Status string `json:"status"`
}
type SearchInvoicesResult struct {
Invoices []Invoice `json:"invoices"`
}
func searchInvoices(ctx context.Context, req *mcp.CallToolRequest, args SearchInvoicesArgs) (*mcp.CallToolResult, SearchInvoicesResult, error) {
// Replace with a query against your own database, scoped to the caller.
invoices := []Invoice{{Number: "INV-1042", Amount: 1250, Status: "unpaid"}}
return nil, SearchInvoicesResult{Invoices: invoices}, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{Name: "invoices", Version: "v0.1.0"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "search_invoices",
Description: "Find invoices for a customer. Returns number, amount and status.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
}, searchInvoices)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})
http.Handle("/mcp", handler)
fmt.Println("MCP server on http://localhost:8080/mcp")
log.Fatal(http.ListenAndServe(":8080", nil))
}
go mod init example.com/invoices
go get github.com/modelcontextprotocol/[email protected]
go mod tidy
go run .
Three choices in there matter:
- The input schema comes from the Go struct. The SDK generates it from
SearchInvoicesArgs, including the field description from thejsonschematag, and validates incoming arguments against it. ReadOnlyHint: truetells clients the tool does not change anything. Keep it honest: in step 4 it becomes a permission.Stateless: truemeans the server keeps no session between requests. The MCP specification published on 28 July 2026 removed protocol sessions altogether, so a stateless server is already the right shape.
Step 2: test it with curl
List the tools:
curl -s -X POST localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
The server answers with the tool, its annotations, and the schemas it generated (trimmed here):
{"jsonrpc":"2.0","id":1,"result":{"tools":[{
"name":"search_invoices",
"description":"Find invoices for a customer. Returns number, amount and status.",
"annotations":{"readOnlyHint":true},
"inputSchema":{"type":"object",
"properties":{"customer":{"type":"string","description":"customer name or part of it"}},
"required":["customer"],"additionalProperties":false},
"outputSchema":{"type":"object","properties":{"invoices":{"...":"..."}}}
}]}}
Call it:
curl -s -X POST localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_invoices","arguments":{"customer":"Acme"}}}'
{"jsonrpc":"2.0","id":2,"result":{
"content":[{"type":"text","text":"{\"invoices\":[{\"amount\":1250,\"number\":\"INV-1042\",\"status\":\"unpaid\"}]}"}],
"structuredContent":{"invoices":[{"amount":1250,"number":"INV-1042","status":"unpaid"}]}
}}
The result comes back twice: as text, for clients that only read text, and as structured content matching the output schema.
Step 3: connect it to Claude
Claude cannot reach localhost. A remote MCP server has to be a public HTTPS endpoint, so deploy it behind TLS first. Then, in Claude, open Customize, then Connectors, choose Add custom connector and paste the URL, for example https://invoices.example.com/mcp. The full steps, including the Team and Enterprise difference, are in how to add a custom connector in Claude.
Do not do that with the server above, though. It has no authentication: anyone with the URL can call it.
Step 4: what changes before production
This is where the tutorial ends and the work starts. Across five of our own services we found the same defects, each of which looked correct on its own. We extracted the fixes into an open-source library, rflkt/mcp (Apache 2.0, Go standard library only).
1. Authentication with discovery that is actually served. A remote server answers an unauthenticated request with a 401 whose WWW-Authenticate header points to a protected-resource metadata document (RFC 9728), which names your OAuth authorization server. The client follows it and sends the user through your login. The bugs we kept finding: the mount path, the document's resource field and the challenge written as three separate strings that drifted apart, and the discovery document mounted inside the authenticated routes, so the one request meant to escape a 401 got a 401. In rflkt/mcp everything derives from one value:
endpoint, err := mcp.New(mcp.Config{
Resource: "https://api.example.com/mcp", // everything derives from this
Issuer: "https://auth.example.com",
Handler: toolSurface,
Verifiers: []mcp.Verifier{oauthVerifier, apiKeyVerifier},
})
Its mcpconform package then drives your real handler in a test and proves that a client rejected with a 401 can follow the challenge to a document that exists.
2. Tokens checked properly, and credentials that don't collide. Check every token's issuer, audience (minted for this server) and expiry. If machine callers already use API keys, make sure your OAuth verifier only claims tokens from its own issuer, or the OAuth rollout breaks the integrations that worked the day before. We had exactly that bug.
3. Permissions per tool, derived from the tool. We map ReadOnlyHint to a read scope and everything else, including a tool with no annotation, to a write scope, at the moment the tool is registered. There is no second list to forget to update, and a read-only token calling a write tool is refused before the tool runs.
4. Identity from your database, on every call. Don't put roles in the token. Look up the caller's current membership on each request and scope every query to it, as the comment in searchInvoices says. Someone removed from an organization should lose access on their next call, not when their token expires.
The threats these defend against, and the gaps we still have, are in MCP security in 2026.
Related: what MCP is, what MCP OAuth is, Claude connectors explained, and 91 tools on one server.
Read next: Anthropic's startup program and how far $1,000 of credits goes, Agentforce pricing and the Claude route.
Also: MCP gateway: when you need one, and what stays in the server.