This site does not allow itself to be embedded. You can still read it on the original site — the toolbar below keeps your place in the directory.
MCP just became stateless, which means your own MCP server is now just an HTTP endpoint that deploys like any web service. Build one with an agent, deploy it on Railway, and point opencode or Claude Code at the public URL. The full build, start to finish.
The [2026-07-28 MCP specification](/blog/stateless-mcp-2026-spec-bun-fleet) removed sessions entirely. No `initialize` handshake, no `Mcp-Session-Id` header, no GET stream endpoint. Every request is now one self-contained HTTP POST to a single endpoint. That change sounds like a wire-format detail, but it quietly rewrites how you ship tools to your agents: a remote MCP server is now just an ordinary web handler, and ordinary web handlers deploy like any other service. Session affinity is gone, so any replica can answer any request, and anything that can host a Node process can host your MCP server.
This guide builds the canonical version end to end: a small MCP server called `ops-brief` with two genuinely useful tools, deployed to a public HTTPS URL and connected to both [OpenCode](https://opencode.ai/go?ref=M6HEHM4JM5) and Claude Code. The scaffolding is done by the agent itself - [OpenCode](https://opencode.ai/go?ref=M6HEHM4JM5) in headless mode is the harness, [DeepSeek V4 Flash](/blog/deepseek-v4-flash-0731-opencode-guide) is the model doing the writing - and [Railway](https://dub.sh/dd-railway) is the host, because for a service that needs a public URL, logs, and redeploys on push, that is exactly its lane. We run a version of this shape for parts of this site; the mechanics below are the portable core. Seven steps, under an hour, every step ending in something you can run.
## Official Sources
| Resource | Description |
|----------|-------------|
| [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) | The stateless wire contract this server implements |
| [MCP server quickstart](https://modelcontextprotocol.io/quickstart/server) | Official SDK setup for building servers |
| [OpenCode MCP servers docs](https://opencode.ai/docs/mcp-servers/) | Local and remote MCP config for opencode |
| [Claude Code MCP docs](https://code.claude.com/docs/en/mcp) | `claude mcp add` and the `.mcp.json` format |
| [Railway Quick Start](https://docs.railway.com/quick-start) | Deploying from GitHub and the CLI |
| [Railway Public Networking](https://docs.railway.com/networking/public-networking) | Railway-provided domains and automatic SSL |
| [Railway GitHub Autodeploys](https://docs.railway.com/deployments/github-autodeploys) | Deploy on every push to the connected branch |
| [Railway Pricing](https://docs.railway.com/pricing/plans) | Free trial grant and Hobby plan |
| [GitHub Releases REST API](https://docs.github.com/en/rest/releases/releases) | `GET /repos/{owner}/{repo}/releases/latest` |
## Step 1: Set up the pieces
Prerequisites: Node.js 20 or newer, a GitHub account, and a free [Railway](https://dub.sh/dd-railway) account (new accounts get a one-time $5 trial grant valid for 30 days, which covers this build several times over).
Install [OpenCode](https://opencode.ai/go?ref=M6HEHM4JM5) with the official one-liner from the [docs](https://opencode.ai/docs/), authenticate a provider, and prove headless mode works:
```bash
curl -fsSL https://opencode.ai/install | bash
opencode auth login
opencode run --model opencode/deepseek-v4-flash "print the current directory tree, two levels deep"
```
If that prints a tree and exits cleanly, the agent side is ready. Two notes before you start: connect your GitHub account to Railway when you sign up, because a verified GitHub account is what unlocks the full trial with unrestricted network access. And keep the trial in mind - Railway's free trial and [plans](https://docs.railway.com/pricing/plans) page is where the numbers live, so you can check them yourself rather than trusting a blog. **What you have now:** a working agent CLI and a Railway account with credit on it.
## Step 2: Have the agent scaffold the server
Create an empty directory and let the agent write the whole project. This is a narrow, well-specified task - exactly what budget models are good at:
```bash
opencode run --model opencode/deepseek-v4-flash --variant high \
"Create a TypeScript MCP server project in ./ops-brief. It exposes two tools: check_endpoint(url) which HTTP-GETs a URL and reports status and latency, and latest_releases(repos) which calls the GitHub REST API GET /repos/{owner}/{repo}/releases/latest for each repo and reports the tag, name, and publish date. Use the official @modelcontextprotocol/sdk, serve the Streamable HTTP transport on POST /mcp via Express, read PORT from the environment with a 3001 default, add a GET /healthz route returning ok, and add a build script that runs tsc. Minimal and typed."
```
The core of what the agent produces, once you strip the boilerplate, looks like this:
```typescript
import express from "express";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const server = new McpServer({ name: "ops-brief", version: "1.0.0" });
server.registerTool(
"check_endpoint",
{
description: "Check whether a URL responds and how long it takes",
inputSchema: z.object({ url: z.string().url().describe("The URL to check") }),
},
async ({ url }) => {
const start = Date.now();
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(10_000) });
return { content: [{ type: "text", text: `${res.status} in ${Date.now() - start} ms (${res.url})` }] };
}
);
server.registerTool(
"latest_releases",
{
description: "Get the latest GitHub release for one or more repos, e.g. 'sst/opencode'",
inputSchema: z.object({ repos: z.array(z.string()).describe("owner/repo pairs") }),
},
async ({ repos }) => {
const lines = [];
for (const repo of repos) {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { Accept: "application/vnd.github+json", "User-Agent": "ops-brief-mcp" },
});
if (!res.ok) { lines.push(`${repo}: no release found (${res.status})`); continue; }
const rel = await res.json();
lines.push(`${repo}: ${rel.tag_name} (${rel.name}) published ${rel.published_at}`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
const app = express();
app.use(express.json());
app.get("/healthz", (_req, res) => res.send("ok"));
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport();
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res);
});
app.listen(Number(process.env.PORT) || 3001, () => {
console.log("ops-brief MCP server listening on /mcp");
});
```
Two things to check in whatever the agent writes before you accept it. First, the tool handlers must be bounded: a `timeout` on the fetch and no unbounded loops, because a remote tool call has no terminal nearby to Ctrl-C it. Second, tool descriptions must tell the model when to use the tool - `check_endpoint` is for verifying a deploy or a docs link, `latest_releases` is for release awareness - because the description is the entire routing contract. Then build and run:
```bash
cd ops-brief && npm install && npm run build
node dist/index.js
```
**What you have now:** a compiled MCP server with two working tools, running locally on port 3001.
## Step 3: Prove the protocol locally with curl
Remote MCP is a protocol contract, and contracts deserve a raw test before you trust an SDK client. The 2026-07-28 spec requires the `MCP-Protocol-Version` and `Mcp-Method` headers on every POST, with `Mcp-Name` added for `tools/call`; servers must reject requests where a header does not match the body with a `HeaderMismatch` error (code `-32020`).
List the tools:
```bash
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
Call one:
```bash
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/call" \
-H "Mcp-Name: check_endpoint" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"check_endpoint","arguments":{"url":"https://example.com"}}}'
```
And confirm the mismatch rejection is real - send `Mcp-Name: wrong` with the same body and you should get a JSON-RPC error with code `-32020`. If your SDK-generated server does not reject that, it is not spec-compliant, and spec non-compliance is exactly what bites you later behind a load balancer. **What you have now:** proof the server speaks the stateless contract correctly, verified by hand.
## Step 4: Deploy it on Railway
This is where the stateless spec pays its rent. Because there is no session state, deployment is the boring, reliable kind: push the code, Railway builds it, traffic hits the container. No sticky sessions, no state migration, no server configuration beyond "run it".
Push the repo to GitHub (create an empty repo, then `git add -A && git commit -m "ops-brief MCP server" && git push`), then in the [Railway](https://dub.sh/dd-railway) dashboard: **New Project → Deploy from GitHub repo → select the repo → Deploy Now**. Railway detects the Node service, installs dependencies, runs the build script, and starts it with `PORT` set in the environment - which is why the server reads `process.env.PORT` instead of hardcoding 3001. Any push to the connected branch triggers a new deployment automatically, so fixing a tool bug later is `git push` and done.
Now expose it: **Settings → Networking → Public Networking → Generate Domain**. Railway provisions a `*.railway.app` domain with automatic SSL - and an HTTPS URL matters here, because agent clients treat plain HTTP remote MCP servers as a non-starter. Verify:
```bash
curl https:// .up.railway.app/healthz
```
That returns `ok` when the deploy is live. The whole thing costs you a rounding error of the trial's $5 grant; a server this small sits comfortably inside the included usage on the $5/month Hobby plan after the trial ends, per the [pricing docs](https://docs.railway.com/pricing/plans). **What you have now:** your MCP server on a public HTTPS URL, redeploying itself on every push.
## Step 5: Point opencode at the public URL
The payoff step. OpenCode reads remote MCP servers from `opencode.json` - the config file in your project root - under the `mcp` key with `type: "remote"`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"ops-brief": {
"type": "remote",
"url": "https:// .up.railway.app/mcp"
}
}
}
```
Confirm the connection with `opencode mcp list` - your server should show up with its tools - then use it in a session:
```text
use ops-brief to check whether https://example.com responds, and tell me the latest release of sst/opencode
```
Watch what happens: the model fetches the tool list from your server, decides both tools fit, calls them over HTTP, and answers from the results. You just gave an agent capabilities it did not have a minute ago - network probing and release awareness - by editing one JSON file. Any machine with that config now has the same tools, which is the whole point of remote servers over the stdio-only kind. **What you have now:** a coding agent using your deployed MCP server as a cloud tool.
## Step 6: Share it with other harnesses and teammates
The same URL works from any MCP client. Claude Code, for example, takes it as a one-liner:
```bash
claude mcp add --transport http ops-brief https:// .up.railway.app/mcp
```
The `--transport http` flag is what marks it as a remote server - without it Claude Code would try to spawn a local process and fail. For team use, add it with `--scope project`, which writes the entry to a `.mcp.json` file in the repo so everyone on the team gets the same tools with the same URL. The client-side config shapes differ slightly per harness - opencode uses `type: "remote"`, Claude Code uses `type: "http"` - but the wire protocol is identical, which is the bet MCP makes and the reason this whole build never touches client code.
Worth knowing while you are in this world: Railway dogfoods the pattern. Its own [MCP server](https://docs.railway.com/ai/mcp-server) exposes project management - create projects, set variables, generate domains - to agents over a hosted endpoint, with OAuth for authentication. It is the same shape you just shipped, done by the platform, and a good reference for what a well-polished remote server looks like. **What you have now:** one URL that any agent harness on your team can adopt.
## Step 7: Harden it before you tell anyone the URL
A public MCP endpoint is an open door by default: anyone can POST `tools/list` and call your tools. Before the server does real work, three cheap moves:
1. **Require a bearer token.** Add `MCP_TOKEN` to the service's **Variables** in Railway, and have the server reject requests without `Authorization: Bearer $MCP_TOKEN` before touching the transport. Clients then send the header: `headers: { "Authorization": "Bearer {env:MCP_TOKEN}" }` in opencode.json, or `--header "Authorization: Bearer $MCP_TOKEN"` on `claude mcp add`. This is the highest-value hardening there is - one env var, one middleware line.
2. **Validate the Origin header.** The spec requires servers to reject requests with an invalid `Origin` with a 403 to prevent DNS rebinding attacks; make sure your SDK wiring does not skip it.
3. **Keep the tool surface small.** Every MCP tool lands in the model's context window, so a server with forty tools costs tokens on every request even when only two get used. Two focused tools beat forty speculative ones - the [OpenCode docs](https://opencode.ai/docs/mcp-servers/) are explicit about this.
For a server that will serve a team publicly, the next step up is OAuth with per-user scopes - the pattern our [zero-touch OAuth guide](/blog/zero-touch-oauth-mcp-enterprise) covers - but for a personal or small-team server, a bearer token is the honest default. **What you have now:** a deployed, authenticated MCP server that any agent on your team can call, that costs cents a month to run, and that you own end to end.
The whole loop, one afternoon: agent writes the server, curl proves the contract, Railway gives it a URL, and two config files give every agent on your team the tools. The stateless spec did the heavy lifting - everything after it is just deploying a web service, which is a solved problem.
## FAQ
### Why deploy an MCP server remotely instead of running it locally?
A remote server runs once and serves every machine and every harness - your laptop, CI, a teammate's editor, a scheduled agent - without each one installing a runtime or managing a process. It can also live next to the data it needs (a database, an internal API) instead of depending on the agent's machine. The tradeoff: it is a network surface, so it needs the auth from Step 7.
### What does a hosted MCP server cost?
A single small Node service on Railway costs a rounding error of the one-time $5 trial grant; after the trial, the $5 per month Hobby plan includes $5 of resource usage, and a server this small sits well inside it. The model side only costs tokens when an agent actually calls a tool. See the [Railway pricing docs](https://docs.railway.com/pricing/plans) for the exact numbers.
### Does the server still need the initialize handshake and session IDs?
No. The 2026-07-28 spec removed protocol-level sessions: every request is one self-contained POST carrying its own metadata, and the SDKs implement the version negotiation and legacy fallback for you. That removal is exactly what makes this build as simple as it is.
### Can the same server work in opencode and Claude Code?
Yes - that is the point of the protocol. The wire format is identical; only the client config shape differs. opencode uses `{"type": "remote", "url": "..."}` in `opencode.json`, Claude Code uses `claude mcp add --transport http ` or a `.mcp.json` entry with `"type": "http"`.
### Is a public MCP server safe?
With the Step 7 hardening in place, reasonably: a required bearer token, Origin validation, and a deliberately small tool list. The rule of thumb is to never put a destructive or unauthenticated tool on a public endpoint, and to treat the token like any other secret - it lives in Railway's Variables, not in the repo.
Some links to tools above are referral links - see our [affiliate disclosure](/affiliate-disclosure).
## Sources
| Source | URL |
|--------|-----|
| MCP Streamable HTTP transport spec (2026-07-28) | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http |
| MCP server quickstart | https://modelcontextprotocol.io/quickstart/server |
| OpenCode MCP servers docs | https://opencode.ai/docs/mcp-servers/ |
| Claude Code MCP docs | https://code.claude.com/docs/en/mcp |
| Railway Quick Start | https://docs.railway.com/quick-start |
| Railway Public Networking | https://docs.railway.com/networking/public-networking |
| Railway GitHub Autodeploys | https://docs.railway.com/deployments/github-autodeploys |
| Railway Pricing Plans | https://docs.railway.com/pricing/plans |
| Railway Free Trial | https://docs.railway.com/pricing/free-trial |
| GitHub Releases REST API | https://docs.github.com/en/rest/releases/releases |
**Last updated:** August 10, 2026
## Continue Reading
- [Stateless MCP Is Here: What the 2026-07-28 Spec Changes](/blog/stateless-mcp-2026-spec-bun-fleet) - the spec change this whole build rides on, and a fleet-of-servers pattern on one process
- [How to Build MCP Servers in TypeScript](/blog/how-to-build-mcp-servers) - the local-first counterpart: building and testing servers with stdio
- [Put an AI Agent Behind a Webhook on Railway](/blog/deploy-agent-webhook-railway) - the other side of shipping agent infrastructure on Railway
- [Put an AI Agent on a Cron Job](/blog/opencode-cron-automation-guide) - scheduled agents, the sibling pattern to remote tools
- [Zero-Touch OAuth for Enterprise MCP](/blog/zero-touch-oauth-mcp-enterprise) - where authentication goes when a bearer token stops being enoughRead on developersdigest.tech ↗
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.