본문 바로가기

AI 개발

WebMCP 2편: Declarative·Imperative API 직접 구현해보기

반응형

1편에서 WebMCP가 왜 등장했는지, 어떤 표준화 흐름에 있는지 살펴봤습니다. 2편은 코드입니다. 실제로 어떻게 웹사이트에 에이전트용 도구를 심는지 — 테스트 환경 셋업부터 Declarative API, Imperative API, 보안 주의사항, React 연동까지 직접 구현 가능한 코드로 전부 정리했습니다.


이 포스트 한 줄 요약 → 테스트 환경: Chrome 146+ Canary, chrome://flags → WebMCP for testing 활성화 → Origin Trial: Chrome 149부터 실제 도메인 트래픽 테스트 가능 (6월 2일~) → HTTPS 필수 — HTTP 페이지에서는 navigator.modelContext 미노출 → Declarative API: HTML <form>에 toolname + tooldescription 어노테이션 → Imperative API: navigator.modelContext.registerTool() JS 함수 등록 → 기능 감지 필수: if ('modelContext' in navigator) 체크 후 사용 → readOnlyHint: true — 확인 없이 실행되는 읽기 전용 도구 → requestUserInteraction() — 민감한 작업에서 사람 확인 단계 삽입 → 서드파티 스크립트 덮어쓰기 보안 이슈 — provideContext() 주의 → Chrome DevTools 확장으로 등록된 도구 확인·수동 호출 가능


0단계: 테스트 환경 셋업

Chrome Canary 플래그 활성화

지금 바로 테스트하려면 Chrome 146 이상 Canary 버전이 필요합니다.

1. chrome://flags 접속
2. "WebMCP for testing" 검색
3. Enabled로 변경
4. Chrome 재시작

Origin Trial(Chrome 149, 6월 2일~)을 쓰면 Canary 없이 실제 도메인에서 테스트할 수 있습니다. Chrome Developer Console에서 도메인 등록 후 발급받은 토큰을 HTML에 삽입합니다.

<!-- Origin Trial 토큰 삽입 -->
<meta http-equiv="origin-trial"
      content="발급받은토큰값" />

DevTools 확장 설치

Chrome 팀이 제공하는 WebMCP DevTools 확장을 설치하면 현재 페이지에 등록된 도구 목록 확인, 수동 호출, JSON Schema 검증이 가능합니다. chrome://extensions에서 Chrome Web Store 링크로 설치합니다. 확장에서 프롬프트를 입력하면 gemini-3-flash-preview가 도구를 호출하는 흐름을 바로 테스트할 수 있습니다.


1단계: Declarative API — HTML 어노테이션으로 폼을 도구로

가장 빠른 진입 방법입니다. 기존 HTML <form>에 어노테이션 세 개만 추가하면 됩니다. JavaScript 한 줄 없이 에이전트가 호출할 수 있는 도구가 됩니다.

기본 구조

<form
  toolname="searchFlights"
  tooldescription="항공편을 검색합니다. 출발지, 도착지, 날짜를 받아 가용 항공편 목록을 반환합니다."
>
  <label>
    출발지
    <input
      name="origin"
      type="text"
      toolparamdescription="출발 공항 코드 (예: ICN, GMP)"
      placeholder="ICN"
    />
  </label>

  <label>
    도착지
    <input
      name="destination"
      type="text"
      toolparamdescription="도착 공항 코드 (예: NRT, LAX)"
      placeholder="NRT"
    />
  </label>

  <label>
    출발일
    <input
      name="departure"
      type="date"
      toolparamdescription="출발 날짜 (YYYY-MM-DD)"
    />
  </label>

  <label>
    인원
    <select name="passengers" toolparamdescription="탑승 인원 수 (1~9)">
      <option value="1">1명</option>
      <option value="2">2명</option>
      <option value="3">3명</option>
    </select>
  </label>

  <button type="submit">항공편 검색</button>
</form>

어노테이션 세 가지 핵심 속성

속성 위치 필수 역할

toolname <form> 도구 이름 (camelCase 권장)
tooldescription <form> 에이전트가 읽는 도구 설명
toolparamdescription 입력 요소 선택 각 파라미터 설명

