diff --git a/AGENTS.md b/AGENTS.md index bdacd79b..f551c259 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # 에이전트 개발 규칙 +## 최우선: 공개 MCP 도구는 10개 고정 + +공개 `tools/list`의 도구는 정확히 **10개**이며 **절대 늘리지 않습니다**. 새 서비스와 기능은 기존 도구의 `service`/`operation`/`requestType`에 통합합니다. 기존 이름은 deprecated 호출 별칭으로만 보존하며 공개 목록에는 추가하지 않습니다. 일반 MCP와 PlayMCP에 동일하게 적용합니다. [공개 도구 계약](docs/mcp-tools.md)과 `tests/core/compact`, `tests/app/compact-contract.test.ts` 검사를 반드시 유지합니다. + 이 문서는 AI 에이전트가 이 프로젝트에서 코드를 작성하고 기여할 때 따라야 하는 규칙과 가이드라인을 정의합니다. ## 목차 diff --git a/ALIGNS.md b/ALIGNS.md index e9861068..6f717318 100644 --- a/ALIGNS.md +++ b/ALIGNS.md @@ -15,6 +15,10 @@ 위 두 항목의 사용자 확인: 2026-10-18 15:51 (Asia/Seoul). 가상 사례의 판단 기준을 확인한 것이며 실제 운영 변경이나 결제를 승인한 것은 아닙니다. +## 최우선 공개 도구 계약 + +사용자 확인: 2026-10-21. 공개 MCP 도구는 정확히 10개이며 앞으로도 늘리지 않습니다. 기존 44개 호출 계약은 하위호환을 위해 유지하되 구형 이름은 deprecated 호출 별칭으로만 남깁니다. 신규 기능은 기존 도구에 통합하고, 요청 제출은 범용 `submit_request` 안에서 확장합니다. 이를 구현·리뷰·CI·운영 검증에 적용합니다. + ## 기존에 명시적으로 합의한 기준 - **운영 대응:** 가능한 장애 조치는 선조치 후보고합니다. GS25 인증 만료도 복구 대상이며, 실제 사용자 로그인·2단계 인증이 필요하면 즉시 알리고 입력 후 복구를 이어갑니다. 구체적인 복구 방법과 실행 권한은 기존 운영 지침을 따릅니다. diff --git a/README.md b/README.md index c911ad1b..377120cd 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ > [!IMPORTANT] > **AI 에이전트의 무설치 MCP 사용:** 공개 원격 서버 `https://mcp.aka.page`에 Streamable HTTP로 연결하면 **로컬 설치·빌드·API 키 준비 없이 바로 사용할 수 있습니다.** 무설치 요청에는 npm 설치나 전체 스키마 탐색을 하지 말고, [짧은 시작 안내](https://mcp.aka.page/llms.txt)를 읽은 뒤 필요한 도구를 호출하세요. > +> **MCP 도구는 10개 고정:** `service`와 `operation`으로 지원 서비스를 선택합니다. 새 연동은 [통합 도구 10개](docs/mcp-tools.md)만 사용하세요. 기존 서비스별 도구는 deprecated 호출 별칭으로 유지하지만 목록에는 표시하지 않습니다. +> > **웹 읽기 전용 에이전트의 `format=html` 사용 필수:** MCP 연결 없이 GET으로 조회할 때는 **모든 `/api/*` 조회 URL에 `format=html`을 반드시 붙이세요.** 상품 검색·상세·매장·재고·후속 조회 모두 적용하며, JSON을 읽을 수 있다고 가정해 생략하지 마세요. > > [첫 HTML 조회](https://mcp.aka.page/api/daiso/products?q=%EC%88%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html) · [REST 사용법](https://mcp.aka.page/prompt) · [원격 MCP 연결](#원격-mcp로-바로-시작하기) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md new file mode 100644 index 00000000..7b9333c6 --- /dev/null +++ b/docs/mcp-tools.md @@ -0,0 +1,85 @@ +# 공개 MCP 도구 + +공개 도구는 정확히 10개이며 절대 늘리지 않습니다. 새 기능과 서비스는 기존 도구의 service/operation/requestType 안에서 확장합니다. 이 규칙은 일반 MCP와 PlayMCP 모두에 적용합니다. + +| 도구 | 역할 | +| --------------------- | ----------------------- | +| `search_products` | 상품 검색 | +| `find_stores` | 매장 검색 | +| `check_inventory` | 상품 재고 조회 | +| `get_product_details` | 상품 상세 조회 | +| `find_places` | 주변 장소와 영화관 검색 | +| `get_showtimes` | 영화와 상영 시간표 조회 | +| `get_fuel_info` | 유가와 주유소 조회 | +| `get_catalog` | 인기 검색어와 기획전 | +| `compare_products` | 상품 비교 | +| `submit_request` | 요청 제출 | + +## 호출 예시 + +상품 검색: `search_products {"service":"daiso","query":"수납박스","pageSize":3}` + +재고: `check_inventory {"service":"gs25","keyword":"오감자","storeKeyword":"강남"}` + +상품명으로 다이소 재고: `check_inventory {"service":"daiso","operation":"by_name","query":"수납박스","storeQuery":"강남"}` + +일본 상세: `get_product_details {"service":"daiso_jp","operation":"product","id":"4550746075917"}`. 일본 매장별 재고는 지원하지 않습니다. + +요청 제출: `submit_request {"requestType":"developer","request":{"title":"문서 개선","description":"개선할 내용을 적습니다","type":"docs"}}`. 현재 개발자 요청 저장만 지원합니다. 다른 요청 유형은 거절하며 요청자의 의도 없이 제출하지 않습니다. 이 도구의 이름과 요청 구분은 미래 유형 확장을 위해 범용으로 유지합니다. + +매개변수는 기존 작업의 이름과 형식을 사용합니다. operation은 같은 서비스에 작업이 여러 개인 경우 필수입니다. 예를 들어 CGV 시간표는 service=cgv, operation=timetable, playDate=YYYYMMDD이며 seats 작업은 지원하지 않습니다. 미지원 조합, 다른 서비스 전용 인수와 오타는 조회 전에 거절합니다. 기본값은 선택한 작업의 기본값만 적용합니다. tools/list의 입력/출력 스키마를 참고하세요. + +## Deprecated 하위호환 + +기존 44개 호출 계약은 유지합니다. compare_products는 공개 이름이 그대로이며 나머지 43개 구형 이름은 deprecated 별칭입니다. 구형 이름도 동일한 / 및 /mcp의 tools/call로 호출할 수 있고 기존 인수/결과/오류 의미를 유지합니다. 성공 또는 반환된 오류 응답의 _meta.deprecated=true와 _meta.replacement는 이동할 공개 도구를 안내합니다. SDK 입력 검증 단계에서 거절된 요청에는 이 추가 정보가 없을 수 있습니다. tools/list와 공개 발견 목록에는 구형 별칭을 노출하지 않습니다. + +REST 경로와 CLI 명령은 변경하지 않습니다. 구형 별칭을 삭제하거나 다시 공개 목록에 등록하지 마세요. 새 연동은 아래 오른쪽 도구만 사용합니다. + +| 기존 이름 | 공개 도구 | service | operation | +| ---------------------------------- | --------------------- | ----------- | ---------------- | +| `daiso_search_products` | `search_products` | daiso | | +| `daiso_jp_search_products` | `search_products` | daiso_jp | | +| `gs25_search_products` | `search_products` | gs25 | | +| `seveneleven_search_products` | `search_products` | seveneleven | | +| `emart24_search_products` | `search_products` | emart24 | | +| `oliveyoung_search_products` | `search_products` | oliveyoung | | +| `daiso_find_stores` | `find_stores` | daiso | | +| `daiso_jp_find_stores` | `find_stores` | daiso_jp | | +| `gs25_find_nearby_stores` | `find_stores` | gs25 | | +| `seveneleven_search_stores` | `find_stores` | seveneleven | | +| `cu_find_nearby_stores` | `find_stores` | cu | | +| `emart24_find_nearby_stores` | `find_stores` | emart24 | | +| `oliveyoung_find_nearby_stores` | `find_stores` | oliveyoung | | +| `daiso_check_inventory` | `check_inventory` | daiso | by_id | +| `daiso_find_inventory_by_name` | `check_inventory` | daiso | by_name | +| `gs25_check_inventory` | `check_inventory` | gs25 | | +| `seveneleven_check_inventory` | `check_inventory` | seveneleven | | +| `cu_check_inventory` | `check_inventory` | cu | | +| `emart24_check_inventory` | `check_inventory` | emart24 | | +| `oliveyoung_check_inventory` | `check_inventory` | oliveyoung | | +| `daiso_get_price_info` | `get_product_details` | daiso | price | +| `daiso_get_display_location` | `get_product_details` | daiso | display_location | +| `daiso_jp_get_product` | `get_product_details` | daiso_jp | product | +| `places_search_nearby` | `find_places` | places | | +| `megabox_find_nearby_theaters` | `find_places` | megabox | | +| `dtryx_list_cinemas` | `find_places` | dtryx | | +| `lottecinema_find_nearby_theaters` | `find_places` | lottecinema | | +| `cgv_find_theaters` | `find_places` | cgv | | +| `megabox_list_now_showing` | `get_showtimes` | megabox | movies | +| `megabox_get_remaining_seats` | `get_showtimes` | megabox | seats | +| `dtryx_list_now_showing` | `get_showtimes` | dtryx | movies | +| `dtryx_get_remaining_seats` | `get_showtimes` | dtryx | seats | +| `lottecinema_list_now_showing` | `get_showtimes` | lottecinema | movies | +| `lottecinema_get_remaining_seats` | `get_showtimes` | lottecinema | seats | +| `cgv_search_movies` | `get_showtimes` | cgv | movies | +| `cgv_get_timetable` | `get_showtimes` | cgv | timetable | +| `opinet_get_average_prices` | `get_fuel_info` | | average | +| `opinet_get_lowest_price_stations` | `get_fuel_info` | | lowest | +| `opinet_search_stations_around` | `get_fuel_info` | | around | +| `opinet_get_station_detail` | `get_fuel_info` | | detail | +| `seveneleven_get_search_popwords` | `get_catalog` | | popwords | +| `seveneleven_get_catalog_snapshot` | `get_catalog` | | snapshot | +| `compare_products` | `compare_products` | | | +| `submit_developer_request` | `submit_request` | | | + +PlayMCP의 등록된 도구 스냅샷은 서버 배포만으로 바로 갱신되지 않을 수 있습니다. 등록 도구를 새 목록으로 갱신한 뒤 실제 대화를 검증하세요. diff --git a/docs/superpowers/plans/2026-10-21-compact-mcp.md b/docs/superpowers/plans/2026-10-21-compact-mcp.md new file mode 100644 index 00000000..8f095741 --- /dev/null +++ b/docs/superpowers/plans/2026-10-21-compact-mcp.md @@ -0,0 +1,27 @@ +# 공개 MCP 도구 10개 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 기본 MCP 도구를 10개로 고정하고 구형 호출을 보존한다. +**Architecture:** 명시적 작업 매핑과 기존 Zod 입력/핸들러를 재사용하는 어댑터. SDK 등록은 모두 유지하고 목록 응답만 고정 공개 계약으로 제공한다. +**Tech Stack:** TypeScript, Zod, MCP SDK, Vitest. + +### 1. 매핑과 스키마 +- [x] tests/core/compact-tools.test.ts에서 매핑 44개 전체, 공개 이름10개, 잘못된 service/operation/인수의 실행전 거절, 모든 작업 dispatch와 구형 응답 보존 테스트를 먼저 작성한다. +- [x] `npx vitest run tests/core/compact-tools.test.ts`의 모듈 부재 실패를 확인한다. +- [x] src/core/compact/{catalog,tools,server}.ts를 만들고 각 책임을 매핑/어댑터/프로토콜 등록으로 분리한다. 기존 핸들러 호출 전 `z.object(tool.metadata.inputSchema).parse(args)`를 수행한다. 새 source가450줄을 넘지 않게 한다. +- [x] `npx vitest run tests/core/compact-tools.test.ts` 성공을 확인한다. + +### 2. 프로토콜과 발견 +- [x] tests/index.test.ts 및 SDK 통합 회귀에 tools/list 정확히10개, cursor/구형 별칭 호출/신형 호출/에러/세션 경로를 추가하고 실패를 확인한다. +- [x] src/index.ts의 createMcpServer와 buildRootInfo를 공개10개 계약에 연결한다. 공개 SDK setRequestHandler와 ListToolsRequestSchema를 사용하며 비공개 SDK 필드를 읽지 않는다. +- [x] scripts/ops/mcp-smoke.ts는 공개 목록10개/신형 호출/구형 호환 확인을 구분한다. 모든 스키마 변환이 성공하고 개발자 요청은 외부에 쓰지 않는지 검사한다. + +### 3. 규칙과 문서 +- [x] AGENTS.md 최상단과 ALIGNS.md에 공개 도구10개 상한을 사용자 최고 우선 요구로 기록한다. README/발견 페이지/llms 문서는 새 이름을 먼저 안내하고 별도 docs에 구형 전체 매핑과 deprecated 정책을 쓴다. REST/CLI 예시는 유지한다. +- [x] 기존 검사에서 공개44개 전제를 새 목록10개와 호환44개로 나눠 검증한다. 단순 기대값 삭제나 검사 약화를 하지 않는다. + +### 4. 검증과 반영 +- [x] `npm run check`, `npm run test:coverage`, `npm run build`를 실행해 전체 성공/네 지표100%를 확인한다. SDK 실제통신과 invalid 조합 회귀는 단순 모킹과 별도로 유지한다. +- [ ] 독립 리뷰 후 검증된 변경을 논리적으로 커밋/푸시하고 PR을 생성·첨부한다. GitHub CI/Coverage/CodeQL 통과 후 병합한다. +- [ ] 운영 / 및 /mcp tools/list10개, 새/구형 상품·매장·재고 호출과 CLI 유지 검증 후 PROJECT.md에 근거와 남은 문제를 쓴다. PlayMCP 콘솔 등록목록은 별도 갱신이 필요함을 구분한다. diff --git a/docs/superpowers/specs/2026-10-21-compact-mcp-design.md b/docs/superpowers/specs/2026-10-21-compact-mcp-design.md new file mode 100644 index 00000000..2f76fff9 --- /dev/null +++ b/docs/superpowers/specs/2026-10-21-compact-mcp-design.md @@ -0,0 +1,11 @@ +# 공개 MCP 도구 10개 통합 + +사용자 승인: 2026-10-21. 공개 MCP 도구는 정확히 10개이며 앞으로도 늘리지 않는다. 기능 확장은 기존 도구의 서비스/작업 선택지로 흡수한다. 기본 / 및 /mcp에 동일하게 적용한다. + +공개 이름은 search_products, find_stores, check_inventory, get_product_details, find_places, get_showtimes, get_fuel_info, get_catalog, compare_products, submit_request이다. 기존 44개 도구는 deprecated 호출 별칭으로 보존하되 tools/list에는 노출하지 않는다. 목록에서 제외하는 것과 호출을 비활성화하는 것은 구분하며 기존 입력/응답 계약을 유지한다. + +기존 서비스/클라이언트/인증/중계/REST/CLI는 재사용한다. 통합 도구는 service와 필요한 경우 operation으로 기존 핸들러를 고른다. 각 작업의 입력 스키마를 재사용하여 검증하며 지원하지 않는 조합/오타는 외부 요청 전에 거절한다. 목록 스키마는 JSON 입력을 명시하고 예시와 조건부 필수 인수를 설명한다. 새 submit_request는 범용 요청 이름과 확장 가능한 요청 구분을 제공하되 현재 실행 가능한 작업은 개발자 요청 제출뿐임을 명시한다. 알 수 없는 요청을 성공 처리하거나 임의 작업을 실행하지 않는다. + +MCP 등록은 SDK 공개 API로 수행한다. 구형 별칭에도 기존 SDK 입력/출력 검증과 진단을 유지한다. deprecated 안내는 응답의 추가 메타정보로 전달하고 기존 content/structuredContent의 내용은 바꾸지 않는다. 목록은 명시적 고정 공개 이름만 포함하며 11번째 도구나 미매핑 신규 서비스 도구가 추가되면 검사를 실패시킨다. + +회귀 검증: 44개 기존 작업이 모두 매핑되는지, 10개 목록과 이름/스키마가 정확한지, 실제 SDK tools/call로 새 이름과 구형 별칭이 동작하는지, 검증 오류/외부 오류/정상 품절/미확인 수량이 보존되는지 확인한다. 요청 제출 검증에는 가짜 저장소만 사용하고 운영 요청을 생성하지 않는다. REST/CLI와 공개 문서·스모크도 동기화한다. 전체 check/coverage100/build/독립 리뷰/CI/CodeQL을 거쳐 배포 후 / 및 /mcp 목록과 대표 새/구형 조회를 검증한다. diff --git a/scripts/ops/mcp-smoke.ts b/scripts/ops/mcp-smoke.ts index 03ff69e0..95033797 100644 --- a/scripts/ops/mcp-smoke.ts +++ b/scripts/ops/mcp-smoke.ts @@ -1,3 +1,4 @@ +import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js'; /** * MCP 클라이언트 관점 smoke 테스트 */ @@ -236,7 +237,11 @@ export async function runMcpSmoke(deps: McpSmokeDeps = {}): Promise { const tools = await client.listTools(); const toolNames = new Set(tools.tools.map((tool) => tool.name)); - const missingTools = smokeTools.map((tool) => tool.name).filter((name) => !toolNames.has(name)); + const missingTools = PUBLIC_TOOL_NAMES.filter(name => !toolNames.has(name)); + if (tools.tools.length !== 10 || toolNames.size !== 10) { + writeErr('MCP smoke 실패: 공개 도구는 정확히 10개여야 합니다.'); + return 1; + } if (missingTools.length > 0) { writeErr(`MCP smoke 실패: 필수 도구 누락 - ${missingTools.join(', ')}`); return 1; @@ -246,9 +251,11 @@ export async function runMcpSmoke(deps: McpSmokeDeps = {}): Promise { writeOut(`MCP smoke 호출: ${scenario.label}`); let result: ToolCallResult | undefined; try { + const group = COMPACT_CATALOG.find(group => group.routes.some(route => route.legacy === scenario.toolName))!; + const route = group.routes.find(route => route.legacy === scenario.toolName)!; result = await client.callTool({ - name: scenario.toolName, - arguments: scenario.args, + name: group.name, + arguments: { ...scenario.args, ...(route.service ? { service: route.service } : {}), ...(route.operation ? { operation: route.operation } : {}) }, }); const payload = parseToolPayload(result); const validationError = scenario.validate(payload); diff --git a/src/core/compact/catalog.ts b/src/core/compact/catalog.ts new file mode 100644 index 00000000..fb462f47 --- /dev/null +++ b/src/core/compact/catalog.ts @@ -0,0 +1,139 @@ +/** 공개 도구는 정확히 10개를 유지합니다. 신규 기능은 아래 작업으로 통합합니다. */ +export interface CompactRoute { + legacy: string; + service?: string; + operation?: string; +} +export interface CompactGroup { + name: string; + title: string; + description: string; + routes: CompactRoute[]; +} +const retail = (names: Record): CompactRoute[] => + Object.entries(names).map(([service, legacy]) => ({ service, legacy })); +export const COMPACT_CATALOG: CompactGroup[] = [ + { + name: 'search_products', + title: '상품 검색', + description: + '서비스별 상품 검색. daiso/daiso_jp/seveneleven은 query, gs25/emart24/oliveyoung은 keyword를 입력합니다. 재고 조회에 사용할 상품 코드를 얻습니다.', + routes: retail({ + daiso: 'daiso_search_products', + daiso_jp: 'daiso_jp_search_products', + gs25: 'gs25_search_products', + seveneleven: 'seveneleven_search_products', + emart24: 'emart24_search_products', + oliveyoung: 'oliveyoung_search_products', + }), + }, + { + name: 'find_stores', + title: '매장 검색', + description: + '서비스별 매장 검색. keyword 또는 latitude/longitude를 입력합니다. 일본은 매장 검색만 제공하며 매장별 재고는 지원하지 않습니다.', + routes: retail({ + daiso: 'daiso_find_stores', + daiso_jp: 'daiso_jp_find_stores', + gs25: 'gs25_find_nearby_stores', + seveneleven: 'seveneleven_search_stores', + cu: 'cu_find_nearby_stores', + emart24: 'emart24_find_nearby_stores', + oliveyoung: 'oliveyoung_find_nearby_stores', + }), + }, + { + name: 'check_inventory', + title: '상품 재고 조회', + description: + '실제 매장 재고 확인. 다이소는 operation=by_name이면 query, by_id이면 productId를 입력합니다. 그 외 서비스는 keyword 또는 해당 상품 코드를 입력합니다. 실패나 미확인 수량은 품절이 아닙니다.', + routes: [ + { service: 'daiso', operation: 'by_id', legacy: 'daiso_check_inventory' }, + { service: 'daiso', operation: 'by_name', legacy: 'daiso_find_inventory_by_name' }, + ...retail({ + gs25: 'gs25_check_inventory', + seveneleven: 'seveneleven_check_inventory', + cu: 'cu_check_inventory', + emart24: 'emart24_check_inventory', + oliveyoung: 'oliveyoung_check_inventory', + }), + ], + }, + { + name: 'get_product_details', + title: '상품 상세 조회', + description: + '일본 상품 상세는 service=daiso_jp, operation=product, id를 입력합니다. 한국 다이소 가격은 operation=price와 productId/productName, 진열 위치는 operation=display_location과 productId/storeCode를 입력합니다.', + routes: [ + { service: 'daiso', operation: 'price', legacy: 'daiso_get_price_info' }, + { service: 'daiso', operation: 'display_location', legacy: 'daiso_get_display_location' }, + { service: 'daiso_jp', operation: 'product', legacy: 'daiso_jp_get_product' }, + ], + }, + { + name: 'find_places', + title: '주변 장소와 영화관 검색', + description: + 'service=places는 음식점/카페 등 장소 검색, 나머지는 영화관 검색입니다. location/keyword 또는 latitude/longitude를 입력합니다.', + routes: retail({ + places: 'places_search_nearby', + megabox: 'megabox_find_nearby_theaters', + dtryx: 'dtryx_list_cinemas', + lottecinema: 'lottecinema_find_nearby_theaters', + cgv: 'cgv_find_theaters', + }), + }, + { + name: 'get_showtimes', + title: '영화와 상영 시간표 조회', + description: + 'operation=movies는 상영 영화, seats는 메가박스/디트릭스/롯데시네마 잔여석, timetable은 CGV 시간표와 잔여석입니다. playDate는 YYYYMMDD, 각 서비스의 theaterId/theaterCode/cinemaCode를 사용합니다.', + routes: [ + { service: 'megabox', operation: 'movies', legacy: 'megabox_list_now_showing' }, + { service: 'megabox', operation: 'seats', legacy: 'megabox_get_remaining_seats' }, + { service: 'dtryx', operation: 'movies', legacy: 'dtryx_list_now_showing' }, + { service: 'dtryx', operation: 'seats', legacy: 'dtryx_get_remaining_seats' }, + { service: 'lottecinema', operation: 'movies', legacy: 'lottecinema_list_now_showing' }, + { service: 'lottecinema', operation: 'seats', legacy: 'lottecinema_get_remaining_seats' }, + { service: 'cgv', operation: 'movies', legacy: 'cgv_search_movies' }, + { service: 'cgv', operation: 'timetable', legacy: 'cgv_get_timetable' }, + ], + }, + { + name: 'get_fuel_info', + title: '유가와 주유소 조회', + description: + '오피넷 정보. operation=average 평균유가, lowest 최저가 주유소, around 주변 주유소, detail 주유소 상세. 각 작업의 지역/좌표/주유소 ID 조건을 입력합니다.', + routes: [ + { operation: 'average', legacy: 'opinet_get_average_prices' }, + { operation: 'lowest', legacy: 'opinet_get_lowest_price_stations' }, + { operation: 'around', legacy: 'opinet_search_stations_around' }, + { operation: 'detail', legacy: 'opinet_get_station_detail' }, + ], + }, + { + name: 'get_catalog', + title: '인기 검색어와 기획전', + description: + '세븐일레븐 공개 카탈로그. operation=popwords 인기 검색어, snapshot 기획전/이슈 목록입니다. 실시간 재고를 의미하지 않습니다.', + routes: [ + { operation: 'popwords', legacy: 'seveneleven_get_search_popwords' }, + { operation: 'snapshot', legacy: 'seveneleven_get_catalog_snapshot' }, + ], + }, + { + name: 'compare_products', + title: '상품 비교', + description: '기존 서비스 상품 가격 후보를 비교합니다.', + routes: [{ legacy: 'compare_products' }], + }, + { + name: 'submit_request', + title: '요청 제출', + description: + '요청을 제출합니다. 현재 requestType=developer만 지원하며 request에 개발자에게 전달할 오류/개선/기능/문서 요청을 입력합니다. 미래 요청 유형도 이 도구에서 확장합니다. 사용자 요청이 있을 때만 제출하고 인증값/개인정보는 넣지 마세요.', + routes: [{ legacy: 'submit_developer_request' }], + }, +]; +export const PUBLIC_TOOL_NAMES = Object.freeze(COMPACT_CATALOG.map((group) => group.name)); +export const PUBLIC_TOOL_LIMIT = 10; diff --git a/src/core/compact/server.ts b/src/core/compact/server.ts new file mode 100644 index 00000000..0615f540 --- /dev/null +++ b/src/core/compact/server.ts @@ -0,0 +1,50 @@ +/** 공개 SDK API로 구형 호출을 유지하고 공개 목록은 10개로 고정합니다. */ +import * as z from 'zod'; +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ServiceRegistry } from '../registry.js'; +import { createToolOutputSchema } from '../outputSchema.js'; +import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from './catalog.js'; +import { createCompactTools } from './tools.js'; + +export function applyCompactTools(server: McpServer, registry: ServiceRegistry): void { + const legacy = registry.getAllTools(); + const compact = createCompactTools(legacy); + const replacements = new Map( + COMPACT_CATALOG.flatMap((group) => + group.routes.map((route) => [route.legacy, group.name] as const), + ), + ); + const hidden = legacy + .filter((tool) => !PUBLIC_TOOL_NAMES.includes(tool.name)) + .map((tool) => { + const replacement = replacements.get(tool.name)!; + return { + ...tool, + metadata: { + ...tool.metadata, + description: `[Deprecated] ${replacement}를 사용하세요. ${tool.metadata.description}`, + _meta: { deprecated: true, replacement }, + }, + handler: async (args: unknown) => { + const result = await tool.handler(args); + return { ...result, _meta: { ...result._meta, deprecated: true, replacement } }; + }, + }; + }); + registry.applyToServer(server, [...hidden, ...compact]); + const tools = compact.map((tool) => ({ + name: tool.name, + title: tool.metadata.title, + description: tool.metadata.description, + inputSchema: z.toJSONSchema(z.object(tool.metadata.inputSchema).strict(), { + io: 'input', + target: 'draft-7', + }) as { type: 'object' }, + outputSchema: z.toJSONSchema(createToolOutputSchema(tool.name, tool.metadata.outputSchema), { + io: 'output', + target: 'draft-7', + }) as { type: 'object' }, + })); + server.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools })); +} diff --git a/src/core/compact/tools.ts b/src/core/compact/tools.ts new file mode 100644 index 00000000..4e42d30a --- /dev/null +++ b/src/core/compact/tools.ts @@ -0,0 +1,104 @@ +/** 기존 작업의 검증과 실행을 재사용하는 공개 도구 어댑터입니다. */ +import * as z from 'zod'; +import { COMPACT_CATALOG, PUBLIC_TOOL_LIMIT, type CompactGroup } from './catalog.js'; +import { createToolOutputSchema } from '../outputSchema.js'; +import type { ToolRegistration, ToolInputSchema } from '../types.js'; + +export function assertCompactCoverage(legacy: ToolRegistration[]): void { + const expected = COMPACT_CATALOG.flatMap((group) => group.routes.map((route) => route.legacy)); + const actual = legacy.map((tool) => tool.name); + if ( + COMPACT_CATALOG.length !== PUBLIC_TOOL_LIMIT || + new Set(expected).size !== expected.length || + actual.length !== expected.length || + actual.some((name) => !expected.includes(name)) || + expected.some((name) => !actual.includes(name)) + ) { + throw new Error( + '공개 도구 10개 계약 또는 기존 작업 매핑이 일치하지 않습니다. 도구를 늘리지 말고 기존 작업에 통합하세요.', + ); + } +} +function withoutDefault(schema: z.ZodType): z.ZodType { + if (schema instanceof z.ZodDefault) return withoutDefault(schema.removeDefault() as z.ZodType); + if (schema instanceof z.ZodOptional) + return withoutDefault(schema.unwrap() as z.ZodType).optional(); + return schema; +} +function mergedInputs(group: CompactGroup, legacy: Map): ToolInputSchema { + const fields = new Map(); + for (const route of group.routes) { + for (const [key, schema] of Object.entries(legacy.get(route.legacy)!.metadata.inputSchema)) { + const variants = fields.get(key) ?? []; + variants.push(withoutDefault(schema)); + fields.set(key, variants); + } + } + const shape: ToolInputSchema = {}; + for (const [key, variants] of fields) + shape[key] = z.union(variants as [z.ZodType, ...z.ZodType[]]).optional(); + const services = [ + ...new Set(group.routes.flatMap((route) => (route.service ? [route.service] : []))), + ]; + const operations = [ + ...new Set(group.routes.flatMap((route) => (route.operation ? [route.operation] : []))), + ]; + if (services.length) + shape.service = z.enum(services as [string, ...string[]]).describe('조회할 서비스'); + if (operations.length) + shape.operation = z + .enum(operations as [string, ...string[]]) + .optional() + .describe('조회 작업. 같은 서비스에 작업이 여러 개면 필수입니다.'); + return shape; +} +export function createCompactTools(legacyTools: ToolRegistration[]): ToolRegistration[] { + assertCompactCoverage(legacyTools); + const legacy = new Map(legacyTools.map((tool) => [tool.name, tool])); + return COMPACT_CATALOG.map((group) => { + const single = legacy.get(group.routes[0].legacy)!; + const inputSchema: ToolInputSchema = + group.name === 'submit_request' + ? { + requestType: z + .literal('developer') + .optional() + .default('developer') + .describe('현재 지원 유형: developer'), + request: z.object(single.metadata.inputSchema).strict().describe('제출할 요청 내용'), + } + : mergedInputs(group, legacy); + const outputSchema: ToolInputSchema = {}; + const outputs = group.routes.map((route) => { + const tool = legacy.get(route.legacy)!; + return createToolOutputSchema(tool.name, tool.metadata.outputSchema).shape as ToolInputSchema; + }); + for (const key of new Set(outputs.flatMap((shape) => Object.keys(shape)))) { + const variants = outputs.map((shape) => shape[key] ?? z.unknown()); + outputSchema[key] = z.union(variants as [z.ZodType, ...z.ZodType[]]).optional(); + } + return { + name: group.name, + strictInput: true, + metadata: { title: group.title, description: group.description, inputSchema, outputSchema }, + handler: async (args: unknown) => { + const parsed = z.object(inputSchema).strict().parse(args); + if (group.name === 'submit_request') return single.handler(parsed.request); + const { service, operation, ...input } = parsed; + const matches = group.routes.filter( + (route) => + route.service === service && (operation === undefined || route.operation === operation), + ); + if (matches.length !== 1) + throw new Error( + '지원하는 service/operation 조합을 명시하세요. ' + + group.routes + .map((route) => [route.service, route.operation].filter(Boolean).join('/')) + .join(', '), + ); + const target = legacy.get(matches[0].legacy)!; + return target.handler(z.object(target.metadata.inputSchema).strict().parse(input)); + }, + }; + }); +} diff --git a/src/core/registry.ts b/src/core/registry.ts index 100e6582..f0778a7b 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -1,3 +1,4 @@ +import * as z from 'zod'; /** * 서비스 레지스트리 * @@ -204,7 +205,11 @@ export class ServiceRegistry { * 모든 도구를 MCP 서버에 등록 * @param server MCP 서버 인스턴스 */ - applyToServer(server: McpServer): void { + applyToServer(server: McpServer, registrations?: ToolRegistration[]): void { + if (registrations) { + for (const tool of registrations) this.registerTool(server, tool); + return; + } for (const service of this.services.values()) { const tools = service.getTools(); @@ -220,6 +225,7 @@ export class ServiceRegistry { private registerTool(server: McpServer, tool: ToolRegistration): void { const metadata = { ...tool.metadata, + inputSchema: tool.strictInput ? z.object(tool.metadata.inputSchema).strict() : tool.metadata.inputSchema, outputSchema: createToolOutputSchema(tool.name, tool.metadata.outputSchema), }; @@ -251,12 +257,14 @@ export class ServiceRegistry { if (result.isError) { return { isError: true, + ...(result._meta ? { _meta: result._meta } : {}), content, }; } return { content, + ...(result._meta ? { _meta: result._meta } : {}), structuredContent: buildStructuredContent(result), }; }); @@ -279,6 +287,10 @@ export class ServiceRegistry { /** * 등록된 모든 도구 이름 반환 */ + getAllTools(): ToolRegistration[] { + return Array.from(this.services.values()).flatMap(service => service.getTools()); + } + getAllToolNames(): string[] { const toolNames: string[] = []; for (const service of this.services.values()) { diff --git a/src/core/types.ts b/src/core/types.ts index 86d477ba..d3ee969f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -11,6 +11,7 @@ import type * as z from 'zod'; * 모든 도구 핸들러가 반환하는 표준 형식 */ export interface McpToolResponse { + _meta?: Record; content: Array<{ type: 'text'; text: string; @@ -72,10 +73,13 @@ export interface ServiceMetadata { * 서비스에서 반환하는 도구 정보 (간소화 버전) */ export interface ToolRegistration { + /** 통합 공개 도구는 SDK 단계부터 알 수 없는 인수를 거절합니다. */ + strictInput?: boolean; /** 도구 이름 (서비스 접두사 포함) */ name: string; /** 도구 메타데이터 */ metadata: { + _meta?: Record; title: string; description: string; inputSchema: ToolInputSchema; diff --git a/src/index.ts b/src/index.ts index 1a9e5509..3c2c5be7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { applyCompactTools } from './core/compact/server.js'; +import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from './core/compact/catalog.js'; import { apiResultHtml } from './pages/apiResult.js'; import { withExternalRouting } from './utils/externalRouting.js'; import { refreshRouteChecks } from './utils/routeProbes.js'; @@ -139,15 +141,15 @@ const createMcpServer = (bindings?: AppBindings) => { version: SERVER_VERSION, }); - registry.applyToServer(server); + applyCompactTools(server, registry); return server; }; export function buildRootInfo() { const registry = createRegistry(); - const services = registry.getServicesInfo(); - const allTools = registry.getAllToolNames(); + const services = registry.getServicesInfo().map(service => ({ ...service, tools: COMPACT_CATALOG.filter(group => group.routes.some(route => service.tools.includes(route.legacy))).map(group => group.name) })); + const allTools = [...PUBLIC_TOOL_NAMES]; return { name: SERVER_NAME, @@ -201,7 +203,6 @@ function createRootInfoResponse(request: Request): Response { 'Content-Length': String(body.byteLength), Vary: 'Accept' }, }); } - /** * initialize 요청 여부를 확인합니다. */ @@ -221,7 +222,6 @@ const isInitializeRequest = (body: unknown): boolean => { (body as { method?: unknown }).method === 'initialize' ); }; - /** * 새 세션 transport/server를 생성합니다. */ diff --git a/src/pages/agentStart.ts b/src/pages/agentStart.ts index 3bf7ee3a..73af0a6f 100644 --- a/src/pages/agentStart.ts +++ b/src/pages/agentStart.ts @@ -3,7 +3,7 @@ export const WEB_READ_RULE = '웹 읽기 전용 에이전트: 모든 /api/* GET /** 설치 없이 첫 실제 조회를 실행하는 에이전트 안내 */ export function buildAgentStartText(baseUrl: string): string { - return `# Daiso MCP: 설치 없이 시작하기 + return `# DAISO MCP: 설치 없이 시작하기 **${WEB_READ_RULE}** @@ -16,14 +16,14 @@ ${baseUrl}/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3& 전체 도구 목록이나 OpenAPI를 먼저 읽을 필요는 없습니다. 사용 가능한 실행 수단으로 아래 첫 조회를 실행하세요. -1. 원격 MCP 클라이언트가 있으면 공개 URL에 연결하세요. 표준 initialize → notifications/initialized 흐름을 사용하고 서버가 발급한 mcp-session-id를 전달합니다. 첫 도구는 daiso_search_products, 인자는 {"query":"수납박스","pageSize":3}입니다. +1. 원격 MCP 클라이언트가 있으면 공개 URL에 연결하세요. 표준 initialize → notifications/initialized 흐름을 사용하고 서버가 발급한 mcp-session-id를 전달합니다. 첫 도구는 search_products, 인자는 {"service":"daiso","query":"수납박스","pageSize":3}입니다. 2. HTTP POST나 셸이 있으면 아래 한 요청으로 실제 MCP 도구를 호출할 수 있습니다. npm 설치는 필요 없습니다. 루트 POST의 stateless 호출은 이 서버가 지원하는 단축 경로이며, /mcp는 initialize가 필요합니다. \`\`\`bash curl --max-time 20 -sS -N ${baseUrl}/ \\ -H 'Content-Type: application/json' \\ -H 'Accept: application/json, text/event-stream' \\ - --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"daiso_search_products","arguments":{"query":"수납박스","pageSize":3}}}' + --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_products","arguments":{"service":"daiso","query":"수납박스","pageSize":3}}}' \`\`\` 응답은 SSE의 data: JSON-RPC 메시지입니다. result.isError 또는 error를 확인하고, 상품은 result.structuredContent.products에서 읽으세요. content[].text에도 상품 JSON이 있습니다. 오류를 상품 없음으로 해석하지 마세요. diff --git a/src/pages/discovery.ts b/src/pages/discovery.ts index a2ac11c1..92fcf5f9 100644 --- a/src/pages/discovery.ts +++ b/src/pages/discovery.ts @@ -15,7 +15,7 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools return { 'discovery.html': ` -다이소 MCP — 설치 없이 상품·매장·재고 조회 +DAISO MCP: 설치 없이 상품·매장·재고 조회 @@ -26,12 +26,12 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools

