Skip to main content

Overview

UMCP acts as a transport bridge, allowing you to connect to MCP providers that use different transport protocols and expose them all through a single unified interface. This means downstream clients only need to connect to UMCP using one transport type, while UMCP handles the complexity of connecting to providers via their native transports.

Supported Transports

UMCP supports three MCP transport protocols:
  1. stdio - Standard input/output (process-based)
  2. sse - Server-Sent Events (HTTP-based)
  3. streamable-http - Streamable HTTP (modern HTTP-based)
From config.ts:11:
const transportKindSchema = z.enum(["stdio", "sse", "streamable-http"]);

How Transport Bridging Works

Upstream Connections

Each provider in your configuration specifies its transport type. UMCP creates the appropriate client connection based on the transport field:
{
  "categories": {
    "example": {
      "providers": [
        {
          "name": "local_tool",
          "transport": "stdio",
          "command": "node",
          "args": ["./tool-server.js"]
        },
        {
          "name": "remote_tool",
          "transport": "streamable-http",
          "url": "https://api.example.com/mcp"
        },
        {
          "name": "legacy_tool",
          "transport": "sse",
          "url": "https://legacy.example.com/mcp"
        }
      ]
    }
  }
}

Downstream Interface

UMCP exposes a single downstream interface that clients connect to. You choose the downstream transport when starting UMCP:
# Serve via stdio
umcp serve

# Serve via HTTP
umcp serve --transport http --host 0.0.0.0 --port 3000 --path /mcp

Transport-Specific Configuration

stdio Transport

For stdio providers, you must specify a command and optionally args. From config.ts:46-52:
if (provider.transport === "stdio" && !provider.command) {
  ctx.addIssue({
    code: z.ZodIssueCode.custom,
    message: "provider.command is required when transport is stdio",
    path: ["command"]
  });
}
Example configuration:
{
  "name": "filesystem",
  "transport": "stdio",
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}

HTTP Transports (sse & streamable-http)

For HTTP-based transports, you must specify a url. From config.ts:54-61:
if ((provider.transport === "sse" || provider.transport === "streamable-http") && !provider.url) {
  ctx.addIssue({
    code: z.ZodIssueCode.custom,
    message: "provider.url is required when transport is sse or streamable-http",
    path: ["url"]
  });
}
Example configuration:
{
  "name": "cloud_service",
  "transport": "streamable-http",
  "url": "https://api.example.com/mcp"
}

Downstream Server Implementation

UMCP’s serve.ts module handles both stdio and HTTP downstream connections.

stdio Server

The stdio server uses StdioServerTransport from the MCP SDK. From serve.ts:109-114:
async function runStdioServe(server: McpServer, logger: Logger): Promise<void> {
  const transport = new StdioServerTransport();
  await server.connect(transport as any);
  logger.info("server.started", "umcp started with stdio transport");
  await waitForShutdownSignal(logger, true);
}

HTTP Server

The HTTP server uses StreamableHTTPServerTransport and handles multiple HTTP methods. From serve.ts:116-177:
async function runHttpServe(
  server: McpServer,
  options: { host: string; port: number; path: string; logger: Logger }
): Promise<void> {
  const { host, port, logger } = options;
  const endpointPath = normalizeEndpointPath(options.path);
  const mcpTransport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined
  });

  await server.connect(mcpTransport as any);

  const httpServer = createServer(async (req, res) => {
    try {
      const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
      if (requestUrl.pathname !== endpointPath) {
        res.statusCode = 404;
        res.end("Not Found");
        return;
      }

      const method = req.method ?? "GET";
      if (method !== "POST" && method !== "GET" && method !== "DELETE") {
        res.statusCode = 405;
        res.end("Method Not Allowed");
        return;
      }

      if (method === "POST") {
        const parsedBody = await readJsonBody(req);
        await mcpTransport.handleRequest(req, res, parsedBody);
      } else {
        await mcpTransport.handleRequest(req, res);
      }
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      logger.error("server.http_error", "HTTP transport request failed", { message });
      if (!res.headersSent) {
        res.statusCode = 400;
        res.setHeader("content-type", "application/json");
        res.end(JSON.stringify({ error: message }));
      }
    }
  });

  await new Promise<void>((resolve, reject) => {
    httpServer.once("error", reject);
    httpServer.listen(port, host, () => resolve());
  });

  logger.info("server.started", "umcp started with streamable-http transport", {
    host,
    port,
    endpointPath
  });

  await waitForShutdownSignal(logger, false);

  await new Promise<void>((resolve) => {
    httpServer.close(() => resolve());
  });
}

Server Setup Flow