toolautosubmit 속성을 <form>에 추가하면 에이전트가 폼을 채운 뒤 자동으로 submit합니다. 없으면 에이전트가 필드를 채우고 사용자가 직접 버튼을 눌러야 합니다. 결제·예약처럼 민감한 작업에서는 자동 submit 없이 사람이 최종 확인하는 게 안전합니다.

등록 해제: toolname 또는 tooldescription 속성을 DOM에서 제거하면 도구가 자동으로 등록 해제됩니다.


2단계: Imperative API — JS 함수를 에이전트 도구로

폼 없이 복잡한 로직, 외부 API 호출, 동적 상태 관리가 필요한 경우에 씁니다.

기본 패턴

// HTTPS 페이지에서만 동작 — 반드시 기능 감지 먼저
if ('modelContext' in navigator) {
  navigator.modelContext.registerTool({
    name: 'searchProducts',            // camelCase, 고유값
    description: '상품을 검색합니다. 키워드, 카테고리, 가격 범위로 필터링 가능합니다.',
    inputSchema: {
      type: 'object',
      properties: {
        query: {
          type: 'string',
          description: '검색 키워드',
        },
        category: {
          type: 'string',
          description: '카테고리 필터 (electronics, clothing, books)',
          enum: ['electronics', 'clothing', 'books'],
        },
        maxPrice: {
          type: 'number',
          description: '최대 가격 (KRW)',
        },
        inStock: {
          type: 'boolean',
          description: '재고 있는 상품만 표시',
          default: true,
        },
      },
      required: ['query'],  // 필수 파라미터
    },
    annotations: {
      readOnlyHint: true,   // 읽기 전용 — 상태 변경 없음 → 확인 없이 실행
    },
    async execute({ query, category, maxPrice, inStock = true }) {
      const params = new URLSearchParams({ q: query });
      if (category) params.set('category', category);
      if (maxPrice)  params.set('maxPrice', maxPrice);
      if (inStock)   params.set('inStock', '1');

      const res  = await fetch(`/api/search?${params}`);
      const data = await res.json();

      // 반환 형식: content 배열
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(data.products.slice(0, 10)),
          },
        ],
      };
    },
  });
}

상태를 변경하는 도구 — 장바구니 추가

상태 변경 도구는 readOnlyHint를 넣지 않습니다. 에이전트가 실행 전 사용자에게 확인을 요청합니다.

if ('modelContext' in navigator) {
  navigator.modelContext.registerTool({
    name: 'addToCart',
    description: '상품을 장바구니에 추가합니다.',
    inputSchema: {
      type: 'object',
      properties: {
        productId: { type: 'string', description: '상품 ID' },
        quantity:  { type: 'number', description: '수량', default: 1 },
        size:      { type: 'string', description: '사이즈 (S/M/L/XL)' },
      },
      required: ['productId'],
    },
    // readOnlyHint 없음 → 에이전트가 실행 전 사용자 확인 요청
    async execute({ productId, quantity = 1, size }) {
      const result = await cartAPI.add({ productId, quantity, size });

      return {
        content: [
          {
            type: 'text',
            text: `✅ 장바구니에 추가됨. 현재 ${result.itemCount}개 항목, 합계 ${result.total.toLocaleString()}원`,
          },
        ],
      };
    },
  });
}

도구 등록 해제

페이지 상태에 따라 도구를 동적으로 등록·해제해야 할 때는 unregisterTool()을 사용합니다.

// 로그인 상태에서만 노출할 도구
function updateAuthTools(isLoggedIn) {
  if (!('modelContext' in navigator)) return;

  if (isLoggedIn) {
    navigator.modelContext.registerTool({
      name: 'getOrderHistory',
      description: '최근 주문 내역을 조회합니다.',
      annotations: { readOnlyHint: true },
      inputSchema: {
        type: 'object',
        properties: {
          limit: { type: 'number', description: '조회할 주문 수', default: 10 },
        },
      },
      async execute({ limit = 10 }) {
        const orders = await api.getOrders({ limit });
        return { content: [{ type: 'text', text: JSON.stringify(orders) }] };
      },
    });
  } else {
    // 로그아웃 시 도구 해제
    try {
      navigator.modelContext.unregisterTool('getOrderHistory');
    } catch (e) {
      // 이미 등록 안 된 경우 무시
    }
  }
}

