MCP subscriptions/listen으로 서버 알림 구독하기

MCP 서버를 만들다 보면 이런 상황을 만나게 됩니다. 사용자가 새 저장소에 접근 권한을 얻어서 서버가 노출하는 도구가 하나 늘었는데, 이미 붙어 있는 클라이언트는 그 사실을 모릅니다. 클라이언트가 다시 tools/list를 부를 때까지 새 도구는 없는 것이나 마찬가지죠. 그래서 MCP에는 예전부터 서버가 클라이언트에게 “목록이 바뀌었어요”라고 알려주는 통로가 있었습니다.
그런데 MCP 2026-07-28 스펙에서 이 통로가 통째로 갈아엎어졌습니다. 클라이언트가 HTTP GET으로 열어두던 독립 스트림이 사라지고, resources/subscribe와 resources/unsubscribe도 함께 없어졌거든요. 그 자리를 subscriptions/listen이라는 요청 하나가 대신합니다. 이 글에서는 이 구독(subscription) 방식이 어떻게 동작하는지, 그리고 서버와 클라이언트를 각각 어떻게 구현하면 되는지 살펴보겠습니다.
독립 GET 스트림은 왜 사라졌을까요?
이전 스펙에서 원격 MCP 서버는 두 가지 방식으로 클라이언트와 대화했습니다. 클라이언트가 POST로 요청을 보내면 서버가 응답을 돌려주는 것이 하나였고, 클라이언트가 같은 엔드포인트에 GET을 걸어 Server-Sent Events 스트림을 하나 열어두면 서버가 아무 때나 알림(notification)을 흘려보내는 것이 다른 하나였습니다.
두 번째 방식에는 불편한 구석이 몇 가지 있었습니다. 우선 클라이언트가 무엇을 받고 싶은지 말할 방법이 없었습니다. 스트림을 열면 서버가 보내기로 한 알림이 전부 쏟아졌죠. 리소스 목록에는 관심이 없고 도구 목록 변경만 알고 싶어도 선택지가 없었습니다.
더 근본적인 문제는 이 GET 스트림이 세션과 한 몸이었다는 점입니다. 서버는 “이 스트림이 어느 클라이언트의 것인지”를 기억해야 했고, 그래야 나중에 알림을 올바른 스트림으로 보낼 수 있었습니다. 세션을 걷어내고 무상태(stateless) 구조로 가겠다는 이번 개정의 방향과는 정면으로 충돌하는 설계였던 셈입니다. 로드 밸런서 뒤에 서버를 여러 대 세워두면, 알림을 만들어낸 인스턴스와 스트림을 붙들고 있는 인스턴스가 서로 다를 수 있으니까요.
그래서 확정 스펙은 알림 통로를 평범한 요청 하나로 끌어내렸습니다. 클라이언트가 subscriptions/listen을 POST로 보내면, 그 요청의 응답이 곧 오래 열려 있는 SSE 스트림이 됩니다. 스트림이 특정 요청에 묶이니 “어느 스트림으로 보내야 하지?”라는 질문 자체가 사라집니다.
sequenceDiagram
accTitle: subscriptions/listen 구독 스트림의 생애 주기
accDescr: 클라이언트가 알림 필터를 담아 subscriptions/listen을 POST로 보내면 서버가 SSE 스트림을 열어 확인 응답을 먼저 보내고, 이후 신청한 알림만 흘려보내다가 어느 한쪽이 스트림을 닫으면 구독이 끝난다.
participant C as 클라이언트
participant S as 서버
C->>S: POST subscriptions/listen<br/>(알림 필터)
Note over S: SSE 스트림을 열고<br/>계속 붙들고 있음
S-->>C: notifications/subscriptions/acknowledged
S-->>C: notifications/tools/list_changed
S-->>C: notifications/resources/updated
Note over C,S: 어느 한쪽이 스트림을 닫을 때까지 유지
subscriptions/listen으로 스트림 열기
요청 자체는 평범한 JSON-RPC 요청입니다. 다른 점은 notifications 필드에 받고 싶은 알림 유형을 직접 적어 넣는다는 것입니다.
{
"jsonrpc": "2.0",
"id": 1,
"method": "subscriptions/listen",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "ExampleClient",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
},
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
세션이 없어졌기 때문에 프로토콜 버전과 클라이언트 정보가 _meta에 실려 요청마다 따라다니는 모습이 보이실 텐데요. 구독 요청도 예외가 아닙니다.
notifications에 넣을 수 있는 필드는 네 가지입니다.
| 필드 | 타입 | 받게 되는 알림 |
|---|---|---|
toolsListChanged | boolean | notifications/tools/list_changed |
promptsListChanged | boolean | notifications/prompts/list_changed |
resourcesListChanged | boolean | notifications/resources/list_changed |
resourceSubscriptions | string[] | notifications/resources/updated |
앞의 세 개는 참으로 켜두면 해당 목록이 바뀔 때 알림이 옵니다. 마지막 resourceSubscriptions만 조금 다른데, 지켜보고 싶은 리소스 URI를 문자열 배열로 나열하면 그 리소스가 바뀔 때마다 알림이 옵니다.
전부 선택 사항이라 적지 않은 항목은 신청하지 않은 것으로 봅니다. 여기서 중요한 규칙이 하나 있습니다. 서버는 클라이언트가 명시적으로 요청하지 않은 유형의 알림을 보내면 안 됩니다. 예전처럼 스트림만 열리면 서버 마음대로 밀어 넣던 구조가 아니라, 철저한 옵트인(opt-in) 방식이 된 거죠.
눈여겨볼 만한 변화는 resources/subscribe가 이 필터의 배열 하나로 접혀 들어갔다는 점입니다. 리소스 열 개를 구독하려고 요청을 열 번 보내던 것이, 이제는 배열에 URI 열 개를 담은 요청 한 번으로 끝납니다.
첫 메시지는 확인 응답입니다
스트림이 열리면 서버가 가장 먼저 보내야 하는 것은 알림이 아니라 확인 응답(acknowledgment)입니다. 어떤 알림도 이보다 앞서 나갈 수 없습니다.
{
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": 1
},
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
확인 응답의 notifications는 클라이언트가 보낸 필터를 그대로 되돌려주는 것이 아닙니다. 서버가 실제로 지원하기로 한 부분집합입니다. 지원하지 않는 유형은 아예 빠진 채로 돌아오죠. 그래서 클라이언트는 이 응답을 자기가 요청한 것과 대조해보고, 빠진 항목이 있으면 그에 맞게 대처해야 합니다. 프롬프트 목록 변경을 신청했는데 확인 응답에 없다면, 그 서버에서는 주기적으로 prompts/list를 다시 부르는 식으로 물러서는 것이 자연스럽습니다.
요청을 보냈는데 아무 반응이 없는 것과, 확인 응답은 왔지만 원하는 항목이 빠진 것은 전혀 다른 상황입니다. 확인 응답 덕분에 클라이언트가 이 둘을 구분할 수 있습니다.
구독 ID로 알림 구분하기
확인 응답의 _meta에 들어 있던 io.modelcontextprotocol/subscriptionId는 이후 스트림을 타고 오는 모든 메시지에 붙습니다. 값은 subscriptions/listen 요청의 JSON-RPC id와 같습니다. 위 예시에서 요청 id가 1이었으니 구독 ID도 1이 됩니다.
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": 1
},
"uri": "file:///project/config.json"
}
}
구독은 여러 개를 동시에 열어둘 수 있습니다. 도구 목록 변경만 받는 구독 하나와 리소스 업데이트만 받는 구독 하나를 따로 유지하는 식이죠. HTTP에서는 구독마다 스트림이 따로 있으니 굳이 구분할 필요가 없어 보이지만, 표준 입출력(stdio)으로 붙는 로컬 서버에서는 이야기가 다릅니다. 모든 메시지가 채널 하나를 공유하기 때문에, 클라이언트는 subscriptionId를 보고 어느 구독에 속한 알림인지 골라내야 합니다.
같은 이유로 stdio에서는 확인 응답이 “가장 먼저”라는 규칙도 구독 단위로 해석합니다. 다른 구독에 속한 메시지가 그 앞에 끼어드는 것은 허용됩니다.
Bun으로 구독 서버 만들어보기
이제 실제로 동작하는 서버를 만들어보겠습니다. Bun의 Bun.serve로 subscriptions/listen 하나만 처리하는 최소 구현입니다.
const encoder = new TextEncoder();
const sse = (message: object) =>
encoder.encode(`data: ${JSON.stringify(message)}\n\n`);
Bun.serve({
port: 3000,
async fetch(req) {
// 독립 GET 스트림은 더 이상 없으므로 POST 외에는 405로 막습니다
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const request = await req.json();
if (request.method !== "subscriptions/listen") {
return Response.json(
{
jsonrpc: "2.0",
id: request.id,
error: { code: -32601, message: "Method not found" },
},
{ status: 404 },
);
}
const wanted = request.params?.notifications ?? {};
const meta = {
"io.modelcontextprotocol/subscriptionId": request.id,
};
let timer: ReturnType<typeof setInterval>;
const stream = new ReadableStream({
start(controller) {
// 첫 메시지는 확인 응답이고, 서버가 지원하는 항목만 담습니다
controller.enqueue(
sse({
jsonrpc: "2.0",
method: "notifications/subscriptions/acknowledged",
params: {
_meta: meta,
notifications: {
toolsListChanged: wanted.toolsListChanged === true,
},
},
}),
);
if (wanted.toolsListChanged !== true) return;
timer = setInterval(() => {
controller.enqueue(
sse({
jsonrpc: "2.0",
method: "notifications/tools/list_changed",
params: { _meta: meta },
}),
);
}, 1000);
},
cancel() {
// 클라이언트가 스트림을 닫은 것이 곧 구독 취소 신호입니다
clearInterval(timer);
console.log(`구독 ${request.id} 취소됨`);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
});
},
});
응답 헤더에 X-Accel-Buffering: no를 붙인 이유가 궁금하실 텐데요. nginx 같은 리버스 프록시가 응답을 모았다가 한꺼번에 내보내면 실시간성이 깨지기 때문에, 버퍼링을 끄라고 알려주는 헤더입니다. 스펙에서도 SSE 스트림을 열 때 이 헤더를 넣기를 권고합니다.
여기에 하나 더 챙기면 좋은 것이 유지 신호(keep-alive)입니다. 구독 스트림은 몇 시간씩 열려 있을 수 있는데, 그동안 알림이 하나도 없으면 중간 프록시나 클라이언트의 유휴 타임아웃이 연결을 끊어버립니다. 이럴 때 콜론으로 시작하는 줄(:\r\n)을 주기적으로 흘려보내면 됩니다. SSE 스펙에서 콜론으로 시작하는 줄은 데이터가 없는 주석이라 클라이언트가 조용히 무시하거든요. 프로토콜 차원의 ping이 이번 개정에서 사라졌으니, 연결을 살려두는 일은 이렇게 전송 계층에서 처리하게 됩니다.
클라이언트에서 스트림 읽기
클라이언트 쪽은 브라우저의 EventSource를 쓸 수 없습니다. EventSource는 GET 요청만 보낼 수 있는데 subscriptions/listen은 본문을 담은 POST여야 하니까요. 그래서 fetch() 함수로 요청을 보내고 응답 본문을 ReadableStream으로 읽으며 직접 파싱합니다.
const response = await fetch("http://localhost:3000/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
// 두 형태 모두 받을 수 있다고 반드시 함께 알려야 합니다
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": "2026-07-28",
"Mcp-Method": "subscriptions/listen",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "subscriptions/listen",
params: {
_meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
notifications: { toolsListChanged: true },
},
}),
});
const lines = response.body!.pipeThrough(new TextDecoderStream());
for await (const chunk of lines) {
for (const line of chunk.split("\n")) {
if (!line.startsWith("data: ")) continue;
const message = JSON.parse(line.slice(6));
const id = message.params._meta["io.modelcontextprotocol/subscriptionId"];
console.log(`[구독 ${id}] ${message.method}`);
}
}
두 파일을 각각 실행하면 확인 응답이 먼저 오고 그 뒤로 알림이 이어집니다.
[구독 1] notifications/subscriptions/acknowledged
[구독 1] notifications/tools/list_changed
[구독 1] notifications/tools/list_changed
[구독 1] notifications/tools/list_changed
MCP-Protocol-Version과 Mcp-Method 헤더를 함께 보낸 것도 이번 개정에서 추가된 규칙입니다. 본문에 있는 값을 헤더에 한 번 더 실어 게이트웨이나 로드 밸런서가 본문을 열어보지 않고도 라우팅할 수 있게 하려는 것인데요. 값이 본문과 어긋나면 서버는 400 Bad Request와 함께 -32020 오류를 돌려줘야 합니다.
구독은 어떻게 끝날까요?
구독이 끝나는 경우는 세 가지입니다. 클라이언트가 끝내거나, 서버가 끝내거나, 전송 계층이 끊기는 경우죠.
클라이언트가 끝낼 때 HTTP에서는 그냥 스트림을 닫으면 됩니다. 앞선 서버 예제의 cancel() 콜백이 바로 이 순간에 불립니다. 요청마다 스트림이 따로 있으니 연결이 끊긴 것만으로 어느 요청을 취소하려는 것인지 분명하고, 그래서 notifications/cancelled 같은 별도 메시지를 보낼 필요가 없습니다. 반대로 stdio에서는 채널을 공유하니 notifications/cancelled로 구독 요청 ID를 짚어줘야 합니다.
서버가 먼저 끝낼 때는 조금 더 예의를 갖춥니다. 예를 들어 서버를 내리는 상황이라면, 스트림을 닫기 전에 원래의 subscriptions/listen 요청에 대한 응답을 보내도록 권고합니다.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"_meta": {
"io.modelcontextprotocol/subscriptionId": 1
}
}
}
오래 미뤄뒀던 JSON-RPC 응답이 마지막에 도착하는 셈인데요. 이 응답이 있고 없고가 클라이언트에게는 꽤 중요합니다. 응답을 받고 스트림이 닫혔다면 정상적으로 끝난 것이고, 아무 응답 없이 연결만 끊겼다면 예기치 못한 단절이라 다시 붙어볼 만한 상황이거든요.
다만 여기에는 뼈아픈 제약이 하나 딸려 옵니다. 끊긴 스트림을 이어받는 방법이 없다는 것입니다. 이전 스펙에 있던 Last-Event-ID 기반 재개가 이번 개정에서 제거됐기 때문에, 놓친 알림을 중간부터 받아오는 길이 막혔습니다. 다시 연결하려면 subscriptions/listen을 처음부터 새로 보내야 하고, 서버는 구독 상태를 전혀 기억하고 있지 않습니다. 그럼 끊긴 사이에 벌어진 일은 어떻게 따라잡을까요? 🤔 재연결 직후에는 알림을 기다리지 말고 tools/list나 resources/read로 현재 상태를 한 번 맞춰보는 편이 안전합니다.
구버전에서 옮겨오기
기존 구현을 새 스펙으로 옮긴다면 대응 관계는 다음과 같습니다.
| 2025-11-25까지 | 2026-07-28 |
|---|---|
HTTP GET으로 스트림 열기 | subscriptions/listen 응답 스트림 |
resources/subscribe | notifications.resourceSubscriptions |
resources/unsubscribe | 스트림을 닫거나 새 필터로 다시 열기 |
| 서버가 정한 알림이 전부 도착 | 신청한 유형만 도착 |
Last-Event-ID로 재개 | 재개 없음, 요청을 새로 보냄 |
구버전 클라이언트를 받아주는 서버라면 처리 방침도 정해져 있습니다. MCP 엔드포인트로 들어온 GET이나 DELETE에는 405 Method Not Allowed로 답하고, Mcp-Session-Id 헤더는 무시하며 세션 ID를 새로 발급하지도 되돌려주지도 않습니다. Last-Event-ID 헤더 역시 무시합니다. 앞의 서버 예제에서 POST가 아닌 요청을 405로 막은 것이 바로 이 규칙을 따른 것입니다.
구현을 마쳤다면 MCP Inspector로 실제 구독 스트림이 어떻게 흐르는지 눈으로 확인해보시길 권합니다.
마치며
지금까지 MCP 2026-07-28 스펙의 subscriptions/listen을 살펴봤습니다. 서버가 아무 때나 밀어 넣던 알림이 클라이언트가 명시적으로 신청한 것만 받는 구조로 바뀌었고, 그 통로가 세션에 묶인 독립 스트림이 아니라 평범한 요청의 응답 스트림이 됐습니다.
한 줄로 요약하면 상태를 서버에서 걷어내고 요청 안으로 옮긴 개정의 연장선입니다. 스트림이 요청에 묶여 있으니 서버는 기억할 것이 없고, 끊기면 다시 요청하면 그만이죠. 잃은 것도 있습니다. 재개가 사라지면서 클라이언트는 재연결 후 상태를 스스로 맞춰야 하는 부담을 떠안았으니까요. 무상태가 공짜는 아니라는 점은 염두에 두는 것이 좋겠습니다.
더 자세한 내용은 MCP 공식 스펙의 Subscriptions 문서를 참고하세요.
This work is licensed under CC BY 4.0