Skip to main content

4부 17장. MCP Apps: 챗봇 안에 임베디드 UI 만들기

지금까지 우리는 MCP 서버를 잘 만드는 법을 배웠다. 좋은 도구를 만들고, 리소스를 노출하고, 프롬프트를 재사용하고, 인증과 배포와 보안까지 챙겼다. 그런데도 한 가지는 여전히 아쉽다. 사용자는 대부분 결과를 텍스트로만 본다는 점이다.

텍스트는 강력하다. 하지만 노트 목록을 훑어보고, 연관 메모를 눌러가며 탐색하고, 한 장의 메모를 카드처럼 읽고, 버튼 하나로 후속 작업을 시작하는 순간이 오면 텍스트만으로는 금방 숨이 찬다. 바로 이 지점에서 MCP Apps가 등장한다.

MCP Apps가 붙는 순간, MCP 서버는 더 이상 보이지 않는 백엔드가 아니다. 사용자가 손으로 만지고 눈으로 이해하는 제품이 된다. PKM-MCP도 마찬가지다. 이제 메모를 찾아주는 서버를 넘어서, 메모를 보여주고, 눌러보고, 대화를 이어가는 앱으로 진화할 차례다.

4부 17장. MCP Apps: 챗봇 안에 임베디드 UI 만들기

UI가 붙는 순간 달라지는 것

기존 MCP 서버는 대체로 이런 느낌이다. 사용자가 "지난주 회의 메모 찾아줘"라고 말하면, 모델이 search_notes를 호출하고, 결과를 몇 줄 텍스트로 정리해서 보여준다. 그다음은 다시 말로 요청해야 한다. "2번 메모 자세히 보여줘." "이 메모와 관련된 것도 찾아줘." "이 내용을 요약해줘."

이 흐름은 가능은 하지만, 매번 입으로 버튼을 누르는 셈이다.

MCP Apps는 이 마찰을 줄인다. 도구가 단순한 텍스트 대신 대화 안에 렌더링되는 HTML UI를 함께 제공하면, 사용자는 카드, 표, 차트, 폼, 대시보드 같은 인터페이스로 바로 상호작용할 수 있다. 이 UI는 대화 바깥 새 탭으로 도망가지 않고, 지금 보고 있는 채팅 안에 그대로 머문다.1

특히 PKM 같은 개인 지식 관리 도구와 궁합이 좋다. 메모는 원래 읽고, 비교하고, 연결하고, 다시 쓰는 일이 잦다. 즉, 한 번 보고 끝나는 데이터가 아니라 탐색형 데이터다. 탐색형 데이터에는 탐색형 UI가 붙어야 자연스럽다.

17.1 ext-apps 사양 개관

MCP Apps의 핵심은 생각보다 단순하다. 기존 MCP 위에 새로운 철학을 덧칠한 것이 아니라, 기존 MCP의 도구와 리소스를 조합해 인터랙티브 UI를 표준화한 것이다.1

가장 중요한 구성 요소는 둘이다.

  • UI를 가진다고 선언하는 Tool

  • 실제 HTML 인터페이스를 담고 있는 ui:// Resource

도구 설명 안에는 _meta.ui.resourceUri 같은 메타데이터가 들어가고, 이 값이 어떤 ui:// 리소스를 렌더링해야 하는지 가리킨다. 호스트는 이 정보를 보고 UI를 미리 불러오거나, 도구 실행 뒤에 인라인으로 렌더링할 수 있다.1

흐름을 한 번 머릿속에 그려보자.

  1. 사용자가 요청을 보낸다.

  2. LLM이 UI가 달린 도구를 선택한다.

  3. 호스트는 서버에서 ui://... 리소스를 가져온다.

  4. 도구 결과와 함께 UI를 대화 안에 렌더링한다.

  5. 사용자가 UI를 누르면, 앱은 호스트를 통해 다시 도구를 호출하거나 대화를 이어간다.1

여기서 재미있는 포인트가 하나 있다. 전송 계층이 두 겹이라는 점이다.