The main runServe function orchestrates the entire server setup. From serve.ts:179-209:
export async function runServe(options: ServeOptions): Promise<void> {
  const logger = options.logger;
  const configPath = resolveConfigPath(options.configPath);
  const { config } = await loadConfig({ configPath, logger });

  const envPool = createRoundRobinEnvPool(logger);
  const providerManager = createProviderManager({ config, envPool, logger });
  const server = new McpServer({
    name: "umcp",
    version: "0.1.0"
  });

  try {
    const bindings = await discoverUnifiedTools(config, providerManager, logger);
    registerUnifiedTools(server, bindings, providerManager, logger);

    if (options.transport === "http") {
      await runHttpServe(server, {
        host: options.host,
        port: options.port,
        path: options.path,
        logger
      });
    } else {
      await runStdioServe(server, logger);
    }
  } finally {
    await providerManager.close();
    await server.close();
  }
}
Key steps:
  1. Load configuration
  2. Create environment pool (for round-robin rotation)
  3. Create provider manager (handles upstream connections)
  4. Create MCP server
  5. Discover and register tools
  6. Start appropriate transport (stdio or HTTP)

HTTP Request Handling

The HTTP server supports GET, POST, and DELETE methods, matching the MCP protocol requirements:
  • GET: Typically used for session management
  • POST: Used for tool calls and other operations
  • DELETE: Used for session cleanup
From serve.ts:138-149:
const method = req.method ?? "GET";
if (method !== "POST" && method !== "GET" && method !== "DELETE") {
  res.statusCode = 405;
  res.end("Method Not Allowed");
  return;
}

if (method === "POST") {
  const parsedBody = await readJsonBody(req);
  await mcpTransport.handleRequest(req, res, parsedBody);
} else {
  await mcpTransport.handleRequest(req, res);
}

Request Body Parsing

For POST requests, UMCP reads and parses JSON bodies with size limits. From serve.ts:30-51:
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  let size = 0;
  const maxSize = 1_048_576;  // 1MB limit

  for await (const chunk of req) {
    const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
    chunks.push(bufferChunk);
    size += bufferChunk.length;

    if (size > maxSize) {
      throw new Error("Request body too large");
    }
  }

  if (chunks.length === 0) {
    return undefined;
  }

  const body = Buffer.concat(chunks).toString("utf8");
  return JSON.parse(body);
}

Path Normalization

HTTP endpoint paths are automatically normalized to start with a forward slash. From serve.ts:26-28:
function normalizeEndpointPath(rawPath: string): string {
  return rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
}

Use Cases

Local Development

Connect to local stdio tools and expose them via HTTP:
umcp serve --transport http --port 3000
Now local AI clients can connect to http://localhost:3000/mcp instead of managing multiple stdio processes.

Centralized Gateway

Create a single HTTP endpoint that aggregates tools from multiple sources:
  • Local stdio tools (file system, shell commands)
  • Remote HTTP services (cloud APIs)
  • Legacy SSE providers

Cloud Deployment

Deploy UMCP as an HTTP service that bridges to stdio tools running in containers:
{
  "categories": {
    "data": {
      "providers": [
        {
          "name": "postgres",
          "transport": "stdio",
          "command": "/usr/local/bin/postgres-mcp",
          "env": {
            "DATABASE_URL": "postgresql://..."
          }
        }
      ]
    }
  }
}
Start UMCP with:
umcp serve --transport http --host 0.0.0.0 --port 8080

Best Practices

  1. Choose the right downstream transport: Use stdio for local development and HTTP for networked access
  2. Secure HTTP deployments: Use reverse proxies (nginx, Caddy) to add TLS and authentication
  3. Monitor provider health: Watch logs for upstream connection failures
  4. Set appropriate timeouts: Consider network latency when connecting to remote HTTP providers

Graceful Shutdown

UMCP handles shutdown signals to cleanly close all connections. From serve.ts:53-81:
function waitForShutdownSignal(logger: Logger, includeStdinClose = true): Promise<string> {
  return new Promise((resolve) => {
    let settled = false;

    const finish = (reason: string) => {
      if (settled) {
        return;
      }
      settled = true;
      process.off("SIGINT", onSigInt);
      process.off("SIGTERM", onSigTerm);
      if (includeStdinClose) {
        process.stdin.off("close", onStdinClose);
      }
      logger.info("shutdown.signal", "Shutdown signal received", { reason });
      resolve(reason);
    };

    const onSigInt = () => finish("SIGINT");
    const onSigTerm = () => finish("SIGTERM");
    const onStdinClose = () => finish("stdin-close");

    process.on("SIGINT", onSigInt);
    process.on("SIGTERM", onSigTerm);
    if (includeStdinClose) {
      process.stdin.on("close", onStdinClose);
    }
  });
}
Signals handled:
  • SIGINT: Ctrl+C in terminal
  • SIGTERM: Standard termination signal
  • stdin close: When stdin pipe closes (stdio mode only)

Build docs developers (and LLMs) love