PDF URL 하나를 채팅 안에서 바로 열어주는 기능을 만들면서 예상치 못한 벽에 부딪혔다. 그 벽의 이름은 CORS였다.
MCP App은 iframe 안에서 산다
MCP App은 Claude 같은 MCP Client 채팅 안에 인터랙티브 UI를 직접 삽입하는 구조다. 별도 탭을 열지 않아도 된다. 대화 흐름 안에서 UI가 동작하는 것이 핵심이다.
동작 순서는 간단하다. 사용자가 PDF URL을 요청하면, MCP Client가 view-pdf tool을 호출한다. MCP 서버가 UI HTML을 tool result로 반환하고, MCP Client가 이것을 샌드박스 iframe으로 로드한다. 그 안에서 PDF.js가 PDF를 렌더링한다.
문제는 "샌드박스 iframe"이라는 단어에 있었다. MCP Client는 보안을 위해 iframe을 격리한다. 이 상태에서 location.origin을 찍어보면 "null"이 나온다. 일반적인 출처가 없는 것이다.
origin: null이 만드는 CORS 문제
브라우저는 다른 출처의 리소스를 요청할 때 서버가 Access-Control-Allow-Origin 헤더를 반환해야만 허용한다. 샌드박스 iframe의 origin: null에서 외부 PDF 서버로 fetch를 시도하면, 서버가 헤더를 반환하지 않을 경우 응답이 도착했음에도 브라우저가 PDF.js에 전달하지 않고 차단해버린다.
결과는 두 가지 오류 중 하나로 나타났다.
Failed to fetch— 서버에Access-Control-Allow-Origin헤더가 없는 경우Invalid PDF structure— ngrok이 HTML 경고 페이지를 반환한 경우
두 번째 오류가 더 당황스러웠다. PDF를 요청했는데 HTML이 온다. PDF.js는 당연히 파싱에 실패한다.
해결: 서버가 대신 가져온다
가장 직관적인 해결책은 iframe이 직접 fetch하는 대신 서버가 대신 fetch하는 것이다. 서버→서버 요청에는 CORS 제약이 없다. 서버가 PDF를 가져와서 Access-Control-Allow-Origin: * 헤더를 붙여 iframe에 돌려주면 된다.
// GET /proxy?url={PDF_URL}
app.get('/proxy', async (req, res) => {
const response = await fetch(req.query.url); // 서버에서 직접 fetch
const buffer = Buffer.from(await response.arrayBuffer());
res.set('Access-Control-Allow-Origin', '*'); // CORS 허용
res.send(buffer);
});
여기서 한 가지 더 고려해야 할 것이 있었다. 샌드박스 iframe은 origin: null이라 상대경로(/proxy?url=...)를 해석할 수 없다. 그래서 MCP tool이 결과를 반환할 때 서버의 절대 URL인 proxyBase도 함께 넣어야 한다.
return { content: [{ type: 'text', text: JSON.stringify({
url,
proxyBase: "https://xxxx.ngrok-free.dev" // 서버 공개 URL
})}]};
// iframe에서 프록시 URL 조합
const proxyUrl = `${proxyBase}/proxy?url=${encodeURIComponent(url)}`;
ngrok이 끼어들 때
로컬 서버를 외부에 노출할 때 ngrok을 쓰는 경우가 많다. 그런데 ngrok 무료 플랜은 브라우저 User-Agent가 감지되면 경고 HTML 페이지를 먼저 반환한다. 프록시 URL도 브라우저를 통해 호출되므로 똑같이 막힌다.
해결책은 ngrok-skip-browser-warning: 1 헤더를 요청에 추가하는 것이다. 이 헤더가 있으면 ngrok이 경고 페이지를 건너뛴다.
그런데 이 헤더가 비표준 커스텀 헤더이기 때문에, 브라우저가 먼저 CORS preflight(OPTIONS 요청)를 보낸다. 서버가 이 preflight에 Access-Control-Allow-Headers: ngrok-skip-browser-warning으로 응답해야 실제 요청이 통과된다. Express의 cors() 미들웨어가 이것을 자동으로 처리해준다.
한 가지 주의할 점이 있다. 이 헤더를 모든 URL에 붙이면 Cloudflare Pages 같은 외부 서버에도 preflight가 발생한다. 그 서버들은 이 헤더를 모르므로 preflight가 실패하고 Failed to fetch가 떨어진다. 그래서 ngrok URL일 때만 조건부로 처리해야 한다.
function resolveUrl(url: string) {
if (proxyEnabled && proxyBase) {
const opts = { url: `${proxyBase}/proxy?url=${encodeURIComponent(url)}` };
// ngrok URL이면 경고 페이지 스킵 헤더 추가
if (proxyBase.includes('ngrok')) {
opts.httpHeaders = { 'ngrok-skip-browser-warning': '1' };
}
return opts;
}
return { url }; // Proxy OFF → 직접 fetch
}
정리하면
샌드박스 iframe에서 출발하는 CORS 문제는 "서버가 대신 가져온다"는 하나의 원칙으로 해결된다. 거기에 ngrok을 쓴다면 skip 헤더와 preflight 처리가 추가된다. 복잡해 보이지만, 각 레이어의 제약을 하나씩 따라가면 필연적으로 도달하는 구조다.
직접 fetch가 안 될 때는 서버에게 시킨다. 서버는 CORS를 모른다.