MCP 이벤트를 사용하면 MCP 서버가 업데이트 발생 즉시 ChatGPT에 푸시할 수 있으므로, 에이전트가 주기적으로 폴링할 필요가 없습니다. 2026년 9월 29일 OpenAI DevDay 이후 ChatGPT는 모든 플랜에서 프로토콜 버전 2026-07-28(MCP 2.0)의 제안된 MCP 이벤트 사양을 지원합니다. 서버는 events/list, events/subscribe, events/unsubscribe 메서드를 구현하고, server/discover에서 events 기능을 광고하며, 콜백 확인 챌린지를 통과하고, 모든 전송에 Standard Webhooks HMAC 서명을 적용해야 합니다. ChatGPT는 웹훅 전송만 지원합니다.
이 글에서는 MCP 이벤트를 구현할 때 필요한 메시지 형식, 보안 규칙, 테스트 절차를 단계별로 정리합니다. MCP가 처음이라면 MCP란 무엇인가부터 확인하세요. Apidog를 사용하면 JSON-RPC 호출을 전송하고 콜백을 모의 테스트할 수 있습니다.
MCP 이벤트 체크리스트
| 항목 | ChatGPT가 기대하는 값 |
|---|---|
| 프로토콜 | MCP 2.0, 버전 2026-07-28
|
| 기능 광고 |
server/discover의 capabilities에 "events": {}
|
| 필수 메서드 |
events/list, events/subscribe, events/unsubscribe
|
| 엔드포인트 | 도구 호출과 동일한 인증된 MCP 엔드포인트 |
| 전송 방식 | 웹훅 전용. 폴링, 스트리밍, gap, terminated 알림 미지원 |
| 서명 | Standard Webhooks HMAC-SHA256 |
| 필수 헤더 |
webhook-id, webhook-timestamp, webhook-signature, X-MCP-Subscription-Id
|
| 비밀 |
whsec_ 접두사가 있는 base64 비밀, 디코딩 후 24~64바이트 |
| 페이로드 제한 | 256 KiB(262,144바이트), 요청당 이벤트 1개 |
| 구독 ID | 주체, 콜백 URL, 이벤트 이름, 인수로부터 결정론적으로 생성 |
| 콜백 요구 사항 | HTTPS, 챌린지 확인, 사설 주소 차단, 리디렉션 미허용 |
출처: OpenAI의 MCP 이벤트 가이드 및 MCP 이벤트 디자인 스케치 초안.
폴링 대신 이벤트를 사용하는 이유
폴링 방식에서는 에이전트가 타이머에 따라 도구를 호출하고 이전 결과와 비교해야 합니다. 변경이 없으면 요청을 낭비하고, 변경이 발생하면 다음 폴링까지 지연됩니다.
MCP 이벤트에서는 서버가 변경 사실을 알고 있으므로 즉시 업데이트를 전송합니다. 이는 전통적인 웹훅 대 폴링 선택과 같은 장단점을 가집니다.
대표적인 활용 사례는 다음과 같습니다.
- 새 프로젝트 작업이 생성되면 연결된 문서를 읽고 계획 초안을 생성
- 특정 채널의 버그 보고서를 초안 풀 리퀘스트로 변환
- 이벤트:
message.created - 필터:
channel_id
- 이벤트:
- 문서에 새 검토 댓글이 추가되면 후속 작업 수행
- 이벤트:
comment.created - 필터:
document_id
- 이벤트:
이 사양은 MCP 트리거 및 이벤트 워킹 그룹의 저장소에서 발전한 실험적 기능입니다. 구현 시 프로토콜 버전을 반드시 2026-07-28에 고정하세요. 관련 출시 정보는 DevDay 2026 허브에서 확인할 수 있습니다.
1. server/discover에서 이벤트 기능 광고하기
먼저 server/discover 응답의 capabilities에 events를 추가합니다.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": {
"tools": {},
"events": {}
}
}
}
다음으로 events/list에서 서버가 제공하는 이벤트 정의를 반환합니다.
각 이벤트 정의에는 최소한 다음 정보가 필요합니다.
-
name: 안정적인 이벤트 이름 -
description: 사용자가 구독 목적을 이해할 수 있는 구체적인 설명 -
delivery: 반드시["webhook"] -
inputSchema: 구독 인수 스키마 -
payloadSchema: 전송되는data객체의 스키마
예를 들어 document_id로 댓글 이벤트를 필터링하는 정의를 제공할 수 있습니다.
{
"name": "comment.created",
"description": "문서에 새 댓글이 생성될 때 발생합니다.",
"delivery": ["webhook"],
"inputSchema": {
"type": "object",
"properties": {
"document_id": {
"type": "string"
}
},
"required": ["document_id"],
"additionalProperties": false
},
"payloadSchema": {
"type": "object",
"properties": {
"document_id": { "type": "string" },
"comment_id": { "type": "string" },
"text": { "type": "string" },
"url": { "type": "string" }
},
"required": ["document_id", "comment_id", "text", "url"]
}
}
구현 시 다음 원칙을 적용하세요.
- 이벤트 이름은 변경하지 않는 안정적인 식별자로 유지합니다.
- 필터는 클라이언트가 아니라 서버에서 적용합니다.
- 연결된 계정이 실제로 접근 가능한 이벤트만 노출합니다.
-
inputSchema와payloadSchema를 엄격하게 검증합니다.
2. events/subscribe 구현하기
사용자가 ChatGPT에 특정 항목을 모니터링하도록 요청하면, ChatGPT는 다음과 같이 events/subscribe를 호출합니다.
{
"jsonrpc": "2.0",
"id": 2,
"method": "events/subscribe",
"params": {
"name": "comment.created",
"arguments": {
"document_id": "doc_123"
},
"delivery": {
"mode": "webhook",
"url": "https://receiver.example.com/mcp-events/callback_123",
"secret": "whsec_<base64-encoded-signing-key>"
},
"cursor": null
}
}
구독을 수락하기 전에 다음을 검증합니다.
- 인증된 사용자가 해당 이벤트와 필터에 접근할 수 있는지 확인합니다.
-
name과arguments가 이벤트 정의의 스키마를 만족하는지 검증합니다. -
secret이whsec_로 시작하는지 확인합니다. -
whsec_뒤의 값이 base64로 디코딩되며 결과가 24~64바이트인지 확인합니다. - 콜백 URL을 검증하고 챌린지 확인을 수행합니다.
- 소유자, 필터, URL, 비밀, 만료 시각을 저장합니다.
성공하면 다음과 같이 응답합니다.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"id": "sub_123",
"refreshBefore": "2026-10-02T12:00:00Z",
"cursor": null,
"truncated": false
}
}
구독 구현 규칙
결정론적 구독 ID 생성
구독 ID는 인증된 주체, 콜백 URL, 이벤트 이름, 인수를 기반으로 생성합니다. 디자인 스케치는 이 키의 잘린 SHA-256 해시를 제안합니다.
subject + callbackUrl + eventName + canonicalJson(arguments)
인수 객체는 키 순서가 달라도 같은 값으로 처리되도록 정규화된 JSON으로 직렬화해야 합니다.
멱등성 업데이트 또는 삽입
같은 구독 요청이 반복되면 새 레코드를 만들지 말고 기존 구독을 업데이트합니다.
same subject + same URL + same event + same arguments
=> same subscription ID
refreshBefore 이전 갱신
ChatGPT는 refreshBefore 이전에 같은 ID와 마지막 커서를 사용해 다시 events/subscribe를 호출합니다. 서버는 새 만료 시각을 반환해야 합니다.
갱신 요청에 새 비밀이 포함된 경우:
- 새 비밀로 교체합니다.
- 짧은 전환 기간에는 이전 비밀과 새 비밀 모두로 서명합니다.
- 재생할 수 없는 이벤트는
cursor: null을 반환합니다.
3. 데이터 전송 전 콜백 확인하기
애플리케이션 이벤트를 전송하기 전에 콜백 URL의 소유권을 검증해야 합니다. 신선하고 일회성이며 짧은 수명을 가진 챌린지를 포함한 서명된 POST 요청을 전송합니다.
{
"type": "verification",
"challenge": "a-single-use-random-value"
}
확인 요청에도 일반 이벤트와 동일한 서명 헤더를 포함합니다.
- 고유한
webhook-id예:msg_verification_123 webhook-timestampwebhook-signatureX-MCP-Subscription-Id
콜백 수신자는 2xx 응답과 함께 챌린지를 정확히 에코해야 합니다.
{
"challenge": "a-single-use-random-value"
}
서버는 챌린지 값을 일정 시간 비교로 확인한 뒤에만 이벤트 전송을 활성화합니다.
검증 실패 시 JSON-RPC 오류 -32015(CallbackEndpointError)를 반환합니다.
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32015,
"message": "Callback endpoint verification failed",
"data": {
"reason": "challenge_failed"
}
}
}
data.reason에는 challenge_failed 또는 timeout 같은 원인을 포함할 수 있습니다.
챌린지는 공격자가 서버를 악의적인 대상 URL로 향하게 하는 것을 방지합니다. 구독자가 비밀을 제공하더라도, 콜백이 실제로 해당 비밀을 검증할 수 있는 엔드포인트인지 확인해야 합니다.
외부 URL 검증에는 다음을 적용하세요.
- HTTPS만 허용
- 연결 시 DNS 해석 결과와 실제 연결 주소 검증
- 사설 IP, 로컬 주소, 링크 로컬 주소 등 비공개 대상 차단
- HTTP 리디렉션 미허용
- 갱신 시에는 주체와 URL별로 검증 성공 결과를 제한된 기간 캐시
4. 이벤트 전송 및 서명하기
필터와 일치하는 이벤트가 발생하면 콜백 URL로 이벤트 객체 하나를 POST합니다.
{
"eventId": "evt_456",
"name": "comment.created",
"timestamp": "2026-10-01T12:05:00Z",
"data": {
"document_id": "doc_123",
"comment_id": "comment_456",
"text": "Can we add the rollout dates to this section?",
"url": "https://docs.example.com/doc_123#comment_456"
},
"cursor": null
}
다음 헤더를 포함합니다.
Content-Type: application/json
webhook-id: evt_456
webhook-timestamp: <unix-timestamp>
webhook-signature: v1,<base64-signature>
X-MCP-Subscription-Id: sub_123
중요한 구현 원칙은 다음과 같습니다.
- 본문은 한 번만 직렬화합니다.
- 서명에 사용한 바이트를 그대로 전송합니다.
- 이벤트 하나당 요청 하나만 전송합니다.
- 페이로드는 256 KiB 미만으로 유지합니다.
- 큰 레코드는 요약만 보내고 상세 내용은 읽기 도구로 제공합니다.
- 사용자 작성 텍스트는 데이터로 취급하며 모델 지침을 포함하지 않습니다.
- 이벤트 ID는 재시도 간에도 유지합니다.
- 재시도마다 새 타임스탬프와 서명을 생성합니다.
- 일시적 실패에는 제한된 지수 백오프를 적용합니다.
-
410과413응답은 재시도하지 않습니다. - 이벤트 도착 순서가 바뀔 수 있으므로 쓰기 도구는 멱등적으로 구현합니다.
재시도 전략은 신뢰할 수 있는 웹훅 디자인 가이드에서 더 자세히 확인할 수 있습니다.
5. Standard Webhooks 서명 검증하기
Standard Webhooks 사양에 따르면 서명 대상 문자열은 다음과 같습니다.
${webhook-id}.${webhook-timestamp}.${body}
서명 키는 다음 순서로 준비합니다.
-
whsec_접두사를 제거합니다. - 나머지 문자열을 base64로 디코딩합니다.
- 디코딩된 바이트를 HMAC-SHA256 키로 사용합니다.
webhook-signature 헤더는 공백으로 구분된 하나 이상의 서명을 포함할 수 있습니다.
webhook-signature: v1,<base64-signature>
MCP 초안은 수신자에게 다음을 요구합니다.
- 5분보다 오래된 타임스탬프 거부
-
webhook-id기준 중복 제거 - HMAC-SHA256 서명 검증
- 원본 바이트 그대로 검증
다음은 Node 18+ 내장 기능만 사용하는 엄격한 로컬 수신자 예시입니다.
// receiver.mjs: local test receiver for Standard Webhooks (Node 18+)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.WEBHOOK_SECRET; // whsec_...
const MAX_BYTES = 256 * 1024;
const TOLERANCE_S = 5 * 60;
const seen = new Set();
export function verify(raw, h, secret, now = Math.floor(Date.now() / 1000)) {
const id = h["webhook-id"];
const ts = h["webhook-timestamp"];
const sigs = h["webhook-signature"];
if (!id || !ts || !sigs) return false;
const timestamp = Number(ts);
if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > TOLERANCE_S) {
return false;
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${ts}.`)
.update(raw)
.digest();
return sigs.split(" ").some((signature) => {
const [version, b64] = signature.split(",");
const received = Buffer.from(b64 ?? "", "base64");
return (
version === "v1" &&
received.length === expected.length &&
timingSafeEqual(received, expected)
);
});
}
if (SECRET) {
createServer((req, res) => {
const chunks = [];
let size = 0;
req.on("data", (chunk) => {
size += chunk.length;
if (size <= MAX_BYTES) chunks.push(chunk);
});
req.on("end", () => {
if (size > MAX_BYTES) {
return res.writeHead(413).end();
}
const raw = Buffer.concat(chunks);
if (!verify(raw, req.headers, SECRET)) {
return res.writeHead(401).end();
}
let body;
try {
body = JSON.parse(raw);
} catch {
return res.writeHead(400).end();
}
if (body.type === "verification") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ challenge: body.challenge }));
}
const id = req.headers["webhook-id"];
if (!seen.has(id)) {
seen.add(id);
console.log(
req.headers["x-mcp-subscription-id"],
body.name,
id
);
}
res.writeHead(200).end();
});
}).listen(8787);
}
다음과 같이 실행합니다.
WEBHOOK_SECRET=whsec_... node receiver.mjs
이 수신자는 다음 동작을 수행합니다.
- 256 KiB를 초과하면
413반환 - 잘못된 서명 또는 만료된 타임스탬프에
401반환 - 검증 챌린지 에코
- 동일한
webhook-id를 한 번만 로깅
verify() 함수는 Standard Webhooks JavaScript 라이브러리의 서명 테스트 벡터에 대해 검증할 수 있습니다. 서명 원리는 웹훅 서명 확인에서 확인하세요.
기본적으로 서버는 localhost나 사설 네트워크 주소로 이벤트를 전송하면 안 됩니다. 개발 환경에서는 명시적인 허용 목록을 추가하거나 터널을 사용해 외부 HTTPS URL을 제공하세요.
6. ChatGPT 연결 전 테스트하기
Apidog에서 다음 환경 변수를 생성합니다.
MCP_URL
MCP_TOKEN
CALLBACK_URL
WEBHOOK_SECRET
각 JSON-RPC 요청은 {{MCP_URL}}로 POST 전송하고 다음 헤더를 포함합니다.
Authorization: Bearer {{MCP_TOKEN}}
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: <JSON-RPC method>
스트리밍 가능 HTTP 바인딩과 MCP 기본 사양에 따라 모든 요청의 params._meta에는 다음 정보도 포함해야 합니다.
{
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
OpenAI 샘플은 _meta를 생략하지만, 실제 서버는 _meta가 없을 때 -32602를 반환할 수 있습니다. 따라서 실패 원인을 분리하려면 테스트 요청에 포함하는 편이 안전합니다.
테스트 1: 검색
-
server/discover를 호출합니다. -
$.result.capabilities.events가 존재하는지 확인합니다. -
$.result.supportedVersions에2026-07-28이 포함되는지 확인합니다. -
events/list를 호출합니다. - 각 이벤트의
delivery에webhook이 포함되는지 확인합니다.
테스트 2: 멱등성 구독
-
{{CALLBACK_URL}},{{WEBHOOK_SECRET}}을 사용해events/subscribe를 호출합니다. -
$.result.id를SUB_ID환경 변수로 저장합니다. - 같은 요청을 다시 전송합니다.
-
arguments객체의 키 순서만 바꾼 요청을 다시 전송합니다. - 모든 응답의
$.result.id가{{SUB_ID}}와 같은지 확인합니다.
테스트 3: 입력 유효성 검사
다음 실패 요청을 전송합니다.
- 24바이트 미만으로 디코딩되는 비밀
-
http://콜백 URL - 사설 IP를 사용하는 콜백 URL
초안 기준으로 잘못된 비밀과 HTTP 콜백은 -32602(InvalidParams)로 매핑되어야 합니다.
{
"error": {
"code": -32602
}
}
테스트 4: 확인 챌린지 실패
Apidog 모의 엔드포인트를 만들고 다음과 같이 잘못된 챌린지를 반환하도록 설정합니다.
{
"challenge": "wrong"
}
해당 모의 URL로 구독하면 다음을 확인해야 합니다.
{
"error": {
"code": -32015,
"data": {
"reason": "challenge_failed"
}
}
}
그다음 CALLBACK_URL을 정상 수신자 URL로 변경하고 구독이 성공하는지 확인합니다.
테스트 5: 초과 페이로드
262,144바이트를 초과하는 이벤트를 트리거합니다.
기대 결과는 다음과 같습니다.
- 발신 서버가 전송 전에 거부
- 수신자까지 전달되었다면 수신자가
413반환 - 서버 로그에 재시도가 아닌 한 번의 전송 시도만 기록
테스트 6: 재생 및 변조
서명된 이벤트 전송의 헤더와 본문을 복사하여 새 요청으로 전송합니다.
- 즉시 재전송
- HTTP
200 - 두 번째 이벤트 처리 로그 없음
- HTTP
- 본문 바이트 하나 변경
- HTTP
401
- HTTP
- 5분 뒤 원본 요청 재전송
- HTTP
401
- HTTP
이 테스트를 Apidog 시나리오로 저장하고 CI에서 실행하세요. MCP 서버 테스트 플레이북은 도구 호출 테스트를 다루며, 웹훅 테스트 방법은 수신자 테스트를 더 자세히 설명합니다.
자주 묻는 질문
MCP 이벤트란 무엇인가요?
클라이언트가 폴링하는 대신 서버가 클라이언트에 이벤트 알림을 푸시할 수 있도록 하는 실험적 MCP 확장 기능입니다. ChatGPT는 2026-07-28 버전의 웹훅 모드를 지원합니다.
ChatGPT는 MCP 이벤트에 폴링이나 스트리밍을 지원하나요?
아니요. ChatGPT는 웹훅 전송과 콜백 확인만 지원합니다. 폴링, 스트리밍, gap, terminated 알림은 지원하지 않습니다.
어떤 ChatGPT 플랜에서 MCP 이벤트를 사용할 수 있나요?
OpenAI DevDay 요약에 따르면 모든 플랜에서 사용할 수 있습니다.
서명 비밀은 누가 생성하나요?
구독자가 생성합니다. ChatGPT는 delivery.secret에 whsec_ 비밀을 전달하고, 서버는 이를 검증·저장·서명에 사용합니다. 서버가 자체적으로 새 비밀을 생성하는 방식이 아닙니다.
Agents API와는 어떻게 다른가요?
MCP 이벤트는 서버에서 ChatGPT로 데이터를 푸시합니다. OpenAI Agents API는 사용자가 구축한 에이전트를 실행하고 스트리밍 또는 웹훅으로 실행 진행 상태를 보고합니다.
다음 단계
처음부터 많은 이벤트를 구현하지 말고 다음 순서로 시작하세요.
-
server/discover에"events": {}추가 - 필터 하나를 갖는 이벤트 하나 정의
-
events/subscribe와 챌린지 확인 구현 - Standard Webhooks 서명 적용
- 로컬 수신자에서 6가지 테스트 실행
- ChatGPT 플러그인에 연결한 뒤 OpenAI 수명 주기 체크리스트 실행
테스트 시나리오를 저장하고 모든 커밋에서 실행하려면 Apidog를 다운로드하세요.
Top comments (0)