Dual transport: STDIO and HTTP

Serve local and remote clients from one server process

In dual mode, one NitroStack server listens on two transports at once. A local client, such as Claude Desktop or NitroStudio, starts the server and talks to it over STDIO. Remote clients, any number of them, reach the same process over HTTP. Your tools, resources, and prompts are registered once and served on both.

How it works

Local clientStarts the server
Remote clientsAny number at once
NitroStack servertransport: dual · STDIO, /mcp, /sse, /mcp/health
One process serves both. Your tools, resources, and prompts are registered once.

STDIO carries JSON-RPC messages over the process's standard input and output. Only the program that started the server can use it, so there is no network exposure and nothing to authenticate.

HTTP serves the MCP endpoint at /mcp using Streamable HTTP. Older clients that only speak Server-Sent Events use /sse and /mcp/messages instead. The HTTP server also answers GET /mcp/health with its status, which load balancers and container platforms use to know the server is ready. Outside development mode, the server root / serves a small page that lists your tools and the MCP endpoint URL.

When dual mode is on

You usually don't turn dual mode on yourself. The server picks its transport when it starts, in this order:

  1. The transport option passed to server.start().
  2. The MCP_TRANSPORT_TYPE environment variable: stdio, http, or dual.
  3. The transport configured in @McpApp.
  4. Otherwise NODE_ENV: STDIO when it's development or unset, dual when it's production.

npm run dev runs in development mode, so it uses STDIO. npm start runs nitrostack-cli start, which sets NODE_ENV=production, so it uses dual mode on port 3000.

Turn it on explicitly

To choose the transport in code, pass options to start() in src/index.ts. Every option is optional; the values below are the defaults, except transport and host.

src/index.ts
import 'dotenv/config';
import { McpApplicationFactory } from '@nitrostack/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const server = await McpApplicationFactory.create(AppModule);

  await server.start({
    transport: 'dual',
    port: Number(process.env.PORT ?? 3000),
    host: '0.0.0.0',
    endpoint: '/mcp',
    enableCors: true,
  });
}

bootstrap();

host defaults to localhost, which accepts connections from the same machine only. Use 0.0.0.0 when other machines, or a container's port mapping, need to reach the server.

The same settings are available as environment variables, which is the better choice when the code shouldn't change between environments:

VariableDefaultDescription
MCP_TRANSPORT_TYPEfrom NODE_ENVstdio, http, or dual
PORT3000HTTP port
HOSTlocalhostInterface to listen on
ENABLE_CORSenabledSet to false to turn off CORS headers

HTTP endpoints

The HTTP side of a dual server answers on these paths:

Method and pathPurpose
POST /mcpMCP requests over Streamable HTTP
GET /mcp, DELETE /mcpServer-sent messages and session close, for clients that use sessions
GET /mcp/healthStatus, transport, protocol version, open sessions, and uptime
GET /sse, POST /mcp/messagesLegacy SSE transport for older clients
GET /Tool list and endpoint URL, outside development mode

Protocol versions

The HTTP endpoint speaks two generations of the MCP protocol. The current revision, 2026-07-28, is stateless: each request stands alone, and clients discover the server's capabilities with server/discover. The earlier revision uses sessions identified by an Mcp-Session-Id header. By default the server answers both, so old and new clients work side by side.

To accept only one, set NITRO_MCP_PROTOCOL_VERSION to 2026-07-28 or legacy. Leave it unset, or set it to auto, to serve both.

OAuth 2.1

When your app imports OAuthModule, the server always runs in dual mode and listens on 0.0.0.0, because clients need to reach the OAuth metadata over HTTP. It publishes that metadata at /.well-known/oauth-protected-resource, and protects /mcp, /sse, and /mcp/messages with token checks.

src/app.module.ts
@Module({
  imports: [
    OAuthModule.forRoot({
      resourceUri: process.env.RESOURCE_URI,
      authorizationServers: [process.env.AUTH_SERVER_URL],
    }),
  ],
})
export class AppModule {}

See OAuth 2.1 for the full configuration.

Secure the HTTP side

STDIO is reachable only by the process that started the server. HTTP is reachable by anyone who can reach the port, so treat it like any other web service.

Serve it over HTTPS, either from a platform that terminates TLS for you, such as NitroCloud or a cloud load balancer, or behind a reverse proxy. With nginx, pass /mcp through and turn off buffering so streamed responses arrive as they're sent:

nginx.conf
server {
  listen 443 ssl;
  server_name mcp.example.com;

  location /mcp {
    proxy_pass http://localhost:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering off;
  }
}

Then require credentials on every call with OAuth 2.1, JWT, or API keys, and limit how often a client can call expensive tools with rate limiting.

Python servers

A Python NitroStack server supports the same three modes, chosen with MCP_TRANSPORT_TYPE. In dual mode the STDIO and HTTP transports run as asyncio tasks in one process and share the same dependency injection container. If either transport stops, whether STDIO reaches end of input or HTTP receives a stop signal, the other stops with it.

ValueUse
stdioThe default outside production. For Claude Desktop and Cursor.
httpStreamable HTTP and legacy SSE. For cloud deployments and the MCP Inspector.
dualThe default when NODE_ENV=production. STDIO and HTTP together.

The HTTP side answers on these paths:

Method and pathPurpose
POST, GET, DELETE /mcpStreamable HTTP. /mcp and /mcp/ are equivalent, but the Inspector wants no trailing slash for the SSE GET.
GET /sse, POST /mcp/messages/Legacy HTTP and SSE. The trailing slash is required.
GET /mcp/healthTransport status.

To set the transport in code, put it in the server configuration:

server = ServerConfig(name="my-server", transport_type="http", max_sessions=100)

Troubleshooting

HTTP clients get connection refused. The server is running over STDIO only. Check that NODE_ENV=production or MCP_TRANSPORT_TYPE is set, then confirm with curl http://localhost:3000/mcp/health.

It works on your machine but not from another one. The server is listening on localhost. Set HOST=0.0.0.0.

The port is already in use. Another process holds port 3000; find it with lsof -i :3000, or set PORT to a free port.

A browser client reports CORS errors. CORS headers are on by default. Check that ENABLE_CORS isn't set to false, and that the client calls /mcp and not another path.

See also

On this page