An MCP server started over stdio works well when the client creates the process. It stops being enough when several clients need the same tools over a URL, when the server must sit behind a proxy, or when a team wants to publish an integration without distributing a package.

For that case, host the server over Streamable HTTP. The current MCP TypeScript SDK separates the server from its transport: stdio serves local integrations, while Streamable HTTP serves remote servers (MCP TypeScript SDK, "Serve over HTTP", retrieved 2026-08-10). The application still registers the same tools, but the runtime now owns HTTP, authentication, and shutdown.

This tutorial covers the smallest useful design, its security boundaries, and a repeatable verification path. The example uses the SDK v2 packages. If your project still uses @modelcontextprotocol/sdk, compare its imports with the SDK migration guide before copying the code.

If you still need to create the local server, start with the TypeScript MCP server for coding agents. The problem here is different: putting the same surface behind a network without confusing transport with authorization.

Diagram shows a TypeScript MCP server passing through an HTTP endpoint, authentication, and tool verification.

Quick answer

  • Use stdio when the host starts a local process; use Streamable HTTP for a remote server.
  • Start stateless when each request can stand on its own.
  • Validate Host, Origin, and authentication before a tool can run.
  • Test tools/list and a real call with a client separate from the server process.

When should an MCP server leave the local process?

A remote server makes sense when the client should not manage the process, when several consumers share the integration, or when the tool needs network access and credentials that should not be copied into every workspace. A local server remains better for private tools that are fast and tightly coupled to a developer's directory.

The protocol does not turn a local integration into a remote one just by changing its URL. The Streamable HTTP transport guide defines one HTTP endpoint that receives JSON-RPC messages through POST. Each request can receive a JSON object or an SSE response. That network boundary needs authentication, limits, and observability.

Use this decision:

Situation Starting transport Reason
IDE or CLI starts the server on the same computer stdio The process and credentials stay on the local host.
Several clients access tools through a URL Streamable HTTP The server has a shared endpoint.
Tools are stateless and scale behind a proxy Stateless Streamable HTTP Any instance can handle a request.
The workflow needs sessions, resumption, or notifications Stateful Streamable HTTP The server must persist or route state.

Do not choose HTTP just because it looks newer. If the only client is a local process and the tool reads files in that workspace, stdio reduces the operational surface. The remote endpoint must earn its extra TLS, identity, network limits, and monitoring work.

What does Streamable HTTP change in the MCP contract?

Streamable HTTP makes the server an independent process and uses POST for each JSON-RPC message. The current specification requires the client to advertise support for application/json and text/event-stream, and the server chooses a JSON response or an SSE stream per request (MCP, "Streamable HTTP", retrieved 2026-08-10). The client no longer talks to stdin and stdout.

That changes the tests. You must check the HTTP method, Content-Type, version headers, JSON-RPC response, cancellation, and behavior when a proxy closes the connection. A test that calls only the tool handler does not prove that the remote endpoint follows the protocol.

Do not treat the protocol version as a permanent detail. Revision 2026-07-28 changed session and stream behavior compared with earlier revisions. The current SDK provides createMcpHandler for a per-request modern path and documents compatibility options for older clients (SDK, "Supporting protocol revision 2026-07-28", retrieved 2026-08-10).

Record the package versions, accepted protocol revisions, and tested clients. Do not add a legacy SSE fallback without a client that needs it. Each extra transport adds states and failure paths that you must maintain.

How do you build a remote MCP server in TypeScript?

The current SDK reduces the HTTP path to three pieces: a factory that creates McpServer, a handler that turns each request into an execution, and an adapter for the Node runtime. The following example is illustrative, but it follows the imports and composition shown in the SDK's official examples.

Install the packages and pin their versions in your lockfile:

npm install @modelcontextprotocol/server @modelcontextprotocol/node zod
npm install --save-dev typescript tsx @types/node

Create a small factory. It should register narrow tools and should not read credentials directly from the prompt. Authenticated context belongs in the HTTP layer, not in a string the model can change.

// src/mcp-server.ts
import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

export function buildServer(): McpServer {
  const server = new McpServer({
    name: "remote-notes",
    version: "1.0.0",
  });

  server.registerTool(
    "find_note",
    {
      description: "Find a note by its public identifier",
      inputSchema: z.object({
        id: z.string().min(1).max(80),
      }),
    },
    async ({ id }) => ({
      content: [{ type: "text", text: `Requested note: ${id}` }],
    }),
  );

  return server;
}

A real tool should query an authorized source and validate its result as well. The return value above is deliberately small. It keeps the contract visible without pretending that a notes API, database, or ticket system exists in this example.

Now create a stateless HTTP entry point. createMcpHandler builds the server from the factory per request, and toNodeHandler adapts it to node:http. The code also applies the Host and Origin protections provided by the Node package.

// src/http.ts
import { createServer } from "node:http";
import {
  localhostHostValidation,
  localhostOriginValidation,
  toNodeHandler,
} from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { buildServer } from "./mcp-server.js";