바로 조회하기

다이소 수납박스 상품 3개 조회

위 링크는 셸·설치 없이 웹 읽기로 호출하는 공개 REST GET의 HTML 결과입니다. 다른 공개 GET 조회도 기존 파라미터에 format=html을 추가하세요. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.

에이전트에서 설치 없이 사용하기

원격 MCP 주소: ${BASE}
전송: Streamable HTTP · 인증: 없음

-

원격 MCP 클라이언트가 있으면 이 주소에 연결하고 daiso_search_products를 {"query":"수납박스","pageSize":3}으로 호출하세요.

+

원격 MCP 클라이언트가 있으면 이 주소에 연결하고 search_products를 {"service":"daiso","query":"수납박스","pageSize":3}으로 호출하세요.

HTTP POST나 셸이 있으면 아래 한 요청으로 첫 조회를 실행할 수 있습니다.

curl --max-time 20 -sS -N ${BASE}/ \\
   -H 'Content-Type: application/json' \\
   -H 'Accept: application/json, text/event-stream' \\
-  --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"daiso_search_products","arguments":{"query":"수납박스","pageSize":3}}}'
+ --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_products","arguments":{"service":"daiso","query":"수납박스","pageSize":3}}}'

웹 읽기 기능으로 이 페이지를 읽는 것만으로 MCP 연결이 생기지는 않습니다. 샌드박스나 셸 없이 웹 읽기만 가능한 채팅 에이전트는 위 HTML REST 링크를 바로 여세요. 오류를 상품 없음으로 해석하지 마세요.

