[2/6] 채팅창에 앱을 띄우는 법 - MCP Apps 호스트 구현기 - AI는 대답하지 말고 화면을 내라
MCP Apps 표준은 메시지 규격까지만 말합니다. 채팅 서비스가 호스트가 되려면 iframe 격리, 도구 브리지와 권한, 결과 전달 순서, 전체화면과 파일 다운로드, 접었다 펼 때의 재마운트, 외부 호스트의 변주까지 직접 풀어야 합니다. 운영하면서 부딪힌 여섯 가지를 코드와 함께 정리합니다.
1편에서 도구 결과를 텍스트 대신 화면으로 돌려주는 흐름을 봤습니다. 그 글에서 “채팅 호스트가 리소스를 읽어 iframe에 띄우고 결과를 넘긴다”고 한 줄로 썼는데, 실제로 그 한 줄을 만드는 데 든 시간이 나머지 전부보다 길었습니다. 이번 글은 그 이야기입니다.
1. 표준이 정해 주는 것과 정해 주지 않는 것
MCP Apps가 정해 주는 건 세 가지입니다. 도구가 _meta.ui.resourceUri로 UI 리소스를 가리킨다는 것, 그 리소스는 text/html;profile=mcp-app HTML이라는 것, 그리고 호스트와 앱이 postMessage 위에서 JSON-RPC로 주고받는 메시지의 이름과 모양입니다. 도구 입력·결과 알림, 앱이 호스트에게 부탁하는 도구 호출·링크 열기·파일 다운로드·표시 모드 변경, 호스트가 앱에 알려 주는 테마·표시 모드 같은 것들이에요.
정해 주지 않는 건 그 메시지들을 어디서, 어떤 권한으로, 어떤 순서로 처리하느냐입니다. 앱을 어떤 iframe에 띄울지, 앱이 도구를 부르면 누구 권한으로 실행할지, 결과가 도구 호출보다 늦게 오면 어떻게 할지, 사용자가 카드를 접었다 펴면 앱을 다시 띄울지, 이건 전부 호스트가 정합니다. 표준이 얇은 게 잘못은 아닙니다. 호스트마다 사정이 다르니까요. 다만 “우리 채팅에 붙이면 되겠지”라고 시작하면 여기서 시간을 씁니다.
호스트 쪽 뼈대는 이 정도입니다. 제품 코드에서 타입과 부가 기능을 걷어낸 것이라 그대로 붙이면 돌아가지는 않지만, 해야 할 일은 다 들어 있어요.
function McpAppFrame({ resourceUri, toolName, toolInput, toolResult, callTool }) {
const ref = useRef<HTMLIFrameElement>(null);
const [html, setHtml] = useState<string | null>(null);
const [height, setHeight] = useState(240);
useEffect(() => { fetchResource(resourceUri).then((r) => setHtml(r.text)); }, [resourceUri]);
useEffect(() => {
const win = ref.current?.contentWindow;
if (!html || !win) return;
const bridge = new AppBridge(null, { name: 'my-chat', version: '1.0.0' },
{ serverTools: {}, downloadFile: {}, openLinks: {}, message: {} },
{ hostContext: { toolInfo: { tool: { name: toolName } }, theme: 'light', displayMode: 'inline' } });
bridge.oncalltool = async ({ name, arguments: args }) => callTool(name, args ?? {});
bridge.onsizechange = ({ height: h }) => setHeight(Math.min(Math.max(h, 80), 640));
bridge.oninitialized = () => {
bridge.sendToolInput({ arguments: toolInput ?? {} });
if (toolResult) bridge.sendToolResult(toolResult);
};
bridge.connect(new PostMessageTransport(win, win));
return () => { bridge.close(); };
}, [html]);
if (!html) return <Skeleton />;
return <iframe ref={ref} srcDoc={html} sandbox="allow-scripts allow-downloads" style={{ height }} />;
}
이 30줄 뒤에 있는 결정 여섯 가지를 차례로 보겠습니다.
2. 격리 - srcdoc과 sandbox, 그리고 주지 않은 권한 하나
앱 HTML은 서버에서 문자열로 받아 srcDoc으로 넣습니다. URL로 띄우지 않는 이유는 두 가지예요. 리소스가 ui:// 스킴이라 브라우저가 직접 열 주소가 아니고, 어차피 호스트가 인증을 거쳐 받아 와야 하니 그 결과를 그대로 넣는 게 자연스럽습니다.
sandbox 속성에는 allow-scripts와 allow-downloads만 줍니다. allow-same-origin은 주지 않습니다. 이 하나가 격리의 핵심입니다. same-origin 없이 뜬 srcdoc iframe은 출처가 불투명(opaque)해져서 호스트의 쿠키, 로컬 스토리지, DOM 어느 것도 만지지 못합니다. 호스트의 로그인 토큰도 당연히 못 봐요. 앱이 할 수 있는 건 postMessage로 호스트에게 부탁하는 것뿐이고, 부탁을 들어줄지는 호스트가 정합니다.
표준 문서는 신뢰할 수 없는 앱을 위해 이중 iframe(외부 도메인의 샌드박스 iframe 안에 앱 iframe)까지 권합니다. 저희는 앱이 전부 저희 서버에서 나오는 것이라 단일 iframe으로 갔습니다. 서드파티 MCP 서버의 앱을 띄울 계획이면 이 결정은 다시 해야 합니다.
3. 브리지와 권한 - 앱은 도구를 “부탁”할 뿐 실행하지 않는다
앱이 다른 도구를 불러야 할 때가 있습니다. 조회 화면이 조건을 바꿔 다시 조회할 때, 입력 폼의 코드 필드가 검색 도움말을 열 때, 수정 폼이 현재 값을 먼저 읽어 올 때입니다. 앱은 tools/call을 호스트에게 보내고, 호스트의 oncalltool이 받아서 서버에 넘깁니다.
여기서 지킨 원칙은 하나입니다. 앱의 도구 호출은 그 대화가 원래 쓰던 규칙 그대로 실행된다. 서버 쪽 처리는 이렇습니다.
router.post('/sessions/:id/tools/call', auth, async (req, res) => {
const session = chat.get(req.params.id, userId(req)); // 이 사용자의 대화인가
const target = resolveTarget(session.profileSlug, session.systemId); // 대화의 프로파일·시스템
const spec = visibleTools(target).find((t) => t.name === name); // 그 대화에 보이는 도구인가
if (!spec) throw new HttpError(404, `도구가 없습니다: ${name}`);
const input = z.object(spec.inputSchema).parse(args); // 입력 스키마 검증
const result = await runTool(spec, input, { conn: await connect(target.systemId, req.user) }, 'app');
res.json(result);
});
앱은 어느 시스템에 대고 실행할지 고를 수 없습니다. 대화에 숨겨진 도구를 부를 수도 없고, 스키마에 없는 인자를 넣을 수도 없습니다. 읽기 전용이 아닌 도구의 환경 제한도 그대로 걸립니다. 앱이 iframe 안에서 아무리 영리해져도 대화 밖으로는 못 나간다는 뜻이에요.
호스트만 아는 도구도 하나 있습니다. 앱 머리글의 “화면 저장” 버튼이 부르는 weaver/save_view인데, MCP 번들에는 없고 호스트의 oncalltool이 서버로 보내기 전에 가로채서 처리합니다. 앱은 그게 서버 도구인지 호스트 도구인지 몰라도 됩니다.
4. 순서 - 결과는 도구 호출보다 늦게 온다
채팅은 스트리밍입니다. 모델이 도구를 부르는 순간 카드가 생기고 앱이 뜨기 시작하는데, 결과는 SAP가 응답한 뒤에야 옵니다. 앱이 초기화되는 시점과 결과가 도착하는 시점의 순서가 보장되지 않아요.
그래서 두 군데에서 보냅니다. 앱이 initialized를 알리면 그때 들고 있는 입력과 결과를 보내고, 나중에 결과가 도착하면 이미 초기화된 브리지에 다시 보냅니다. 문제는 React가 부모를 다시 그릴 때마다 같은 결과를 새 객체로 만든다는 것. 그대로 두면 앱이 같은 표를 몇 번씩 다시 그립니다. 마지막으로 보낸 결과를 직렬화해 두고 같으면 보내지 않는 작은 게이트 하나가 필요했습니다.
function createResultDelivery(send) {
let last;
return (result) => {
const payload = JSON.stringify(result);
if (payload === last) return;
last = payload;
return send(result);
};
}
사소해 보이는데, 이게 없을 때 표가 두 번 깜빡이는 걸 사용자는 바로 알아챕니다.
5. 크기·전체화면·다운로드 - 호스트가 해 줘야 앱이 할 수 있는 것들
iframe 안의 앱은 자기 높이를 모릅니다. 앱이 내용 높이를 size-change로 알려 주면 호스트가 iframe 높이를 맞춥니다. 저희는 80px에서 640px 사이로 묶었어요. 그 이상은 앱 안에서 스크롤합니다. 표가 200행이라고 채팅창이 200행만큼 길어지면 대화가 안 되니까요.
전체화면은 앱이 request-display-mode로 부탁하고 호스트가 허락합니다. 허락하면 iframe을 뷰포트 크기로 키우고 body의 스크롤을 잠급니다. 호스트 컨텍스트에 “이 호스트는 inline과 fullscreen을 지원한다”고 적어 두면 앱이 그걸 보고 최대화 버튼을 그립니다. 지원하지 않는 호스트에서는 버튼이 안 보여요. 다운로드도 같습니다. 앱이 ui/download-file로 파일 내용을 보내면 호스트가 Blob을 만들어 내려받게 합니다. 파일 이름은 URI에서 뽑되 경로 문자를 걸러 내고, 크기는 50MB에서 자릅니다. 이 기능을 선언하지 않은 호스트에서는 앱의 “HTML 내보내기” 버튼이 나타나지 않습니다.

