MCP는 자연어로 사용자의 의도를 전달하여 원하는 지식을 얻는데 특화하였다. 그럼 이제 UI가 덜 중요해질까? 오히려 UI가 더 중요해질 것이다
그래서 MCP 서버는 단순 텍스트 응답을 넘어서, UI 그 자체를 클라이언트로 보낼 수 있게 한다. 기존의 MCP가 텍스트, 리소스, 툴, 프롬프트 중심이었다면 MCP UI는 HTML,DOM,iframe을 이용하여 UI 컴포넌트 자체를 전송한다.
import { createUIResource } from '@mcp-ui/server'
const resource = await createUIResource({
uri: 'ui://weather-card/lagos-nigeria',
content: {
type: 'rawHtml',
htmlString: `
<div style="padding: 20px;">
<h1>Test</h1>
</div>
`,
},
encoding: 'text',
})
이런 식으로 createUIResource을 사용하여 HTML을 전송하면, MCP 서버는 HTML을 사용자가 볼 수 있도록 렌더링한다. 이러한 MCP-UI의 워크 플로우를 먼저 살펴보자.
사용자가 날씨 관련된 정보를 질문했다고 가정하자

유저는 자연어로 요청하고 앱은 툴을 호출한다. 그러면 클라이언트는 서버로 요청을 넘기고 MCP 서버는 실제 날씨 데이터를 조회한다.

그리고 MCP 서버는 HTML을 만들고 createUIResource로 UI를 패키징해서 응답한다.

앱은 서버로부터 받은 HTML을 렌더링하고

화면에 보여지게된다.
그럼 본격적으로 MCP-UI에 대해 알아보자!

view_tag라는 툴이 text content 대신 html을 반환하도록 수정할 것이다.

콜백에 html을 작성한다. 이제 content에 담아 리턴할 것이다.

여기서 uri는 UI의 라우트고 type은 rawHtml로 해두고 인스펙터에 가서 확인해보자.

view_tag 툴에 id가 1을 넣으면 html이 잘 나온다.

이렇게 MCP UI를 이용하여 에이전트 앱이 쓰는 UI를 그대로 사용하고 싶다고 하자. Raw HTML UI는 빠르게 화면을 띄울 수 있지만, 실무에서 쓰긴 적합하진 않다.
MCP-UI가 하려는 것은 결국 에이전트 UI에서 사용하는 컴포넌트를 외부 MCP 서버에서도 사용해서 커스텀하고 싶다는 것이다. 만약 에이전트가 자신의 컴포넌트를 외부(여기서는 MCP 서버)에 풀면 보안 문제가 생긴다.
그래서 iframe으로 격리하여 HTML을 렌더링한다. 하지만 iframe도 문제가 있다. 에이전트의 UI가 호스트와 분리되어 안전하지만, iframe 안에서 에이전트의 컴포넌트(가령 버튼)에 접근이 불가하다. 그래서 ifame으로는 통일된 UI를 만드는 것이 어렵다
const stack = document.createElement('ui-stack')
stack.setAttribute('direction', 'vertical')
stack.setAttribute('spacing', '20')
stack.setAttribute('align', 'center')
const title = document.createElement('ui-text')
title.setAttribute('content', '태그 이름')
stack.appendChild(title)
root.appendChild(stack)
그래서 Remote DOM라는 것을 사용하는데, Remote DOM란 서버가 JS로 UI를 생성하고 관련 데이터만 JSON으로 전송하는 방식이다. (RSC와 비슷한?) 위 코드처럼 작성한다!

view_tag 툴에서 콜백 부분에서 type과 framework를 수정한다. 이 UI 컴포넌트들의 실제 구현은 호스트 앱이 한다고 MCP 서버는 인지한다.

getTagViewUI에 db랑 id를 넘긴다. getTagViewUI는 UI 뼈대를 구성하는 함수다.

getTagViewUI는 기존의 raw html을 그대로 보내는 방식이다. 딱봐도 작성하기 어려워보인다.

getTagRemoteDomUIScript를 보면, 호스트가 제공하는 root에 필요한 UI 뼈대만 붙여놓는다.
이런 방법의 가장 큰 장점은 호스트 애플리케이션이 ui-stack이 뭔지, ui-text가 뭔지를 어떻게 정의하느냐에 따라서 그 디자인을 그대로 가져갈 수 있다는 거다. 그럼 getTagRemoteDomUIScript로 변경하여 테스트 해보자

테스트를 위해 인스펙터가 아닌 로컬 에이전크를 실행해보겠다. nanobot을 사용했다.

오... 아주 간단한 html이 적용되어 응답이 왔다. 이번엔 button을 넣어보자