const handler = toNodeHandler(createMcpHandler(buildServer));
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

const httpServer = createServer((req, res) => {
  if (!validateHost(req, res) || !validateOrigin(req, res)) return;
  void handler(req, res);
});

httpServer.listen(3000, "127.0.0.1", () => {
  console.error("MCP endpoint listening on http://127.0.0.1:3000/mcp");
});

process.on("SIGTERM", () => {
  httpServer.close(() => process.exit(0));
});

The loopback bind is appropriate for local development. In production, use an HTTP framework or runtime adapter with an allowed-host list, listen on the address supplied by the environment, and put authentication before the handler. Do not copy 127.0.0.1 into a container that must receive external traffic.

The SDK uses the same separation in its gateway example: createMcpHandler owns the protocol, while toNodeHandler adapts it to Node (SDK, "Gateway example", retrieved 2026-08-10). That makes it possible to test the handler without a listening port and the complete process over HTTP.

How do you protect the endpoint before the first tool call?

Protect the endpoint in layers. The MCP server should reject an invalid host or origin, the proxy should terminate TLS, and the identity layer should validate a token before the request reaches the handler. The MCP authorization tutorial recommends OAuth for remotely hosted HTTP servers, while stdio servers can use local environment credentials (MCP, "Understanding Authorization", retrieved 2026-08-10).

For a public Node service, the shape looks like this. verifier is an adapter for your identity provider. Do not put a real token in the repository or treat this excerpt as a complete OAuth implementation.

import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import {
  createMcpHandler,
  requireBearerAuth,
} from "@modelcontextprotocol/server";
import { buildServer } from "./mcp-server.js";

const app = createMcpExpressApp({
  host: "0.0.0.0",
  allowedHosts: ["mcp.example.com"],
});

const verifier = createVerifierFromYourIdentityProvider();
const auth = requireBearerAuth({
  verifier,
  requiredScopes: ["mcp:tools"],
});

const handler = toNodeHandler(createMcpHandler(buildServer));

app.all("/mcp", auth, (req, res) => {
  void handler(req, res, req.body);
});

app.listen(8080, () => {
  console.error("MCP endpoint listening on port 8080");
});

The SDK documentation describes requireBearerAuth, requiredScopes, and the WWW-Authenticate challenge at this boundary (SDK, "Authorization", retrieved 2026-08-10). A tool should still apply domain authorization: a valid token does not mean the caller can read every note, database, or repository.

Replace mcp.example.com with the real host and keep an explicit allowlist. The specification also requires Origin validation to prevent DNS rebinding. If you assemble the server with node:http without a framework factory, put the corresponding guards before the handler.

Do not approve tools by name just because the server passed authentication. Separate reads from mutations, limit scope per user, and record which identity called which tool. The article on MCP allowlists for coding agents covers the tool policy above authentication.

Should the server be stateless or sessionful?

Start stateless when each request can rebuild the server and access storage on its own. The SDK scaling guide explains that createMcpHandler creates an instance per request, so stateless servers can scale behind a load balancer without session affinity (SDK, "Sessions, state, and scaling", retrieved 2026-08-10).

This works well for tools that receive all arguments, query an external source, and return a result. Application state belongs in a database, cache, or queue, not in the McpServer object created for one request.

Use persistent state when the workflow must resume a stream, follow notifications, keep a subscription, or run work across calls. Do not let an in-memory Map be the only copy of a session if the service can have two replicas or restart.

The practical choices are:

  1. Stateless: the handler creates the server per request and any instance can serve any call.
  2. External state: sessions, events, or results live in shared storage, so any node can serve the request.
  3. Session affinity: state stays in one node's memory and the proxy pins the client to it. This is simple, but makes failure and scaling harder.

The SDK scaling guide also describes a shared bus for notifications between nodes. Use it only when a feature truly needs cross-instance communication. A simple query tool does not need that cost on day one.

The useful cut is not "HTTP with a session" versus "HTTP without a session". Separate state required by the domain from state accidentally introduced by the transport. A long-running task needs persistence because the work exists. An in-memory map copied from a sample is operational debt.

How do you verify a remote MCP server without trusting its process?

Verify the server in layers: the process starts, the endpoint responds, the client discovers tools, a valid call produces a result, an invalid call is rejected, and an unauthorized request produces no side effect. The MCP Inspector is useful for interactive debugging, but the CI check should start or call the endpoint like a real client.

During development, run the process and point the Inspector at the endpoint:

npx @modelcontextprotocol/inspector http://127.0.0.1:3000/mcp

Confirm this sequence in the panel and server logs:

  1. The client completes initialize and negotiates a supported revision.
  2. tools/list shows find_note with the published schema.
  3. A valid identifier returns the expected content.
  4. An empty identifier fails validation before the real query.
  5. An invalid origin, host, or credential fails before the tool handler.