구현 예 - 카드 머리글의 내보내기·저장·최대화 버튼은 호스트가 지원한다고 선언한 기능만큼만 나타납니다. 맨 오른쪽 “결과 접기”는 앱이 아니라 호스트의 버튼입니다.

앱이 부탁하고 호스트가 허락한 전체화면. 원래 크기로 돌아가는 버튼이 같은 자리에 있습니다.
6. 재마운트 - 접었다 펴면 앱은 새로 태어난다
카드에는 “결과 접기” 버튼이 있습니다. 접으면 iframe을 DOM에서 뗍니다. 펼치면 다시 붙이는데, 이때 앱은 처음부터 다시 뜹니다. 사용자가 조건 칸에 넣어 둔 값은 사라져요.
이걸 막으려면 iframe을 떼지 말고 숨겨야 하는데, 그러면 접힌 카드 수십 개가 메모리에 살아 있게 됩니다. 저희는 떼는 쪽을 택하고 대신 두 가지를 했습니다. 접기 버튼의 툴팁에 “다시 펼치면 앱을 새로 불러옵니다”라고 적었고, 리소스 HTML은 한 번 받으면 캐시해서 다시 펼칠 때 서버를 다시 부르지 않게 했습니다. 그리고 원칙을 하나 세웠어요. 앱 안의 입력 상태는 휘발성이고, 남겨야 할 상태는 호스트나 서버가 든다. 조회 조건을 남기는 방법은 3편의 “화면 저장”이 됩니다.
7. 외부 호스트의 변주 - Claude가 결과에 글을 덧붙였다
같은 MCP 서버를 Claude에 붙였을 때 앱이 뜨긴 뜨는데 표가 비어 있는 일이 있었습니다. 원인은 Claude가 도구 결과 텍스트 뒤에 위젯 사용 안내 문구를 덧붙여 앱에 넘긴다는 것이었어요. 앱은 결과 텍스트를 JSON.parse하다가 실패하고 조용히 빈 표를 그렸습니다.
고친 방법은 파서였습니다. structuredContent가 있으면 그걸 먼저 쓰고, 없으면 텍스트에서 첫 { 또는 [부터 괄호 짝이 맞는 지점까지만 JSON으로 읽습니다. 문자열 안의 괄호와 이스케이프는 건너뛰어야 하니 정규식으로는 안 되고 짧은 스캐너를 썼습니다.
function parseResultJson(text) {
const s = text.trim();
try { return JSON.parse(s); } catch { /* 뒤에 뭔가 붙었는지 본다 */ }
if (!s.startsWith('{') && !s.startsWith('[')) throw new SyntaxError('JSON 결과가 아닙니다');
const stack = []; let quoted = false, escaped = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (quoted) { if (escaped) escaped = false; else if (c === '\\') escaped = true; else if (c === '"') quoted = false; continue; }
if (c === '"') quoted = true;
else if (c === '{' || c === '[') stack.push(c);
else if (c === '}' || c === ']') { stack.pop(); if (!stack.length) return JSON.parse(s.slice(0, i + 1)); }
}
throw new SyntaxError('JSON이 닫히지 않았습니다');
}
교훈은 파서보다 큰 데 있습니다. 표준을 따른 앱이라도 호스트마다 결과를 조금씩 다르게 넘깁니다. 앱은 자기 호스트만 믿고 짜면 안 되고, 결과의 “데이터”와 “호스트가 덧붙인 것”을 분리해 읽어야 합니다.
8. 배포 - 앱은 외부 스크립트 없는 HTML 한 장이어야 한다
앱 HTML이 Claude 같은 외부 호스트에서도 돌아가려면 CDN이든 자기 서버든 외부 스크립트를 참조하면 안 됩니다. 어느 출처에서 뜰지 모르고, 샌드박스 안에서는 상대 경로가 의미가 없으니까요.
그래서 빌드 때 앱 SDK와 공통 조각(폼, 표, 렌더러)을 HTML 안에 인라인합니다. SDK 번들 끝의 export {…}는 srcdoc 안에서 못 쓰니 전역 할당으로 바꾸고, 인라인 스크립트 안의 </script 문자열이 태그를 닫아 버리지 않게 이스케이프합니다. 이 두 가지가 처음에 한 번씩 발목을 잡습니다.
let sdk = fs.readFileSync('node_modules/@modelcontextprotocol/ext-apps/dist/src/app-with-deps.js', 'utf8');
sdk = sdk.replace(/export\{([^}]*)\};?\s*$/, (_, names) => `globalThis.__McpApps={App:${pick(names, 'App')}};`);
sdk = sdk.replace(/<\/script/gi, '<\\/script');
html = html.replace('<!--__SDK__-->', () => `<script type="module">\n${sdk}</script>`);
서버는 기동할 때 빌드된 폴더를 읽어 리소스로 냅니다. 앱을 하나 더 만드는 일은 HTML 한 장을 폴더에 두고 도구 정의에 app: 'query-list'처럼 이름 하나를 다는 것으로 끝납니다. 지금 앱은 넷이에요. 조회 화면, 단건 결과, 입력 폼, 업무 앱. 이 넷으로 도구 수백 개를 다 그립니다. 어떻게 그게 되는지는 5편에서 다룹니다.
9. 여기까지의 한계, 그리고 다음 글
호스트가 서니 앱이 뜹니다. 앱이 도구를 부르니 조회 화면이 스스로 다시 조회할 수 있습니다. 그런데 사용자 입장에서는 아직 한 가지가 어색합니다. 표는 화면에 떠 있는데, “이번엔 다른 플랜트로”라고 하려면 여전히 모델에게 말을 겁니다. 조건이 대화 이력 속에 있으니까요.
조건을 대화 밖으로 꺼내 화면에 두고, 재조회는 모델을 거치지 않게 하고, 그 조건을 저장하고 파일로 들고 나가는 이야기가 3편입니다.
이 글의 구현은 Intellidesk Weaver에 들어 있습니다. 다음 글: [3/6] 같은 질문을 두 번 묻지 않기 - 조회 상태를 대화 밖으로.
Intellidesk Weaver
SAP 자산을 AI가 쓰는 도구로, 답은 화면으로.
OData·CDS·테이블·ABAP 자산을 하나의 카탈로그로 묶어 MCP 도구로 내보내고, 결과는 채팅 안의 조회 화면·입력 폼·업무 앱으로 돌려줍니다.
Weaver 살펴보기