postMessage를 사용하여 동적 이벤트를 만들어서 MCP 툴을 직접 호출할 수 있게 만든다.

태그 삭제 버튼을 누르면

delete tag를 호출하여 툴 삭제까지 된다!
Remote DOM을 이용하여 UI를 사용자에게 제공했지만 이걸로는 부족하다. 매번 UI를 그릴 때 인라인으로 작성하는 건 참 불편하기 때문에 무언가 더 생산적인 방식이 필요하다.
어차피 에이전트 UI를 보여줄 때 iframe으로 들어갈 거라면 차라리 외부 URL을 주고 그걸 iframe URL로 가리키게 하면 어떨까. MCP 서버가 웹 앱을 iframe으로 스트리밍하는 방법에 대해 알아보자

사용자가 대시보드를 요청한다고 가정하자.
호스트는 클라이언트에게 get_dashboard 툴을 호출하고 싶다고 전달하고 서버는 그 요청을 받아서 iframe URL을 만든다.

그리고 UI 리소스로 패킹하고 호스트로 반환한다.

호스트는 MCP UI 스펙을 따르거나 그 스펙에 맞게 iframe 엘리먼트를 만들고,

그 iframe을 로드하게 된다.그리고 iframe은 UI lifecycle iframe ready라고 불리는 이벤트를 보낸다
약간 포트에서 리스닝 같은 느낌으로 통신 준비 완료라고 생각하면 된다.
이제 에이전트가 UI에게 말을 걸 수도 있고, UI가 에이전트에게 다시 말을 걸 수도 있는 상태다.

이제 서버는 데이터를 가지고 UI를 렌더링하고 호스트로 보내 호스트는 iframe에 맞게 리사이징한다.

그리하여 드디어 사용자는 대시보드에서 뭔가 상호작용을 할 수도 있고, 그 대시보드가 툴 호출이나 프롬프트 요청, 혹은 링크 열기 같은 요청을 보낼 수도 있게 된다.
자, 그럼 예시로 살펴보자.

일단 프로젝트 내 리액트 웹앱을 세팅해두고

라우팅과 서버 엔트리를 셋업해둔다. 요청이 들어오면 라우트에 맞게 JournalViewer라는 컴포넌트를 보여줄 것이다.
MCP로 돌아가서, 에이전트에서 스트리밍 가능한 UI로 만들려면 iframe URL을 생성해야한다.
그러려면 https://myapp.com 처럼 오리진이 필요하다. 오리진은 환경마다 다르기 때문에 현재 들어온 요청에 대한 오리진을 런타임 때 획득 해야 한다.
그래서 Cloudflare Workers를 사용하여 오리진을 생성한다.
// worker/index.ts
export default {
fetch: async (
request: Request,
env: Env,
ctx: ExecutionContext<McpProps>,
) => {
const url = new URL(request.url)
if (url.pathname === '/mcp') {
ctx.props.baseUrl = url.origin // 오리진 설정
return EpicMeMCP.serve('/mcp', {
binding: 'EPIC_ME_MCP_OBJECT',
}).fetch(request, env, ctx)
}
return requestHandler(request, {
db,
cloudflare: { env, ctx },
})
},
}
사용자가 AI 에이전트에게 특정 데이터를 보여달라고 요청했다. 여기서는 특정 데이터는 저널이라고 가정하고 MCP tool인 view_journal을 호출한다.
// mcp/tools.ts
async () => {
const iframeUrl = new URL('/ui/journal-viewer', agent.requireBaseUrl())
// 결과: https://myapp.workers.dev/ui/journal-viewer
return {
content: [
await createUIResource({
content: {
type: 'externalUrl',
iframeUrl: iframeUrl.toString(),
},
}),
],
}
}
이제 원본 request에서 baseUrl을 뽑아내고 -> 그 baseUrl을 MCP 서버로 전달하고 -> agent 쪽에서 그 값을 쉽게 꺼내 쓸 수 있게 만들었다.
이제 툴의 응답을 받은 클라이언트는 iframe 렌더링을 하고
<iframe src="https://epic-me-mcp.workers.dev/ui/journal-viewer"></iframe>
HTML에 iframe 를 추가하게 되면서 iframe이 페이지 로드 요청을 한다.
GET https://epic-me-mcp.workers.dev/ui/journal-viewer
요청을 받은 Cloudflare Worker는 MCP 라우팅 말고 requestHandler를 리턴한다.
// worker/index.ts
export default {
fetch: async (request: Request, env: Env, ctx: ExecutionContext) => {
const url = new URL(request.url)
// url.pathname = '/ui/journal-viewer'
if (url.pathname === '/mcp') {
// 매칭 안됨
return ...
}
// 여기로 옴
return requestHandler(request, {
db, // DB 클라이언트
cloudflare: { env, ctx }
})
}
}
requestHandler는 빌드된 라우트 파일을 로드한다.
const requestHandler = createRequestHandler(
() => import('virtual:react-router/server-build'),
import.meta.env.MODE,
)
이제 빌드 파일 내부에 있는app/entry.server.tsx로 요청이 넘어가고
// entry.server.tsx
export default async function handleRequest(...) {
// 1. ServerRouter 렌더링 시작
const body = await renderToReadableStream(
<ServerRouter context={routerContext} url={request.url} />,
)
// 2. ServerRouter 내부에서:
// - loader 실행 (journal-viewer.tsx의 loader)
//
// - 컴포넌트 렌더링
//
// - HTML 스트림 생성
// 3. Response 반환
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
})
}
이제 긴 여정이 끝나고 브라우저는 HTML 스트림을 받아서 파싱을한다. 이제 에이전트에서 저널을 보여달라고 요청해보자!