서버와 호스트 사이에서는 우리가 익숙한 MCP 전송, 즉 stdio나 HTTP가 계속 쓰인다. 반면 UI와 호스트 사이에서는 웹의 기본 메커니즘인 postMessage 위에서 JSON-RPC 스타일 메시지가 오간다. 다시 말해, MCP Apps는 "MCP를 버리고 웹앱을 만든다"가 아니라, MCP 위에 UI 채널을 하나 더 얹는다에 가깝다.1

이 구조 덕분에 장점이 분명해진다.

  • 대화 맥락을 잃지 않는다.

  • UI가 다시 서버 도구를 호출할 수 있다.

  • 호스트가 이미 가진 인증, 권한, 연결된 기능을 활용할 수 있다.

  • 샌드박스 iframe 덕분에 제3자 UI도 비교적 안전하게 렌더링할 수 있다.1

공식 ext-apps 저장소는 이 확장을 위한 SDK와 예제 서버를 함께 제공하며, 2026-01-26 버전 사양을 안정 버전으로 공개했다. 또한 React, Vue, Svelte, Preact, Solid, 바닐라 JS까지 다양한 시작 템플릿을 제공하므로, 프론트엔드 취향이 제각각인 팀도 큰 마찰 없이 시작할 수 있다.2

여기서 중요한 판단 기준이 하나 있다. 항상 MCP Apps를 써야 하는 것은 아니다.

단순히 "노트 제목 3개를 보여준다" 정도면 텍스트 응답이 더 빠르고 낫다. 반대로 아래와 같은 순간에는 UI가 훨씬 자연스럽다.

  • 여러 메모를 카드로 비교해야 할 때

  • 태그, 날짜, 중요도 등 필터를 동시에 조절해야 할 때

  • 클릭으로 연관 메모를 계속 파고들어야 할 때

  • 사용자의 선택을 받아 다음 작업을 이어가야 할 때

쉽게 말해, 읽기만 하면 되는 정보는 텍스트, 만져봐야 이해되는 정보는 앱이라고 생각하면 된다.

17.2 sendPrompt와 인터랙티브 UI

MCP Apps를 처음 보면 많은 사람이 이렇게 생각한다.

"아, 채팅 안에 작은 웹페이지 하나 넣는 거구나."

절반만 맞다. 화면만 그리는 것이었다면, 그냥 예쁜 액자일 뿐이다. MCP Apps의 진짜 힘은 대화를 다시 움직이게 하는 능력에 있다.

이 장에서 말하는 sendPrompt는 바로 그 지점을 가리킨다. 버튼을 눌렀을 때 앱이 호스트에 후속 발화를 보내고, 대화를 다음 단계로 밀어주는 패턴이다. 공식 문서와 예제에서는 이 기능이 sendMessage, updateModelContext, 앱에서의 도구 재호출 같은 형태로 드러난다. 이름은 SDK 버전과 호스트 구현에 따라 조금 달라도, 본질은 같다. UI가 대화의 다음 한 수를 만든다는 점이다.3

예를 들어 PKM 노트 카드에 이런 버튼이 있다고 해보자.

  • "이 메모 3줄로 요약하기"

  • "관련 메모 더 찾기"

  • "이번 주 회고 초안 만들기"

이 버튼들은 단순한 링크가 아니다. 누르는 순간 앱은 다음 행동을 제안할 수 있다.

첫 번째 버튼은 모델에게 후속 요약 프롬프트를 보낼 수 있다. 두 번째 버튼은 semantic_search_notes를 호출해 카드 아래에 연관 메모를 붙일 수 있다. 세 번째 버튼은 현재 메모를 문맥에 추가하고, 회고 초안을 만들어 달라는 후속 메시지를 보낼 수 있다.

즉, 인터랙티브 UI는 보통 세 가지 일을 한다.

  • 보여준다: 도구 결과를 카드, 표, 뷰어로 렌더링한다.

  • 다시 부른다: UI 안에서 다른 도구를 호출한다.

  • 대화를 민다: 사용자의 선택을 바탕으로 후속 프롬프트를 보낸다.13

이 세 가지가 합쳐지면, 채팅은 단순한 질의응답을 넘어 작은 작업 공간이 된다.