3단계: 사람이 개입해야 하는 도구 — requestUserInteraction

결제, 개인정보 변경처럼 에이전트가 단독으로 처리하면 안 되는 작업에서 실행을 일시 중단하고 사용자 확인을 받습니다.

if ('modelContext' in navigator) {
  navigator.modelContext.registerTool({
    name: 'initiateCheckout',
    description: '결제를 시작합니다. 사용자 확인 후 진행됩니다.',
    inputSchema: {
      type: 'object',
      properties: {
        paymentMethod: {
          type: 'string',
          description: '결제 수단',
          enum: ['card', 'kakaopay', 'naverpay'],
        },
      },
      required: ['paymentMethod'],
    },
    async execute({ paymentMethod }, { client }) {
      // 실행 일시 중단 → 사용자에게 확인 UI 표시
      const confirmed = await client.requestUserInteraction(async () => {
        return new Promise((resolve) => {
          // 커스텀 확인 다이얼로그 표시
          showConfirmDialog({
            message: `${paymentMethod}로 결제를 진행하시겠습니까?`,
            onConfirm: () => resolve(true),
            onCancel:  () => resolve(false),
          });
        });
      });

      if (!confirmed) {
        return { content: [{ type: 'text', text: '결제가 취소되었습니다.' }] };
      }

      const result = await paymentAPI.initiate({ method: paymentMethod });
      return {
        content: [{ type: 'text', text: `결제 완료: ${result.orderId}` }],
      };
    },
  });
}

4단계: React 앱에서 WebMCP 연동

@mcp-b/react-webmcp 패키지를 쓰면 React 컴포넌트 생명주기와 맞게 도구를 등록·해제할 수 있습니다.

npm install @mcp-b/react-webmcp
import { useMCPTool } from '@mcp-b/react-webmcp';

function ProductSearch() {
  // 컴포넌트 마운트 시 등록, 언마운트 시 자동 해제
  useMCPTool({
    name: 'searchProducts',
    description: '상품을 검색합니다.',
    inputSchema: {
      type: 'object',
      properties: {
        query: { type: 'string', description: '검색 키워드' },
      },
      required: ['query'],
    },
    annotations: { readOnlyHint: true },
    execute: async ({ query }) => {
      const results = await fetch(`/api/search?q=${query}`).then(r => r.json());
      return {
        content: [{ type: 'text', text: JSON.stringify(results) }],
      };
    },
  });

  return <div>{/* 기존 검색 UI */}</div>;
}

지원하지 않는 브라우저 대응 — 폴리필

Firefox나 Safari에서도 동일한 코드가 에러 없이 동작하게 하려면 폴리필을 씁니다. 지원 브라우저에서는 noop으로 처리되므로 실제 동작에는 영향이 없습니다.

npm install @mcp-b/global
// 앱 진입점 최상단에 한 번만 import
import '@mcp-b/global';

// 이후 코드에서 기능 감지 없이 바로 사용 가능
// (미지원 브라우저에서는 noop)
navigator.modelContext.registerTool({ ... });

5단계: 보안 주의사항

서드파티 스크립트 덮어쓰기 문제

navigator.modelContext.registerTool()은 같은 이름의 도구가 이미 있으면 InvalidStateError를 던집니다. 하지만 provideContext()는 기존 도구를 모두 지우고 새로 등록하기 때문에, 악의적인 서드파티 스크립트가 이 경로로 공식 도구를 가로챌 수 있습니다.

// ❌ 이 패턴은 기존 도구를 전부 덮어씀 (보안 위험)
navigator.modelContext.provideContext({ tools: [...] });

// ✅ 권장: registerTool로 개별 등록, 에러 핸들링
try {
  navigator.modelContext.registerTool({ name: 'myTool', ... });
} catch (e) {
  if (e.name === 'InvalidStateError') {
    console.warn('이미 등록된 도구:', e.message);
  }
}

광고·분석·채팅 위젯 같은 서드파티 스크립트가 많이 들어오는 사이트에서는 특히 주의가 필요합니다. W3C 이슈 트래커에 이 문제가 공식 등록돼 있고 스펙이 보완되고 있습니다.

readOnlyHint 정확하게 사용

