Skip to main content
Summary

먼저 성공 경로를 작게 정한다

첫 번째 앱의 목표는 멋진 기능이 아니다. 사용자가 자연어로 요청하고, ChatGPT가 MCP 도구를 고르고, 서버가 결과를 반환하고, 위젯이 대화 안에 렌더링되는 흐름을 한 번 끝까지 통과하는 것이다.

기능은 작을수록 좋다. 공식 예제 갤러리도 피자 가게, 쇼핑 카트, 주방 싱크, 태양계처럼 한 가지 경험을 분명히 보여 주는 작은 앱들로 구성되어 있다. 예제 저장소는 MCP 서버가 도구를 노출하고, 도구 결과와 메타데이터를 함께 반환해 ChatGPT 안에서 위젯을 표시하는 방식을 보여 준다1.

여기서는 할 일 목록 보기 앱을 만든다고 가정하자. 외부 API도, 로그인도, 데이터베이스도 없다. 서버 안에 임시 데이터를 두고 사용자의 요청에 맞춰 목록을 반환한다.

최소 성공 경로는 다음과 같다.

  1. 사용자가 "오늘 할 일 보여줘"라고 말한다.

  2. ChatGPT가 show_todos 도구를 호출한다.

  3. MCP 서버가 할 일 데이터를 만든다.

  4. 서버는 모델이 읽을 content와 위젯이 사용할 structuredContent를 함께 반환한다.

  5. ChatGPT는 연결된 HTML 위젯을 대화 안에 표시한다.

이 경로가 성공하면 Apps SDK의 큰 그림은 이미 잡힌 것이다. 이후에 인증, 상태 저장, React UI, 외부 API를 붙이는 일은 이 구조 위에 하나씩 추가하면 된다.

첫 번째 ChatGPT 앱 만들기

도구 계약부터 작성한다

MCP 서버의 도구는 단순한 함수가 아니다. 모델이 언제 이 도구를 써야 하는지 판단할 수 있도록 설명이 붙은 계약이다. MCP 서버는 대화 중 모델이 호출할 수 있는 도구를 노출하고, 정해진 파라미터에 따라 결과를 반환한다2.

show_todos 도구의 계약은 다음처럼 잡을 수 있다.

const TODO_WIDGET_URI = "ui://todo-mini/todo-list.html";

server.registerTool(
  "show_todos",
  {
    title: "할 일 목록 보기",
    description:
      "사용자가 오늘 할 일, 남은 일, 완료된 일을 보고 싶어 할 때 할 일 목록을 반환합니다.",
    inputSchema: {
      type: "object",
      properties: {
        filter: {
          type: "string",
          enum: ["all", "active", "done"],
          description: "표시할 할 일 범위입니다. 기본값은 all입니다."
        }
      },
      required: []
    },
    outputSchema: {
      type: "object",
      properties: {
        todos: {
          type: "array",
          items: {
            type: "object",
            properties: {
              id: { type: "string" },
              title: { type: "string" },
              done: { type: "boolean" }
            },
            required: ["id", "title", "done"]
          }
        }
      },
      required: ["todos"]
    },
    annotations: {
      readOnlyHint: true
    },
    _meta: {
      "openai/outputTemplate": TODO_WIDGET_URI,
      "openai/toolInvocation/invoking": "할 일을 불러오는 중…",
      "openai/toolInvocation/invoked": "할 일을 불러왔습니다."
    }
  },
  async ({ filter = "all" }) => {
    return buildTodoResult(filter);
  }
);

여기서 중요한 값은 네 가지다.

name인 show_todos는 서버 내부와 모델 호출에서 쓰이는 안정적인 식별자다. 짧고 동사 중심으로 짓는 편이 좋다.

title과 description은 사람뿐 아니라 모델을 위한 설명이다. "할 일 목록 보기"처럼 기능이 명확해야 하고, description에는 호출 조건을 자연어로 적는다.

inputSchema는 모델이 어떤 인자를 넣어야 하는지 알려 준다. 스키마가 모호하면 모델은 엉뚱한 값을 넣거나 도구 호출을 피할 수 있다.

_meta["openai/outputTemplate"]는 도구 결과를 어떤 위젯 리소스와 연결할지 알려 주는 값이다. Apps SDK 계열 통합에서는 도구 descriptor의 이 메타데이터를 통해 ChatGPT가 렌더링할 UI 템플릿을 찾는다3.

서버는 두 종류의 결과를 돌려준다

도구 핸들러는 사용자의 요청을 실제 데이터로 바꾸는 곳이다. 첫 앱에서는 서버 안의 배열만 사용하자.

const TODOS = [
  { id: "1", title: "Apps SDK 예제 실행하기", done: true },
  { id: "2", title: "첫 번째 MCP 도구 만들기", done: false },
  { id: "3", title: "위젯 리소스 연결 확인하기", done: false }
];

function buildTodoResult(filter: "all" | "active" | "done") {
  const todos = TODOS.filter((todo) => {
    if (filter === "active") return !todo.done;
    if (filter === "done") return todo.done;
    return true;
  });

  const activeCount = todos.filter((todo) => !todo.done).length;

  return {
    content: [
      {
        type: "text",
        text: `할 일 ${todos.length}개를 찾았습니다. 이 중 ${activeCount}개가 아직 남아 있습니다.`
      }
    ],
    structuredContent: {
      todos,
      filter,
      summary: {
        total: todos.length,
        active: activeCount
      }
    }
  };
}

content는 모델과 사용자에게 설명하기 좋은 텍스트다. ChatGPT는 이 값을 읽고 "아직 남은 일이 2개입니다"처럼 자연스러운 응답을 만들 수 있다.