sendPrompt를 잘 쓰는 패턴

좋은 sendPrompt 패턴은 사용자의 의도를 짧고 또렷하게 대화로 되돌려준다.

예를 들어 "요약하기" 버튼을 누를 때, UI가 모델에게 보내는 후속 메시지는 이렇게 생길 수 있다.

이 메모를 3줄로 요약하고, 왜 중요한지 한 문단으로 설명해줘. note_id=note-123

이 메시지는 짧지만 충분하다. 사용자가 어떤 행동을 눌렀는지 분명하고, 모델이 이어서 할 작업도 명확하다.

반대로 나쁜 패턴은 이런 것이다.

이 메모와 관련된 모든 정보를 분석하고 앞으로 해야 할 일과 위험 요소와 가능한 가설을 다 추론해서 적절한 툴을 알아서 써서 진행해줘

이런 문장은 버튼 뒤에 숨은 작은 혼돈이다. 사용자는 "관련 메모 찾기"를 눌렀는데, 실제로는 반쯤 자율 에이전트가 깨어난다. UI가 붙을수록, 오히려 행동의 범위를 더 좁고 명시적으로 써야 한다.

UI는 모델과 경쟁하지 않는다

좋은 MCP App은 모델을 대체하지 않는다. 모델과 UI는 역할이 다르다.

UI는 사용자가 빠르게 선택하고, 비교하고, 확인하는 일을 맡는다. 모델은 그 선택을 해석하고 설명하고 다음 문장으로 이어붙이는 일을 맡는다. 즉, UI는 손이고 모델은 입이다. 둘이 같은 일을 하려고 들면 어색해진다.

그래서 노트 카드 UI에서도 욕심을 줄여야 한다. 메모 본문 전체를 브라우저 편집기처럼 다 때려 넣기보다, 먼저 카드형 요약 뷰를 만들고 정말 긴 편집이 필요할 때만 더 넓은 작업면으로 넘기는 편이 좋다. 실제 호스트들 가운데는 인라인 위젯과 더 넓은 side-by-side 작업면을 함께 제공하는 경우도 있으므로, 가벼운 카드 UI와 무거운 편집 UI를 구분해 설계하면 사용성이 좋아진다.5

17.3 보안 경계와 신뢰 모델

UI가 붙는 순간 기능은 강력해지지만, 그만큼 질문도 날카로워진다.

"이 HTML, 믿어도 되나?"

MCP Apps의 대답은 단순하다. 완전히 믿지 말고, 격리해서 다뤄라.

공식 문서가 강조하는 핵심은 샌드박스 iframe이다. MCP App은 보통 호스트가 관리하는 샌드박스 iframe 안에서 실행되며, 이 때문에 부모 페이지의 DOM에 접근하거나, 호스트의 쿠키나 로컬 스토리지를 읽거나, 마음대로 상위 페이지를 조작할 수 없다.1 다시 말해, UI는 예쁘게 보이더라도 사실은 투명한 유리상자 안에 들어 있다.

그리고 모든 통신은 postMessage를 통해 호스트를 거친다. 이 말은 매우 중요하다. 앱이 직접 서버를 휘젓는 것이 아니라, 호스트가 문지기 역할을 한다는 뜻이기 때문이다.1

이 신뢰 모델을 한 줄로 요약하면 이렇다.

서버는 UI를 제공할 수 있지만, 권한은 호스트가 쥐고, 최종 의도는 사용자가 결정한다.

이 구조를 PKM-MCP에 대입해보자.

당신의 메모는 안전해 보이지만, 사실 완전히 신뢰할 수 있는 데이터만 있는 것은 아니다. 웹에서 긁어온 문장, 회의록, 외부 문서 요약, 누군가가 붙여넣은 코드 블록이 들어 있다. 다시 말해 메모 본문 자체가 불신 데이터가 될 수 있다. 그렇다면 노트 카드 UI는 다음 원칙을 지켜야 한다.

  • 메모 내용에 숨어 있는 지시문을 그대로 실행하지 않는다.

  • 사용자가 누르지 않은 파괴적 도구를 자동 호출하지 않는다.

  • 외부 스크립트와 리소스는 필요한 출처만 허용한다.

  • 버튼 이름과 실제 행동이 다르면 안 된다.

