메인 콘텐츠로 건너뛰기

MCP App 퀵스타트: Tool + UI로 만드는 첫 MCP 애플리케이션

wislan
wislan
조회수 13
요약

클립으로 정리됨 (생성형 AI 도구 활용)

출처 및 참고 : https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html

핵심 요약

MCP App은 "서버의 Tool"과 "iframe 안에서 뜨는 UI(View)"를 한 세트로 묶어 쓰는 형태입니다.
이 튜토리얼은 Node/TypeScript + MCP SDK + Vite로, 서버 시간을 보여주는 아주 단순한 MCP App을 만드는 전 과정을 설명합니다.
스타트업 환경에서는 이 패턴을 익혀두면, LLM 안에 붙는 작은 유틸리티·대시보드를 빠르게 실험하는 데 바로 써먹을 수 있습니다.

MCP App 개념: Tool + UI Resource

MCP App은 크게 두 조각으로 이해하면 됩니다.

첫 번째는 기존 MCP 서버에서 쓰던 것과 같은 Tool입니다.
LLM이 호출할 수 있는 함수 같은 개념이고, 여기서는 get-time이라는 이름의 Tool이 현재 서버 시간을 문자열로 반환합니다.

두 번째는 UI Resource입니다.
Tool이 반환하는 데이터나 추가 액션을 사용자가 직접 보고 조작할 수 있는 HTML 기반 화면으로, MCP 호스트(예: Claude Desktop) 안에서 iframe처럼 렌더링됩니다.

이 둘은 Tool 메타데이터의 _meta.ui.resourceUri로 연결됩니다.
호스트는 Tool을 호출하면서 이 URI를 보고 어떤 HTML을 불러와서 화면에 띄울지 결정합니다.

즉, "LLM이 부르는 서버 로직 + 그 결과를 사용자에게 보여주는 작은 웹앱"을 한 번에 제공하는 구조가 MCP App입니다.

프로젝트 셋업: 최소한의 TypeScript + Vite 구조

프로젝트는 크게 서버 코드와 UI 빌드 두 부분으로 나뉩니다.

Node 18+와 TypeScript를 전제로, MCP TypeScript SDK와 MCP Apps 확장 패키지, Express, Vite 등을 설치합니다.
package.json의 스크립트는 대략 다음 두 가지 흐름으로 구성됩니다.

하나는 빌드입니다. TypeScript 타입 체크를 하고, 서버용 타입 선언을 뽑은 뒤, Vite로 UI HTML을 하나의 파일로 번들링합니다.

npm pkg set scripts.build="tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build"

다른 하나는 개발용 스타트입니다.
Vite는 UI를 watch 모드로 빌드하고, tsx는 서버 코드를 watch 모드로 돌려줍니다.

npm pkg set scripts.start="concurrently 'cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch' 'tsx watch main.ts'"

TypeScript는 브라우저용 코드(srcmcp-app.ts)와 서버용 코드(server.ts, main.ts)를 서로 다른 설정으로 관리합니다.
tsconfig.json은 프론트 쪽, tsconfig.server.json은 Node 서버 쪽을 위한 설정입니다.

Vite 설정(vite.config.ts)에서는 vite-plugin-singlefile을 사용해 HTML, JS, CSS를 하나의 dist/mcp-app.html로 묶어 MCP Resource에 그대로 실어 보낼 수 있게 만듭니다.

MCP Apps의 핵심 연결: Tool과 UI Resource 등록

서버 코드의 핵심은 "Tool 등록 + UI Resource 등록"입니다.

server.ts에서 McpServer 인스턴스를 만들고, @modelcontextprotocol/ext-apps/server가 제공하는 두 함수를 사용합니다.

  • registerAppTool

  • registerAppResource

먼저 Resource URI를 하나 정합니다. 여기서는:

const resourceUri = "ui://get-time/mcp-app.html";

그 다음, Tool을 등록할 때 _meta.ui.resourceUri로 이 URI를 지정합니다.

registerAppTool(
  server,
  "get-time",
  {
    title: "Get Time",
    description: "Returns the current server time.",
    inputSchema: {},
    _meta: { ui: { resourceUri } },
  },
  async () => {
    const time = new Date().toISOString();
    return { content: [{ type: "text", text: time }] };
  },
);

이제 MCP 호스트는 get-time Tool을 호출하면, _meta.ui.resourceUri를 보고 "아, 이 Tool은 UI가 있구나"라고 인식하고, 해당 URI를 가진 Resource를 다시 MCP 서버에서 요청해 View를 iframe에 렌더링합니다.

Resource 쪽은 실제 HTML을 돌려주는 부분입니다.

