The common objection to MCP is that a REST API would do the same job. It is a fair objection: the agent already speaks HTTP, and a tool is just a function you call. I have built REST APIs for twenty years. The way to settle it is to build the same tool both ways and compare what crosses the wire. That is this post.
This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source
txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision2025-11-25, with the official Go SDK atv1.6.1.
In the last post I drove a real MCP server by hand and read the four messages that make up a session. The word_count tool there became the running example. Here it comes back, rebuilt as a plain HTTP endpoint, so the comparison is exact: same logic, same answer, two different boundaries.
§The Same Tool, as REST
Here is word_count as an ordinary Go HTTP handler. It takes a JSON body, counts words and characters, returns JSON.
package main
import (
"encoding/json"
"net/http"
"strings"
)
type countReq struct {
Text string `json:"text"`
}
type countResp struct {
Words int `json:"words"`
Chars int `json:"chars"`
}
func main() {
http.HandleFunc("/word_count", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var in countReq
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
out := countResp{Words: len(strings.Fields(in.Text)), Chars: len([]rune(in.Text))}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
})
http.ListenAndServe(":8099", nil)
}
Run it and call it:
go run .
curl -s -X POST localhost:8099/word_count \
-H 'Content-Type: application/json' \
-d '{"text":"read the wire"}'
{"words":3,"chars":13}
Three words, thirteen characters. Same answer the MCP tool gave in the last post. As an interface for a program that already knows this endpoint exists, this is fine. It is fast, it is cacheable, it is everything REST is good at. The trouble starts the moment the caller is a model that has never seen it.
§What the Agent Does Not Get
A model connects to this server. What does it know? Ask the only way it can, by making a request:
curl -s -o /dev/null -w "GET / -> HTTP %{http_code}\n" localhost:8099/
GET / -> HTTP 404
Nothing. There is no list of endpoints, no description of what /word_count does, no statement that text is required and must be a string, no shape for the response. REST does not carry any of that on the wire. The endpoint works perfectly and is, to a newcomer, invisible.
You already know the fixes, because the industry built them. You publish an OpenAPI document describing the path, the request schema, and the response schema. Then you write client glue that reads that document, or more likely you hand-write the integration for this specific API. Then you pick a convention for how the agent should authenticate, how errors come back, how it pages through a long list. Every one of those is a real decision, and you make it again for the next API, and the one after that.
Stand back and look at what that pile of decisions actually is. A way to discover what exists. A machine-readable description of every argument. A uniform way to invoke and to report errors. That is a protocol. You can hand-roll it per API, out of band, and keep the OpenAPI doc in sync with the code by discipline, or you can use one that every agent and every server already agrees on.
§The Same Tool, as MCP
The MCP version from the last post does not need a second document. Discovery and the argument contract travel on the same connection as the call. When the agent sends tools/list, this comes back, generated from the Go struct, in the same session:
{
"name": "word_count",
"description": "Count the words and characters in a piece of text.",
"inputSchema": {
"type": "object",
"properties": { "text": { "type": "string", "description": "the text to measure" } },
"required": ["text"],
"additionalProperties": false
}
}
The OpenAPI document and the endpoint can drift apart, because one is code and the other is a file you remembered to update. The inputSchema cannot drift, because the SDK derives it from the same struct the handler unmarshals into. Discovery is tools/list. The contract is inputSchema. Errors, paging, and cancellation each have one defined shape. None of it is per-server glue.
That last point is the one that compounds. Picture M hosts that want tools and N servers that provide them. Wire each host to each server directly and you are signing up for M times N bespoke integrations, every pair a little different. Put one protocol in the middle and each host implements it once and each server implements it once: M plus N. This is not a new idea. MCP took it from the Language Server Protocol, which solved the same explosion for code editors and language tooling. Before LSP, every editor integrated every language by hand. After it, an editor that speaks LSP speaks to all of them. An agent that speaks MCP is in the same position.
The first three rows are conveniences. A disciplined team can live without them, publishing specs and writing glue. The last row is not a convenience. It is something REST cannot do at all, and it is where the argument stops being about taste.
§The Direction REST Cannot Go
REST is one-directional by design. The client asks, the server answers, the exchange ends. The server has no way to turn around mid-call and ask the client for something. For a lot of tools that is fine. For an agent tool it is a wall, because the most useful thing a tool can do is borrow the host’s model or ask the user a question, and both of those run the wrong way down the pipe.
MCP has three methods that run server to client. sampling/createMessage lets the server ask the host to run the model. elicitation/create lets the server ask the user for input. roots/list lets the server ask which directories it is allowed to touch. Each gets its own post later. Right now I only want to prove the first one is real, because it is the one people assume must be a diagram and not actual traffic.
Here is a server with a summarize tool. The server has no model of its own. When the tool runs, it sends a sampling request back to the client and uses the reply:
mcp.AddTool(server, &mcp.Tool{
Name: "summarize",
Description: "Summarize text in one sentence using the host's model.",
}, func(ctx context.Context, req *mcp.CallToolRequest, in SummInput) (*mcp.CallToolResult, any, error) {
// The server does not have a model. It asks the client to run one.
res, err := req.Session.CreateMessage(ctx, &mcp.CreateMessageParams{
MaxTokens: 100,
Messages: []*mcp.SamplingMessage{{
Role: "user",
Content: &mcp.TextContent{Text: "Summarize in one sentence: " + in.Text},
}},
})
if err != nil {
return nil, nil, err
}
text := res.Content.(*mcp.TextContent).Text
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, nil, nil
})
The client sets a CreateMessageHandler, which is what a real host wires to its model. I gave it a canned reply so the run is deterministic and needs no API key:
client := mcp.NewClient(&mcp.Implementation{Name: "host-app", Version: "v0.1.0"}, &mcp.ClientOptions{
CreateMessageHandler: func(ctx context.Context, req *mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) {
return &mcp.CreateMessageResult{
Role: "assistant",
Model: "claude-opus-4-8",
Content: &mcp.TextContent{Text: "The wire is the protocol; the SDK is just a typist."},
}, nil
},
})
Connect the two over an in-memory transport wrapped in the SDK’s LoggingTransport, which prints every frame, then call the tool once. This is the real log, write: is the client sending, read: is the client receiving:
write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"host-app",...},"protocolVersion":"2025-11-25","capabilities":{"sampling":{},"roots":{"listChanged":true}}}}
read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{},"tools":{"listChanged":true}},"protocolVersion":"2025-11-25","serverInfo":{"name":"summarizer",...}}}
write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
write: {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"summarize","arguments":{"text":"MCP is a stateful, two-way session."}}}
read: {"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"maxTokens":100,"messages":[{"content":{"type":"text","text":"Summarize in one sentence: MCP is a stateful, two-way session."},"role":"user"}]}}
write: {"jsonrpc":"2.0","id":1,"result":{"content":{"type":"text","text":"The wire is the protocol; the SDK is just a typist."},"model":"claude-opus-4-8","role":"assistant","stopReason":"endTurn"}}
read: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"The wire is the protocol; the SDK is just a typist."}]}}
Read the fifth line. It is a read:, which means the client received it, and its method is sampling/createMessage. The server sent the client a request, in the middle of handling the client’s tool call, and then waited for the answer. That is the direction REST has no name for. Notice the server’s request carries "id":1 even though the client’s own tools/call was id:2. Request ids are per sender, so each side numbers its own outgoing requests, and a single connection carries two independent streams of them. The sequence diagram at the top of this post is that exact round-trip.
To get this behavior out of REST you would stand up a second server so the first one could call back, or add webhooks, or hold a socket open and invent a message format to multiplex requests in both directions over it. The moment you do the last one, you have written a stateful, bidirectional, JSON-over-a-socket session protocol. You have written MCP, with fewer eyes on it.
§Stateful, Negotiated, Bidirectional
The last post proved on the wire that an MCP session is stateful, methods are illegal before the handshake, and negotiated, the server answers with a version it actually speaks. This post adds the third property: it is bidirectional. Those three together are the whole answer to why not REST. REST is stateless, client-initiated, and one-directional, by design and for good reasons. Those reasons stop applying at the boundary between a model and a tool, where you need discovery, a contract that cannot drift, and a server that can ask the model and the user for help mid-task.
None of this makes REST wrong. Most MCP servers I build are a thin layer over REST and gRPC services that already exist, including the ones in txn2/mcp-data-platform. REST stays the right tool inside the house. MCP is the contract at the door, where an agent that has never seen your system needs to discover it, call it safely, and be called back. A protocol is what you put at a door that strangers walk through.
This is the shape of a real platform boundary. txn2/mcp-data-platform proxies a stack of REST and HTTP services, Salesforce, GitHub, Stripe, Jira, behind one MCP endpoint, with a single authenticated and audited path to all of them. The protocol is the door; the REST APIs stay inside the house, reached on the governed path rather than directly.
§What’s Next
I have leaned on the phrase “JSON-RPC 2.0” twice now without slowing down on it. The next post fixes that. JSON-RPC 2.0: The Grammar Underneath MCP takes apart the three message shapes that every MCP method is built from, the request, the response, and the notification, and builds a working reader and writer for them in Go in about a hundred and fifty lines. Once that grammar is in hand, every primitive in the rest of the series is just a method name dropped into a shape you already know.
The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.