Updated 1 August 2026. The 2026-07-28 revision has landed, the TypeScript SDK v2 is GA, and
@maxhealth.tech/mcp-http0.3.0 has moved onto it. Corrections are called out where they apply.
Software engineering is dead. That is what they say.
Partially true. If a model can write the function, fewer humans will sit down and type it out. But losing the typing is a long way from losing the engineering. What actually happened is that the interesting question moved up a level. It used to be how do I build this application. Now it is how do a human, an application, and a model share one workspace.
That question is why the Model Context Protocol went from a curiosity to infrastructure so fast. Everyone hears MCP as the model quietly calling your API in the background. What it actually means is the model, the user, and your app all operating on the same live surface, at the same time.
So: how do you actually build one of these things?
The protocol is a moving target, and that is the point
Anyone who shipped an MCP server in 2025 has rewritten it at least twice. The transport story alone went stdio, then SSE, then Streamable HTTP. Streamable HTTP won and is now the dominant way to build.
And on 28 July 2026 the next revision landed. It is the biggest one yet, because it makes MCP stateless.
The initialize / initialized handshake is gone (SEP-2575). Protocol version, client info and capabilities now ride in _meta on every request, under keys like io.modelcontextprotocol/protocolVersion. Protocol-level sessions are gone too (SEP-2567), which means no more Mcp-Session-Id and any request can hit any instance.
The standalone GET stream endpoint is removed. Long lived streams did not disappear, they moved: change notifications now arrive on the response stream of a subscriptions/listen POST, which stays open and carries only the notification types you opted into. What did die is resumability, because Last-Event-ID and SSE event ids are gone outright. A broken stream now means re-issuing the request with a fresh id.
Servers no longer send requests to clients at all. This is the deepest change. Sampling, elicitation and roots used to be the server asking the client a question mid-flight. They are now embedded in an InputRequiredResult, which the client answers by retrying the original call with matching inputResponses (SEP-2322, "multi round trip requests"). The spec is blunt about it: a server "MUST NOT send independent JSON-RPC requests" on a response stream, and clients "MUST NOT send JSON-RPC responses" at all. Every exchange is now client-initiated, which is what makes the whole thing safe to load balance.
A few smaller things travel with it. Two routing headers become required (SEP-2243): Mcp-Method on every request, and Mcp-Name on tools/call, resources/read and prompts/get only, so intermediaries can route and rate limit on the operation without parsing a body. Mind the casing asymmetry when you write those down, because MCP-Protocol-Version is all caps and has existed since 2025-06-18, while the new pair is Mcp-. Cancellation on Streamable HTTP is now simply closing the response stream. Every result carries a required resultType field. And list and read results gain required ttlMs and cacheScope fields via a new CacheableResult interface (SEP-2549), where omitting them gets you the conservative { ttlMs: 0, cacheScope: 'private' } fallback and clients that re-fetch constantly.
There is a second half to this revision that the first draft of this article skipped, and it lands squarely on the layer I claimed was solved. OAuth Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents. RFC 7591 still works against authorization servers that have nothing else, but new implementations should not reach for it. Alongside that, authorization servers SHOULD send the iss parameter on authorization responses (RFC 9207) and clients MUST validate it before redeeming the code, clients must send an appropriate application_type during DCR, and persisted credentials must be keyed by issuer so they are never replayed against a different authorization server.
Finally, Roots, Sampling and Logging are deprecated on a twelve month clock under a new feature lifecycle policy, and ping, logging/setLevel and notifications/roots/list_changed are removed outright.
Read that list again and notice what it adds up to. Your MCP server stops being a stateful daemon you have to keep alive and turn into sticky sessions. It becomes a pure function:
(Request, Token) -> Response
Which is exactly the shape of a Cloudflare Worker, a Deno Deploy handler, a Pages Function, or a Bun.serve fetch. Round robin load balancing, zero shared state, no session store to leak memory.
That is a much simpler thing to build. It also means the engineering is no longer in the plumbing. It is in the contracts.
Three layers, three contracts
Once the server is a pure function, a well built MCP plugin decomposes cleanly into three concerns that have nothing to do with each other:
| Layer | Concern | Package |
|---|---|---|
| Transport | HTTP, OAuth, CORS, lifecycle | @maxhealth.tech/mcp-http |
| Surface | What the user sees and clicks | @maxhealth.tech/prefab |
| Skin | What it looks like, everywhere | brandc |
Each one is a contract rather than a framework. You can throw any of the three away and keep the other two, and that is the claim the rest of this article has to earn. If you want the surface layer from someone else, @mcp-ui/client renders MCP Apps views in a React host and slots into the same place. If you want the skin from somewhere else, any stylesheet defining the same custom properties will do.
Layer 1: transport
@maxhealth.tech/mcp-http is a framework agnostic MCP HTTP layer built on the Web Fetch API. It runs on Workers, Pages Functions, Deno Deploy, Bun, Node 18+, and anything Hono deploys to. It has been stateless first from day one, which is why the July revision needed no redesign here. Additions, yes. Redesign, no.
Here is a complete authenticated MCP server on Cloudflare Workers:
import { createWorkerFetch, forwardBearer } from '@maxhealth.tech/mcp-http'
import { McpServer } from '@modelcontextprotocol/server'
export default {
fetch: createWorkerFetch({
authorizationServer: 'https://auth.example.com',
createServer: (token) => {
const server = new McpServer({ name: 'my-api', version: '1.0.0' })
const fetchFn = forwardBearer(token)
// register tools using fetchFn for upstream calls
return server
},
}),
}
That import line is worth a paragraph on its own. The first version of this article said a peer range of >= 1.29.0 "wants a beta bump", which undersold it twice. v2 went GA on 27 July, and it is a package split rather than a version bump: the monolithic @modelcontextprotocol/sdk became @modelcontextprotocol/server and siblings. So migrating means adding a new peer dependency rather than widening a range. mcp-http 0.3.0 made that move, and because no export was removed, renamed or reshaped, and McpServer is API compatible across the two majors, for consumers it came down to one import path.
For those twenty lines you also get a working OAuth resource server. RFC 9728 protected resource metadata is served automatically, RFC 8414 authorization server metadata optionally, and unauthenticated calls get a proper 401 with a WWW-Authenticate header pointing at that metadata, which is how a compliant client discovers where to go and log in. Expired tokens are rejected on exp before any work happens, with a 30 second clock skew buffer. CORS is configurable, and an onRequest hook reports outcome, status and duration per call.
Two design decisions in there are worth stealing regardless of what you end up using.
createServer is a per request factory, and it receives the caller's raw token. That is the whole trick of stateless auth. The server instance lives exactly as long as the request that authorized it, so there is no shared instance for a token to leak through, and no window in which one user's credential is reachable from another user's call. It also means the implementation underneath is boring in the way you want security-adjacent code to be boring: construct a transport, connect the server, handle the request, throw it all away. There is no session id generator anywhere in the codebase, because there is nothing to correlate.
forwardBearer(token) is the seam for on-behalf-of calls. It returns a fetch that carries the caller's identity upstream, so your tools never handle a credential directly. They receive a function and call it. That sounds like a small ergonomic difference and it is actually the compliance story: in healthcare, every FHIR read has to be attributable to a specific human with specific scopes, and a tool that cannot see a token also cannot accidentally log one, widen one, or send one to the wrong host. The seam is the difference between a demo and something you can put in front of an auditor.
There is also a handleMcpPostStateful path, which keeps a transport alive across requests so the server can issue sampling calls. Be clear eyed about what that is now: it routes on Mcp-Session-Id and mints sessions on initialize, and both of those are gone in the new revision. As of 0.3.0 it is formally deprecated along with SessionStore and the stateful / sessionTtlMs options, scheduled for removal in 0.4.0. Design so you do not need it.
What the new revision actually cost a transport
Being honest about the gap is more useful than claiming there is none. Stateless was the right shape early and the shape held, so the forecast work was small: header and body mismatches now have to be rejected with 400 and JSON-RPC -32020 (HeaderMismatch), GET and DELETE on the MCP endpoint should answer 405 where they used to 404 so old clients can tell "wrong era" from "wrong URL", and the default CORS config had to stop exposing Mcp-Session-Id and Last-Event-ID, two headers that no longer exist.
Two things bit that were not on that list, and both are better lessons than anything that was.
The first was CORS, and it was invisible from the desktop. MCP-Protocol-Version has been required on every MCP HTTP request since 2025-06-18, and it is not on the CORS safelist. Leave it out of Access-Control-Allow-Headers and every browser-hosted MCP client fails preflight and never reaches your endpoint, while Claude Desktop works perfectly the entire time because it is not a browser and never sends a preflight. That class of bug is worth internalising: any MCP server tested in exactly one host is a server that works in exactly one host. Fixed in 0.2.0, along with deriving Access-Control-Allow-Methods per route (preflight was advertising GET and DELETE while the handler answered those with 405) and admitting the per-tool Mcp-Param-* headers that SEP-2243 introduces, whose names are chosen by the server per tool and so cannot be covered by a fixed list.
The second was RFC 9728 itself. ยง3.1 forms the metadata URL by inserting the well-known segment between the host and the resource path, so an endpoint mounted at /mcp publishes its metadata at /.well-known/oauth-protected-resource/mcp. We served only the bare path and pointed the WWW-Authenticate challenge there, which is off spec for any non-root mount point. It was found by diffing our behaviour against @modelcontextprotocol/server@2.0.0, which implements the rule correctly, and that is a technique worth stealing too: when a reference implementation exists, run it beside yours and compare bytes.
None of that touched anyone's tool logic. That part of the seam held.
Layer 2: surface
Now the interesting part. Your tool ran, you have data. What comes back?
The default answer is a wall of text for the model to summarize. That is fine for a lookup and terrible for anything a human should touch. If MCP is about using applications together with AI, then a tool result has to be able to be an application.
That is what @maxhealth.tech/prefab does. You build a component tree on the server and hand it to display():
import { display, Column, H1, autoTable } from '@maxhealth.tech/prefab'
async function listPatients() {
const patients = await db.query('SELECT * FROM patients')
return display(Column([H1('Patients'), autoTable(patients)]), { title: 'Patients' })
}
display() does two things at once, and the second is the one people miss. It serializes the tree to the $prefab wire format and puts that JSON in structuredContent, which is what the host renders. It also puts a text form in content[], which is what the model reads. Those are different audiences with different needs, and a tool result has to satisfy both: hosts without UI support, and the model itself, still need something meaningful when the interface never paints.
The rendering side is deliberately unglamorous. A zero dependency vanilla DOM renderer, loaded as a single script tag inside a ui:// resource, paints the tree in the host's sandboxed iframe. No bundler, no framework, no client build step in your project at all. What you get for that is 115+ components, reactive template expressions, and auto renderers (autoTable, autoChart, autoForm, autoMetrics) that turn raw rows into a real interface in one call.
The mechanism underneath is MCP Apps, and its status is worth being precise about. MCP Apps is not part of the core specification. It is an official extension, identified as io.modelcontextprotocol/ui, living in its own repository (modelcontextprotocol/ext-apps) and versioning independently. It landed with protocol version 2026-01-26, which is still current even though core has moved on to 2026-07-28.
Two corrections to the first draft here:
I wrote that MCP Apps is "one of two official extensions". There are four: OAuth Client Credentials and Enterprise-Managed Authorization under ext-auth, MCP Apps, and MCP Tasks. Tasks is the interesting one, because the 2026-07-28 revision is precisely what promoted it. Tasks used to be experimental inside core, and SEP-2663 moved them out into io.modelcontextprotocol/tasks with a redesigned polling API.
I also wrote that "the 2026-07-28 core revision does not mention Apps at all", but the revision's index page lists MCP Apps under Extensions, beside Tasks and Skills over MCP. What the revision added is the extensions capability field that makes the separation official and negotiable.
Wiring a tool to a UI takes two things: a registered viewer resource, and a pointer to it on the tool.
import { registerViewerResource, PREFAB_RESOURCE_URI } from '@maxhealth.tech/prefab/mcp'
registerViewerResource(server)
server.registerTool(
'browse',
{ title: 'Browse', inputSchema: schema, _meta: { ui: { resourceUri: PREFAB_RESOURCE_URI } } },
async (args) => ({
content: [{ type: 'text', text: JSON.stringify(data) }],
structuredContent: data,
}),
)
Reach for registerTool here rather than tool(). Every tool() overload is deprecated in favour of it, and only registerTool's config object accepts inputSchema and _meta. Note also where _meta.ui.resourceUri sits: on the tool definition, so it appears in tools/list and the host can fetch and warm the template before the tool is ever called. Putting it on the result is the intuitive mistake, and it costs you that prefetch.
That one-line helper exists because of three failures that each cost an afternoon.
The MIME type is exactly text/html;profile=mcp-app, with no space after the semicolon. Plain text/html is silently treated as an ordinary resource and never loads in an iframe. There is no error, the UI simply never appears.
CSP has to go on both the resource listing and the content item. The spec has hosts check both, preferring the content item and falling back to the listing. So the content item wins wherever both are set, which sounds like the listing is redundant, right up until you meet a host that only reads the content item and a policy declared solely on the listing is dropped. You get a black iframe and no explanation. Setting both is always safe.
structuredContent is required. Return only content[] and the host prints raw JSON, because the UI path never fires at all.
As of prefab 0.3.6 that same call also declares the io.modelcontextprotocol/ui capability on the server and emits the ttlMs and cacheScope the new revision expects. Which brings me to the part of my own argument that did not survive contact.
Where the seam did not hold
The original version of this article closed the transport section with "none of that touches your tools. That is the point of the seam." That was too strong, and the two exceptions are more interesting than the rule.
ttlMs and cacheScope live on results, so they reached the surface layer directly. prefab's viewer HTML is a pure function of the package version, because the CDN base pins it, which means it is safely shared-cacheable and can ship { ttlMs: 86_400_000, cacheScope: 'public' }. Without that it inherits the conservative fallback and hosts re-fetch the viewer on every single render. That is a result-shaped change in a layer I had claimed the transport insulated.
resultType looked like exactly the same problem and turned out not to be one. It is a wire level key: the SDK stamps it at its encode seam and strips it before results reach consumers, which is why its public result types omit it entirely. I was ready to add it by hand in five places, and doing so would have been wrong. The honest version of my original line is "none of that touches your tool logic", which is weaker and true. The seam protects the handler body. It does not protect the envelope.
The pattern that makes all of this compose is that every handler returns a self contained UI. A list view carries a button whose click calls a detail tool. The detail view carries one that opens an edit form. Submitting calls a save tool. Multi screen flows fall out of independent handlers with no client side router and no shared state, which is exactly what a stateless protocol wants. And when only the numbers change rather than the layout, display_update() sends a state patch that the renderer merges into the live store instead of rebuilding the tree.
Layer 3: skin
Two layers in, there is a quiet duplication problem waiting. Your marketing site has a palette. Your web app has the same palette again in Tailwind config. Now your MCP UI needs it a third time as prefab wire JSON. Three copies of one brand in three vocabularies is three chances to drift, and they will drift silently, because nothing fails when they disagree.
brandc is a brand compiler that solves this by separating two axes. The contract is the names of the variables (--primary, --card, --radius, --success, --font-sans), stable across every brand and every stack. A brand is the values, with each colour authored once as a { light, dark } pair in structured TypeScript. Every delivery format is generated from that single source, so they cannot disagree: a plain CSS custom property stylesheet for SSR string injection, a theme.css for bundlers, a Tailwind v4 @theme inline preset that maps by reference so runtime dark switching keeps working, and a prefab wire theme.
import { toPrefabTheme, maxhealth } from 'brandc'
import { display } from '@maxhealth.tech/prefab'
return display(view, { theme: toPrefabTheme(maxhealth) })
Look at what makes that line work. toPrefabTheme(brand) returns { light, dark } string maps. Prefab's Theme interface accepts { light?, dark? } string maps. The two packages have no knowledge of each other, no shared dependency and no coordination, and they compose exactly, because both agreed on a shape instead of on an implementation. That is the whole thesis of this article in one function call.
Rebranding is then a matter of supplying different values against the same contract, which is a data change rather than a code change. One gotcha if you do this in a Tailwind v4 app and want to rebrand colours only: pass an empty scalars, because the contract's --radius* and --shadow* names collide with the keys Tailwind uses in @theme, and emitting them overrides Tailwind's own scale.
One more finding since publication, for anyone building their own token layer. brandc 0.6.0 stopped @property-registering the scheme-dependent tokens. Registering a custom property forces its value to resolve at computed value time, which means a light-dark() token declared on :root decides the colour scheme there, for the entire document, and a .dark further down the tree silently does nothing to it. Native controls in that subtree still flip, so you get a light panel containing a dark input and no obvious cause. Leaving the scheme-dependent half unregistered is what lets a subtree theme itself.
The whole plugin
Stacked up, an authenticated, branded, interactive MCP plugin is about thirty lines:
import { createWorkerFetch, forwardBearer } from '@maxhealth.tech/mcp-http'
import { display, autoTable, Column, H1 } from '@maxhealth.tech/prefab'
import { registerViewerResource, PREFAB_RESOURCE_URI } from '@maxhealth.tech/prefab/mcp'
import { McpServer } from '@modelcontextprotocol/server'
import { toPrefabTheme, maxhealth } from 'brandc'
const theme = toPrefabTheme(maxhealth)
export default {
fetch: createWorkerFetch({
authorizationServer: 'https://auth.example.com',
createServer: (token) => {
const server = new McpServer({ name: 'patients', version: '1.0.0' })
const fetchFn = forwardBearer(token)
registerViewerResource(server)
server.registerTool(
'list_patients',
{ title: 'List Patients', _meta: { ui: { resourceUri: PREFAB_RESOURCE_URI } } },
async () => {
const rows = await fetchFn('https://fhir.example.com/Patient').then(r => r.json())
return display(Column([H1('Patients'), autoTable(rows)]), { title: 'Patients', theme })
},
)
return server
},
}),
}
No session store. No sticky routing. No handshake to keep alive. Nothing in memory between requests. It scales behind a round robin balancer because there is nothing to scale, and it was already the shape the July 2026 revision asks for before that revision was written.
The question that follows every demo
authorizationServer: 'https://auth.example.com' quietly hides the thing people ask about thirty seconds after seeing this run. What does that server actually have to be?
Four things. Your server needs RFC 9728 protected resource metadata, so a client can discover which authorization server guards you. That server needs RFC 8414 metadata at /.well-known/oauth-authorization-server, so its endpoints and grants are discoverable rather than configured by hand. It needs a client onboarding story, meaning Client ID Metadata Documents now or DCR while it lasts, because without one every consumer has to be hand-registered by you. And it needs scopes that can express something in your domain, since those scopes are the boundary of what you are actually selling.
All four are checkable in one command:
curl -s $ISSUER/.well-known/oauth-authorization-server \
| jq '{registration_endpoint, grant_types_supported, scopes_supported}'
A 404 on that URL is itself the answer. Plenty of perfectly good OIDC identity providers serve only /.well-known/openid-configuration, expose no registration_endpoint, and grant authorization_code alone. That signs users in and does it well. Authorizing a plugin is a different job, and pointing discoverAuthorizationServer at one gets you a 404 rather than a useful error.
So is software engineering dead?
The typing is dying. Good.
What is left is the part that was always the actual job: deciding where the seams go. createServer taking a token instead of closing over one. display() returning an envelope where a string would have done. toPrefabTheme() returning a shape another package happens to accept. None of those are hard to write, and a model will write any of them for you in seconds. Knowing that they are the three cuts that let a plugin survive a protocol rewrite is the work.
It is worth saying plainly what updating this article demonstrated, because it is a more useful result than the original claim. Of the three seams, the transport one held completely: a package rename, a CORS header, a well-known path. The surface seam leaked, because ttlMs and cacheScope are result-shaped and results are the thing the surface layer builds. Contracts do not make you immune to a spec revision. They make the blast radius small enough to fix in an afternoon, and they mean you hear about it from a changelog instead of from your error rate.
The protocol will change again on some Tuesday. Build against contracts and that will be a Tuesday you can plan for.
Sources
- MCP specification 2026-07-28: key changes
- Extensions overview and negotiation
- MCP Apps extension and its repository
- Streamable HTTP transport, 2026-07-28
- Feature lifecycle and deprecation policy
- SEP-2575: Make MCP Stateless
- SEP-2322: Multi round trip requests
@modelcontextprotocol/server@maxhealth.tech/mcp-http@maxhealth.tech/prefabbrandc
Top comments (0)