{"slug": "how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec", "title": "How to build and deploy an MCP server to production (2026-07-28 spec)", "summary": "The maintainers of the Model Context Protocol (MCP) shipped the 2026-07-28 spec, which removes the handshake and session requirements, making MCP servers stateless HTTP services that can be load-balanced and autoscaled. A developer demonstrates building and deploying an MCP server to Cloudflare Workers using the new TypeScript SDK v2, highlighting features like MRTR for interactive tools and caching with ttlMs.", "body_md": "MCP just had its biggest release since launch.\n\nOn July 28, the maintainers shipped the **2026-07-28 spec**, and it changes how MCP servers work at a pretty fundamental level. The handshake is gone. Sessions are gone. Three long-standing features are deprecated.\n\nThe maintainers themselves called it the most substantial change since authorization was added. Their words, not mine.\n\nSounds scary. But it actually makes MCP servers much easier to deploy. And what am I here for? I'm here to help you build and deploy one.\n\nYour MCP server is now just a regular stateless HTTP service. Round-robin load balancing, autoscaling, and caching all work. No sticky sessions or shared session state.\n\nIn this guide, we'll build a small MCP server on the new spec, connect a client to it, see every headline feature actually running, and then deploy it to Cloudflare Workers. For free.\n\nℹ️ All the code here uses the new\n\nTypeScript SDK v2, released alongside the spec. If you're on the old`@modelcontextprotocol/sdk`\n\npackage, that's v1 now.\n\n`ttlMs`\n\nQuick rundown of what's new. If you want the full changelog, it's on the [official spec site](https://modelcontextprotocol.io/specification/2026-07-28/changelog).\n\nThe `initialize`\n\n/ `initialized`\n\nexchange and the Mcp-Session-Id header are officially retired.\n\nEvery request is now **self-describing**. It carries its own protocol version, client identity, and capabilities in `_meta`\n\n. Any request can land on any server instance behind a plain load balancer. Such a relief!!\n\nThere's an optional `server/discover`\n\nRPC if a client wants capabilities up front. But it's optional. One bare POST is a complete conversation now.\n\nThis one is my favorite.\n\nBefore, if a tool needed something from the user mid-call, such as confirmation or a missing parameter, the server had to push an `elicitation/create`\n\nrequest back over a held-open stream. That meant you needed a held-open stream, which was bad for stateless deployments.\n\nMRTR flips it. The server returns `resultType: \"input_required\"`\n\nwith the questions it needs answered, and closes the connection. The client collects the answers and retries the original call with them attached, plus an opaque `requestState`\n\ntoken so the server knows where it left off.\n\nNo open streams. No sessions. Interactive tools on fully stateless infra.\n\nRequests now carry `Mcp-Method`\n\nand `Mcp-Name`\n\nHTTP headers. Your gateway, rate limiter, or WAF can route and meter on headers without parsing JSON bodies.\n\n`tools/list`\n\n, `prompts/list`\n\n, `resources/list`\n\n, and `resources/read`\n\nresponses now carry `ttlMs`\n\nand `cacheScope`\n\nfields, modeled on HTTP's Cache-Control. Clients cache your tool catalog instead of re-fetching it every time they connect.\n\nTasks moved out of the experimental core into an official extension (`io.modelcontextprotocol/tasks`\n\n). MCP Apps and Enterprise Managed Authorization live there too. You can build your own extensions as well.\n\nAnd the deprecations:\n\nThere's also a formal deprecation policy now: a 12-month minimum window for anything marked deprecated. So you get to plan upgrades, which is noicee!\n\nOne more thing before we build: the TypeScript SDK is no longer one package.\n\nv2 splits it into `@modelcontextprotocol/server`\n\n, `@modelcontextprotocol/client`\n\n, and thin framework adapters (`@modelcontextprotocol/hono`\n\n, `express`\n\n, `fastify`\n\n, `node`\n\n).\n\nFinally, we're onto the build. We will build a quick tiny deploy bot over MCP.\n\nIt has three tools:\n\n`deploy`\n\nasks the user for confirmation before deploying (MRTR in action).`list_deployments`\n\nreads back the deployment history`server_stats`\n\nproves a fresh server instance handled every requestHere's the trick that pays off at deploy time: all the MCP logic lives in **one platform-neutral file** (`bot.ts`\n\n), and each platform gets a tiny entry file. Node gets `server.ts`\n\n. Cloudflare gets `worker.ts`\n\n. Both are about ten lines. An MCP server on the new spec is just a fetch handler; the platform is a serving shim.\n\nYou'll understand everything along the way.\n\nRun the following command:\n\n```\nmkdir updated-mcp-spec-bot && cd updated-mcp-spec-bot\nnpm init -y && npm pkg set type=module\nnpm install @modelcontextprotocol/server @modelcontextprotocol/client \\\n  @modelcontextprotocol/hono @hono/node-server hono zod tsx\n```\n\nℹ️ On TypeScript 6+, add\n\n`\"types\": [\"node\"]`\n\nto your tsconfig`compilerOptions`\n\nafter installing`@types/node`\n\n. TS 6 no longer auto-includes`@types/*`\n\n, and you'll get`Cannot find name 'process'`\n\nerrors without it. Ask me how I know. 😴\n\nCreate `bot.ts`\n\n. This is the whole MCP server, with zero platform code in it:\n\n``` python\n// 👇 bot.ts\n\nimport type {\n  CallToolResult,\n  InputRequiredResult,\n} from \"@modelcontextprotocol/server\";\nimport {\n  acceptedContent,\n  CLIENT_CAPABILITIES_META_KEY,\n  createRequestStateCodec,\n  inputRequired,\n  McpServer,\n} from \"@modelcontextprotocol/server\";\nimport * as z from \"zod/v4\";\n\nconst deployments: { env: string; at: string }[] = [];\nlet requestsServed = 0;\n\ntype DeployState = { step: \"confirm\"; env: string };\n\n// set STATE_KEY in production so all instances share the secret\n// lazy init: Workers forbids generating random values at module scope\nlet codec: ReturnType<typeof createRequestStateCodec<DeployState>> | undefined;\nfunction stateCodec() {\n  if (!codec) {\n    const key = globalThis.process?.env?.STATE_KEY;\n    codec = createRequestStateCodec<DeployState>({\n      key: key\n        ? new TextEncoder().encode(key)\n        : crypto.getRandomValues(new Uint8Array(32)),\n      ttlSeconds: 600,\n    });\n  }\n  return codec;\n}\n\nconst CONFIRM_SCHEMA = {\n  type: \"object\" as const,\n  properties: { confirm: { type: \"boolean\" as const } },\n  required: [\"confirm\"],\n};\n\n// runs per request, keep it cheap\nexport function buildServer(): McpServer {\n  requestsServed++;\n\n  const server = new McpServer(\n    { name: \"updated-mcp-spec-bot\", version: \"1.0.0\" },\n    {\n      cacheHints: {\n        \"tools/list\": { ttlMs: 30_000, cacheScope: \"public\" },\n      },\n      requestState: { verify: (...a) => stateCodec().verify(...a) },\n    },\n  );\n\n  server.registerTool(\n    \"list_deployments\",\n    {\n      title: \"List deployments\",\n      description: \"List all deployments recorded by this server.\",\n    },\n    async (): Promise<CallToolResult> => ({\n      content: [\n        {\n          type: \"text\",\n          text: deployments.length\n            ? deployments.map((d) => `${d.env} @ ${d.at}`).join(\"\\n\")\n            : \"No deployments yet.\",\n        },\n      ],\n    }),\n  );\n\n  server.registerTool(\n    \"server_stats\",\n    {\n      title: \"Server stats\",\n      description:\n        \"How many requests this process served, each on a fresh server instance.\",\n    },\n    async (): Promise<CallToolResult> => ({\n      content: [\n        {\n          type: \"text\",\n          text: `pid=${globalThis.process?.pid ?? \"edge\"} requestsServed=${requestsServed}`,\n        },\n      ],\n    }),\n  );\n\n  server.registerTool(\n    \"deploy\",\n    {\n      title: \"Deploy\",\n      description:\n        \"Deploy to an environment. Requires confirmation: interactive clients get a prompt, others must pass confirm: true.\",\n      inputSchema: z.object({\n        env: z.enum([\"staging\", \"prod\"]).describe(\"Target environment\"),\n        confirm: z\n          .boolean()\n          .optional()\n          .describe(\"Set true to confirm, only after asking the user\"),\n      }),\n    },\n    async (\n      { env, confirm },\n      ctx,\n    ): Promise<CallToolResult | InputRequiredResult> => {\n      const caps =\n        (ctx.mcpReq.envelope as Record<string, unknown> | undefined)?.[\n          CLIENT_CAPABILITIES_META_KEY\n        ] ?? server.server.getClientCapabilities();\n      const canElicit = Boolean(\n        (caps as { elicitation?: unknown } | undefined)?.elicitation,\n      );\n\n      if (canElicit) {\n        const state = ctx.mcpReq.requestState<DeployState>();\n        const confirmed = acceptedContent<{ confirm: boolean }>(\n          ctx.mcpReq.inputResponses,\n          \"confirm\",\n        );\n        if (!state || !confirmed?.confirm) {\n          return inputRequired({\n            inputRequests: {\n              confirm: inputRequired.elicit({\n                message: `Deploy to ${env}? This will go live.`,\n                requestedSchema: CONFIRM_SCHEMA,\n              }),\n            },\n            requestState: await stateCodec().mint({ step: \"confirm\", env }),\n          });\n        }\n        const record = { env: state.env, at: new Date().toISOString() };\n        deployments.push(record);\n        return {\n          content: [\n            { type: \"text\", text: `Deployed to ${record.env} at ${record.at}` },\n          ],\n        };\n      }\n\n      // fallback for clients without elicitation support\n      if (confirm !== true) {\n        return {\n          content: [\n            {\n              type: \"text\",\n              text: `Deploy to ${env} needs confirmation. Ask the user, then call deploy again with confirm: true.`,\n            },\n          ],\n        };\n      }\n      const record = { env, at: new Date().toISOString() };\n      deployments.push(record);\n      return {\n        content: [\n          { type: \"text\", text: `Deployed to ${record.env} at ${record.at}` },\n        ],\n      };\n    },\n  );\n\n  return server;\n}\n```\n\nA few things worth explaining here:\n\n`buildServer()`\n\nruns on every single request. Not once at startup. Every request gets a brand-new `McpServer`\n\ninstance.\n\nIf that surprises you, I get it. It surprised me too. But this is literally the canonical pattern from the SDK's own examples, and it's the whole point of the release.\n\nConstruction is just object creation and a handler map, microseconds of work. There's no protocol state to preserve anymore, so there's nothing to keep alive.\n\n**Per-request server construction, per-process resources**. App state (our deployments array, the state codec, your DB pool in real life) lives at module level. The server instance is disposable.\n\nThe `deploy`\n\ntool never blocks. When it needs confirmation, it returns `inputRequired(...)`\n\nand the request is over. Done. Connection closed. The `requestState`\n\ntoken is the only thing that survives between rounds, and it round-trips through the client.\n\nThis means the client could tamper with it. That's why we seal it with `createRequestStateCodec`\n\n, so tampered or expired state gets rejected with a wire-level error before our handler even runs.\n\nNotice the codec is **lazily created** on first use instead of at module level. That looks like a pointless indirection on Node. It's not. Cloudflare Workers forbids generating random values in global scope, and this exact line is what lets the same file run on both platforms. Same story with the `globalThis.process?.`\n\nguards: Workers has no `process`\n\nglobal by default.\n\nSo the tool reads the client's declared capabilities from the per-request envelope (that's the `CLIENT_CAPABILITIES_META_KEY`\n\nlookup, with a legacy-connection fallback) and if:\n\n`confirm: true`\n\nargument, and without it, it returns a plain instruction: \"Ask the user, then call deploy again with confirm: true\"Create `server.ts`\n\n. This is everything Node-specific:\n\n``` js\n// 👇 server.ts\n\nimport { serve } from \"@hono/node-server\";\nimport { createMcpHonoApp } from \"@modelcontextprotocol/hono\";\nimport { createMcpHandler } from \"@modelcontextprotocol/server\";\nimport { buildServer } from \"./bot.js\";\n\nconst handler = createMcpHandler(buildServer);\n\n// in production set ALLOWED_HOSTS to your public domain\nconst allowedHosts = process.env.ALLOWED_HOSTS?.split(\",\").map((h) => h.trim());\nconst app = createMcpHonoApp(allowedHosts ? { allowedHosts } : {});\napp.get(\"/healthz\", (c) => c.text(\"ok\"));\napp.all(\"/mcp\", (c) => handler.fetch(c.req.raw));\n\nconst port = Number(process.env.PORT ?? 3000);\nconst hostname = process.env.HOST ?? \"127.0.0.1\";\nserve({ fetch: app.fetch, port, hostname }, () => {\n  console.error(`updated-mcp-spec-bot listening on <http://$>{hostname}:${port}/mcp`);\n});\n```\n\nThat's it. `createMcpHandler`\n\ngives you a standard fetch-style handler, and Hono is just routing. `createMcpHonoApp()`\n\nvalidates Host/Origin headers (DNS rebinding protection) and only allows localhost out of the box, so the `ALLOWED_HOSTS`\n\nenv var is there for when this runs behind a real domain.\n\nEverything is env-driven (`PORT`\n\n, `HOST`\n\n, `ALLOWED_HOSTS`\n\n, `STATE_KEY`\n\n) because that's what a VM or a PaaS like Railway wants. We won't use this file for the Cloudflare deploy, but it's your path if you'd rather run this on Node anywhere.\n\nCreate `client.ts`\n\n:\n\n``` js\n// 👇 client.ts\n\nimport {\n  Client,\n  StreamableHTTPClientTransport,\n} from \"@modelcontextprotocol/client\";\n\nconst url = process.env.MCP_URL ?? \"<http://127.0.0.1:3000/mcp>\";\n\nconst client = new Client(\n  { name: \"mcp-demooo-client\", version: \"1.0.0\" },\n  {\n    capabilities: { elicitation: { form: {} } },\n    versionNegotiation: { mode: \"auto\" }, // use 2026-07-28 when the server does\n  },\n);\n\n// The elicitation handler: in a real app this renders a confirm dialog.\n// Here we auto-accept and log what the server asked.\nclient.setRequestHandler(\"elicitation/create\", async (request) => {\n  const { message } = request.params as { message: string };\n  console.log(`\\n[elicitation] server asks: \"${message}\" -> answering yes`);\n  return { action: \"accept\", content: { confirm: true } };\n});\n\nawait client.connect(new StreamableHTTPClientTransport(new URL(url)));\nconsole.log(\n  `connected, negotiated protocol: ${client.getNegotiatedProtocolVersion()}`,\n);\n\nconst tools = await client.listTools();\nconst { ttlMs, cacheScope } = tools as { ttlMs?: number; cacheScope?: string };\nconsole.log(`tools/list: ${tools.tools.map((t) => t.name).join(\", \")}`);\nconsole.log(`cache hints: ttlMs=${ttlMs} cacheScope=${cacheScope}`);\n\nawait client.listTools();\nconsole.log(\"second listTools served from cache\");\n\nconst before = await client.callTool({ name: \"list_deployments\" });\nconsole.log(\n  `list_deployments: ${(before.content[0] as { text: string }).text}`,\n);\n\nconst result = await client.callTool({\n  name: \"deploy\",\n  arguments: { env: \"prod\" },\n});\nconsole.log(`deploy: ${(result.content[0] as { text: string }).text}`);\n\nconst stats = await client.callTool({ name: \"server_stats\" });\nconsole.log(`server_stats: ${(stats.content[0] as { text: string }).text}`);\n\nconst after = await client.callTool({ name: \"list_deployments\" });\nconsole.log(`list_deployments: ${(after.content[0] as { text: string }).text}`);\n\nawait client.close();\n```\n\n⚠️ Don't miss\n\n`versionNegotiation: { mode: 'auto' }`\n\n. Without it, the client negotiates the legacy 2025-11-25 protocol and the MRTR flow fails. This took me half an hour to debug.\n\nNotice the elicitation handler is a completely normal `elicitation/create`\n\nhandler, the same one you'd write for the old flow. The SDK's auto-fulfillment engine routes the embedded MRTR request through it and retries the tool call for you. Your code doesn't even see the round trip.\n\nIn two terminals (better with [tmux](https://github.com/tmux/tmux/wiki)), run the following:\n\nIn the first terminal:\n\n```\nnpx tsx server.ts\n```\n\nAnd in the other:\n\n```\nnpx tsx client.ts\n```\n\nThis is the kinda output you'd get:\n\n```\nconnected, negotiated protocol: 2026-07-28\n\ntools/list: list_deployments, server_stats, deploy\ncache hints: ttlMs=30000 cacheScope=public\n\nsecond listTools served from cache\nlist_deployments: No deployments yet.\n\n[elicitation] server asks: \"Deploy to prod? This will go live.\" -> answering yes\ndeploy: Deployed to prod at 2026-08-01T08:02:45.601Z\n\nserver_stats: pid=159984 requestsServed=6\nlist_deployments: prod @ 2026-08-01T08:02:45.601Z\n```\n\nEvery line here demonstrates a spec feature, and I designed it that way:\n\n`2026-07-28`\n\n: we're on the new protocol, not the legacy fallback`ttlMs=30000`\n\n+ `served from cache`\n\n: the second `listTools()`\n\nnever touched the network`tools/call`\n\nPOSTs. First one returned `input_required`\n\nand closed. Second had the answer plus the sealed `requestState`\n\n. No stream was ever held open.`requestsServed=6`\n\n: six requests, six fresh server instances, one process. Under a load balancer, those six could've hit six different machines. How cool is that?`list_deployments`\n\n: app state survived even though protocol state didn't.And the math is here: 4 tool calls, plus 2 `listTools()`\n\nwhere only 1 hit the wire, plus 1 extra round for the MRTR retry = 6 server builds.\n\nLet's see the \"no handshake\" thing. One bare curl, with no initialization:\n\nRun the following command:\n\n```\ncurl -s -X POST http://127.0.0.1:3000/mcp \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json, text/event-stream\" \\\n  -H \"MCP-Protocol-Version: 2026-07-28\" \\\n  -H \"Mcp-Method: tools/call\" -H \"Mcp-Name: list_deployments\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"list_deployments\",\"arguments\":{},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientInfo\":{\"name\":\"curl\",\"version\":\"1.0\"},\"io.modelcontextprotocol/clientCapabilities\":{}}}}'\n```\n\nbtw, this curl command was suggested by Claude.\n\nHere's the result you get back:\n\n```\n{\n  \"result\": {\n    \"content\": [{ \"type\": \"text\", \"text\": \"prod @ 2026-08-01T08:02:45.601Z\" }],\n    \"resultType\": \"complete\",\n    \"_meta\": {\n      \"io.modelcontextprotocol/serverInfo\": {\n        \"name\": \"updated-mcp-spec-bot\",\n        \"version\": \"1.0.0\"\n      }\n    }\n  },\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1\n}\n```\n\nTwo things worth noticing in that response.\n\nThe result already shows the prod deployment, because I ran this curl against the same server process the client just deployed through.\n\nA totally separate client, no handshake, no session, and it reads the record the TypeScript client wrote. App state persists, protocol state doesn't.\n\nLook at those headers. `Mcp-Method`\n\nand `Mcp-Name`\n\nare right there for your gateway to route on. And the `_meta`\n\nmakes the request fully self-describing.\n\nThe DX here is genuinely good.\n\nWe're deploying this to Cloudflare Workers, and it costs nothing: the free plan gives you 100,000 requests a day and a `*.workers.dev`\n\nsubdomain, no credit card needed.\n\nWhy Workers? Because it's the natural way for a stateless MCP server. `createMcpHandler`\n\nreturns a fetch-style handler, and fetch handlers are literally what Workers runs. The entire platform difference fits in one tiny file.\n\n⚠️ Cloudflare has quick-start MCP templates (\n\n`npm create cloudflare -- --template=cloudflare/ai/demos/remote-mcp-authless`\n\n). As of writing, Cloudflare's own docs warn that these still scaffold the deprecated`McpAgent`\n\npath and say, \"Do not use that path for a new server.\" It's the old stateful world, and it doesn't speak 2026-07-28. Skip the template.`createMcpHandler`\n\nis the recommended path, and it's what we're already using.\n\nCreate `worker.ts`\n\n:\n\n``` js\n// 👇 worker.ts\n\nimport { Hono } from \"hono\";\nimport { createMcpHandler } from \"@modelcontextprotocol/server\";\nimport { buildServer } from \"./bot.js\";\n\nconst handler = createMcpHandler(buildServer);\n\nconst app = new Hono();\napp.get(\"/healthz\", (c) => c.text(\"ok\"));\napp.all(\"/mcp\", (c) => handler.fetch(c.req.raw));\n\nexport default app;\n```\n\nEleven lines. Same `buildServer`\n\n, same tools, same MRTR flow.\n\nTwo deliberate differences from the Node entry:\n\n`new Hono()`\n\ninstead of `createMcpHonoApp()`\n\n. The Host validation in `createMcpHonoApp`\n\nis DNS rebinding protection `serve(...)`\n\n. Workers calls your exported fetch handler itself.Wrangler is Cloudflare's CLI for Workers. It bundles your TypeScript (no build step needed), runs it locally on the real production runtime, manages secrets, and deploys.\n\n```\nnpm install -D wrangler\n```\n\nCreate `wrangler.jsonc`\n\n:\n\n```\n{\n  \"name\": \"updated-mcp-spec-bot\",\n  \"main\": \"worker.ts\",\n  \"compatibility_date\": \"2026-07-01\",\n  \"compatibility_flags\": [\"nodejs_compat\"]\n}\n```\n\nThe `nodejs_compat`\n\nflag fills in Node-ish globals so npm packages behave.\n\n```\nnpx wrangler dev\n```\n\nThis runs `worker.ts`\n\non **workerd**, the same engine that runs in Cloudflare production, at `http://localhost:8787`\n\n. Point the client at it:\n\n```\nMCP_URL=http://localhost:8787/mcp npx tsx client.ts\n```\n\nSame full output as the Node run: `2026-07-28`\n\nnegotiated, cache hints, the MRTR deploy round-trip. Except one line:\n\n```\nserver_stats: pid=1 requestsServed=6\n```\n\n`pid=1`\n\n. That's the edge runtime saying hello. 🫡\n\nCreate a free account at [dash.cloudflare.com/sign-up](https://dash.cloudflare.com/sign-up) if you don't have one, then:\n\n```\nnpx wrangler login\n```\n\nSet the production `requestState`\n\nsecret (this is the shared HMAC key, so every edge instance can verify tokens minted by any other):\n\n```\nopenssl rand -hex 32          # copy the output\nnpx wrangler secret put STATE_KEY   # paste it when prompted\n```\n\n⚠️ One gotcha from my own run: the key must be at least 32 bytes or the codec throws at startup.\n\n`openssl rand -hex 32`\n\ngives you 64 hex characters, which is plenty.\n\nAnd ship it:\n\n```\nnpx wrangler deploy\n```\n\nFirst deploy asks you to pick your free `workers.dev`\n\nsubdomain. Ten seconds later:\n\n```\nhttps://updated-mcp-spec-2026.<your-subdomain>.workers.dev\n```\n\nYour MCP server is live on Cloudflare's global edge.\n\nRun the Step 6 curl against the public URL (just swap the host), hit `/healthz`\n\nin a browser, and then the real proof:\n\n```\nMCP_URL=https://updated-mcp-spec-2026.<your-subdomain>.workers.dev/mcp npx tsx client.ts\n```\n\nSame output. Except now it's on the internet.\n\nConnect a real agent to it, with no tunnel and no ngrok:\n\n```\nclaude mcp add --transport http updated-mcp-spec-2026 \\\n  https://updated-mcp-spec-bot.<your-subdomain>.workers.dev/mcp\n```\n\nRun `/mcp`\n\nin a Claude Code session to see it connected, then ask it to \"deploy to staging\". Since Claude Code doesn't declare the elicitation capability yet, our capability-aware fallback kicks in: the tool tells the agent to confirm with you first, you say yes in chat, and the deploy lands.\n\nBonus: run `npx wrangler tail`\n\nwhile you do it and watch the requests land in your production logs live.\n\nHere’s a small demo:\n\nOur `deployments`\n\narray lives in memory, and on Workers, memory is extra ephemeral: isolates spin up and down per location, so two requests might see different histories. That's not a bug in the demo; it's the whole lesson of the spec, one more time. Protocol state is gone by design, and app state belongs in real storage. On Cloudflare, that's KV, D1, or Durable Objects.\n\nWhat we just built is one server with three tools. Real agents need Gmail, Slack, Notion, GitHub, Linear, and fifty other things.\n\nYou could build and deploy a similar server for every one of those. Handle each app's OAuth. Keep up with every API change. Run all that infra.\n\nOr you point your agent at Composio, which gives you 1000+ apps behind a single MCP endpoint:\n\n`https://connect.composio.dev/mcp`\n\nBuild custom MCP servers (like the one we built) for your own domain logic, and let Composio be the app layer for everything else.\n\nThe 2026-07-28 spec is a breaking release, and it's the good kind of breaking.\n\nMCP servers are now boring HTTP services. Deploy them like you deploy everything else: stateless, load-balanced, cacheable, autoscaled. The handshake is gone, sessions are gone, and interactive tools work anyway thanks to MRTR.\n\nIf you're starting a new server today: use SDK v2, use the `createMcpHandler(buildServer)`\n\nfactory pattern, keep resources at module level, seal your `requestState`\n\n, and split your logic from your platform entry. We went from localhost to Cloudflare's global edge with an eleven-line file, and the same split works for Railway, Render, Fly, or a plain VM through `server.ts`\n\n.\n\nIf you have existing servers: you've got a 12-month window on everything deprecated. Use it.\n\nNow your MCP server finally gets to be just another web service. 🤌\n\nℹ️ You can find the entire source code in\n\n[the repository].", "url": "https://wpnews.pro/news/how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec", "canonical_source": "https://dev.to/composiodev/how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec-50p3", "published_at": "2026-08-05 12:13:02+00:00", "updated_at": "2026-08-05 12:48:05.003451+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure", "artificial-intelligence"], "entities": ["Model Context Protocol", "Cloudflare Workers", "TypeScript SDK v2"], "alternates": {"html": "https://wpnews.pro/news/how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec", "markdown": "https://wpnews.pro/news/how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec.md", "text": "https://wpnews.pro/news/how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec.txt", "jsonld": "https://wpnews.pro/news/how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec.jsonld"}}