registerAppResource(
  server,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => {
    const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
    return {
      contents: [
        { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
      ],
    };
  },
);

요약하면, URI 하나로 Tool과 HTML UI를 잇는 "관계키"를 만드는 구조입니다.

서버 실행 구조: HTTP와 stdio 두 가지 모드

main.ts는 MCP 서버를 어떻게 호스트에 노출할지 결정하는 진입점입니다.

하나는 HTTP 기반의 Streamable 모드입니다.
Express 앱을 만들고 /mcp 엔드포인트에서 MCP 요청을 받으면, 매 요청마다 새로운 McpServer 인스턴스를 생성해 StreamableHTTPServerTransport로 연결합니다.

app.all("/mcp", async (req, res) => {
  const server = createServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });

  res.on("close", () => {
    transport.close().catch(() => {});
    server.close().catch(() => {});
  });

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

이 방식은 HTTP로 붙는 실험용 호스트(예: 예제 basic-host)와 연동할 때 편합니다.

다른 하나는 StdioServerTransport입니다.
--stdio 플래그로 실행하면 stdin/stdout 기반으로 동작해, 데스크톱 클라이언트처럼 프로세스 직접 실행하는 호스트와 연결하는 데 사용됩니다.

실행 모드는 매우 단순합니다.

if (process.argv.includes("--stdio")) {
  await startStdioServer(createServer);
} else {
  await startStreamableHTTPServer(createServer);
}

스타트업 입장에서 보면, HTTP 모드는 웹 기반 실험, stdio 모드는 데스크톱 클라이언트 연동에 해당한다고 생각하면 이해가 쉽습니다.

View 구현: 호스트와 통신하는 작은 웹앱

UI는 그냥 "일반 웹페이지 + MCP Apps 클라이언트"입니다.

mcp-app.html은 단순한 HTML입니다. 서버 시간을 보여주는 <code> 태그와 버튼 하나가 전부고, 실제 로직은 /src/mcp-app.ts에서 처리합니다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Get Time App</title>
  </head>
  <body>
    <p>
      <strong>Server Time:</strong> <code id="server-time">Loading...</code>
    </p>
    <button id="get-time-btn">Get Server Time</button>
    <script type="module" src="/src/mcp-app.ts"></script>
  </body>
</html>

src/mcp-app.ts에서는 @modelcontextprotocol/ext-apps가 제공하는 App 클래스로 호스트와의 연결을 관리합니다.

import { App } from "@modelcontextprotocol/ext-apps";

const serverTimeEl = document.getElementById("server-time")!;
const getTimeBtn = document.getElementById("get-time-btn")!;

const app = new App({ name: "Get Time App", version: "1.0.0" });

app.ontoolresult = (result) => {
  const time = result.content?.find((c) => c.type === "text")?.text;
  serverTimeEl.textContent = time ?? "[ERROR]";
};

getTimeBtn.addEventListener("click", async () => {
  const result = await app.callServerTool({ name: "get-time", arguments: {} });
  const time = result.content?.find((c) => c.type === "text")?.text;
  serverTimeEl.textContent = time ?? "[ERROR]";
});

app.connect();

핵심 포인트는 두 가지입니다.

첫째, app.ontoolresult는 호스트가 Tool을 실행한 결과를 UI 쪽으로 밀어줄 때 호출되는 핸들러입니다.
이 덕분에 사용자가 MCP 호스트에서 Tool을 호출하면, UI는 별도 요청 없이도 최초 결과를 받을 수 있습니다.

둘째, app.callServerTool은 UI가 능동적으로 Tool 호출을 트리거하는 기능입니다.
여기서는 "Get Server Time" 버튼 클릭 시 직접 서버에 새 시간을 요청하는 용도로 쓰입니다.

이 패턴을 확장하면, UI에서 필터 입력 → Tool 호출 → 결과 목록 갱신 같은 간단한 대시보드를 얼마든지 만들 수 있습니다.

실행 흐름: 로컬에서 동작 확인하기

동작 확인은 크게 두 단계입니다.

먼저, MCP App 서버를 띄웁니다.

npm run build
npm start

정상이라면 다음과 같은 메시지를 볼 수 있습니다.

MCP server listening on http://localhost:3001/mcp

다음으로, 테스트용 MCP 호스트를 띄웁니다.
공식 예제 리포지토리에서 basic-host를 실행합니다.

git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps/examples/basic-host
npm install
npm start

브라우저에서 http://localhost:8080에 접속하면, Tool 선택 드롭다운이 있는 간단한 테스트 화면이 나옵니다.

  1. Tool Name에서 get-time 선택

  2. "Call Tool" 클릭 → 하단 sandbox에 UI가 뜸

  3. "Get Server Time" 버튼을 눌러 현재 시간 표시 확인

이 과정을 통해 Tool–UI–호스트 간 데이터 흐름을 한 번에 체감할 수 있습니다.

MCP App 동작 예시 스크린샷

이 구조를 한 번 이해해 두면, 같은 패턴으로 다양한 내부용 툴을 빠르게 붙일 수 있습니다.

확장 아이디어와 실전 활용 인사이트

이 예제는 단순히 "시간 한 줄 보여주기"지만, 패턴 자체가 중요합니다.

스타트업 관점에서 MCP App은 "LLM 기반 워크플로우 안에 들어가는 미니 웹앱"이라고 보면 됩니다.
예를 들어, CRM 요약 결과를 보여주고 필터링하는 뷰, 로그 검색 결과를 타임라인으로 시각화하는 뷰, 간단한 재무 지표 대시보드 등을 MCP Tool + View 조합으로 구현할 수 있습니다.

실전에서 쓸 때는 다음 정도를 고려하면 좋습니다.

UI 프레임워크는 자유입니다. 이 튜토리얼은 바닐라 JS를 쓰지만, 공식 예제로 React, Vue, Svelte, Preact, Solid 버전도 있습니다. 팀 내 익숙한 스택으로 View만 교체해도 기본 패턴은 동일합니다.

Tool 설계는 "LLM이 쓰기 좋은 API" 관점에서, View 설계는 "사람이 쓰기 좋은 UX" 관점에서 나누어 생각하세요. MCP App은 이 둘을 느슨하게 연결해주는 역할입니다.

초기에는 이 튜토리얼 수준으로 아주 작은 기능 하나만 붙인 뒤, 실제 팀에서 얼마나 쓰이는지 보고 점진적으로 확장하는 것이 좋습니다.
MVP를 빨리 돌려보는 데 MCP App 구조가 꽤 적합합니다.

출처 및 참고 : Quickstart | @modelcontextprotocol/ext-apps - v1.0.1

#MCP App#TypeScript#Node.js#Vite#LLM

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