Do not treat a 200 response alone as proof. The HTTP layer can respond while the tool contract is wrong. The MCP TypeScript contract-testing tutorial shows how to automate discovery, a valid call, invalid input, and transport errors for a local server. For this remote spoke, replace stdio with the HTTP URL and add authentication and proxy cases.

A smoke test can call the endpoint over a test network, never with a production credential. Capture status, Content-Type, JSON-RPC body, response time, and redacted logs. If the endpoint uses SSE, also check that the proxy does not buffer the response.

This article does not claim a production benchmark or first-hand deployment result. The proposed proof is intentionally small: a separate client discovers the tool, calls a valid case, forces an invalid case, and confirms that the identity barrier stops execution. That is more honest and more useful than calling the handler directly and declaring the remote server ready.

Which failures appear first in production?

The first failures usually occur at the boundary, not in model reasoning. A wrong path returns 404; a missing token returns 401; a missing scope returns 403; a rejected origin can return 403; an incompatible client fails during negotiation; and a badly configured proxy holds or closes a stream. Record the error without storing tokens, prompts, or tool data.

Symptom Likely cause Check
404 while connecting URL does not point to /mcp or the route is missing Confirm the final route and the POST method.
401 before tools/list Missing, expired, or incorrectly issued token Inspect WWW-Authenticate and use a test token.
403 on localhost Host or Origin guard rejected the request Test the explicit origin and do not bind openly without an allowlist.
No tools appear Negotiation failed or the factory registered none Read the initialize response and the construction log.
Works on one replica, fails on another Session state exists only in memory Use stateless mode, shared storage, or deliberate affinity.
Tool is slow and the connection drops Proxy, server, or client timeout Distinguish cancellation from tool failure and measure each layer.

The most dangerous case is repeating a mutation after a disconnect. A dropped connection does not prove that the server did not apply the effect. For writes, use an idempotency key, a confirmation query, or a compensating operation. Reads are usually easier to repeat, but they still need a limit.

Another limit: Streamable HTTP does not authorize a tool by itself. The transport delivers messages, authentication identifies the caller, and the application policy decides whether that caller may act. Keep those decisions in separate layers so a transport change cannot accidentally expand agent agency.

Checklist before publishing the endpoint

Before giving the URL to a client, walk through this list:

  • [ ] The server uses Streamable HTTP for remote access and does not expose stdio on the network.
  • [ ] The /mcp path is documented and tested by an external client.
  • [ ] TLS terminates at the proxy or process, according to the environment.
  • [ ] Host and Origin have explicit validation, without * as a permanent setting.
  • [ ] Authentication validates issuer, audience, expiry, and scope.
  • [ ] Each tool applies domain authorization in addition to global authentication.
  • [ ] Stateless or stateful mode was chosen from the actual workflow.
  • [ ] Logs redact tokens, prompts, personal data, and sensitive results.
  • [ ] CI tests discovery, schema, success, rejection, transport, and shutdown.
  • [ ] Mutations have idempotency or effect confirmation before retry.

If you need to deploy on Cloud Run, treat the service as the HTTP process that receives the MCP endpoint. The article on service or worker pool for long-lived agents on Cloud Run helps with the execution model. It does not replace the protocol, auth, and scaling tests here.

Conclusion

A reliable remote MCP server only looks like an ordinary HTTP service. The transport defines how messages arrive, but Host, Origin, identity, permission, state, and verification decide whether the integration can operate safely. Start stateless when the domain allows it, and add persistence only for a concrete requirement.

For long Claude Code and Codex workflows, I use RemoteCode as the author's tool for continuing work between sessions. It helps preserve continuity, but it does not replace authentication, contract tests, or the decision about which tools a caller may execute.

Frequently asked questions

Can the same MCP server support both stdio and Streamable HTTP?

Yes. Register tools in a shared factory and choose a transport entry point per environment. stdio serves a local process, while createMcpHandler or an HTTP transport serves remote requests. Test both paths because their lifecycle, authentication, and shutdown behavior differ.

Does Streamable HTTP need an in-memory session?

No. The current SDK supports a stateless per-request handler. Use a session or external state when the workflow needs resumption, notifications, or work that crosses calls. A local Map is acceptable in a sample, but not as the only source of truth for a service that scales or restarts.

Can I protect MCP with only an API key?

An API key can work for a simple integration, but it must be validated before the handler and limited to the smallest possible tool set. For user data, consent, auditing, or distinct scopes, use a suitable identity system and validate issuer, audience, expiry, and service permissions.

Does the MCP Inspector replace contract tests?

No. The Inspector speeds up manual investigation of connections, schemas, and calls. CI should repeat those cases with an automated client and fail when the published surface changes. The test should use the same transport as production, instead of calling only an internal function.

Do I need to accept older MCP clients?

Only if a client you need still requires them. The current SDK documents compatibility across revisions, but every additional path expands the test matrix. Declare the accepted revision, record tested clients, and remove a legacy fallback when it no longer serves a real use case.

Sources consulted