Skip to main content
Glama
eomgerm

gempack-shop

by eomgerm
README.md
# 겜팩샵 — WebMCP + MCP 데모

닌텐도 스위치 게임팩 쇼핑몰. **AI 에이전트가 웹 애플리케이션을 조작하는 두 가지 방식**을
한 프로젝트에서 나란히 구현해 비교합니다.

- **WebMCP** — 페이지가 `document.modelContext` 와 HTML 속성으로 툴을 노출. 브라우저 내장 에이전트,
  같은 트리의 동일 출처 문서, `exposedTo` 로 허용한 iframe 에이전트가 호출
- **MCP** — 서버가 `/mcp` 엔드포인트로 툴을 노출. 외부 클라이언트(Claude Code 등)가 호출

두 경로와 사람의 UI 조작이 **같은 서버 상태를 공유**하므로, 어느 쪽에서 바꿔도 열려 있는 모든 브라우저가 즉시 따라옵니다.

빌드 단계 없음. 프레임워크 없음. 의존성은 MCP SDK 와 zod 둘.

---

## 빠른 시작

```bash
npm install
npm start        # http://localhost:4173
npm test         # 로직 + 서버 상태 검증
```

브라우저에서 바로 쇼핑할 수 있습니다 — 검색, 발매일 범위 필터, 5가지 정렬, 장바구니, 데모 결제.

### 외부 MCP 클라이언트 연결

```bash
claude mcp add --transport http gempack-shop http://localhost:4173/mcp
```

연결하면 에이전트가 상품을 검색하고 장바구니를 조작할 수 있습니다.
그때마다 열려 있는 브라우저 화면의 해당 영역이 빨간 테두리로 표시되고 토스트가 뜹니다.

### 페이지 내 WebMCP 활성화

WebMCP 는 아직 Chrome 플래그 뒤에 있습니다.

1. Chrome 149+ 에서 `chrome://flags/#enable-webmcp-testing` → **Enabled** → 재시작
2. `http://localhost:4173` 접속 (localhost 는 SecureContext 로 인정되어 https 불필요)
3. 우측 상단 배지가 `WebMCP 연결됨` 이면 준비 완료

미지원 브라우저에서도 쇼핑몰과 서버측 MCP 는 정상 동작합니다. 선언형 HTML 속성은 그냥 무시됩니다.

---

## 아키텍처

```
        사람이 UI 조작 ─────┐
                            │
  WebMCP 폼 제출 ───────────┼──▶  state.js  ──SSE──▶  열려 있는 모든 브라우저
  (브라우저 내장 에이전트)   │   (서버 단일 상태)
                            │
  MCP tools/call ───────────┘
  (외부 에이전트, /mcp)
```

`state.js` 의 `apply(action, args, by)` 가 **유일한 변경 진입점**입니다. `by` 가 출처를 구분합니다.

| `by` | 출처 | 하이라이트 |
|---|---|---|
| `human` | 사람이 버튼 클릭 | 없음 |
| `webmcp` | 에이전트가 채운 폼을 사람이 제출 | 제출 전에 `:tool-form-active` 가 이미 표시 |
| `mcp` | 외부 MCP 클라이언트 | 변경된 영역에 `.mcp-touched` + 토스트 브로드캐스트 |

브라우저는 필터·정렬을 직접 계산하지 않습니다. 서버가 완성된 스냅샷을 SSE 로 밀어주고 화면은 그것을 렌더링만 합니다.

### 하이라이트

AI 가 건드린 자리를 눈에 보이게 하는 것이 이 데모의 핵심입니다. 두 경로가 서로 다른 수단을 씁니다.

**WebMCP** 는 브라우저가 상태를 CSS 의사클래스로 노출하므로 스타일만 덮어씁니다.

```css
form:tool-form-active { outline: 3px solid #e60012; }   /* 에이전트가 채운 폼 */
:tool-submit-active   { animation: aiPulse … }          /* 그 폼의 제출 버튼 */
```

`toolautosubmit` 을 **의도적으로 쓰지 않습니다.** 그래서 에이전트가 툴을 호출하면 폼이 채워진 채
하이라이트되고 멈추며, 사람이 제출 버튼을 눌러야 실행됩니다. 그 시점에 `SubmitEvent.respondWith()`
가 결과를 에이전트에게 돌려줍니다. 결제를 AI 가 단독으로 완료할 수 없는 것이 설계 의도입니다.

