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
'AI 개발' 카테고리의 다른 글
| 코드베이스에 모델 ID 박아놨습니까 — 6월 15일 API retirement 완전 대응 가이드 (0) | 2026.05.26 |
|---|---|
| WebMCP 3편: Agentic SEO — 에이전트 시대에 웹사이트는 무엇이 달라져야 하나 (0) | 2026.05.26 |
| 브라우저가 에이전트를 위한 API 레이어가 된다 — WebMCP 1편: 표준의 탄생 (0) | 2026.05.26 |
| xAI가 터미널 코딩 에이전트 시장에 뛰어들었다 — Grok Build CLI 완전 가이드 (0) | 2026.05.26 |
| Antigravity SDK 심화편—Managed Agents API·GCP 엔터프라이즈 연동·CI/CD 파이프라인 실전 구축: Antigravity 2.0 (0) | 2026.05.23 |