Article cover: Model Context Protocol, the standard that connects agents to tools and data

Model Context Protocol (MCP): the standard that connects AI agents to tools and data

Every team that ships an agent to production walks into the same wall. The model is excellent, the prompt is sharp, but the agent is only useful when it touches the real world — Jira, the data lake, the ERP, that internal service nobody documented. And that is when you discover 80% of the effort is not AI at all: it is writing yet another bespoke connector for every tool, in every framework, with a slightly different tool calling shape. Switch models and rewrite it. Switch frameworks and rewrite it again. The Model Context Protocol (MCP) exists to kill exactly that rework.

2026 update On July 28, 2026, MCP published revision 2026-07-28 — the largest change since launch: nine major changes, twelve minor ones, and six formally deprecated features. The protocol went stateless, the initialize handshake and the Mcp-Session-Id header were removed, the GET endpoint gave way to subscriptions/listen, SSE resumability is gone, extensions became first-class, and authorization moved closer to real-world OAuth 2.x. There are breaking changes here. This article covers the before and after — and includes a migration checklist.

What is the Model Context Protocol?

MCP is an open protocol that standardizes how an AI agent discovers and uses external capabilities — tools, data, and ready-made workflows. The analogy that stuck is the USB-C of AI: instead of a proprietary cable for every device, one connector, and anything that speaks the same protocol plugs in.

In practice it defines three roles:

  • Host — the application where the agent lives (your app, an IDE, a copilot).
  • MCP client — the component inside the host that speaks the protocol, typically one per connected server.
  • MCP server — the process that exposes capabilities. It is the one that knows how to talk to Jira, Databricks, or your internal API.

And the server exposes three primitives:

Primitive What it is Who controls it
Tools Functions the model can invoke (search, create, compute) The model decides to call
Resources Readable data addressed by URI (files, records, config) The application decides to attach
Prompts Ready-made workflows and templates exposed as commands The user decides to trigger

That distinction matters more than it looks: a tool is an action with side effects, a resource is a read. Conflating the two is the number one design mistake in MCP servers — and it is what turns a helpful agent into a dangerous one.

The problem it solves

Without a standard, integrating M agents with N tools is an M × N problem: every pair needs its own connector, with its own authentication, schema shape, and error handling. With MCP it becomes M + N: each agent speaks the protocol once, each tool exposes the protocol once, and every combination comes for free.

It is the same economics ODBC brought to databases in the 90s and the Language Server Protocol brought to code editors. Nobody has written one driver per (application, database) pair since — and this is exactly why.

Three concrete pains disappear:

  1. Framework lock-in — the MCP server you write today serves an agent in LangGraph, in Agent Framework, and in the copilot that does not exist yet.
  2. Runtime discovery — the agent asks the server which tools exist and gets a JSON schema for each. You add a capability without redeploying the agent.
  3. A concentrated security surface — authentication, scope, and audit live in one place (the server), not scattered across twenty homemade connectors.
Model Context Protocol diagram in two bands: on the left the MCP server with tools, resources, prompts and extensions; on the right the flow of a call — host and agent, MCP client, self-contained POST /mcp and result — with an authorization rail of OAuth, least privilege, human approval and tracing applied at every stage.
What you expose once (left) sustains what flows on every call (right) — and, since revision 2026-07-28, with no session in the protocol.

How it works — step by step

  1. Expose the capabilities — on the server, declare each tool with a name, a description, and an input schema. The description is not decoration: it is what the model reads to decide whether and when to call.
  2. Connect the client — the host opens a connection to the server over one of two transports: stdio (local process, ideal for desktop and IDE) or Streamable HTTP (remote server, a single POST /mcp endpoint).
  3. Discover — the client calls tools/list and gets the catalog with schemas. As of 2026-07-28, the response carries ttlMs and cacheScope, so the client knows how long it may cache that catalog instead of asking again every turn.
  4. Let the model choose — the agent receives the available tools alongside the task and decides which to invoke, with which arguments.
  5. Execute with approval — the client calls tools/call. Destructive or irreversible actions go through human confirmation before they leave.
  6. Return the result to context — the output comes back as content (and, optionally, schema-validated structuredContent) and the model continues reasoning.
        [ Agent ]  "what is the balance on contract 4471?"
              |
              v
      +----------------+       tools/list  (cacheable: ttlMs)
      |   MCP client   |  <------------------------------+
      +----------------+                                 |
              |  POST /mcp   Mcp-Method: tools/call       |
              |              Mcp-Name: get_contract       |
              v                                           |
      +----------------+                          +--------------+
      |   MCP server   | -----------------------> |  ERP / Lake  |
      +----------------+       SQL / REST         +--------------+
              |
              v
      [ result ]  or  [ input_required -> confirm? ]