**MCP** 는 의사클래스가 없으므로 서버가 변경 영역 이름(`filters`, `list`, `cart`, `order`, `game-<id>`)을
스냅샷에 담아 보내고, 브라우저가 3.4초간 클래스를 붙입니다.

### 코드를 수정할 때 주의할 점

WebMCP 선언형 API 의 세 가지 제약이 이 코드의 구조를 결정했습니다.

1. **스키마는 폼이 DOM 에 삽입되는 순간 합성됩니다.** `<select>` 를 JS 로 채우므로, HTML 에
   `toolname` 을 두면 빈 select 로 합성되어 `enum` 이 비어버립니다. 그래서 `data-toolname` 으로
   두고 옵션과 리스너를 모두 붙인 뒤 마지막에 `toolname` 을 답니다.
2. **`respondWith()` 는 이벤트 디스패치 중 동기적으로 호출해야 합니다.** `await` 뒤로 밀리면
   invocation 이 실패합니다. 인자로 프로미스를 받으므로 핸들러는 동기로 두고 프로미스를 넘깁니다.
3. **`form.reset()` 은 실행 중인 invocation 을 취소합니다.** 성공 메시지를 넘긴 뒤 폼을 정리하려
   `reset()` 을 부르면 에이전트는 결과 대신 취소를 받습니다. 그래서 어느 핸들러도 `reset()` 을 부르지 않습니다.

추가로 `<option disabled>` 은 쓰지 않습니다. HTML 스펙상 "선택됐지만 disabled 인 option" 은
FormData 에서 제외되므로, 품절 상품을 그렇게 막으면 파라미터가 `undefined` 로 도착합니다.
품절은 제출을 받고 애플리케이션 로직이 이유를 돌려주는 방식으로 처리합니다.

---

## 툴

### 서버측 MCP (`/mcp`)

| 툴 | 힌트 | 설명 |
|---|---|---|
| `list_games` | readOnly | 게임팩 18종의 id·제목·발매일·가격·재고·장르 |
| `search_games` | idempotent | 이름 부분일치 + 발매일 범위 + 정렬. 빈 문자열로 조건 해제 |
| `view_cart` | readOnly | 장바구니 항목과 합계 |
| `set_cart_item` | idempotent | 수량 지정. `0` 이면 제거. 담기·변경·제거를 하나로 처리 |
| `checkout` | **destructive** | 데모 결제. 주문 생성 후 장바구니 비움 |

입력은 zod 로 검증합니다. `gameId` 는 18개 id 의 enum 이고, 잘못된 값은 JSON-RPC `-32602` 로 막힙니다.
재고 초과나 빈 장바구니 같은 비즈니스 오류는 `isError: true` 로 돌려주므로 에이전트가 이유를 읽고 재시도할 수 있습니다.

### 페이지 내 WebMCP

| 툴 | 방식 | 하이라이트 |
|---|---|---|
| `search_games` | 선언형 (`<form toolname>`) | 검색 폼 |
| `set_cart_item` | 선언형 | 장바구니 폼 |
| `checkout` | 선언형 | 결제 폼 |
| `list_games` | 명령형 (`registerTool`) | — (읽기 전용) |
| `view_cart` | 명령형 | — (읽기 전용) |

선언형 툴의 JSON Schema 는 브라우저가 HTML 에서 합성합니다. `toolparamdescription` 이 설명이 되고,
`type="date"` 는 `format: "date"` 로, `<select>` 옵션은 `enum` 으로, `min`/`max`/`step` 은
`minimum`/`maximum`/`multipleOf` 로, `required` 는 `required` 배열로 변환됩니다.

---

## 프로젝트 구조

```
shop.js       게임팩 18종 + 순수 로직 (필터·정렬·장바구니·결제 계산)
state.js      서버 단일 상태 + 액션 + 구독. 모든 변경이 여기를 지난다
mcp.js        MCP SDK 툴 정의, Streamable HTTP 트랜스포트
server.js     정적 파일 + /events(SSE) + /action + /mcp
index.html    UI 전체 + WebMCP 툴 등록 (Bootstrap 5 · Bootstrap Icons, CDN)
covers/       게임팩 패키지 이미지 18장
test.js       node test.js
```

`shop.js` 는 순수 함수라 서버와 테스트가 함께 씁니다. 브라우저는 렌더링에 필요한 만큼만 가져다 씁니다.