특히 위험한 패턴은 "보이지 않는 sendPrompt"다. 예를 들어 노트를 열자마자 앱이 자동으로 후속 메시지를 보내거나, 사용자가 '요약' 버튼을 눌렀는데 실제로는 쓰기 도구를 연달아 호출해 노트를 바꾸는 식이다. 이런 행동은 기술적으로 가능할 수 있어도, 신뢰를 금방 잃는다.

공식 소개 글에서도, 앱이 보낼 수 있는 메시지와 도구 호출은 모두 감사 가능하고, 호스트가 필요하면 차단하거나 사용자 승인을 요구할 수 있어야 한다고 강조한다.3 보안은 마법이 아니라 가시성이다. 누가 무엇을 눌렀고, 어떤 도구가 어떤 인자로 호출되었는지가 보여야 한다.

결국 보안 경계는 네 겹으로 생각하면 이해가 쉽다.

  • 메모 데이터 경계: 메모 내용은 유용하지만 신뢰하지 않는다.

  • 앱 코드 경계: UI는 실행되지만 샌드박스 안에 가둔다.

  • 호스트 권한 경계: 어떤 기능을 허용할지는 호스트가 정한다.

  • 사용자 의도 경계: 실제 행동은 사용자가 이해하고 승인할 수 있어야 한다.

이 네 겹을 잊지 않으면, UI는 화려해져도 통제는 흐려지지 않는다.

만들기: PKM-MCP에 노트 카드 UI 추가하기

이제 PKM-MCP를 정말 앱처럼 느껴지게 만들어보자. 목표는 간단하다. 사용자가 메모 하나를 고르면, 그 메모를 대화 안에서 카드처럼 보여주고, 카드 위 버튼으로 후속 작업을 시작할 수 있게 만드는 것이다.

이번 구현은 8장에서 만든 TypeScript 버전의 PKM-MCP를 기준으로 설명하겠다. 이유는 단순하다. 현재 MCP Apps의 공식 SDK와 예제가 TypeScript 생태계에 가장 풍부하기 때문이다.2

완성 후 모습

우리가 만들 UI는 이런 카드다.

  • 메모 제목

  • 마지막 수정 시각

  • 태그 목록

  • 본문 미리보기

  • 버튼 세 개: 요약하기, 관련 메모 찾기, 이 메모로 회고 초안 만들기

이 정도면 작지만 충분히 제품스럽다. 메모가 갑자기 "대화 가능한 객체"로 바뀐다.

1단계: UI 전용 도구를 하나 만든다

기존 get_note나 search_notes를 그대로 재활용할 수도 있지만, UI를 붙일 때는 의도가 분명한 도구를 따로 두는 편이 좋다. 여기서는 show_note_card라는 도구를 만들겠다.

이 도구는 일을 많이 하지 않는다. 딱 하나의 메모를 읽고, 텍스트 설명과 구조화된 데이터를 함께 돌려준다. 포인트는 _meta.ui.resourceUri로 이 도구가 어떤 UI를 렌더링해야 하는지 알려주는 것이다.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAppTool, registerAppResource } from "@modelcontextprotocol/ext-apps/server";
import { createUIResource } from "@mcp-ui/server";
import { z } from "zod";

const server = new McpServer({
  name: "pkm-mcp",
  version: "0.17.0"
});

const noteCardUI = await createUIResource({
  uri: "ui://pkm/note-card",
  content: {
    type: "rawHtml",
    htmlString: NOTE_CARD_HTML
  },
  encoding: "text",
  metadata: {
    title: "PKM Note Card",
    description: "A compact interactive card for a single note"
  }
});

registerAppResource(
  server,
  "note_card_ui",
  noteCardUI.resource.uri,
  {},
  async () => ({
    contents: [noteCardUI.resource]
  })
);