짧은 시작 안내 · 전체 API 사용 안내 · 개발자용 서비스·도구 목록 JSON

지원 서비스

${services}

diff --git a/src/pages/promptReferenceText.ts b/src/pages/promptReferenceText.ts index e3e93beb..c32f0fd6 100644 --- a/src/pages/promptReferenceText.ts +++ b/src/pages/promptReferenceText.ts @@ -66,11 +66,11 @@ export function buildPromptReferenceText(baseUrl: string): string { ## 에이전트 실행 레시피 - "콜라 어디가 싸?", "컵라면 가격 비교해줘": 먼저 \`compare_products\` 또는 /api/compare/products를 호출합니다. 실제 재고와 매장 행사가가 필요하면 최저가 후보의 서비스별 재고/상품 도구로 한 번 더 확인합니다. -- "강남역 근처 카페", "성수동 브런치 음식점": 브랜드를 되묻지 말고 \`places_search_nearby\` 또는 /api/places/search를 호출합니다. 이 검색은 키워드 기반이므로 좌표 반경 결과처럼 단정하지 않습니다. +- "강남역 근처 카페", "성수동 브런치 음식점": 브랜드를 되묻지 말고 \`find_places\` (service=places) 또는 /api/places/search를 호출합니다. 이 검색은 키워드 기반이므로 좌표 반경 결과처럼 단정하지 않습니다. - "다이소 핫식스 재고", "올리브영 두바이초콜릿 있어?": 상품이 브랜드와 어색해 보여도 브랜드를 바꾸지 말고 먼저 다이소로 검색하거나 명시된 서비스로 검색합니다. 결과가 없을 때만 다른 서비스 대안을 제안합니다. - "GS25 강남 오감자 재고": 상품명과 위치를 분리해 상품 후보를 먼저 찾고, 선택한 itemCode나 keyword와 storeKeyword로 재고를 확인합니다. - "오늘 강남 CGV 시간표": 오늘 날짜는 KST 기준 YYYYMMDD로 계산하고, theaterCode가 없으면 극장 검색 후 시간표를 조회합니다. -- MCP 기능이 제대로 동작하지 않거나 개선/신규 기능 요청이 있으면 \`submit_developer_request\` 또는 /api/feedback/requests를 사용해 개발자에게 전달합니다. 제목과 설명은 필수입니다. +- MCP 기능이 제대로 동작하지 않거나 개선/신규 기능 요청이 있으면 \`submit_request\` (requestType=developer, request에 제목과 설명) 또는 /api/feedback/requests를 사용해 개발자에게 전달합니다. 제목과 설명은 필수입니다. - 도구 호출이 실패하면 같은 도구를 한 번 더 좁은 조건으로 재시도합니다. 두 번 실패하면 실패한 서비스와 요청 조건을 밝히고 추측으로 결과를 만들지 않습니다. - 결과에 imageUrl이 있으면 각 항목 이미지 표시를 우선합니다. 쿼리 문자열이 있는 이미지 URL도 그대로 사용합니다. @@ -113,7 +113,7 @@ export function buildPromptReferenceText(baseUrl: string): string { 12. **올리브영 재고 해석**: inventory.products[].storeInventory.stores[]가 있으면 그 매장별 stockLabel과 remainQuantity를 우선 사용하고, inStock는 그 주변 매장 기준 결과로 해석합니다. 13. **올리브영 상품 이미지 표시**: /api/oliveyoung/products 또는 oliveyoung_search_products 결과에 imageUrl이 있으면 목록형 답변에서도 각 상품 이미지를 생략하지 말고 모두 렌더링합니다. 특히 \`?l=ko\` 같은 query string을 삭제하지 말고 전체 URL 그대로 마크다운 이미지 src에 넣습니다. 14. **통합 가격 후보 비교**: 사용자가 브랜드를 특정하지 않고 "어디가 싸?", "비교해줘"처럼 물으면 \`compare_products\` 또는 /api/compare/products를 먼저 사용합니다. 새 외부 키 없이 기존 상품 검색을 묶은 1차 가격 후보이므로 실제 재고/행사가는 각 서비스 상세 조회로 확인해야 합니다. -15. **주변 음식점/카페 검색**: 음식점이나 카페만 물으면 \`places_search_nearby\` 또는 /api/places/search를 먼저 사용합니다. 이 기능은 네이버 지역 검색 기반 키워드 검색이므로 좌표 반경 검색처럼 단정하지 말고, \`searchMode: "keyword"\`임을 필요할 때 짧게 밝혀주세요. +15. **주변 음식점/카페 검색**: 음식점이나 카페만 물으면 \`find_places\` (service=places) 또는 /api/places/search를 먼저 사용합니다. 이 기능은 네이버 지역 검색 기반 키워드 검색이므로 좌표 반경 검색처럼 단정하지 말고, \`searchMode: "keyword"\`임을 필요할 때 짧게 밝혀주세요. --- @@ -122,34 +122,20 @@ export function buildPromptReferenceText(baseUrl: string): string { MCP를 지원하는 AI 에이전트(Claude 등)는 더 풍부한 기능을 사용할 수 있습니다. MCP 연결 정보: ${baseUrl}/mcp -지원 도구: -- daiso_search_products: 제품 검색 -- daiso_find_stores: 매장 검색 -- daiso_check_inventory: 재고 확인 -- daiso_get_price_info: 가격 정보 조회 -- daiso_get_display_location: 진열 위치 조회 -- gs25_find_nearby_stores: GS25 주변 매장 탐색 -- gs25_search_products: GS25 상품 검색 -- gs25_check_inventory: GS25 재고 조회 -- seveneleven_search_products: 세븐일레븐 상품 검색 -- seveneleven_search_stores: 세븐일레븐 매장 검색 -- seveneleven_check_inventory: 세븐일레븐 재고 조회 -- seveneleven_get_search_popwords: 세븐일레븐 인기 검색어 조회 -- seveneleven_get_catalog_snapshot: 세븐일레븐 카탈로그 조회 -- compare_products: 키 없는 통합 상품 가격 후보 비교 -- submit_developer_request: MCP 오류, 개선 요청, 신규 기능 요청을 개발자에게 전달 -- places_search_nearby: 음식점/카페 등 주변 장소 검색 -- oliveyoung_search_products: 올리브영 상품 검색 -- oliveyoung_find_nearby_stores: 올리브영 주변 매장 탐색 -- oliveyoung_check_inventory: 올리브영 재고 파악 -- megabox_find_nearby_theaters: 메가박스 주변 지점 탐색 -- megabox_list_now_showing: 메가박스 영화 목록 조회 -- megabox_get_remaining_seats: 메가박스 잔여 좌석 조회 -- lottecinema_find_nearby_theaters: 롯데시네마 주변 지점 탐색 -- lottecinema_list_now_showing: 롯데시네마 영화 목록 조회 -- lottecinema_get_remaining_seats: 롯데시네마 잔여 좌석 조회 -- cgv_find_theaters: CGV 극장 검색 -- cgv_search_movies: CGV 영화 검색 -- cgv_get_timetable: CGV 시간표 조회 +공개 도구는 정확히 10개이며 앞으로도 늘리지 않습니다. 서비스는 service, 작업은 operation으로 선택합니다. +- search_products: 상품 검색 +- find_stores: 매장 검색 +- check_inventory: 재고 조회 +- get_product_details: 상품 상세·가격·진열 위치 +- find_places: 주변 장소·영화관 검색 +- get_showtimes: 영화·시간표·잔여석 +- get_fuel_info: 유가·주유소 조회 +- get_catalog: 인기 검색어·기획전 +- compare_products: 상품 비교 +- submit_request: 요청 제출 (현재 requestType=developer) + +예: search_products {"service":"daiso","query":"수납박스","pageSize":3} +예: check_inventory {"service":"gs25","keyword":"오감자","storeKeyword":"강남"} +기존 서비스별 도구 이름은 deprecated 호출 별칭으로만 유지하며 tools/list에 표시하지 않습니다. 새 에이전트는 위 10개만 사용하세요. 구형 매핑: https://github.com/hmmhmmhm/daiso-mcp/blob/main/docs/mcp-tools.md `; } diff --git a/tests/app/app-info-pages.test.ts b/tests/app/app-info-pages.test.ts index a9732b98..7ebe2799 100644 --- a/tests/app/app-info-pages.test.ts +++ b/tests/app/app-info-pages.test.ts @@ -164,44 +164,44 @@ describe('GET /', () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('daiso_search_products'); - expect(data.tools).toContain('daiso_find_stores'); - expect(data.tools).toContain('daiso_check_inventory'); - expect(data.tools).toContain('daiso_get_price_info'); + expect(data.tools).toContain('search_products'); + expect(data.tools).toContain('find_stores'); + expect(data.tools).toContain('check_inventory'); + expect(data.tools).toContain('get_product_details'); }); it('올리브영 도구들이 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('oliveyoung_find_nearby_stores'); - expect(data.tools).toContain('oliveyoung_check_inventory'); + expect(data.tools).toContain('find_stores'); + expect(data.tools).toContain('check_inventory'); }); it('CU 도구들이 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('cu_find_nearby_stores'); - expect(data.tools).toContain('cu_check_inventory'); + expect(data.tools).toContain('find_stores'); + expect(data.tools).toContain('check_inventory'); }); it('GS25 도구들이 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('gs25_find_nearby_stores'); - expect(data.tools).toContain('gs25_search_products'); - expect(data.tools).toContain('gs25_check_inventory'); + expect(data.tools).toContain('find_stores'); + expect(data.tools).toContain('search_products'); + expect(data.tools).toContain('check_inventory'); }); it('이마트24 도구들이 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('emart24_find_nearby_stores'); - expect(data.tools).toContain('emart24_search_products'); - expect(data.tools).toContain('emart24_check_inventory'); + expect(data.tools).toContain('find_stores'); + expect(data.tools).toContain('search_products'); + expect(data.tools).toContain('check_inventory'); }); it('종료된 롯데마트 도구들은 포함하지 않는다', async () => { @@ -216,51 +216,51 @@ describe('GET /', () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('megabox_find_nearby_theaters'); - expect(data.tools).toContain('megabox_list_now_showing'); - expect(data.tools).toContain('megabox_get_remaining_seats'); + expect(data.tools).toContain('find_places'); + expect(data.tools).toContain('get_showtimes'); + expect(data.tools).toContain('get_showtimes'); }); it('CGV 도구들이 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('cgv_find_theaters'); - expect(data.tools).toContain('cgv_search_movies'); - expect(data.tools).toContain('cgv_get_timetable'); + expect(data.tools).toContain('find_places'); + expect(data.tools).toContain('get_showtimes'); + expect(data.tools).toContain('get_showtimes'); }); it('롯데시네마 도구들이 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('lottecinema_find_nearby_theaters'); - expect(data.tools).toContain('lottecinema_list_now_showing'); - expect(data.tools).toContain('lottecinema_get_remaining_seats'); + expect(data.tools).toContain('find_places'); + expect(data.tools).toContain('get_showtimes'); + expect(data.tools).toContain('get_showtimes'); }); it('세븐일레븐 도구들이 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('seveneleven_search_products'); - expect(data.tools).toContain('seveneleven_search_stores'); - expect(data.tools).toContain('seveneleven_get_search_popwords'); - expect(data.tools).toContain('seveneleven_get_catalog_snapshot'); + expect(data.tools).toContain('search_products'); + expect(data.tools).toContain('find_stores'); + expect(data.tools).toContain('get_catalog'); + expect(data.tools).toContain('get_catalog'); }); it('장소 검색 도구가 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('places_search_nearby'); + expect(data.tools).toContain('find_places'); }); it('개발자 요청 제출 도구가 포함되어 있다', async () => { const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); - expect(data.tools).toContain('submit_developer_request'); + expect(data.tools).toContain('submit_request'); }); it('통합 비교 도구가 포함되어 있다', async () => { @@ -380,7 +380,7 @@ describe('문서 페이지', () => { expect(res.headers.get('Cache-Control')).toBe('public, max-age=3600'); const text = await res.text(); expect(text).toContain('https://guide.example/prompt'); - expect(text).toContain('daiso_search_products'); + expect(text).toContain('search_products'); expect(mockFetch.mock.calls.length).toBe(callsBefore); }); diff --git a/tests/app/app-mcp-client.test.ts b/tests/app/app-mcp-client.test.ts index 205a7913..b21ec4c3 100644 --- a/tests/app/app-mcp-client.test.ts +++ b/tests/app/app-mcp-client.test.ts @@ -77,6 +77,14 @@ describe('MCP client smoke', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); + it('통합 상품 상세에서 한국 가격 응답을 일본 통화로 제한하지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(createMockProductResponse([{ PD_NO:'1049516', PDNM:'수납박스', PD_PRC:'5000' }],1)))); + const client=await createLocalMcpClient(); + try {const result=await client.callTool({name:'get_product_details',arguments:{service:'daiso',operation:'price',productId:'1049516'}}); + expect(result.isError).not.toBe(true);expect(result.structuredContent).toMatchObject({currency:'KRW',currentPrice:5000}); + } finally {await client.close();} + }); + it('SDK 도구 목록의 모든 도구가 outputSchema를 노출한다', async () => { const client = await createLocalMcpClient(); try { @@ -95,12 +103,12 @@ describe('MCP client smoke', () => { const tools = await client.listTools(); const byName = new Map(tools.tools.map((tool) => [tool.name, tool])); - expect(byName.get('daiso_search_products')?.outputSchema?.properties).toHaveProperty('products'); - expect(byName.get('daiso_find_stores')?.outputSchema?.properties).toHaveProperty('stores'); - expect(byName.get('gs25_check_inventory')?.outputSchema?.properties).toHaveProperty('inventory'); - expect(byName.get('cgv_find_theaters')?.outputSchema?.properties).toHaveProperty('theaters'); - expect(byName.get('megabox_list_now_showing')?.outputSchema?.properties).toHaveProperty('movies'); - expect(byName.get('seveneleven_get_search_popwords')?.outputSchema?.properties).toHaveProperty('keywords'); + expect(byName.get('search_products')?.outputSchema?.properties).toHaveProperty('products'); + expect(byName.get('find_stores')?.outputSchema?.properties).toHaveProperty('stores'); + expect(byName.get('check_inventory')?.outputSchema?.properties).toHaveProperty('inventory'); + expect(byName.get('find_places')?.outputSchema?.properties).toHaveProperty('theaters'); + expect(byName.get('get_showtimes')?.outputSchema?.properties).toHaveProperty('movies'); + expect(byName.get('get_catalog')?.outputSchema?.properties).toHaveProperty('keywords'); } finally { await client.close(); } @@ -140,13 +148,13 @@ describe('MCP client smoke', () => { const client = await createLocalMcpClient(); try { const tools = await client.listTools(); - const daisoTool = tools.tools.find((tool) => tool.name === 'daiso_find_inventory_by_name'); + const daisoTool = tools.tools.find((tool) => tool.name === 'check_inventory'); expect(daisoTool).toBeTruthy(); expect(daisoTool?.outputSchema?.properties).toHaveProperty('summary'); const result = await client.callTool({ - name: 'daiso_find_inventory_by_name', - arguments: { query: '수납박스', storeQuery: '강남역', pageSize: 1, productLimit: 1 }, + name: 'check_inventory', + arguments: { service: 'daiso', operation: 'by_name', query: '수납박스', storeQuery: '강남역', pageSize: 1, productLimit: 1 }, }); const content = 'content' in result ? result.content : []; const firstText = content[0]?.type === 'text' ? content[0].text : ''; diff --git a/tests/app/compact-contract.test.ts b/tests/app/compact-contract.test.ts new file mode 100644 index 00000000..c0ad6a2e --- /dev/null +++ b/tests/app/compact-contract.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from 'vitest'; +import app from '../../src/index.js'; +import { PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js'; +function request(path: string, method: string, params?: unknown) { + return app.fetch( + new Request('https://local.test' + path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }), + ); +} +it('루트 공개 목록과 발견 정보는 통합 도구10개만 표시한다', async () => { + const root = await (await app.request('/root.json')).json(); + expect(root.totalTools).toBe(10); + expect(root.tools).toEqual([...PUBLIC_TOOL_NAMES]); + expect( + root.services + .flatMap((s: { tools: string[] }) => s.tools) + .every((name: string) => PUBLIC_TOOL_NAMES.includes(name)), + ).toBe(true); + const r = await request('/', 'tools/list', {}); + expect(r.status).toBe(200); + const line = (await r.text()).split('\n').find((l) => l.startsWith('data: '))!; + const j = JSON.parse(line.slice(6)); + expect(j.result.tools.map((t: { name: string }) => t.name)).toEqual([...PUBLIC_TOOL_NAMES]); + expect( + JSON.stringify( + j.result.tools.find((t: { name: string }) => t.name === 'submit_request').inputSchema, + ), + ).not.toContain('"propertyNames"'); + expect( + j.result.tools.find((t: { name: string }) => t.name === 'submit_request').inputSchema.properties + .request.properties.userContext, + ).toBeDefined(); +}); diff --git a/tests/core/compact/server.test.ts b/tests/core/compact/server.test.ts new file mode 100644 index 00000000..2fd3e896 --- /dev/null +++ b/tests/core/compact/server.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { ServiceRegistry } from '../../../src/core/registry.js'; +import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../../src/core/compact/catalog.js'; +import { applyCompactTools } from '../../../src/core/compact/server.js'; + +describe('실제 MCP 프로토콜 10개 목록과 deprecated 호환', () => { + it('SDK 검증을 거쳐 신형과 구형을 모두 실행하고 목록에는10개만 표시한다', async () => { + const registry = new ServiceRegistry(); + const response = { + content: [ + { type: 'text' as const, text: JSON.stringify({ product: { id: '123' }, quantity: null }) }, + ], + }; + const handler = vi.fn(async () => response); + registry.register(() => ({ + metadata: { id: 'all', name: 'all', version: '1' }, + getTools: () => + COMPACT_CATALOG.flatMap((g) => + g.routes.map((r) => ({ + name: r.legacy, + metadata: { + title: r.legacy, + description: r.legacy, + inputSchema: { query: z.string(), page: z.number().default(1) }, + }, + handler, + })), + ), + })); + const server = new McpServer({ name: 'compact-test', version: '1' }); + applyCompactTools(server, registry); + const client = new Client({ name: 'test', version: '1' }); + const [a, b] = InMemoryTransport.createLinkedPair(); + await server.connect(a); + await client.connect(b); + try { + const list = await client.listTools(); + expect(list.tools.map((t) => t.name)).toEqual([...PUBLIC_TOOL_NAMES]); + expect(list.nextCursor).toBeUndefined(); + expect(list.tools.every((t) => t.inputSchema.type === 'object')).toBe(true); + const modern = await client.callTool({ + name: 'search_products', + arguments: { service: 'daiso', query: '테ープ' }, + }); + const old = await client.callTool({ + name: 'daiso_search_products', + arguments: { query: 'テープ' }, + }); + expect(modern.isError).not.toBe(true); + expect(old.isError).not.toBe(true); + expect(old.content).toEqual(modern.content); + expect(old.structuredContent).toEqual(modern.structuredContent); + expect(old._meta).toMatchObject({ deprecated: true, replacement: 'search_products' }); + expect(handler).toHaveBeenCalledWith({ query: 'テープ', page: 1 }); + const bad = await client.callTool({ + name: 'search_products', + arguments: { service: 'daiso' }, + }); + expect(bad.isError).toBe(true); + const legacyBad = await client.callTool({ + name: 'daiso_search_products', + arguments: { query: 1 }, + }); + expect(legacyBad.isError).toBe(true); + const typo = await client.callTool({ + name: 'search_products', + arguments: { service: 'daiso', query: 'x', typo: true }, + }); + expect(typo.isError).toBe(true); + expect(handler).toHaveBeenCalledTimes(2); + handler.mockResolvedValueOnce({ + isError: true, + content: [{ type: 'text', text: 'unavailable' }], + } as typeof response); + const legacyError = await client.callTool({ + name: 'daiso_search_products', + arguments: { query: 'x' }, + }); + expect(legacyError.isError).toBe(true); + expect(legacyError._meta).toMatchObject({ deprecated: true }); + handler.mockRejectedValueOnce(new Error('upstream failure')); + expect( + ( + await client.callTool({ + name: 'search_products', + arguments: { service: 'daiso', query: 'x' }, + }) + ).isError, + ).toBe(true); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/tests/core/compact/tools.test.ts b/tests/core/compact/tools.test.ts new file mode 100644 index 00000000..43dbdc0a --- /dev/null +++ b/tests/core/compact/tools.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod'; +import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../../src/core/compact/catalog.js'; +import { createCompactTools, assertCompactCoverage } from '../../../src/core/compact/tools.js'; + +function fixtures() { + return [ + ...new Set(COMPACT_CATALOG.flatMap((group) => group.routes.map((route) => route.legacy))), + ].map((name) => ({ + name, + metadata: { + title: name, + description: name, + inputSchema: { keyword: z.string().optional(), limit: z.number().default(5) }, + }, + handler: vi.fn(async (args: unknown) => ({ + content: [{ type: 'text' as const, text: JSON.stringify({ name, args }) }], + })), + })); +} +describe('공개 MCP 10개 계약', () => { + it('기존44개 작업 전체를 중복 없이 매핑한다', () => { + const tools = fixtures(); + expect(tools).toHaveLength(44); + expect(PUBLIC_TOOL_NAMES).toHaveLength(10); + expect(createCompactTools(tools).map((t) => t.name)).toEqual([...PUBLIC_TOOL_NAMES]); + expect(() => + assertCompactCoverage([...tools, { ...tools[0], name: 'new_unmapped_tool' }]), + ).toThrow('매핑'); + expect(() => assertCompactCoverage(tools.slice(1))).toThrow('매핑'); + }); + it('모든 서비스/작업은 기존 핸들러에 원래 인수와 기본값으로 전달한다', async () => { + const legacy = fixtures(); + const compact = createCompactTools(legacy); + for (const group of COMPACT_CATALOG) { + const tool = compact.find((t) => t.name === group.name)!; + for (const route of group.routes) { + const control = { + ...(route.service ? { service: route.service } : {}), + ...(route.operation ? { operation: route.operation } : {}), + }; + const args = + group.name === 'submit_request' + ? { requestType: 'developer', request: { keyword: '테스트' } } + : { ...control, keyword: '테스트' }; + const out = await tool.handler(args); + expect(out.content[0].text).toBe( + JSON.stringify({ name: route.legacy, args: { keyword: '테스트', limit: 5 } }), + ); + } + } + }); + it('잘못된 조합과 인수,알 수 없는 요청 유형은 외부 호출 없이 실패한다', async () => { + const legacy = fixtures(); + const tools = createCompactTools(legacy); + const invoke = (name: string, args: unknown) => + tools.find((t) => t.name === name)!.handler(args); + await expect(invoke('search_products', { service: 'unknown' })).rejects.toThrow(); + await expect(invoke('get_showtimes', { service: 'cgv', operation: 'seats' })).rejects.toThrow(); + await expect(invoke('search_products', { service: 'daiso', typo: true })).rejects.toThrow(); + await expect(invoke('search_products', { service: 'daiso', limit: 'five' })).rejects.toThrow(); + await expect( + invoke('submit_request', { requestType: 'payment', request: {} }), + ).rejects.toThrow(); + await expect(invoke('submit_request', { request: { typo: 1 } })).rejects.toThrow(); + expect( + legacy.every((t) => (t.handler as ReturnType).mock.calls.length === 0), + ).toBe(true); + }); + it('실패/미확인 재고 응답을 그대로 반환하고 요청 제출은 기존 저장 동작만 쓴다', async () => { + const legacy = fixtures(); + const target = legacy.find((t) => t.name === 'cu_check_inventory')!; + target.handler = vi.fn(async () => ({ + isError: true, + content: [{ type: 'text', text: 'unknown' }], + })); + const tools = createCompactTools(legacy); + expect( + await tools.find((t) => t.name === 'check_inventory')!.handler({ service: 'cu' }), + ).toEqual({ isError: true, content: [{ type: 'text', text: 'unknown' }] }); + await tools.find((t) => t.name === 'submit_request')!.handler({ request: {} }); + expect(legacy.find((t) => t.name === 'submit_developer_request')!.handler).toHaveBeenCalledWith( + { limit: 5 }, + ); + }); +}); diff --git a/tests/pages/agentStart.test.ts b/tests/pages/agentStart.test.ts index 37715dae..e1278f81 100644 --- a/tests/pages/agentStart.test.ts +++ b/tests/pages/agentStart.test.ts @@ -10,7 +10,7 @@ describe('에이전트 시작 안내', () => { expect(body).toBeDefined(); expect(JSON.parse(body!)).toEqual({ jsonrpc: '2.0', id: 1, method: 'tools/call', - params: { name: 'daiso_search_products', arguments: { query: '수납박스', pageSize: 3 } }, + params: { name: 'search_products', arguments: { service: 'daiso', query: '수납박스', pageSize: 3 } }, }); expect(text).toContain('https://example.com/'); expect(text).toContain('application/json, text/event-stream'); diff --git a/tests/pages/discovery.test.ts b/tests/pages/discovery.test.ts index 54093e40..8a6866ba 100644 --- a/tests/pages/discovery.test.ts +++ b/tests/pages/discovery.test.ts @@ -9,17 +9,17 @@ describe('공개 발견 자산', () => { expect(await res.text()).toContain('설치 없이'); const metadata = await app.request('/root.json'); expect(metadata.headers.get('Content-Type')).toContain('application/json'); - expect((await metadata.json()).totalTools).toBe(44); + expect((await metadata.json()).totalTools).toBe(10); }); it('현재 레지스트리의 목록을 그대로 제공하며 설치 없는 첫 조회를 연결한다', async () => { const info = await (await app.request('/', { headers: { Accept: 'application/json' } })).json(); const assets = buildDiscoveryAssets(info); expect(JSON.parse(assets['root.json'])).toEqual(info); - expect(info.totalTools).toBe(44); + expect(info.totalTools).toBe(10); expect(info.services.some((s: { id: string }) => s.id === 'lottemart')).toBe(false); expect(assets['discovery.html']).toContain(''); expect(assets['discovery.html']).toContain('설치 없이'); - expect(assets['discovery.html']).toContain('daiso_search_products'); + expect(assets['discovery.html']).toContain('search_products'); expect(assets['discovery.html']).toContain('/api/daiso/products?q='); expect(assets['discovery.html']).toContain('https://mcp.aka.page/'); expect(assets['discovery.html']).not.toContain('lottemart'); diff --git a/tests/pages/prompt.test.ts b/tests/pages/prompt.test.ts index a5e1ab3b..a7c10698 100644 --- a/tests/pages/prompt.test.ts +++ b/tests/pages/prompt.test.ts @@ -111,32 +111,11 @@ describe('generatePromptText', () => { const text = generatePromptText('https://example.com'); expect(text).toContain('https://example.com/mcp'); - expect(text).toContain('daiso_search_products'); - expect(text).toContain('daiso_find_stores'); - expect(text).toContain('daiso_check_inventory'); - expect(text).toContain('daiso_get_price_info'); - expect(text).toContain('daiso_get_display_location'); + for (const name of ['search_products','find_stores','check_inventory','get_product_details','find_places','get_showtimes','get_fuel_info','get_catalog','compare_products','submit_request']) expect(text).toContain(name); expect(text).not.toContain('lottemart_find_nearby_stores'); expect(text).not.toContain('lottemart_search_products'); - expect(text).toContain('gs25_find_nearby_stores'); - expect(text).toContain('gs25_search_products'); - expect(text).toContain('gs25_check_inventory'); - expect(text).toContain('seveneleven_check_inventory'); - expect(text).toContain('oliveyoung_search_products'); - expect(text).toContain('oliveyoung_find_nearby_stores'); - expect(text).toContain('oliveyoung_check_inventory'); - expect(text).toContain('megabox_find_nearby_theaters'); - expect(text).toContain('megabox_list_now_showing'); - expect(text).toContain('megabox_get_remaining_seats'); - expect(text).toContain('lottecinema_find_nearby_theaters'); - expect(text).toContain('lottecinema_list_now_showing'); - expect(text).toContain('lottecinema_get_remaining_seats'); - expect(text).toContain('cgv_find_theaters'); - expect(text).toContain('cgv_search_movies'); - expect(text).toContain('cgv_get_timetable'); - expect(text).toContain('places_search_nearby'); - expect(text).toContain('submit_developer_request'); - expect(text).toContain('/api/feedback/requests'); + expect(text).toContain('service'); + expect(text).toContain('operation'); }); it('사용 팁을 포함한다', () => { @@ -203,7 +182,7 @@ describe('generatePromptText', () => { expect(text).toContain('콜라 어디가 싸?'); expect(text).toContain('compare_products'); expect(text).toContain('강남역 근처 카페'); - expect(text).toContain('places_search_nearby'); + expect(text).toContain('find_places'); expect(text).toContain('다이소 핫식스 재고'); expect(text).toContain('브랜드를 바꾸지 말고 먼저 다이소로 검색'); expect(text).toContain('실패하면 같은 도구를 한 번 더 좁은 조건으로 재시도'); diff --git a/tests/scripts/mcp-smoke.test.ts b/tests/scripts/mcp-smoke.test.ts index 2ae728b2..77d48128 100644 --- a/tests/scripts/mcp-smoke.test.ts +++ b/tests/scripts/mcp-smoke.test.ts @@ -1,3 +1,4 @@ +import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js'; /** * MCP smoke 스크립트 테스트 */ @@ -46,9 +47,10 @@ describe('runMcpSmoke', () => { it('MCP 클라이언트로 도구 목록과 대표 도구 호출을 검증한다', async () => { const close = vi.fn(); const listTools = vi.fn().mockResolvedValue({ - tools: MCP_SMOKE_TOOL_NAMES.map((name) => ({ name })), + tools: PUBLIC_TOOL_NAMES.map((name) => ({ name })), }); - const callTool = vi.fn().mockImplementation(({ name }: { name: string }) => { + const callTool = vi.fn().mockImplementation(({ name, arguments: args }: { name: string; arguments: {service?:string;operation?:string} }) => { + const key = COMPACT_CATALOG.find(g=>g.name===name)!.routes.find(r=>r.service===args.service && r.operation===args.operation)!.legacy; const payloads: Record = { daiso_find_inventory_by_name: { query: '수납박스', @@ -65,7 +67,7 @@ describe('runMcpSmoke', () => { }; return Promise.resolve({ - content: [{ type: 'text', text: JSON.stringify(payloads[name]) }], + content: [{ type: 'text', text: JSON.stringify(payloads[key]) }], }); }); @@ -79,8 +81,8 @@ describe('runMcpSmoke', () => { expect(listTools).toHaveBeenCalledOnce(); expect(callTool).toHaveBeenCalledTimes(MCP_SMOKE_SCENARIOS.length); expect(callTool).toHaveBeenCalledWith({ - name: 'gs25_search_products', - arguments: expect.objectContaining({ keyword: '콜라' }), + name: 'search_products', + arguments: expect.objectContaining({ service:'gs25', keyword: '콜라' }), }); expect(close).toHaveBeenCalledOnce(); }); @@ -88,7 +90,7 @@ describe('runMcpSmoke', () => { it('service가 지정되면 해당 서비스 도구만 호출한다', async () => { const close = vi.fn(); const listTools = vi.fn().mockResolvedValue({ - tools: MCP_SMOKE_TOOL_NAMES.map((name) => ({ name })), + tools: PUBLIC_TOOL_NAMES.map((name) => ({ name })), }); const callTool = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: JSON.stringify({ keyword: '콜라', products: [{}] }) }], @@ -104,8 +106,8 @@ describe('runMcpSmoke', () => { expect(exitCode).toBe(0); expect(callTool).toHaveBeenCalledOnce(); expect(callTool).toHaveBeenCalledWith({ - name: 'gs25_search_products', - arguments: expect.objectContaining({ keyword: '콜라' }), + name: 'search_products', + arguments: expect.objectContaining({ service:'gs25', keyword: '콜라' }), }); expect(close).toHaveBeenCalledOnce(); }); @@ -114,7 +116,7 @@ describe('runMcpSmoke', () => { const errors: string[] = []; const close = vi.fn(); const listTools = vi.fn().mockResolvedValue({ - tools: MCP_SMOKE_TOOL_NAMES.map((name) => ({ name })), + tools: PUBLIC_TOOL_NAMES.map((name) => ({ name })), }); const callTool = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: JSON.stringify({ summary: {} }) }], @@ -148,7 +150,7 @@ describe('MCP 도구 오류 응답', () => { const exitCode = await runMcpSmoke({ service: 'gs25', createClient: async () => ({ - listTools: async () => ({ tools: [{ name: 'gs25_search_products' }] }), + listTools: async () => ({ tools: PUBLIC_TOOL_NAMES.map(name=>({name})) }), callTool: async () => ({ isError: true, content: text === undefined ? [] : [{ type: 'text', text }], @@ -169,7 +171,7 @@ describe('MCP 도구 오류 응답', () => { const exitCode = await runMcpSmoke({ service: 'gs25', createClient: async () => ({ - listTools: async () => ({ tools: [{ name: 'gs25_search_products' }] }), + listTools: async () => ({ tools: PUBLIC_TOOL_NAMES.map(name=>({name})) }), callTool: async () => ({ isError: false, content: [{ type: 'text', text }] }), close: async () => undefined, }), @@ -179,3 +181,15 @@ describe('MCP 도구 오류 응답', () => { expect(exitCode).toBe(1); }); }); + +describe('공개 도구 개수 회귀 방지',()=>{ + it.each([ + [...PUBLIC_TOOL_NAMES,'eleventh_tool'], + [...PUBLIC_TOOL_NAMES.slice(1),'legacy_name'], + [...PUBLIC_TOOL_NAMES.slice(1),PUBLIC_TOOL_NAMES[1]], + ].map(names=>[names]))('11번째/미등록/중복 도구 목록을 호출 전에 거절한다',async names=>{ + const callTool=vi.fn();const close=vi.fn(); + const code=await runMcpSmoke({service:'gs25',createClient:async()=>({listTools:async()=>({tools:names.map(name=>({name}))}),callTool,close}),writeOut:()=>undefined,writeErr:()=>undefined}); + expect(code).toBe(1);expect(callTool).not.toHaveBeenCalled();expect(close).toHaveBeenCalledOnce(); + }); +});