What changed in revision 2026-07-28

If you already run an MCP server, this is the section that matters. The previous revision was 2025-11-25, and the jump is a big one: nine major changes, twelve minor ones, six formally deprecated features and — for the first time — a public lifecycle policy for protocol features.

There is a single thread running through all of it: take state out of the transport. Almost everything else follows from that.

The protocol went stateless

Before, every conversation started with an initialize handshake, followed by notifications/initialized, and the server returned an Mcp-Session-Id that pinned the client to that instance. Scaling horizontally required sticky sessions and a shared session store.

Now initialize and Mcp-Session-Id are gone. Every request carries what it needs in _meta:

_meta key What it carries
io.modelcontextprotocol/protocolVersion the protocol version for that request
io.modelcontextprotocol/clientCapabilities client capabilities
io.modelcontextprotocol/clientInfo client identity
io.modelcontextprotocol/serverInfo server identity (in the result’s _meta)

A version mismatch is now an explicit UnsupportedProtocolVersionError instead of a handshake that fails in the dark. And the list endpoints (tools/list, resources/list, prompts/list) no longer vary per connection, which finally makes client-side caching trustworthy.

To pick a version before any call there is the new server/discover, which every server MUST implement: it advertises supported versions, capabilities, and identity. On stdio it also doubles as a backward-compatibility probe against older servers.

The practical effect is large: any request can land on any instance. Plain round-robin does the job, and an MCP server now fits comfortably on serverless platforms that scale to zero — Azure Functions on the consumption plan, for instance — which used to fight with session affinity.

A stateless protocol does not mean a stateless application. If your server needs to carry context across calls, the spec now prescribes the explicit pattern: the server mints a handle (basket_id, job_id), returns it as a result, and the model passes it back as an ordinary tool argument on the next call. State becomes visible to the model instead of hidden in transport metadata — which is more powerful and far easier to debug.

The GET endpoint is gone: enter subscriptions/listen

The HTTP GET endpoint and the resources/subscribe / resources/unsubscribe pair were replaced by a single method: subscriptions/listen, a long-lived POST-response stream where the client explicitly opts in to the notification types it wants — toolsListChanged, promptsListChanged, resourcesListChanged, and resourceSubscriptions. The server acknowledges the subscription and tags every notification with io.modelcontextprotocol/subscriptionId.

Request-scoped notifications (notifications/progress, notifications/message) keep flowing on the response stream of the request they belong to — not on that channel. It is a clean split between “tell me when the catalog changes” and “tell me how this call is going”.

A broken stream is now a lost request

SSE resumability is out: there is no more Last-Event-ID and no message redelivery. If the response stream drops, the in-flight request is lost and the client must re-issue it with a new request id.

That changes how you design long-running tools, in two very concrete ways:

  • Idempotency stopped being a best practice and became a requirement. Re-issues will happen; your write tool has to tolerate the second call without duplicating the effect.
  • Long work belongs to the Tasks extension, not to a synchronous call holding a stream open for minutes.

Asking the user mid-call changed shape

Elicitation, roots/list, and sampling/createMessage were server-initiated requests. That whole mechanism is gone, replaced by the Multi Round-Trip Requests (MRTR) pattern.

Every result now carries a required resultType field:

  • "complete" — an ordinary result;
  • "input_required" — the server returns inputRequests with what it needs to know, plus an opaque requestState.

The client gathers the answers and re-issues the original call with inputResponses and the same requestState. Because everything the server needs is in the payload, the retry can land on any instance. For backward compatibility, a result from an older server without resultType must be treated as "complete".

Gone with it: the notifications/elicitation/complete notification and the elicitationId field. Anyone who needs to correlate an out-of-band interaction across retries now encodes their own identifier in requestState.

And one rule of etiquette became mandatory: the server may only initiate requests while processing a client request. The user is never interrupted out of nowhere.

The traffic became operable

  • Mcp-Method and Mcp-Name headers are now required on Streamable HTTP POSTs. Your gateway routes, throttles, and measures per operation without opening the request body — in API Management, that is the difference between a one-line policy and a script.
  • x-mcp-header lets you promote tool parameters to custom HTTP headers.
  • ttlMs and cacheScope became required on results from tools/list, prompts/list, resources/list, resources/read, and resources/templates/list, through the new CacheableResult interface. ttlMs is a freshness hint in milliseconds; cacheScope ("public" or "private") says whether a shared intermediary may cache that result.
  • Deterministic ordering in tools/list became a recommendation — and it is not cosmetic: a stable list improves the model’s prompt cache hit rate, and that shows up on the bill.
  • W3C Trace Context (traceparent, tracestate, baggage) standardized in _meta — the trace crosses host, SDK, MCP server, and whatever comes next, and shows up as a single span tree in your OpenTelemetry backend.

