{"slug": "transport-surface-skin-building-mcp-plugins-that-survive-the-spec", "title": "Transport, Surface, Skin: Building MCP Plugins That Survive the Spec", "summary": "A developer details how to build MCP plugins that survive the spec, noting that the July 28, 2026 revision makes MCP stateless by moving to Streamable HTTP and removing resumability. The server becomes a pure function (Request, Token) -> Response, enabling deployment on Cloudflare Workers, Deno Deploy, or Bun without sticky sessions. The developer's @maxhealth.tech/mcp-http package provides a framework-agnostic transport built on the Web Fetch API.", "body_md": "Software engineering is dead. That is what they say.\n\nPartially true. Some traditional elements of the craft are genuinely hard to find now. If a model can write the function, why would a human sit down and type it out? The honest answer is: increasingly, they would not.\n\nBut losing the typing is not the same as 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*.\n\nThat question is why the Model Context Protocol went from a curiosity to infrastructure so fast. MCP is the answer to using one or more applications together with AI, simultaneously and synchronously. Not the model calling your API behind the user's back, but the model, the user, and your app all operating on the same live surface.\n\nSo: how do you actually build one of these things?\n\nAnyone 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.\n\nAnd on **July 28, 2026**, the next revision lands. It is the biggest one yet, because it makes MCP stateless:\n\n`initialize`\n\n/ `initialized`\n\nhandshake is `_meta`\n\non every request, under keys like `io.modelcontextprotocol/protocolVersion`\n\n.`Mcp-Session-Id`\n\n. Any request can hit any instance.`subscriptions/listen`\n\nPOST, which stays open and carries only the notification types you opted into. What did die is resumability, because `Last-Event-ID`\n\nand SSE event ids are gone outright.`InputRequiredResult`\n\n, which the client answers by retrying the original call with matching `inputResponses`\n\n(SEP-2322, \"multi round trip requests\"). The spec is blunt: a server \"MUST NOT send independent JSON-RPC requests\" on a response stream, and clients \"MUST NOT send JSON-RPC responses\" at all.`Mcp-Method`\n\non every request, and `Mcp-Name`\n\non `tools/call`\n\n, `resources/read`\n\nand `prompts/get`\n\nonly. Intermediaries can now route and rate limit on the operation without parsing a body. `MCP-Protocol-Version`\n\nis `Mcp-`\n\nfor the new pair.`notifications/cancelled`\n\non this transport.`extensions`\n\nfield (SEP-2133), which makes extensions genuinely first class: reverse DNS identifiers, negotiated through capability maps, living in their own repositories on their own version cadence.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:\n\n``` php\n(Request, Token) -> Response\n```\n\nWhich is exactly the shape of a Cloudflare Worker, a Deno Deploy handler, a Pages Function, or a `Bun.serve`\n\nfetch. Round robin load balancing, zero shared state, no session store to leak memory.\n\nThat is a much simpler thing to build. It also means the engineering is no longer in the plumbing. It is in the contracts.\n\nOnce the server is a pure function, a well built MCP plugin decomposes cleanly into three concerns that have nothing to do with each other:\n\n| Layer | Concern | Package |\n|---|---|---|\nTransport |\nHTTP, OAuth, CORS, lifecycle |\n`@maxhealth.tech/mcp-http` |\n\n`@maxhealth.tech/prefab`\n\n`brandc`\n\nEach one is a contract, not a framework. You can throw any of the three away and keep the other two. Let us walk them.\n\n`@maxhealth.tech/mcp-http`\n\nis a framework agnostic MCP HTTP transport 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 needs no redesign here. Additions, yes. Redesign, no.\n\nA complete authenticated MCP server on Cloudflare Workers:\n\n``` js\nimport { createWorkerFetch, forwardBearer } from '@maxhealth.tech/mcp-http'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\n\nexport default {\n  fetch: createWorkerFetch({\n    authorizationServer: 'https://auth.example.com',\n    createServer: (token) => {\n      const server = new McpServer({ name: 'my-api', version: '1.0.0' })\n      const fetchFn = forwardBearer(token)\n      // register tools using fetchFn for upstream calls\n      return server\n    },\n  }),\n}\n```\n\nThat is the whole server, and you get more than a transport out of it. You get [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) `/.well-known/oauth-protected-resource`\n\nserved automatically, optional RFC 8414 authorization server metadata, Bearer extraction with a proper 401 and a `WWW-Authenticate`\n\nresource metadata pointer, JWT `exp`\n\nearly rejection with a 30 second clock skew buffer, configurable CORS, and an `onRequest`\n\nobservability hook that reports outcome, status, and duration.\n\nTwo design decisions in there are worth stealing regardless of what you use.\n\n** createServer is a per request factory.** 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 a token can never leak into another user's call. The internals are refreshingly boring:\n\n``` js\n// one transport per POST, no session id generator at all\nconst transport = new WebStandardStreamableHTTPServerTransport()\nawait server.connect(transport)\nconst res = await transport.handleRequest(normalizedReq)\n```\n\n** forwardBearer(token) is the seam for on behalf of calls.** It hands you a\n\n`fetch`\n\nthat carries the caller's identity upstream. Your tools never see a credential, they see a function. In a healthcare context, where every FHIR read has to be attributable to a real human with real scopes, that seam is the difference between a demo and something you can put in front of an auditor.There is also a `handleMcpPostStateful`\n\npath, which keeps a transport alive across requests so the server can issue sampling and `createMessage`\n\ncalls. Be clear eyed about what that is now: it routes on `Mcp-Session-Id`\n\nand mints sessions on `initialize`\n\n, and both of those are gone in the new revision. Sampling, roots and logging are themselves deprecated with a twelve month removal window, while `ping`\n\n, `logging/setLevel`\n\nand `notifications/roots/list_changed`\n\nare removed immediately. So it is not \"the thing stateless mode cannot do,\" it is a legacy client path for features on their way out. Design so you do not need it.\n\nBeing honest about the gap is more useful than claiming there is none. Stateless was the right shape early, and the shape holds. The work that remains is real but bounded:\n\n`400`\n\nand JSON-RPC `-32020`\n\n(`HeaderMismatch`\n\n), including missing required headers. That is squarely transport layer work.`GET`\n\nand `DELETE`\n\non the MCP endpoint SHOULD return `405`\n\n`404`\n\n, so old clients can tell \"wrong era\" from \"wrong URL.\" Anything that 404s every non POST needs a small change.`Mcp-Session-Id`\n\nand `Last-Event-ID`\n\nis now exposing two headers that no longer exist.`_meta`\n\nversioning, MRTR, `subscriptions/listen`\n\n) come from `@modelcontextprotocol/sdk`\n\n, so a peer range of `>= 1.29.0`\n\nwants a beta bump. Beta SDKs for Python, TypeScript, Go and C# are already out.None of that touches your tools. That is the point of the seam.\n\nNow the interesting part. Your tool ran, you have data. What comes back?\n\nThe 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.\n\nThat is what `@maxhealth.tech/prefab`\n\ndoes. You build a component tree on the server, hand it to `display()`\n\n, and return the envelope:\n\n``` js\nimport { display, Column, H1, autoTable } from '@maxhealth.tech/prefab'\n\nasync function listPatients() {\n  const patients = await db.query('SELECT * FROM patients')\n  return display(Column([H1('Patients'), autoTable(patients)]), { title: 'Patients' })\n}\n```\n\n`display()`\n\nserializes the tree to the `$prefab`\n\nwire format and folds it into an MCP tool result, putting the JSON in `content[]`\n\nas the model's fallback and in `structuredContent`\n\nfor the host to render. A zero dependency vanilla DOM renderer, loaded as a single script tag in a `ui://`\n\nresource, paints it inside the host's sandboxed iframe. 115+ components, reactive template expressions, and auto renderers (`autoTable`\n\n, `autoChart`\n\n, `autoForm`\n\n, `autoMetrics`\n\n) that turn raw rows into a real UI in one call.\n\nThe mechanism underneath is **MCP Apps**, and its status is worth being precise about, because a lot of writing gets this wrong. MCP Apps is not part of the core specification. It is one of two official *extensions*, it landed with protocol version `2026-01-26`\n\n, it lives in its own repository (`modelcontextprotocol/ext-apps`\n\n) and it versions independently. The 2026-07-28 core revision does not mention Apps at all. What it adds is the `extensions`\n\nfield on capabilities (SEP-2133) that makes that separation official.\n\nWhich is a better story than \"it is in the spec.\" An extension on an independent cadence can iterate on interactive UI at UI speed, while the core protocol stays boring and stable. You want your renderer shipping fixes monthly and your transport changing once a year.\n\nRegistering the viewer resource correctly is where most people lose an afternoon, so it is one call:\n\n``` js\nimport { registerViewerResource, PREFAB_RESOURCE_URI } from '@maxhealth.tech/prefab/mcp'\n\nregisterViewerResource(server)\n\nserver.registerTool(\n  'browse',\n  {\n    title: 'Browse',\n    inputSchema: schema,\n    _meta: { ui: { resourceUri: PREFAB_RESOURCE_URI } },\n  },\n  async (args) => ({\n    content: [{ type: 'text', text: JSON.stringify(data) }],\n    structuredContent: data,\n  }),\n)\n```\n\nNote `registerTool`\n\n, not `tool`\n\n. Every `tool()`\n\noverload in the SDK is deprecated in favour of it, and only `registerTool`\n\n's config object accepts `inputSchema`\n\nand `_meta`\n\n. The `tool()`\n\nsignature takes `Args | ToolAnnotations`\n\nthere, so passing a config literal fails the excess property check. Note also that `_meta.ui.resourceUri`\n\nbelongs on the **tool definition**, so it shows up in `tools/list`\n\nwhere the host can discover and prefetch the template, not on the result.\n\nThree more bugs that helper exists to prevent, all of which cost real hours:\n\n`text/html;profile=mcp-app`\n\n.`text/html`\n\nis silently treated as an ordinary resource and never loads in an iframe.`registerViewerResource`\n\nsets `_meta`\n\nin both places for exactly this reason.`structuredContent`\n\nis required.`content[]`\n\nand the host renders raw JSON, because the UI path never fires.The pattern that makes 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 precisely what a stateless protocol wants. And when only the numbers change rather than the layout, `display_update()`\n\nsends a state patch the renderer merges into the live store instead of rebuilding the tree.\n\nTwo layers in, there is a quiet duplication problem waiting. Your marketing site has a palette. Your web app has the same palette in Tailwind config. Now your MCP UI needs it a third time, as prefab wire JSON. Three copies of the same brand in three vocabularies is three chances to drift.\n\n`brandc`\n\nis a brand compiler that solves this by separating two axes:\n\n`--primary`\n\n, `--card`\n\n, `--radius`\n\n, `--success`\n\n, `--font-sans`\n\n). Stable across every brand and every stack.`{ light, dark }`\n\npair in structured TypeScript.Every delivery format is generated from that one source, so they cannot drift: a CSS custom property stylesheet for SSR string injection, a `theme.css`\n\nfile for bundlers, a Tailwind v4 `@theme inline`\n\npreset that maps by reference so runtime dark switching keeps working, and:\n\n``` js\nimport { toPrefabTheme, maxhealth } from 'brandc'\nimport { display } from '@maxhealth.tech/prefab'\n\nreturn display(view, { theme: toPrefabTheme(maxhealth) })\n```\n\nLook at what makes that line work. `toPrefabTheme(brand)`\n\nreturns `{ light: Record<string, string>, dark: Record<string, string> }`\n\n. Prefab's `Theme`\n\ninterface is `{ light?: Record<string, string>, dark?: Record<string, string> }`\n\n. The two packages know nothing about each other and 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.\n\nRebranding is then a new `Brand`\n\non the same contract:\n\n``` js\nconst ocean: Brand = {\n  name: 'ocean',\n  colors: { ...maxhealth.colors, primary: { light: 'oklch(0.58 0.06 195)', dark: 'oklch(0.7 0.06 195)' } },\n  scalars: { ...maxhealth.scalars, radius: '0.625rem' },\n}\n```\n\nThe CSS output is deliberately modern: `light-dark()`\n\nso each colour is one declaration rather than a duplicated dark block, `@property`\n\nregistration so colour tokens have a `<color>`\n\ncontract and are animatable, and oklch throughout. One gotcha worth flagging: if a Tailwind v4 app wants to rebrand colours only, pass `scalars: {}`\n\n, because the contract's `--radius*`\n\nand `--shadow*`\n\nnames are the same keys Tailwind uses in `@theme`\n\n, and emitting them overrides Tailwind's own scale.\n\nStacked up, an authenticated, branded, interactive MCP plugin is about thirty lines:\n\n``` js\nimport { createWorkerFetch, forwardBearer } from '@maxhealth.tech/mcp-http'\nimport { display, autoTable, Column, H1 } from '@maxhealth.tech/prefab'\nimport { registerViewerResource, PREFAB_RESOURCE_URI } from '@maxhealth.tech/prefab/mcp'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { toPrefabTheme, maxhealth } from 'brandc'\n\nconst theme = toPrefabTheme(maxhealth)\n\nexport default {\n  fetch: createWorkerFetch({\n    authorizationServer: 'https://auth.example.com',\n    createServer: (token) => {\n      const server = new McpServer({ name: 'patients', version: '1.0.0' })\n      const fetchFn = forwardBearer(token)\n\n      registerViewerResource(server)\n\n      server.registerTool(\n        'list_patients',\n        { title: 'List Patients', _meta: { ui: { resourceUri: PREFAB_RESOURCE_URI } } },\n        async () => {\n          const rows = await fetchFn('https://fhir.example.com/Patient').then(r => r.json())\n          return display(Column([H1('Patients'), autoTable(rows)]), { title: 'Patients', theme })\n        },\n      )\n\n      return server\n    },\n  }),\n}\n```\n\nNo 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.\n\nThe typing is dying. Good.\n\nWhat is left is the part that was always the actual job: deciding where the seams go. `createServer`\n\ntaking a token instead of closing over one. `display()`\n\nreturning an envelope instead of a string. `toPrefabTheme()`\n\nreturning a shape another package happens to accept.\n\nNone of those are hard to write. A model will write any of them for you in seconds. Knowing that they are the three cuts that make a plugin survive a protocol rewrite, that is the work. The protocol changes again on Tuesday. It will change after that too. Build against contracts and you find out from the changelog instead of from your error rate.\n\n`@maxhealth.tech/mcp-http`\n\n`@maxhealth.tech/prefab`\n\n`brandc`", "url": "https://wpnews.pro/news/transport-surface-skin-building-mcp-plugins-that-survive-the-spec", "canonical_source": "https://dev.to/quotentiroler/transport-surface-skin-building-mcp-plugins-that-survive-the-spec-5dln", "published_at": "2026-07-25 12:57:57+00:00", "updated_at": "2026-07-25 13:02:02.451150+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-agents", "ai-infrastructure"], "entities": ["Model Context Protocol", "Cloudflare Workers", "Deno Deploy", "Bun", "Hono", "@maxhealth.tech/mcp-http", "@modelcontextprotocol/sdk"], "alternates": {"html": "https://wpnews.pro/news/transport-surface-skin-building-mcp-plugins-that-survive-the-spec", "markdown": "https://wpnews.pro/news/transport-surface-skin-building-mcp-plugins-that-survive-the-spec.md", "text": "https://wpnews.pro/news/transport-surface-skin-building-mcp-plugins-that-survive-the-spec.txt", "jsonld": "https://wpnews.pro/news/transport-surface-skin-building-mcp-plugins-that-survive-the-spec.jsonld"}}