읽기 전용(readOnlyHint: true)으로 등록한 도구는 에이전트가 확인 없이 바로 실행합니다. 상태 변경이 없는 도구에만 사용해야 합니다. 잘못 분류하면 사용자 확인 없이 데이터가 바뀔 수 있습니다.

// ✅ 읽기 전용으로 쓰기 OK
{ name: 'getProductInfo', annotations: { readOnlyHint: true } }
{ name: 'searchFlights',  annotations: { readOnlyHint: true } }

// ❌ 아래는 readOnlyHint 넣으면 안 됨
{ name: 'addToCart'      }  // readOnlyHint 없음 → 확인 단계 있음
{ name: 'submitPayment'  }  // readOnlyHint 없음
{ name: 'deleteAccount'  }  // readOnlyHint 없음

전체 구현 예시 — e-커머스 상품 페이지

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>WebMCP 구현 예시</title>
  <!-- Origin Trial 토큰 (Chrome 149+) -->
  <meta http-equiv="origin-trial" content="발급받은토큰값" />
</head>
<body>

  <!-- Declarative API: 검색 폼 -->
  <form
    toolname="searchProducts"
    tooldescription="상품을 키워드로 검색합니다."
    action="/search"
    method="GET"
  >
    <input
      name="q"
      type="search"
      toolparamdescription="검색할 상품명 또는 키워드"
    />
    <button type="submit">검색</button>
  </form>

  <script>
    // Imperative API: 장바구니·주문 도구
    if ('modelContext' in navigator) {

      // 상품 상세 조회 (읽기 전용)
      navigator.modelContext.registerTool({
        name: 'getProductDetail',
        description: '상품 ID로 상세 정보를 조회합니다.',
        inputSchema: {
          type: 'object',
          properties: {
            productId: { type: 'string', description: '상품 ID' },
          },
          required: ['productId'],
        },
        annotations: { readOnlyHint: true },
        async execute({ productId }) {
          const data = await fetch(`/api/products/${productId}`).then(r => r.json());
          return { content: [{ type: 'text', text: JSON.stringify(data) }] };
        },
      });

      // 장바구니 추가 (상태 변경)
      navigator.modelContext.registerTool({
        name: 'addToCart',
        description: '상품을 장바구니에 추가합니다.',
        inputSchema: {
          type: 'object',
          properties: {
            productId: { type: 'string', description: '상품 ID' },
            quantity:  { type: 'number', description: '수량', default: 1 },
          },
          required: ['productId'],
        },
        async execute({ productId, quantity = 1 }) {
          const res = await fetch('/api/cart', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ productId, quantity }),
          });
          const data = await res.json();
          return {
            content: [
              { type: 'text', text: `장바구니에 추가됨. 총 ${data.total}원` },
            ],
          };
        },
      });
    }
  </script>
</body>
</html>

✅ 결론

항목 평가

구현 난이도 (Declarative) ✅ HTML 속성 3개로 즉시 가능
구현 난이도 (Imperative) ✅ 표준 JS async 함수 수준
기존 코드 변경 최소화 ✅ HTML 어노테이션은 기존 폼 유지
폴리필로 미지원 브라우저 대응 ✅ @mcp-b/global로 에러 방지
서드파티 덮어쓰기 위험 ⚠️ provideContext() 사용 시 주의
readOnlyHint 오남용 위험 ⚠️ 상태 변경 도구에 절대 사용 금지
현재 테스트 가능 여부 ✅ Chrome Canary 플래그 즉시 가능

Origin Trial이 시작된 지금이 실험하기 가장 좋은 시점입니다. 사이트에서 가장 중요한 사용자 플로우 한 가지를 골라 도구로 정의해두면, Gemini in Chrome이 공식 지원을 시작하는 시점에 바로 활용할 수 있습니다.


관련 포스트

https://cell-devlog.tistory.com/258

 

브라우저가 에이전트를 위한 API 레이어가 된다 — WebMCP 1편: 표준의 탄생

AI 에이전트가 웹을 사용하는 방식은 지금까지 이랬습니다. 화면을 캡처하고, DOM을 파싱하고, 버튼이 어디 있을지 추측하고, 클릭하고, 다시 기다립니다. 느리고, 깨지기 쉽고, 디자이너가 클래

cell-devlog.tistory.com

 

반응형