무언가 리액트로 작성한 페이지가 렌더링됐다.

버튼에는 아직 툴 요청을 달지 않아서 에러가 나온다. 여기까지의 과정을 다시 요약해 보자면
// worker/index.ts
const url = new URL(request.url)
if (url.pathname === '/mcp') {
// MCP 서버
ctx.props.baseUrl = url.origin
//...
return EpicMeMCP.serve('/mcp', {
binding: 'MY_MCP_OBJECT',
}).fetch(request, env, ctx)
} else {
// 리액트 라우터
}
엔트리 포인트로 요청이 들어오면 baseUrl을 context props에 주입해서 MCP 서버로 전달한다.
// mcp/tools.ts
async () => {
const iframeUrl = new URL('/ui/journal-viewer', agent.requireBaseUrl()) // baseURL 가져오기
return {
content: [
await createUIResource({
uri: `ui://view-journal/${Date.now()}`,
content: {
type: 'externalUrl',
iframeUrl: iframeUrl.toString(),
},
encoding: 'text',
}),
],
}
}
해당 툴이 iframe url을 반환하면 그 뒤로 MCP 클라이언트가 iframe 렌더링 한다.
까지 하면 에이전트가 UI를 렌더링하는 것이다. 이제 프론트엔드 웹앱과 (거의) 상호작용하는 MCP-UI가 되었다.
그럼 iframe으로 렌더링된 웹앱에서 몇 가지 상호작용을 추가해서 사용자의 편의를 높여보자.

이러한 플로우로 양방향 통신이 진행되는데, 먼저 링크를 클릭하면 열리게 할 것이다. 먼저 URL을 넘기면 그 링크를 열어주는 send link MCP 메시지부터 시작해보자.

사용자가 버튼을 클릭하면, iframe이 message ID를 만들고, 호스트 애플리케이션(부모 프레임)으로 POST 메시지를 보낸다.

예를 들어 링크를 클릭하면, 부모(호스트)가 그걸 네비게이션 요청으로 처리해서 페이지를 열어줄 수 있다. 그리고 나서 응답을 다시 보낸다.

그러면 UI 쪽에서 그 응답을 받아서 응답 성공 페이지에 대한 화면을 업데이트할 수 있다. 툴을 호출할 때도 비슷하다

사용자가 툴을 호출하면 여기서도 message ID를 만들고, postMessage로 툴 메시지를 보낸다.

그 다음 실제로는 클라이언트가 서버로 보내고, 서버가 응답을 주고, 클라이언트가 그걸 호스트로 보내고,