---

## 보안

MCP Streamable HTTP 스펙의 요구사항을 구현했습니다.

- **Origin 헤더 검증** — DNS 리바인딩 공격 차단. 허용 목록 외 Origin 은 403
- **루프백 바인딩** — `127.0.0.1` 만 LISTEN. 모든 인터페이스에 노출하지 않음 (`HOST` 환경변수로 변경 가능)
- **입력 검증** — zod 스키마
- **레이트 리밋** — 툴 호출 60회 / 10초

정적 파일 서빙은 경로 탈출을 차단하고, 요청 본문은 1MB 로 제한합니다.
결제는 데모이므로 카드번호 입력 필드를 아예 만들지 않았습니다.

---

## 테스트

```bash
npm test
```

필터·정렬 경계, 재고 초과 거절 시 장바구니 불변성, 결제 검증, `by` 별 하이라이트 규칙,
커버 이미지 존재 여부를 확인합니다.

에이전트 없이 WebMCP 툴을 직접 실행해보려면 **DevTools → Application → WebMCP** 패널을 쓰면 됩니다.
툴 목록과 합성된 스키마를 보고, 파라미터를 입력해 `Run tool` 로 실행하고, 호출 로그를 확인할 수 있습니다.

---

## 제약

이 프로젝트는 프로토콜 비교를 위한 데모입니다.

- **결제는 가짜입니다.** PG 연동 없음, 실제 청구 없음, 가짜 주문번호만 발급
- **재고가 차감되지 않습니다.** `GAMES` 는 상수이고 결제가 재고를 건드리지 않습니다
- **상태는 프로세스 메모리에 전역 1개.** 서버를 재시작하면 초기화되고, 사용자 구분이 없어 모든 탭이 같은 장바구니를 봅니다
- **WebMCP 는 W3C Community Group Draft** 이며 Chrome 플래그 뒤에 있습니다. API 가 바뀔 수 있습니다
  (`navigator.modelContext` 는 Chrome 150 에서 deprecated → 이 프로젝트는 `document.modelContext` 사용)

---

## 왜 MCP 엔드포인트를 함께 두는가

WebMCP 툴의 기본 노출 대상은 스펙상 **문서 자신, 같은 트리의 동일 출처 문서, 브라우저 내장 에이전트**
세 가지입니다. `exposedTo` 로 작성자가 지정한 교차 출처 iframe 에도 열 수 있고, 페이지나 iframe 에
직접 박은 author-provided agent 도 툴을 발견하고 실행할 수 있습니다.

스펙이 정의하지 **않는** 것은 **브라우저 밖 프로세스를 위한 전송 계층**입니다. 불가능하다는 뜻은 아닙니다 —
확장 프로그램(페이지 툴을 로컬 MCP 서버로 중계)이나 CDP 기반 브리지로 외부 MCP 클라이언트를 연결하는
방식이 이미 널리 쓰입니다. 표준화되지 않았을 뿐입니다.

이 프로젝트가 서버측 `/mcp` 를 함께 두는 이유는 그 방식과의 트레이드오프 때문입니다.

| | 확장/CDP 브리지 | 서버측 `/mcp` (이 프로젝트) |
|---|---|---|
| 설치 | 확장 설치 필요 | 없음 |
| 수명 | 탭이 열려 있는 동안 | 서버가 사는 동안 |
| 상태 | 탭별 | 서버 공유 (모든 탭이 같은 장바구니) |
| 툴 정의 위치 | 페이지 | 서버 |

두 방식은 배타적이지 않습니다. Chrome 문서의 권고도 *"가장 효과적인 에이전트 애플리케이션은
MCP 와 WebMCP 를 모두 쓴다"* 입니다.

---

## 이미지 출처

`covers/` 의 패키지 이미지는 영문 위키백과 각 게임 문서의 박스아트입니다
(MediaWiki `pageimages` API, `pilicense=any`).

저작권은 **닌텐도 및 각 퍼블리셔**에 있습니다. 위키백과에서 비자유 저작물로 공정 이용되는 자료이며,
그 공정 이용 근거는 위키백과의 맥락에 한정됩니다. 이 저장소는 학습·시연 목적이며
상업적 이용이나 재배포를 의도하지 않습니다. 게임 제목과 상표 역시 각 권리자의 것입니다.