Methods that simply disappeared

ping, logging/setLevel, and notifications/roots/list_changed were removed. Log level is now set per request, via io.modelcontextprotocol/logLevel in _meta — and the server MUST NOT emit notifications/message for requests that did not ask for logs. Less noise on the stream, fewer egress bytes, less cost.

Extensions became first-class citizens

ClientCapabilities and ServerCapabilities gained an extensions field. Extensions are identified by reverse-DNS, negotiated as capabilities, and versioned outside the spec — that is how MCP intends to evolve without bloating the core. Two are already official:

  • MCP Apps — the server ships HTML interfaces the host renders in a sandboxed iframe.
  • Tasks (io.modelcontextprotocol/tasks) — moved out of the experimental core into a redesigned extension: the blocking tasks/result gave way to polling with tasks/get; tasks/update was added so the client can feed information in during execution; tasks/list was removed; and the server may return a task handle without per-request opt-in.

Authorization: closer to real-world OAuth

  • The authorization server SHOULD include the iss parameter in the response (RFC 9207), and the MCP client MUST validate it against the recorded issuer before redeeming the code — closing the door on mix-up attacks.
  • The client must declare application_type during dynamic registration, avoiding redirect URI conflicts with OpenID Connect.
  • Credentials are bound to the issuer that minted them: key them by issuer, never reuse them with a different authorization server, and re-register when it changes.
  • And the change of direction: Dynamic Client Registration (RFC 7591) was deprecated as a registration mechanism in favor of Client ID Metadata Documents. DCR still works for backward compatibility, but it is no longer the recommended path.

Deprecation now has written rules

MCP adopted a feature lifecycle policy: every feature is Active, Deprecated, or Removed, with a minimum twelve-month window between deprecation and removal, and a public registry of what is deprecated. That is the difference between “this will break someday” and “you have a year and a date”.

On the list: Roots (use tool parameters, resource URIs, or server configuration), Sampling (call the LLM provider API directly), Logging (stderr on stdio, or OpenTelemetry), the old HTTP+SSE transport (migrate to Streamable HTTP), the "thisServer" / "allServers" values of includeContext, and DCR, mentioned above.

Details that break clients quietly

  • inputSchema and outputSchema now accept full JSON Schema 2020-12 (oneOf, $ref, $defs), and structuredContent accepts any JSON value. You gain expressiveness — and the obligation to resolve $ref.
  • A missing resource moved from -32002 to the standard JSON-RPC -32602 (Invalid Params).
  • The error range was partitioned: -32000 to -32019 stays with implementations, and -32020 to -32099 is reserved for the spec. The new codes were renumbered: HeaderMismatch -32001-32020, MissingRequiredClientCapability -32003-32021, UnsupportedProtocolVersion -32004-32022.

Migration checklist

What you have today What to do
initialize + Mcp-Session-Id Remove them; send version and capabilities in _meta on every request
Sticky sessions on the load balancer Turn them off; any instance serves any request
State kept per session Mint an explicit handle and pass it back as a tool argument
GET endpoint, resources/subscribe Move to subscriptions/listen, with per-type opt-in
Reconnect via Last-Event-ID Re-issue the request with a new id; make tools idempotent
roots/list, sampling/createMessage, elicitation Adopt MRTR: resultType, inputRequests, requestState
logging/setLevel and ping Per-request log level in _meta; health check on your own HTTP endpoint
List results without caching Fill in ttlMs and cacheScope; order tools/list deterministically
DCR (RFC 7591) Evaluate Client ID Metadata Documents
Handling of -32002 Start handling -32602

None of this is optional if you operate a remote server. The good news is that almost every change trades infrastructure complexity for explicitness in the payload — and an explicit payload is exactly what you can log, cache, version, and audit.

Building and hosting an MCP server on Azure

A minimal server in Python fits in twenty lines. What separates the prototype from production is where it runs and who can call it.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("contracts")

@mcp.tool()
def get_contract(number: str) -> dict:
    """Returns registration data for a contract by number.
    Use when the user mentions a contract number."""
    return repository.get(number)           # read, no side effects

@mcp.tool()
def terminate_contract(number: str, reason: str) -> dict:
    """Terminates a contract. IRREVERSIBLE ACTION — requires confirmation."""
    return repository.terminate(number, reason)

