MCP, Plainly
A plain explanation of what MCP changes, and why role-based access is the part that gets hard.
MCP, Plainly
A plain explanation of what MCP changes, and why role-based access is the part that gets hard.

Baltimore Visionary Art Museum
MCP stands for Model Context Protocol. On its own, a model only produces text. A tool lets it do something instead: look up a customer record, send an email, check whether an item is in stock. None of that is new. Tools like these existed before MCP, but each one was built into a single application, in that application’s own code, wired to one model, reused nowhere else. The next team that wanted the same lookup wrote it again. MCP pulls the tool out of the application and puts it behind one shared interface, where any model can find and call it while it runs. That change is what people are reacting to.
Demos make it look trivial. The trouble starts when a real system wants to know who is asking before it answers.
What actually changes
Start with the thing MCP gets compared to. A traditional API is something a programmer wires up once. They read the documentation, learn that one service expects a particular request at a particular address, write code that sends exactly that request, and ship it. The sequence is fixed before the program ever runs. If the customer record has to be fetched before the order history, a person decided that ordering and typed it out.
MCP moves the decision. Instead of a programmer choosing the calls in advance, the model reads a list of available tools while the conversation is happening and picks what to call next. The list describes each tool in plain language: a name, a sentence about what it does, and the inputs it expects. The model sees that description, decides a tool fits the question in front of it, and invokes it. Nobody wired that order in advance; the model assembled it from the tool descriptions while it ran.
A traditional API expects a developer to read it, integrate it, and test the result before any user touches it. An MCP server gets a model instead, meeting the tools cold and using them with nobody reviewing the choice in the moment.
The easy part, in forty lines
Here is a working MCP server in Go. It exposes one tool that looks up an account. If you do not read code, skip past the block to the paragraph after it.
package main
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
s := server.NewMCPServer("account-tools", "1.0.0")
tool := mcp.NewTool("get_account",
mcp.WithDescription("Look up an account by its ID and return the customer's details."),
mcp.WithString("account_id",
mcp.Required(),
mcp.Description("The account to look up"),
),
)
s.AddTool(tool, handleGetAccount)
if err := server.ServeStdio(s); err != nil {
fmt.Printf("server error: %v\n", err)
}
}
func handleGetAccount(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
accountID := req.GetString("account_id", "")
account, err := db.LookupAccount(accountID)
if err != nil {
return mcp.NewToolResultError("account not found"), nil
}
return mcp.NewToolResultText(account.String()), nil
}
That is a working tool. The model can look up any account by ID. In a demo, that is enough to check the box that says MCP is supported.
Read the handler again and notice the gap. It takes an account ID and a context, and nothing else. No identity reaches it. It returns account 4471 to anyone who asks for account 4471, because the function never learns who is on the other end of the conversation.
(The example uses the mark3labs/mcp-go library and is trimmed for readability. _db.LookupAccount_ is a stand-in for whatever real system holds the data.)
Where it breaks
This is fine until the underlying system has rules about who may see what. Most real systems do. A support agent at the first tier sees a customer’s name and plan, nothing more. The second tier gets billing history. A manager sees everything, down to the notes other agents left. The system enforces those tiers by checking the identity of the caller against a role, and it has done that reliably for years, because the caller was a piece of code holding one specific user’s credentials.
The MCP server breaks that assumption in a quiet way. The server holds one set of credentials, usually a service account with broad access, because it needs to reach the underlying system at all. The model calls the server. The server calls the system. From the system’s point of view, every request arrives wearing the same badge, the service account, regardless of which human started the conversation. The tier-one agent and the manager look identical by the time the request reaches the database.
Security people have a name for this. The confused deputy. A program with broad permissions acts on behalf of someone with narrower permissions and fails to check the difference. The MCP server is the deputy. It can reach everything, it acts for whoever is talking to the model, and by default it does not carry their limits with it.
Closing the gap
The person’s identity has to travel the whole way, from the conversation to the model to the server to the system, and the server has to act with that person’s permissions rather than its own. None of that is automatic. A few pieces get added by hand.
The server has to learn who the user is. Over the HTTP transport, that arrives as a token in the request, the same way a web application receives one. The stdio version in the example above has no such channel, which is one reason production deployments tend to run over HTTP. The server then has to turn that token into a role before it does anything else, and it has to do that on every call rather than once at startup. Last, the handler has to enforce the role, returning less data to the tier-one agent than to the manager, or refusing the call outright.
The same handler, doing the work it skipped the first time:
func handleGetAccount(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
user, err := userFromContext(ctx)
if err != nil {
return mcp.NewToolResultError("not authenticated"), nil
}
accountID := req.GetString("account\_id", "")
account, err := db.LookupAccount(accountID)
if err != nil {
return mcp.NewToolResultError("account not found"), nil
}
view := account.ViewFor(user.Role)
return mcp.NewToolResultText(view.String()), nil
}
The context now carries the user. The handler reads the role and returns a view shaped to it. Getting the user into that context is its own piece of work, a layer of code on the HTTP transport that validates the incoming token and stores the result before the handler ever runs. That layer is where most of the real engineering goes, and none of it shows up in the tool description the model reads.
The model does not understand the role and should not be trusted to. It will ask for whatever seems useful to answer the question in front of it, and a determined user can often talk it into asking for more. The enforcement cannot live in the model’s judgment or in the wording of the tool descriptions. It has to live in the server, in code, on every call, the same place it would live if a person were making the request through an ordinary screen.
Most of the work is authorization
The protocol is genuinely easy. A server with one tool takes an afternoon, and most of that afternoon is reading the library’s examples. The rest is the work that predates MCP entirely: knowing who is calling, converting that into a role, and checking the role before any data leaves the building.
So the model now decides which tools to call. Deciding who is allowed to call them is still your job, written into the server by hand, one function at a time.
By Joshua McDonald on May 21, 2026.
Exported from Medium on August 26, 2026.
Reader discussion