MCP의 두 가지 핵심 전송 방식인 stdio와 Streamable HTTP의 동작 원리, 장단점, 선택 기준을 상세히 다룹니다.
MCP 프로토콜은 메시지의 형식(JSON-RPC 2.0)과 의미(도구 호출, 리소스 읽기 등)를 정의합니다. 하지만 이 메시지를 실제로 어떻게 주고받을 것인가는 별도의 관심사입니다. 이것이 전송 계층(Transport Layer)의 역할입니다.
MCP는 전송 계층을 프로토콜 본체와 분리하여 설계했습니다. 덕분에 동일한 MCP 서버 로직을 다양한 전송 방식 위에서 실행할 수 있습니다. 현재 MCP 사양에서 정의하는 전송 방식은 두 가지입니다.
이 장에서는 각 전송 방식의 동작 원리를 살펴보고, 상황에 맞는 선택 기준을 제시합니다.
stdio 전송에서 MCP 서버는 호스트 애플리케이션의 자식 프로세스(child process)로 실행됩니다. 클라이언트와 서버 사이의 통신은 운영체제의 표준 입출력 스트림을 통해 이루어집니다.
\n)로 구분됩니다.import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "local-file-server",
version: "1.0.0",
});
// 도구 등록
server.tool(
"read_file",
"파일의 내용을 읽습니다",
{ path: { type: "string", description: "파일 경로" } },
async (params) => {
const fs = await import("fs/promises");
const content = await fs.readFile(params.path, "utf-8");
return {
content: [{ type: "text", text: content }],
};
}
);
// stdio 전송으로 서버 시작
const transport = new StdioServerTransport();
await server.connect(transport);클라이언트 측에서는 서버를 자식 프로세스로 실행하고, stdin/stdout 스트림에 연결합니다.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "node",
args: ["server.js"],
});
const client = new Client({
name: "my-client",
version: "1.0.0",
});
await client.connect(transport);
// 도구 목록 조회
const tools = await client.listTools();
console.log("사용 가능한 도구:", tools);장점:
한계:
stdio 전송은 로컬 개발 도구, IDE 플러그인, CLI 도구 등에 가장 적합합니다. Claude Desktop에서 MCP 서버를 연결할 때 가장 흔히 사용되는 방식이기도 합니다. 원격 서비스와의 통합이 필요한 경우에는 Streamable HTTP를 사용합니다.
MCP 초기 버전(2024-11-05)에서는 원격 통신을 위해 HTTP+SSE(Server-Sent Events) 전송을 사용했습니다. SSE 방식에서는 클라이언트가 서버에 SSE 연결을 먼저 수립하고, 이 지속적 연결을 통해 서버에서 클라이언트로의 메시지를 수신했습니다. 클라이언트에서 서버로의 메시지는 별도의 HTTP POST 요청으로 전송했습니다.
이 방식에는 실무적인 한계가 있었습니다.
2025년 3월 사양(2025-03-26)에서 이 문제를 해결한 Streamable HTTP 전송이 도입되었으며, 기존 SSE 전송은 더 이상 사용하지 않는(deprecated) 상태가 되었습니다.
Streamable HTTP에서 서버는 단일 HTTP 엔드포인트를 제공합니다. 클라이언트는 이 엔드포인트에 POST와 GET 요청을 보냅니다.
POST 요청: 클라이언트가 서버에 JSON-RPC 메시지를 전송합니다.
GET 요청: 서버가 클라이언트에 보내야 하는 메시지(알림, 서버 발신 요청)를 수신하기 위한 SSE 스트림을 엽니다.
Streamable HTTP에서 세션 관리는 선택적입니다. 서버가 상태를 유지해야 하는 경우, 초기화 응답에 Mcp-Session-Id 헤더를 포함시킵니다.
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: session-abc123
{"jsonrpc":"2.0","id":1,"result":{...}}
이후 클라이언트는 모든 요청에 이 세션 ID를 포함시킵니다.
POST /mcp HTTP/1.1
Content-Type: application/json
Mcp-Session-Id: session-abc123
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
상태가 없는(stateless) 서버는 세션 ID를 사용하지 않습니다. 이 경우 각 요청은 독립적으로 처리되며, 로드 밸런서 뒤에서 어떤 서버 인스턴스가 처리해도 무방합니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
const app = express();
app.use(express.json());
app.all("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport("/mcp");
const server = new McpServer({
name: "remote-server",
version: "1.0.0",
});
server.tool(
"search_documents",
"문서를 검색합니다",
{ query: { type: "string", description: "검색어" } },
async (params) => {
const results = await searchIndex(params.query);
return {
content: [{ type: "text", text: JSON.stringify(results) }],
};
}
);
await server.connect(transport);
await transport.handleRequest(req, res);
});
app.listen(3000, () => {
console.log("MCP 서버가 http://localhost:3000/mcp에서 실행 중입니다");
});무상태 운영 가능: 세션 ID를 사용하지 않으면 완전히 무상태로 동작합니다. 로드 밸런서 뒤에 여러 서버 인스턴스를 배치하여 수평 확장이 가능합니다.
유연한 응답 방식: 단순한 요청에는 일반 JSON 응답을, 장시간 실행되는 작업에는 SSE 스트리밍을 선택적으로 사용할 수 있습니다.
표준 HTTP 인프라 활용: 기존의 프록시, CDN, 로드 밸런서, 모니터링 도구를 그대로 활용할 수 있습니다.
인증 통합 용이: OAuth 2.1, API 키 등 표준 HTTP 인증 메커니즘을 자연스럽게 적용할 수 있습니다.
프로젝트의 요구사항에 따라 적절한 전송 방식을 선택해야 합니다.
| 기준 | stdio | Streamable HTTP |
|---|---|---|
| 배포 위치 | 로컬 전용 | 로컬 또는 원격 |
| 설정 복잡도 | 매우 낮음 | 중간 |
| 인증 | 불필요 (OS 수준) | 필요 (OAuth 등) |
| 확장성 | 단일 클라이언트 | 다중 클라이언트 |
| 지연 시간 | 극히 낮음 | 네트워크 의존적 |
| 적합한 용도 | IDE, CLI, 로컬 도구 | 클라우드 서비스, 팀 공유 |
실무에서는 다음과 같은 패턴이 일반적입니다.
MCP SDK는 전송 계층을 추상화하므로, 서버 로직을 수정하지 않고 전송 방식만 교체할 수 있습니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
// 서버 로직은 전송 방식과 독립적
function createServer(): McpServer {
const server = new McpServer({
name: "flexible-server",
version: "1.0.0",
});
server.tool("greet", "인사합니다", {
name: { type: "string" },
}, async (params) => ({
content: [{ type: "text", text: "안녕하세요, " + params.name + "님" }],
}));
return server;
}
// 명령줄 인자로 전송 방식 결정
const mode = process.argv[2];
if (mode === "http") {
const transport = new StreamableHTTPServerTransport("/mcp");
const server = createServer();
await server.connect(transport);
// HTTP 서버 설정...
} else {
const transport = new StdioServerTransport();
const server = createServer();
await server.connect(transport);
}개발 단계에서는 stdio로 빠르게 테스트하고, 배포 단계에서 Streamable HTTP로 전환하는 패턴이 효율적입니다. 서버 로직을 별도 함수로 분리해두면 전환이 수월합니다.
MCP SDK가 제공하는 전송 방식 외에 직접 전송 계층을 구현할 수도 있습니다. 예를 들어 WebSocket 전송이나 메시지 큐 기반 전송을 구현할 수 있습니다.
커스텀 전송을 구현하려면 Transport 인터페이스를 만족시키면 됩니다.
interface Transport {
// 메시지를 전송합니다
send(message: JSONRPCMessage): Promise<void>;
// 수신 메시지 콜백을 설정합니다
onMessage: (message: JSONRPCMessage) => void;
// 연결 종료 콜백을 설정합니다
onClose: () => void;
// 에러 콜백을 설정합니다
onError: (error: Error) => void;
// 연결을 시작합니다
start(): Promise<void>;
// 연결을 종료합니다
close(): Promise<void>;
}이 인터페이스만 구현하면, 기존 MCP 서버 코드를 수정하지 않고 새로운 전송 방식 위에서 실행할 수 있습니다.
이 장에서 다룬 핵심 내용을 요약합니다.
4장에서는 MCP 서버의 세 가지 프리미티브(도구, 리소스, 프롬프트)를 상세히 다룹니다. 각 프리미티브의 스키마 정의, 구현 패턴, 실전 활용 사례를 코드와 함께 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
MCP 서버가 제공하는 세 가지 핵심 프리미티브의 스키마 정의, 구현 패턴, 실전 활용 사례를 상세히 다룹니다.
MCP의 JSON-RPC 2.0 기반 메시지 형식, 연결 생명주기, 능력 협상 메커니즘을 상세히 분석합니다.
TypeScript SDK를 사용하여 프로젝트 설정부터 도구, 리소스, 프롬프트 구현, 테스트까지 MCP 서버를 구축하는 전 과정을 다룹니다.