registerAppTool(
  server,
  "show_note_card",
  {
    description: "Render one PKM note as an interactive card inside the chat. Use this when the user wants to inspect a note and continue with follow-up actions.",
    inputSchema: {
      note_id: z.string().describe("Stable note identifier")
    },
    _meta: {
      ui: {
        resourceUri: noteCardUI.resource.uri
      }
    }
  },
  async ({ note_id }) => {
    const note = await repo.getNoteById(note_id);

    return {
      content: [
        {
          type: "text",
          text: `메모 \"${note.title}\"를 카드로 표시합니다.`
        }
      ],
      structuredContent: {
        note: {
          id: note.id,
          title: note.title,
          updatedAt: note.updatedAt,
          tags: note.tags,
          preview: note.body.slice(0, 280),
          body: note.body
        }
      }
    };
  }
);

여기서 createUIResource는 손으로 HTML 리소스 객체를 조립할 때 생기는 자잘한 실수를 줄여주는 편의 함수다. ui:// URI, HTML MIME 타입, 메타데이터를 비교적 안전하게 포장해준다.4

2단계: 카드 HTML을 만든다

이제 NOTE_CARD_HTML에 들어갈 앱을 만든다. 복잡한 프레임워크가 꼭 필요한 것은 아니다. 노트 카드 정도는 바닐라 JS로도 충분하다.

핵심은 세 가지다.

  • 호스트에 연결한다.

  • 도구 결과를 받아 화면에 렌더링한다.

  • 버튼 클릭으로 후속 행동을 일으킨다.

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>PKM Note Card</title>
    <style>
      body {
        margin: 0;
        font-family: ui-sans-serif, system-ui, sans-serif;
        background: #0b1020;
        color: #eef2ff;
      }
      .card {
        padding: 16px;
        border-radius: 16px;
        background: linear-gradient(180deg, #121936, #0f1530);
      }
      .meta {
        color: #a5b4fc;
        font-size: 12px;
        margin-bottom: 8px;
      }
      .tags {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
        margin: 10px 0 14px;
      }
      .tag {
        background: #1e293b;
        padding: 4px 8px;
        border-radius: 999px;
        font-size: 12px;
      }
      .actions {
        display: flex;
        gap: 8px;
        flex-wrap: wrap;
        margin-top: 14px;
      }
      button {
        border: 0;
        border-radius: 10px;
        padding: 10px 12px;
        cursor: pointer;
      }
      #related {
        margin-top: 14px;
      }
    </style>
  </head>
  <body>
    <div class="card">
      <div class="meta" id="updatedAt"></div>
      <h2 id="title">노트를 불러오는 중...</h2>
      <div class="tags" id="tags"></div>
      <p id="preview"></p>

      <div class="actions">
        <button id="ask-summary">3줄 요약</button>
        <button id="find-related">관련 메모 찾기</button>
        <button id="draft-review">회고 초안 만들기</button>
      </div>

      <div id="related"></div>
    </div>

    <script type="module">
      import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps";

      const app = new App({ name: "pkm-note-card", version: "1.0.0" });
      let currentNote = null;

      const titleEl = document.getElementById("title");
      const updatedAtEl = document.getElementById("updatedAt");
      const tagsEl = document.getElementById("tags");
      const previewEl = document.getElementById("preview");
      const relatedEl = document.getElementById("related");

      function renderNote(note) {
        currentNote = note;
        titleEl.textContent = note.title;
        updatedAtEl.textContent = `마지막 수정: ${note.updatedAt}`;
        previewEl.textContent = note.preview;
        tagsEl.innerHTML = (note.tags || [])
          .map(tag => `<span class="tag">#${tag}</span>`)
          .join("");
      }

      function renderRelated(items) {
        relatedEl.innerHTML = `
          <h3>관련 메모</h3>
          <ul>
            ${items.map(item => `<li>${item.title}</li>`).join("")}
          </ul>
        `;
      }

      app.ontoolresult = (result) => {
        const note = result?.structuredContent?.note;
        if (note) renderNote(note);
      };

      document.getElementById("ask-summary").addEventListener("click", async () => {
        if (!currentNote) return;

        await app.sendMessage({
          role: "user",
          content: [
            {
              type: "text",
              text: `이 메모를 3줄로 요약하고 왜 중요한지 설명해줘. note_id=${currentNote.id}`
            }
          ]
        });
      });

      document.getElementById("find-related").addEventListener("click", async () => {
        if (!currentNote) return;

        const result = await app.callServerTool({
          name: "semantic_search_notes",
          arguments: {
            query: currentNote.title,
            limit: 5
          }
        });

        renderRelated(result?.structuredContent?.items || []);
      });

      document.getElementById("draft-review").addEventListener("click", async () => {
        if (!currentNote) return;

        await app.updateModelContext({
          content: [
            {
              type: "text",
              text: `사용자는 현재 \"${currentNote.title}\" 메모를 주간 회고의 핵심 문맥으로 보고 있다.`
            }
          ]
        });

        await app.sendMessage({
          role: "user",
          content: [
            {
              type: "text",
              text: `이 메모를 바탕으로 이번 주 회고 초안을 작성해줘. note_id=${currentNote.id}`
            }
          ]
        });
      });

      await app.connect();
    </script>
  </body>