호스트가 다시 iframe으로 UI message response 형태로 돌려준다.
그러면 UI가 그 결과에 맞게 업데이트할 수 있다.
그럼 코드로 살펴보자. 먼저 UI 컴포넌트 쪽에 postMessage 함수를 넣을 것이다
// JournalViewer.tsx
//...
function XPostLinkImpl({ entryCount }: { entryCount: number }) {
const [isPending, startTransition] = useTransition()
const handlePostOnX = () => {
startTransition(async () => {
try {
// ...
const url = new URL('https://x.com/intent/post')
url.searchParams.set('text', text)
await sendLinkMcpMessage(url.toString())
} catch (err) {
// ...
}
})
}
return (
<button
onClick={handlePostOnX}
// ...
버튼 클릭 함수에 sendLinkMcpMessage를 호출하게 한다. sendLinkMcpMessage는 요청과 응답을 위해 id를 만든다.
export function sendLinkMcpMessage(url: string) {
const messageId = crypto.randomUUID()
// 실무에서는 보통 origin 제한함
window.parent.postMessage({ type: 'link', messageId, payload: { url } }, '*')
return new Promise((resolve, reject) => {
function handleMessage(event: MessageEvent) {
// ...
}
window.addEventListener('message', handleMessage)
})
}
window.parent.postMessage로 호스트앱과 통신을 하고 응답을 받기 위해 이벤트 리스너를 등록한다. 이제 에이전트에서 테스트해보자

에이전트에게 저널을 보여달라고 하고 X post 버튼을 클릭하면

새창이 열리면 X로 넘어간다!
정리하자면 iframe에서 parent로 링크 여는 postMessage 보내고, iframe은 parent의 응답을 기다렸다가 Promise로 받았다
여기서 그냥 iframe 안에서 <a href="...">로 이동하지 않은 이유는 iframe의 현재 앱이 그 URL로 네비게이션되기 때문이다.
그렇게 되면 브라우저와 부모 프레임의 히스토리가 꼬여서 UX가 이상해진다.
즉, 링크 열기와 같은 행위는 iframe이 아니라 호스트가 책임져야 하는 이벤트다.
그럼 단순히 UI를 보여주고 이벤트를 발생시키는 것 이상으로 무언가 생산적인 것을 할 수 있는 기능을 만들어보자.
그 중에서도 Tool Results Flow와 Initial Render Data Flow가 실무에서도 활용가능한 기능이다.

먼저 Tool Results Flow란 툴의 결과를 가지고 다시 UI를 업데이트 시키는 것이다. 툴을 호출하고 LLM 실행 후 postMessage로 UI에 돌아온 결과를 가지고 UI가 스스로 업데이트가 가능하다는 것!

가령 위 UI에서 Delete 버튼을 눌러 delete 툴을 호출하면 UI 자체에서 delete된 상태를 보여줄 수 있도록 업데이트가 된다면 좀 더 자동화될 것이다.
바로 코드로 살펴보자
agent.server.registerTool(
'delete_entry',
{
// ...
outputSchema: { success: z.boolean(), entry: entryWithTagsSchema },
},
async ({ id }) => {
//...
}
먼저 delete entry의 outputSchema 를 확인하고 검증을 위해 스키마 zod를 만든다.
// app/route/ui/index.tsx
const deleteEntrySchema = z.object({
structuredContent: z.object({ success: z.boolean() }),
})
우리는 성공 여부만 필요하므로 entry, content 를 제외한 불리언값만 받는다. 버튼 핸들러에 post message 함수를 달아준다
const handleDelete = () => {
startTransition(async () => {
try {
const result = await sendMcpMessage(
'tool',
{ toolName: 'delete_entry', params: { id: entry.id } },
{ schema: deleteEntrySchema },
)
if (result.structuredContent.success) {
onDeleted()
} else {
// ...
}
} catch (err) {
// ..
}
})
}
sendMcpMessage를 통해서 Iframe에서 host app 으로 RPC 패킷을 전송한다. MCP 서버는 툴을 실행해서 처리하고 다시 응답을 내려주고 sendMcpMessage가 zod로 검증후 promise도 변환한다.
여기서 Zod가 중요한데, 요청 데이터는 여러 네트워크 경계를 오가기 떄문에 정합성이 맞는지 체크를 해줘야한다. 이로서 좀 더 단단한 UI를 가진 MCP-UI가 된다. 바로 테스트해보자.

아주 잘 된다! 하지만 우리가 간과한 것이있다.
현재 MCP-UI의 URL은 /ui/journal/:id처럼 UI를 보여주는 엔드포인트”로 설계되었고, React Router와 SSR 기반으로 iframe 안에서 렌더되는 웹앱 형태다
문제는 UI 내부에서 DB에 접근하고 있다는 것이다

즉, UI 라우트가 곧 DB에 접글할 수 있게 된다. 인증, 권한 체크가 없어서 생기는 문제다. 이러한 문제를 해결하기 위해 Render Data Flow를 도입한다
이 사실을 떠올리자 LLM은 어차피 이 데이터에 접근 가능하다는 걸 이미 인지하고 있다. 그러면 할 수 있는 일은,
iframe이 만들어지고 나서 iframe이 준비 되었다는 lifecycle 이벤트를 보내면,
호스트 앱(=부모)이 툴 응답에 포함돼 있던 그 render data를 iframe로 보내준다. 이 부모가 UI에 내려주는 초기 데이터를 여기서 render data라고 부른다.
즉, 정리하자면 /ui/journal/:id 처럼 UI URL로 직접 요청시 지금처럼 데이터가 나오는 게 아니라 render data를 기다리는 스피너가 일단 보이고 UI는 호스트에서 받은 데이터만 보이게 한다.
그럼 서버 툴이 render data를 보내도록 설정히고 UI가 render data를 받아서 렌더링하도록 설정해야 한다
(권한 붕괴 관련은 좀 더 공부해야겠다)