@mcp.resource("contract://{number}/clauses")
def clauses(number: str) -> str:
    """Clause text — read-only data, attached by the application."""
    return repository.clauses(number)

Notice what matters: the docstring is your contract with the model. It says what the tool does and when to use it. A vague description is the root cause of most agents that call the wrong tool.

To ship it, you have four paths on Azure, from simplest to most controlled:

  1. Azure Functions — a remote MCP server with scale-to-zero. Best value for intermittently used servers, and it integrates with managed identity so you reach resources without secrets in code.
  2. Azure Container Apps — when you want the container your way. With internal ingress and a dedicated subnet, the server is reachable only from inside the VNet.
  3. Azure API Management — the AI gateway in front of the server. This is where you apply authentication, rate limiting, per-consumer quotas, content policies, and telemetry. With the Mcp-Method and Mcp-Name headers, you can write per-operation policy — blocking tools/call for terminate_contract for a specific consumer, for example.
  4. Foundry Agent Service — the other side of the counter: here you connect the agent to MCP servers (yours or third-party) without writing the client plumbing.

Add to that the Azure MCP Server, which exposes Azure’s own resources as tools — useful for operations and diagnostics agents.

Best practices for production

  • Treat the tool description as production code. It is what the model reads to decide. Be specific about when to use it and when not to.
  • Separate reads from writes and require human confirmation on anything irreversible. input_required exists for this.
  • Least privilege, per server. One token per MCP server, with the smallest possible scope. A broad token is the fuel for a confused deputy attack, where the server acts with more privilege than the user has.
  • Treat tool output as untrusted input. Tool poisoning — malicious instructions hidden in a tool description or return value — is currently the most exploited vector. A third-party tool description is hostile content until proven otherwise.
  • Allowlist and review third-party servers. An approved server can change behavior in an update (rug pull). Pin versions and review schema changes.
  • Validate the Origin header and never expose a local server on 0.0.0.0. A local stdio server listens on localhost — and only there.
  • Instrument with OpenTelemetry from day one. With Trace Context standardized, you can answer “which tool did the agent call, with which arguments, and what did it cost” — which is exactly what audit will ask.
  • Cache tools/list honoring ttlMs. Listing tools every turn wastes tokens and latency.

Frequently asked questions (FAQ)

Does MCP replace the model’s function calling?

No. They are different layers. Function calling is the model’s ability to emit a structured call; MCP is the protocol that standardizes how that tool is discovered, described, and executed by an external process. In practice MCP feeds function calling with a standardized catalog.

Do I need to migrate my server to revision 2026-07-28 now?

If it is remote and you want to scale, yes — it is worth the effort, because the need for sticky sessions and a shared session store disappears. Since there are breaking changes (no more initialize, Mcp-Session-Id, or SSE resumability; a new subscriptions/listen; a different elicitation flow), plan a window, work through the migration checklist, and validate against the conformance suite. Local stdio servers feel far less impact. And remember: the new lifecycle policy guarantees at least twelve months for anything merely deprecated — what is urgent is what was removed.

When should I use stdio versus Streamable HTTP?

stdio for servers running on the user’s machine alongside the host (IDE, desktop, CLI) — no network, no exposed port. Streamable HTTP for servers shared by many users or that need to live close to the data, inside your Azure environment.

Is MCP secure?

The protocol gives you the tools (OAuth 2.x, scopes, explicit consent, audit), but security is your architecture’s job. Real incidents do not come from the protocol: they come from tokens with too broad a scope, unreviewed third-party servers, and missing human approval on destructive actions.

Is it worth writing an MCP server for an internal API only one agent uses?

It is worth it if you expect more than one consumer over time — and you usually do. The marginal cost of exposing it over MCP instead of a homemade connector is low, and the payoff shows up with the second agent, the second framework, or the day another team wants the same capability.

Conclusion

MCP stopped being a curiosity and became infrastructure. With revision 2026-07-28, it stopped demanding infrastructure tricks to scale: it runs on ordinary HTTP, behind an ordinary load balancer, with ordinary caching, routing, and tracing. That changes the math for anyone who was waiting for the standard to mature.

The architecture decision left for you is no longer “should I adopt MCP?”. It is what you expose, at which scope, and under which approval — because every tool you publish is a door a model can open on its own.

👉 If you are designing the integration layer for your agents — especially in a regulated environment, where scope and audit are not optional — MCP is the decision that saves you from rewriting connectors every time you switch models. Want to talk about MCP and agent architecture? Reach out on LinkedIn.

Leave a Reply