</html>

이 예제에서 눈여겨볼 부분은 두 군데다.

첫째, app.ontoolresult다. 이 콜백은 도구 호출 결과를 받아 카드에 반영한다. 즉, 앱은 단순 HTML 조각이 아니라 도구 실행 결과를 먹고 자라는 뷰다.3

둘째, sendMessage와 updateModelContext다. 이 두 함수가 바로 우리가 말한 sendPrompt 패턴의 핵심이다. 버튼을 누르면 앱이 대화를 다음 단계로 넘긴다. 사용자는 더 이상 "이제 이걸 요약해줘"를 다시 타이핑하지 않아도 된다.3

3단계: 어떤 버튼을 둘지 신중하게 고른다

노트 카드에 버튼을 마구 늘어놓으면 곧 잡화점이 된다. 시작은 세 개면 충분하다.

  • 3줄 요약: 현재 메모를 빠르게 이해하게 해준다.

  • 관련 메모 찾기: PKM의 연결성을 체감하게 해준다.

  • 회고 초안 만들기: 검색에서 생성으로 넘어가는 순간을 만든다.

이 세 버튼이 좋은 이유는 역할이 서로 다르기 때문이다. 하나는 읽기, 하나는 탐색, 하나는 생성이다. 카드 한 장 안에서 PKM-MCP의 성격이 한눈에 드러난다.

4단계: 도구 설명도 UI 친화적으로 쓴다

도구에 UI가 붙으면 설명 문장도 달라져야 한다. 예전에는 모델이 "언제 이 도구를 써야 하는가"만 이해하면 됐다. 이제는 "이 도구를 쓰면 사용자에게 어떤 화면 경험이 열리는가"까지 드러나는 편이 좋다.

예를 들어 show_note_card 설명은 이렇게 쓰는 편이 낫다.

Show one note as an interactive card in chat. Use this when the user wants to inspect a specific note, click follow-up actions, or branch into related-note exploration.

이 설명은 모델에게 단순히 "노트 하나 읽기" 이상의 힌트를 준다. 결과가 카드형 UI라는 것, 후속 탐색이 가능하다는 것, 특정 노트를 자세히 볼 때 쓰라는 것이 모두 담겨 있다.

5단계: PKM 데이터는 믿지 말고 렌더링한다

여기서 보안 체크를 한 번 더 하자. 노트 본문에는 HTML처럼 보이는 문자열, 이상한 링크, 악의적인 지시문이 들어 있을 수 있다. 그러므로 카드 UI는 메모를 렌더링할 때 다음을 지키는 편이 안전하다.

  • 본문은 기본적으로 텍스트로 출력한다.

  • 정말 필요한 일부 마크다운만 제한적으로 렌더링한다.

  • 링크 열기는 호스트 허용 정책을 따른다.

  • 카드 로드 시 자동 도구 호출은 만들지 않는다.

특히 "카드를 열자마자 관련 메모 자동 검색" 같은 기능은 처음에는 멋져 보이지만, 실제로는 불필요한 호출과 예기치 않은 동작을 만든다. 인터랙션은 사용자가 눌렀을 때 시작되는 편이 더 예측 가능하다.