structuredContent는 위젯이 안정적으로 읽을 데이터다. UI는 문장을 파싱하면 안 된다. 문장은 사람을 위한 것이고, 객체는 프로그램을 위한 것이다. 위젯은 structuredContent.todos를 보고 체크박스 목록을 그리면 된다.

이 구분은 작지만 중요하다. 처음부터 텍스트와 구조화 데이터를 분리하면 나중에 React 컴포넌트, 정렬, 필터, 상태 저장을 붙여도 서버 응답 형태가 흔들리지 않는다.

HTML 위젯을 리소스로 등록한다

이제 도구 결과를 보여 줄 HTML을 만든다. 첫 번째 위젯은 순수 HTML과 JavaScript로 충분하다. React는 다음 단계에서 붙여도 된다.

<div class="card">
  <h2>할 일 목록</h2>
  <p id="summary"></p>
  <ul id="todos"></ul>
</div>

<script type="module">
  const host = window.openai;
  const output = host?.toolOutput?.structuredContent ?? host?.toolOutput ?? {};
  const todos = output.todos ?? [];

  document.getElementById("summary").textContent =
    `총 ${output.summary?.total ?? todos.length}개 중 ` +
    `${output.summary?.active ?? 0}개가 남아 있습니다.`;

  const list = document.getElementById("todos");
  for (const todo of todos) {
    const item = document.createElement("li");
    item.textContent = `${todo.done ? "✅" : "⬜"} ${todo.title}`;
    list.appendChild(item);
  }
</script>

위젯은 ChatGPT가 제공하는 호스트 환경 안에서 실행된다. 예제 갤러리의 Kitchen Sink 계열 샘플은 위젯이 window.openai를 통해 toolInput, toolOutput, displayMode, theme, widgetState 같은 호스트 상태를 읽고, 필요하면 다시 도구를 호출하는 흐름을 보여 준다1. 첫 앱에서는 읽기만 사용한다.

서버에서는 이 HTML을 리소스로 등록한다.

server.registerResource(
  "todo-list-widget",
  TODO_WIDGET_URI,
  {},
  async () => ({
    contents: [
      {
        uri: TODO_WIDGET_URI,
        mimeType: "text/html+skybridge",
        text: todoWidgetHtml,
        _meta: {
          "openai/widgetDescription": "할 일 목록을 카드 형태로 보여줍니다.",
          "openai/widgetPrefersBorder": true
        }
      }
    ]
  })
);

흐름은 단순하다. 도구 descriptor는 TODO_WIDGET_URI를 가리킨다. ChatGPT는 도구 호출 결과를 받은 뒤 이 URI의 리소스를 가져와 위젯으로 렌더링한다. 위젯은 호스트가 전달한 toolOutput을 읽고 화면을 구성한다.

로컬 서버를 ChatGPT에 연결한다

서버가 /mcp 엔드포인트로 실행된다고 하자.

pnpm dev
# 또는
node dist/server.js

로컬에서 http://localhost:8000/mcp가 열린다면 다음 단계는 ChatGPT가 접근할 수 있는 HTTPS 주소를 만드는 것이다. 공식 예제 저장소도 로컬 MCP 서버를 ChatGPT에 추가할 때 ngrok 같은 터널 도구를 사용할 수 있다고 안내한다1.

ngrok http 8000

ngrok이 다음과 같은 주소를 준다고 가정하자.

https://todo-mini-example.ngrok-free.app

ChatGPT 개발자 모드에서 Connectors 설정으로 이동해 MCP 서버 주소를 등록한다.

https://todo-mini-example.ngrok-free.app/mcp

Python MCP 서버를 터널로 노출하는 경우에는 DNS rebinding 보호 때문에 허용 호스트와 오리진을 환경 변수로 지정해야 할 수 있다. 예제 저장소는 MCP_ALLOWED_HOSTS와 MCP_ALLOWED_ORIGINS를 터널 호스트에 맞춰 설정하라고 안내한다1. Node 서버라도 프레임워크나 보안 미들웨어가 호스트 검사를 한다면 같은 관점으로 확인해야 한다.

첫 실행은 로그로 검증한다

ChatGPT 대화에서 앱을 선택한 뒤 다음처럼 요청한다.

오늘 할 일 보여줘.

또는 앱 이름을 직접 부른다.

Todo Mini로 아직 남은 할 일만 보여줘.

서버 로그에는 최소한 다음 세 가지가 보여야 한다.

[list_tools] show_todos advertised
[call_tool] show_todos { "filter": "active" }
[tool_result] todos=2 widget=ui://todo-mini/todo-list.html

문제가 생기면 먼저 응답 형태를 확인한다. structuredContent가 객체인지, todos가 배열인지, _meta["openai/outputTemplate"]의 URI와 등록한 리소스 URI가 같은지 본다. 위젯이 비어 있다면 서버보다 HTML에서 window.openai.toolOutput을 어떻게 읽는지 확인한다. 도구는 호출됐는데 UI가 안 뜬다면 대부분 리소스 URI, MIME 타입, 메타데이터 연결 문제다.

첫 앱에서 중요한 것은 기능의 양이 아니다. 자연어 요청이 도구 호출로 바뀌고, 서버 결과가 구조화 데이터로 정리되고, 그 데이터가 ChatGPT 안의 위젯으로 보이는 한 바퀴를 완성하는 것이다. 이 한 바퀴가 Apps SDK 개발의 기본 루프다.

참고

1openai/openai-apps-sdk-examples README

2MCP Server - Apps SDK | OpenAI Developers

3OpenAI Apps SDK Integration

책 목록

ChatGPT 앱을 만드는 법: OpenAI Apps SDK 실전 가이드

전체 목차 보기

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

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

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

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

참여하기