PKM-MCP가 앱으로 바뀌는 순간

이제 사용자가 이렇게 말한다고 해보자.

오늘 회의 메모 중에서 API 설계 관련 노트를 카드로 보여줘.

그러면 모델은 search_notes로 후보를 찾고, 적절한 메모 ID를 고른 뒤 show_note_card를 호출할 수 있다. 사용자는 카드에서 본문 일부를 읽고, "관련 메모 찾기"를 누르고, 그 결과를 보고, 다시 "회고 초안 만들기"를 누른다.

이 짧은 흐름 안에서 중요한 변화가 일어난다.

예전에는 사용자가 매 단계를 말로 설명해야 했다. 이제는 UI가 의도를 좁혀주고, 모델은 그 좁혀진 의도를 더 똑똑하게 이어받는다. 대화는 줄었지만, 오히려 상호작용은 풍부해졌다.

이것이 MCP Apps의 진짜 가치다. 텍스트를 버리는 것이 아니라, 텍스트가 가장 잘하는 일과 UI가 가장 잘하는 일을 분업시키는 것이다.

실전에서 꼭 기억할 설계 원칙

마지막으로, PKM-MCP에 UI를 붙일 때 오래 살아남는 원칙만 추려보자.

첫째, 작게 시작하라. 처음부터 메모 편집기 전체를 넣지 말고 카드 하나부터 붙여라. 카드가 잘 작동하면 목록, 비교 뷰, 주간 회고 패널 같은 다음 화면으로 확장하면 된다.

둘째, 버튼은 문장이어야 한다. 사용자가 버튼 이름만 보고도 무슨 일이 일어날지 예측할 수 있어야 한다. "실행", "진행", "분석" 같은 추상적인 말은 피하라.

셋째, sendPrompt는 짧고 구체적으로 써라. 버튼 하나가 자율 에이전트의 입구가 되면 사용자는 불안해진다. 후속 메시지는 사용자가 직접 말했어도 어색하지 않을 정도로 또렷해야 한다.

넷째, 도구 호출과 대화 유도를 구분하라. 어떤 버튼은 서버 도구를 바로 부르는 것이 맞고, 어떤 버튼은 모델에게 후속 설명을 요청하는 것이 맞다. 이 둘을 섞어 쓰되, 사용자는 그 차이를 이해할 수 있어야 한다.

다섯째, 호스트를 보안 경계로 존중하라. UI가 할 수 있는 모든 일은 호스트가 허락한 범위 안에서만 해야 한다. 샌드박스, 승인, 감사 로그는 사용성을 방해하는 장치가 아니라, 제품이 오래 버티게 만드는 장치다.13

마무리

MCP Apps는 MCP 서버에 단순히 화면을 붙이는 기능이 아니다. 서버가 사용자와 만나는 방식을 바꾸는 확장이다.

PKM-MCP는 이 장 전까지 똑똑한 도구 상자에 가까웠다. 검색도 하고, CRUD도 하고, RAG도 하고, 인증도 하고, 배포도 했다. 하지만 이제는 거기에 얼굴이 생긴다. 사용자는 메모를 카드로 보고, 버튼을 누르고, 바로 다음 생각으로 넘어간다.

바로 그 순간, MCP 서버는 백엔드가 아니라 제품이 된다.

다음 장에서는 이렇게 만든 서버를 자기 컴퓨터 안에만 두지 않고, 다른 사람도 발견하고 연결할 수 있게 세상 밖으로 내보내는 방법을 살펴본다. 좋은 도구를 만드는 것 다음에는, 좋은 소개와 배포가 기다리고 있다.

참고

1MCP Apps - Model Context Protocol

2GitHub - modelcontextprotocol/ext-apps: Official repo for spec & SDK of MCP Apps protocol - standard for UIs embedded AI chatbots, served by MCP servers

3MCP Apps - Bringing UI Capabilities To MCP Clients

4@mcp-ui/server Usage & Examples

5MCP Apps now available in Copilot chat

책 목록

만들면서 배우는 MCP: PKM-MCP로 끝내는 설계·구현·운영·배포

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기