From 8bdaddf7339207b5b967d87b545b80f9da5f6414 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 21 Oct 2026 17:02:30 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20PlayMCP=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EA=B3=BC=20=EC=8B=A4=ED=96=89=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EC=9D=BC=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/compact/catalog.ts | 4 +- src/core/compact/tools.ts | 56 +++++++-- src/services/daiso/tools/searchProducts.ts | 17 ++- tests/app/playmcp-workflow.test.ts | 125 ++++++++++++++++++++ tests/core/compact/playmcp-contract.test.ts | 109 +++++++++++++++++ 5 files changed, 299 insertions(+), 12 deletions(-) create mode 100644 tests/app/playmcp-workflow.test.ts create mode 100644 tests/core/compact/playmcp-contract.test.ts diff --git a/src/core/compact/catalog.ts b/src/core/compact/catalog.ts index fb462f47..2c88965e 100644 --- a/src/core/compact/catalog.ts +++ b/src/core/compact/catalog.ts @@ -46,7 +46,7 @@ export const COMPACT_CATALOG: CompactGroup[] = [ name: 'check_inventory', title: '상품 재고 조회', description: - '실제 매장 재고 확인. 다이소는 operation=by_name이면 query, by_id이면 productId를 입력합니다. 그 외 서비스는 keyword 또는 해당 상품 코드를 입력합니다. 실패나 미확인 수량은 품절이 아닙니다.', + '실제 매장 재고 확인. 다이소는 operation=by_name이면 query, by_id이면 productId를 입력합니다. 그 외 서비스는 keyword 또는 해당 상품 코드를 입력합니다. storeQuery는 다이소 위치, storeLimit은 매장 수(pageSize와 같은 값), productLimit은 후보 수이며 실제 조회 상품은 selectedProduct 하나입니다. 실패나 미확인 수량은 품절이 아닙니다.', routes: [ { service: 'daiso', operation: 'by_id', legacy: 'daiso_check_inventory' }, { service: 'daiso', operation: 'by_name', legacy: 'daiso_find_inventory_by_name' }, @@ -115,7 +115,7 @@ export const COMPACT_CATALOG: CompactGroup[] = [ name: 'get_catalog', title: '인기 검색어와 기획전', description: - '세븐일레븐 공개 카탈로그. operation=popwords 인기 검색어, snapshot 기획전/이슈 목록입니다. 실시간 재고를 의미하지 않습니다.', + '세븐일레븐 공개 카탈로그. operation=popwords 인기 검색어, snapshot 기획전/이슈 목록입니다. operation은 필수입니다. includeIssues/includeExhibition/limit은 snapshot 전용이며 popwords에는 label만 지정합니다. 실시간 재고를 의미하지 않습니다.', routes: [ { operation: 'popwords', legacy: 'seveneleven_get_search_popwords' }, { operation: 'snapshot', legacy: 'seveneleven_get_catalog_snapshot' }, diff --git a/src/core/compact/tools.ts b/src/core/compact/tools.ts index 4e42d30a..efdff190 100644 --- a/src/core/compact/tools.ts +++ b/src/core/compact/tools.ts @@ -20,23 +20,36 @@ export function assertCompactCoverage(legacy: ToolRegistration[]): void { } } function withoutDefault(schema: z.ZodType): z.ZodType { - if (schema instanceof z.ZodDefault) return withoutDefault(schema.removeDefault() as z.ZodType); + if (schema instanceof z.ZodDefault) { + const inner = withoutDefault(schema.removeDefault() as z.ZodType); + return schema.description ? inner.describe(schema.description) : inner; + } 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(); + const descriptions = 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 notes = descriptions.get(key) ?? new Set(); + notes.add( + `${[route.service, route.operation].filter(Boolean).join('/') || group.name}: ${schema.description || key}`, + ); + descriptions.set(key, notes); } } const shape: ToolInputSchema = {}; for (const [key, variants] of fields) - shape[key] = z.union(variants as [z.ZodType, ...z.ZodType[]]).optional(); + shape[key] = ( + variants.length === 1 ? variants[0] : z.union(variants as [z.ZodType, ...z.ZodType[]]) + ) + .optional() + .describe([...descriptions.get(key)!].join(' · ')); const services = [ ...new Set(group.routes.flatMap((route) => (route.service ? [route.service] : []))), ]; @@ -45,11 +58,12 @@ function mergedInputs(group: CompactGroup, legacy: Map ]; 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('조회 작업. 같은 서비스에 작업이 여러 개면 필수입니다.'); + if (operations.length) { + const operation = z.enum(operations as [string, ...string[]]); + shape.operation = (services.length ? operation.optional() : operation).describe( + '조회 작업. 같은 서비스에 작업이 여러 개면 필수입니다.', + ); + } return shape; } export function createCompactTools(legacyTools: ToolRegistration[]): ToolRegistration[] { @@ -97,7 +111,33 @@ export function createCompactTools(legacyTools: ToolRegistration[]): ToolRegistr .join(', '), ); const target = legacy.get(matches[0].legacy)!; - return target.handler(z.object(target.metadata.inputSchema).strict().parse(input)); + // 다이소의 매장 수 조건은 다른 편의점과 같은 storeLimit 이름도 받습니다. + if ( + group.name === 'check_inventory' && + service === 'daiso' && + input.storeLimit !== undefined + ) { + if (input.pageSize !== undefined && input.pageSize !== input.storeLimit) + throw new Error('storeLimit과 pageSize는 같은 매장 수를 지정해야 합니다.'); + input.pageSize = input.storeLimit; + delete input.storeLimit; + } + const ignored = Object.keys(input).filter( + (key) => !Object.hasOwn(target.metadata.inputSchema, key), + ); + const result = await target.handler(z.object(target.metadata.inputSchema).parse(input)); + if (ignored.length === 0 || result.isError) return result; + return { + ...result, + _meta: { ...result._meta, ignoredArguments: ignored }, + content: [ + ...result.content, + { + type: 'text', + text: `선택한 서비스/작업에서 지원하지 않는 옵션은 적용하지 않았습니다: ${ignored.join(', ')}. 이 옵션의 조건을 충족했다고 안내하지 마세요.`, + }, + ], + }; }, }; }); diff --git a/src/services/daiso/tools/searchProducts.ts b/src/services/daiso/tools/searchProducts.ts index d67743ff..e209398d 100644 --- a/src/services/daiso/tools/searchProducts.ts +++ b/src/services/daiso/tools/searchProducts.ts @@ -16,6 +16,7 @@ interface SearchProductsArgs { query: string; page?: number; pageSize?: number; + includeSoldOut?: boolean; } /** @@ -60,13 +61,14 @@ export async function fetchProducts( * 제품 검색 핸들러 */ async function searchProducts(args: SearchProductsArgs): Promise { - const { query, page = 1, pageSize = 30 } = args; + const { query, page = 1, pageSize = 30, includeSoldOut = true } = args; if (!query || query.trim().length === 0) { throw new Error('검색어를 입력해주세요.'); } - const { products, totalCount } = await fetchProducts(query, page, pageSize); + const { products: candidates, totalCount } = await fetchProducts(query, page, pageSize); + const products = includeSoldOut ? candidates : candidates.filter((product) => !product.soldOut); const result = { query, @@ -75,6 +77,11 @@ async function searchProducts(args: SearchProductsArgs): Promise vi.unstubAllGlobals()); +async function call(name: string, args: Record) { + const r = await app.fetch( + new Request('https://local.test/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args }, + }), + }), + { CONVENIENCE_RELAY_URL: 'https://relay.example', CONVENIENCE_RELAY_TOKEN: 'test-token' }, + ); + expect(r.status).toBe(200); + const line = (await r.text()).split('\n').find((l) => l.startsWith('data: '))!; + return JSON.parse(line.slice(6)).result; +} +it('실제 다이소 상품 검색과 인증 매장 재고를 통합 도구로 실행하고 0/미확인을 구분한다', async () => { + const stockBody: unknown[] = []; + const fetcher = vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(input); + if (url.startsWith(DAISOMALL_API.SEARCH_PRODUCTS)) + return Response.json({ + resultSet: { + result: [ + { + totalSize: 1, + resultDocuments: [{ PD_NO: '1049516', PDNM: '수납박스', PD_PRC: '1000' }], + }, + ], + }, + }); + if (url === DAISOMALL_API.ONLINE_STOCK) + return Response.json({ success: true, data: { stck: 4 } }); + if (url === DAISOMALL_API.STORE_SEARCH_V2) + return Response.json({ + success: true, + data: [1, 2, 3].map((i) => ({ + strCd: 's' + i, + strNm: '강남' + i + '점', + strAddr: '서울 강남구', + strLttd: 37.5, + strLitd: 127, + })), + }); + if (url === DAISOMALL_API.AUTH_REQUEST) + return new Response('test-token', { headers: { 'X-DM-UID': 'test-uid' } }); + if (url === DAISOMALL_API.STORE_INVENTORY_V2) { + stockBody.push(JSON.parse(String(init?.body))); + expect(new Headers(init?.headers).has('Authorization')).toBe(true); + return Response.json({ + success: true, + data: [ + { strCd: 's1', stck: 5 }, + { strCd: 's2', stck: 0 }, + ], + }); + } + throw Error('예상하지 않은 원본 요청: ' + url); + }); + vi.stubGlobal('fetch', fetcher); + const search = await call('search_products', { + service: 'daiso', + query: '수납박스', + includeSoldOut: true, + }); + expect(search.isError).not.toBe(true); + expect(search.structuredContent.products[0].id).toBe('1049516'); + const r = await call('check_inventory', { + service: 'daiso', + operation: 'by_name', + query: '수납박스', + storeQuery: '강남', + latitude: 37.5, + longitude: 127, + storeLimit: 3, + productLimit: 1, + }); + expect(r.isError).not.toBe(true); + expect(r.structuredContent.selectedProduct.id).toBe('1049516'); + expect(r.structuredContent.storeInventory).toMatchObject({ + inStockCount: 1, + outOfStockCount: 1, + unknownStockCount: 1, + }); + expect( + r.structuredContent.storeInventory.stores.map((s: { quantity: unknown }) => s.quantity), + ).toEqual([5, 0, null]); + expect(stockBody).toEqual([[1, 2, 3].map((i) => ({ pdNo: '1049516', strCd: 's' + i }))]); + expect(MCP_SMOKE_SCENARIOS[0].validate(r.structuredContent)).toBeNull(); +}); +it('카탈로그는 편의점 중계 응답을 검증하며 502를 빈 성공으로 바꾸지 않는다', async () => { + const fetcher = vi.fn(async (input: unknown) => { + if (String(input).endsWith('/seven-popwords')) + return Response.json({ success: true, data: ['커피'] }); + throw Error('예상하지 않은 중계 경로'); + }); + vi.stubGlobal('fetch', fetcher); + const good = await call('get_catalog', { + operation: 'popwords', + includeIssues: true, + includeExhibition: true, + }); + expect(good.isError).not.toBe(true); + expect(good.structuredContent).toMatchObject({ available: true, count: 1, keywords: ['커피'] }); + expect(good.content.at(-1).text).toContain('적용하지 않았습니다'); + fetcher.mockImplementation(async () => + Response.json({ error: 'Relay request failed' }, { status: 502 }), + ); + const bad = await call('get_catalog', { operation: 'snapshot' }); + expect(bad.isError).toBe(true); + expect(bad.structuredContent).toBeUndefined(); + expect(bad.content[0].text).toContain('CONVENIENCE_RELAY_FAILED'); +}); diff --git a/tests/core/compact/playmcp-contract.test.ts b/tests/core/compact/playmcp-contract.test.ts new file mode 100644 index 00000000..aaaa66f1 --- /dev/null +++ b/tests/core/compact/playmcp-contract.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as z from 'zod'; +import { COMPACT_CATALOG } from '../../../src/core/compact/catalog.js'; +import { createCompactTools } from '../../../src/core/compact/tools.js'; +import { createDaisoService } from '../../../src/services/daiso/index.js'; +import { createSevenElevenService } from '../../../src/services/seveneleven/index.js'; +import { createGs25Service } from '../../../src/services/gs25/index.js'; +import type { ToolRegistration } from '../../../src/core/types.js'; + +function actualTools() { + const real = [ + ...createDaisoService().getTools(), + ...createSevenElevenService().getTools(), + ...createGs25Service().getTools(), + ]; + const legacy: ToolRegistration[] = COMPACT_CATALOG.flatMap((g) => + g.routes.map( + (r) => + real.find((t) => t.name === r.legacy) ?? { + name: r.legacy, + metadata: { title: r.legacy, description: '', inputSchema: {} }, + handler: async () => ({ content: [{ type: 'text', text: '{}' }] }), + }, + ), + ); + return { legacy, compact: createCompactTools(legacy) }; +} +afterEach(() => vi.unstubAllGlobals()); + +describe('PlayMCP 실제 입력 계약', () => { + it('get_catalog의 실행에 필수인 operation을 공개 스키마에서도 요구한다', () => { + const { compact } = actualTools(); + const t = compact.find((t) => t.name === 'get_catalog')!; + const schema = z.toJSONSchema(z.object(t.metadata.inputSchema), { io: 'input' }); + expect(schema.required).toContain('operation'); + }); + it('실제 PlayMCP가 보낸 popwords의 다른 작업 옵션 때문에 전체 조회가 실패하지 않으며 미적용을 알린다', async () => { + const { compact } = actualTools(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ success: true, data: ['커피'] })), + ); + const r = await compact + .find((t) => t.name === 'get_catalog')! + .handler({ operation: 'popwords', includeIssues: true, includeExhibition: true }); + expect(JSON.parse(r.content[0].text)).toMatchObject({ keywords: ['커피'], available: true }); + expect(r._meta).toMatchObject({ ignoredArguments: ['includeIssues', 'includeExhibition'] }); + expect(r.content.at(-1)?.text).toContain('적용하지 않았습니다'); + }); + it('다이소 재고의 storeLimit을 실제 매장 페이지 크기로 전달한다', async () => { + const { legacy } = actualTools(); + const target = legacy.find((t) => t.name === 'daiso_find_inventory_by_name')!; + const handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: '{}' }] })); + target.handler = handler; + const t = createCompactTools(legacy).find((t) => t.name === 'check_inventory')!; + await t.handler({ + service: 'daiso', + operation: 'by_name', + query: '수납박스', + storeQuery: '강남역', + productLimit: 5, + storeLimit: 2, + }); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ pageSize: 2, productLimit: 5 })); + await expect( + t.handler({ service: 'daiso', operation: 'by_name', query: 'x', storeLimit: 2, pageSize: 3 }), + ).rejects.toThrow('storeLimit'); + }); + it.each([true, false])( + '다이소 상품 검색에서 공개된 includeSoldOut=%s를 실제로 적용한다', + async (includeSoldOut) => { + const { compact } = actualTools(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + resultSet: { + result: [ + { + totalSize: 2, + resultDocuments: [ + { PD_NO: '1', PDNM: '수납박스', PD_PRC: '1000', SOLD_OUT_YN: 'N' }, + { PD_NO: '2', PDNM: '품절 수납박스', PD_PRC: '1000', SOLD_OUT_YN: 'Y' }, + ], + }, + ], + }, + }), + ), + ); + const r = await compact + .find((t) => t.name === 'search_products')! + .handler({ service: 'daiso', query: '수납박스', includeSoldOut }); + const j = JSON.parse(r.content[0].text); + expect(j.products.map((p: { id: string }) => p.id)).toEqual( + includeSoldOut ? ['1', '2'] : ['1'], + ); + expect(j.count).toBe(includeSoldOut ? 2 : 1); + expect(j.totalCount).toBe(2); + }, + ); + it('실제 인수의 위치 설명과 허용 서비스가 합친 공개 스키마에서 사라지지 않는다', () => { + const { compact } = actualTools(); + const fields = compact.find((t) => t.name === 'check_inventory')!.metadata.inputSchema; + expect(fields.storeQuery.description).toContain('daiso'); + expect(fields.storeQuery.description).toContain('위치'); + expect(fields.storeLimit.description).toContain('gs25'); + }); +}); From d86a1f33368a3220904240dc4d58e7d70ac383cc Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 21 Oct 2026 17:02:30 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EA=B2=80=EC=83=89=EC=96=B4=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EB=B9=88=20=EC=84=B1=EA=B3=B5=EA=B3=BC=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/seveneleven/client.ts | 40 +------------------ src/services/seveneleven/popwords.ts | 36 +++++++++++++++++ tests/app/app-api-seveneleven.test.ts | 9 ++++- tests/services/seveneleven/popwords.test.ts | 28 +++++++++++++ .../tools/getSearchPopwords.test.ts | 10 ++++- 5 files changed, 83 insertions(+), 40 deletions(-) create mode 100644 src/services/seveneleven/popwords.ts create mode 100644 tests/services/seveneleven/popwords.test.ts diff --git a/src/services/seveneleven/client.ts b/src/services/seveneleven/client.ts index c6d489ba..6bc3119b 100644 --- a/src/services/seveneleven/client.ts +++ b/src/services/seveneleven/client.ts @@ -1,3 +1,4 @@ +import { parseSevenElevenPopwords } from './popwords.js'; import { SevenElevenSearchPagination } from './searchPagination.js'; import { hasConvenienceRelay, requestConvenienceRelay, type ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** @@ -285,44 +286,7 @@ export async function fetchSevenElevenSearchPopwords( options, ); - const { data } = response; - if (Array.isArray(data)) { - return data.map((item) => toStringValue(item)).filter((item) => item.length > 0); - } - - if (!data || typeof data !== 'object') { - return []; - } - - const record = data as Record; - const candidates = [record.keywords, record.popwords, record.wordList, record.list]; - - for (const candidate of candidates) { - if (!Array.isArray(candidate)) { - continue; - } - - const values = candidate - .map((item) => { - if (typeof item === 'string') { - return item; - } - - if (item && typeof item === 'object') { - const value = item as Record; - return toStringValue(value.keyword || value.word || value.text || value.name); - } - - return ''; - }) - .filter((item) => item.length > 0); - - if (values.length > 0) { - return values; - } - } - - return []; + return parseSevenElevenPopwords(response.data); } export async function fetchSevenElevenStockProductMeta( diff --git a/src/services/seveneleven/popwords.ts b/src/services/seveneleven/popwords.ts new file mode 100644 index 00000000..76ed40e8 --- /dev/null +++ b/src/services/seveneleven/popwords.ts @@ -0,0 +1,36 @@ +/** 정상 빈 목록과 해석할 수 없는 원본 응답을 구분합니다. */ +import * as z from 'zod'; +import { ServiceError } from '../../core/errors.js'; +const text = z.string().trim().min(1); +const entry = z.union([ + text, + z.object({ keyword: text }).transform((v) => v.keyword), + z.object({ word: text }).transform((v) => v.word), + z.object({ text }).transform((v) => v.text), + z.object({ name: text }).transform((v) => v.name), +]); +const words = z.array(entry); +const response = z.union([ + words, + z + .object({ + keywords: words.optional(), + popwords: words.optional(), + wordList: words.optional(), + list: words.optional(), + }) + .transform((v) => [v.keywords, v.popwords, v.wordList, v.list].filter((v) => v !== undefined)) + .refine((lists) => lists.length > 0) + .transform((lists) => lists.find((list) => list.length > 0) ?? []), +]); +export function parseSevenElevenPopwords(data: unknown): string[] { + const parsed = response.safeParse(data); + if (!parsed.success) + throw new ServiceError( + 'SEVENELEVEN_INVALID_RESPONSE', + '세븐일레븐 인기 검색어 응답 형식을 확인할 수 없습니다.', + 502, + true, + ); + return parsed.data; +} diff --git a/tests/app/app-api-seveneleven.test.ts b/tests/app/app-api-seveneleven.test.ts index b08d22e3..7c6d6356 100644 --- a/tests/app/app-api-seveneleven.test.ts +++ b/tests/app/app-api-seveneleven.test.ts @@ -76,8 +76,15 @@ describe('GET /api/seveneleven/popwords', () => { expect(data.data.keywords).toEqual(['삼각김밥', '도시락']); }); + it('형식이 잘못된 인기 검색어 원본은 빈 성공 대신 502를 반환한다', async () => { + mockFetch.mockResolvedValue(Response.json({ success: true, data: {} })); + const res = await app.request('/api/seveneleven/popwords?label=home'); + expect(res.status).toBe(502); + expect((await res.json()).error.code).toBe('SEVENELEVEN_INVALID_RESPONSE'); + }); + it('인기 검색어가 비어 있으면 unavailable 안내를 포함한다', async () => { - mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: {} }))); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: [] }))); const res = await app.request('/api/seveneleven/popwords?label=home'); expect(res.status).toBe(200); diff --git a/tests/services/seveneleven/popwords.test.ts b/tests/services/seveneleven/popwords.test.ts new file mode 100644 index 00000000..c7f37514 --- /dev/null +++ b/tests/services/seveneleven/popwords.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from 'vitest'; +import { parseSevenElevenPopwords } from '../../../src/services/seveneleven/popwords.js'; +it.each([ + [['커피'], ['커피']], + [ + [{ keyword: '커피' }, { word: '과자' }, { text: '도시락' }, { name: '음료' }], + ['커피', '과자', '도시락', '음료'], + ], + [{ keywords: ['커피'] }, ['커피']], + [{ popwords: ['커피'] }, ['커피']], + [{ wordList: ['커피'] }, ['커피']], + [{ list: ['커피'] }, ['커피']], + [[], []], + [{ keywords: [], list: ['커피'] }, ['커피']], + [{ keywords: [], popwords: [], wordList: [], list: [] }, []], +])('공개 검색어 배열/이름을 명시적으로 해석한다: %j', (input, output) => { + expect(parseSevenElevenPopwords(input)).toEqual(output); +}); +it.each([ + {}, + null, + { surprise: [] }, + { list: [null] }, + { keywords: [''] }, + { list: [{ name: 4 }] }, +])('모르는 형식을 빈 목록으로 위장하지 않는다: %j', (input) => { + expect(() => parseSevenElevenPopwords(input)).toThrow('인기 검색어 응답'); +}); diff --git a/tests/services/seveneleven/tools/getSearchPopwords.test.ts b/tests/services/seveneleven/tools/getSearchPopwords.test.ts index b2313673..aa2820a9 100644 --- a/tests/services/seveneleven/tools/getSearchPopwords.test.ts +++ b/tests/services/seveneleven/tools/getSearchPopwords.test.ts @@ -47,7 +47,7 @@ describe('createGetSearchPopwordsTool', () => { }); it('검색어가 없으면 안내 메시지를 반환한다', async () => { - mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: {} }))); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: [] }))); const tool = createGetSearchPopwordsTool(); const result = await tool.handler({}); @@ -57,6 +57,14 @@ describe('createGetSearchPopwordsTool', () => { expect(parsed.note).toContain('찾지 못했습니다'); }); + it.each([{}, { unexpected: true }, null, { keywords: [null] }])( + '해석할 수 없는 원본은 빈 성공으로 바꾸지 않는다: %j', + async (data) => { + mockFetch.mockResolvedValue(Response.json({ success: true, data })); + await expect(createGetSearchPopwordsTool().handler({})).rejects.toThrow('인기 검색어 응답'); + }, + ); + it('키가 있어도 차단된 인기 검색어 요청에 유료 호출을 보내지 않는다', async () => { mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); await expect( From 86512256138b53ba70cfa6676dfaafbceaf51c23 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 21 Oct 2026 17:02:30 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EC=8B=A4=EC=A0=9C=20=EB=A7=A4?= =?UTF-8?q?=EC=9E=A5=20=EC=9E=AC=EA=B3=A0=EC=99=80=20=EC=B9=B4=ED=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mcp-tools.md | 12 ++- scripts/ops/mcp-smoke-validation.ts | 48 +++++++++ scripts/ops/mcp-smoke.ts | 76 +++++++++---- tests/scripts/mcp-smoke-inventory.test.ts | 71 +++++++++++++ tests/scripts/mcp-smoke.test.ts | 123 ++++++++++++++++------ vitest.config.ts | 2 +- 6 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 scripts/ops/mcp-smoke-validation.ts create mode 100644 tests/scripts/mcp-smoke-inventory.test.ts diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 7b9333c6..98e0c82d 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -27,7 +27,17 @@ 요청 제출: `submit_request {"requestType":"developer","request":{"title":"문서 개선","description":"개선할 내용을 적습니다","type":"docs"}}`. 현재 개발자 요청 저장만 지원합니다. 다른 요청 유형은 거절하며 요청자의 의도 없이 제출하지 않습니다. 이 도구의 이름과 요청 구분은 미래 유형 확장을 위해 범용으로 유지합니다. -매개변수는 기존 작업의 이름과 형식을 사용합니다. operation은 같은 서비스에 작업이 여러 개인 경우 필수입니다. 예를 들어 CGV 시간표는 service=cgv, operation=timetable, playDate=YYYYMMDD이며 seats 작업은 지원하지 않습니다. 미지원 조합, 다른 서비스 전용 인수와 오타는 조회 전에 거절합니다. 기본값은 선택한 작업의 기본값만 적용합니다. tools/list의 입력/출력 스키마를 참고하세요. +매개변수는 기존 작업의 이름과 형식을 사용합니다. operation은 같은 서비스에 작업이 여러 개인 경우 필수입니다. 예를 들어 CGV 시간표는 service=cgv, operation=timetable, playDate=YYYYMMDD이며 seats 작업은 지원하지 않습니다. 미지원 조합과 알 수 없는 인수·오타는 조회 전에 거절합니다. 공개 스키마에 있지만 선택한 서비스/작업에서 쓰지 않는 옵션은 실행을 막지 않고 응답의 추가 안내와 `_meta.ignoredArguments`로 미적용 사실을 알립니다. 미적용 조건을 충족했다고 안내하면 안 됩니다. 기본값은 선택한 작업의 기본값만 적용합니다. tools/list의 입력/출력 스키마를 참고하세요. + +### 카탈로그와 매장 재고 검증 + +`get_catalog`는 세븐일레븐의 공개 콘텐츠 조회입니다. `operation`은 필수이며 `popwords`(인기 검색어) 또는 `snapshot`(상품·이슈·기획전)을 지정합니다. `includeIssues`와 `includeExhibition`은 `snapshot` 전용입니다. 인기 검색어 원본 목록이 비어 있으면 `available=false`이며 외부 오류와 구분합니다. 원본 차단·중계 실패는 오류로 반환하며 빈 성공으로 바꾸지 않습니다. + +다이소 `check_inventory`에서 `storeLimit`은 `pageSize`와 같은 매장 수 조건입니다. 함께 전달하면 두 값이 같아야 합니다. `productLimit`은 상품 후보 수이며 여러 상품의 재고를 모두 조회하는 옵션이 아닙니다. 결과의 `selectedProduct`가 재고 조회 기준 상품입니다. + +다이소 `search_products`의 `includeSoldOut=false`는 현재 페이지의 품절 상품을 제외합니다. `count`는 반환된 상품 수이며 `totalCount`는 필터 전 전체 검색 건수입니다. + +정기 MCP smoke는 실제 상품 ID·이름·매장 코드·매장명·수량과 집계 일치를 검사합니다. 정상 수량 0은 통과하지만 수량 미확인만 있는 응답이나 요약 문장만 있는 응답은 정상 조회로 통과시키지 않습니다. 세븐일레븐 matrix는 상품 검색 외에 카탈로그 두 작업도 실제 호출합니다. 단위 테스트와 캐시된 성공만으로 PlayMCP 정상 동작을 판단하지 않고, 등록 스키마 갱신 후 플랫폼 채팅에서 실제 인수·결과를 확인합니다. ## Deprecated 하위호환 diff --git a/scripts/ops/mcp-smoke-validation.ts b/scripts/ops/mcp-smoke-validation.ts new file mode 100644 index 00000000..21c2052e --- /dev/null +++ b/scripts/ops/mcp-smoke-validation.ts @@ -0,0 +1,48 @@ +/** 실제 조회가 빠진 요약·빈 기본값을 운영 정상으로 판정하지 않습니다. */ +import * as z from 'zod'; +const text = z.string().trim().min(1); +const count = z.number().int().nonnegative(); +const inventory = z.object({ + summary: z.object({ headline: text }), + selectedProduct: z.object({ id: text, name: text }), + storeInventory: z.object({ + inStockCount: count, + outOfStockCount: count, + unknownStockCount: count, + stores: z + .array(z.object({ storeCode: text, storeName: text, quantity: count.nullable() })) + .min(1), + }), +}); +export function validateDaisoInventoryByName(payload: Record): string | null { + const parsed = inventory.safeParse(payload); + if (!parsed.success) return '상품 ID/이름과 매장별 실제 재고 응답이 없습니다'; + const data = parsed.data.storeInventory; + const positive = data.stores.filter((s) => s.quantity !== null && s.quantity > 0).length; + const zero = data.stores.filter((s) => s.quantity === 0).length; + const unknown = data.stores.filter((s) => s.quantity === null).length; + return positive + zero > 0 && + positive === data.inStockCount && + zero === data.outOfStockCount && + unknown === data.unknownStockCount + ? null + : '재고 수량이 모두 미확인이거나 집계가 실제 매장 수량과 다릅니다'; +} +const popwords = z.object({ available: z.boolean(), count, keywords: z.array(text) }); +export function validateCatalogPopwords(payload: Record): string | null { + const parsed = popwords.safeParse(payload); + if (!parsed.success) return '인기 검색어 응답 계약이 올바르지 않습니다'; + const data = parsed.data; + return data.count === data.keywords.length && data.available === data.count > 0 + ? null + : '인기 검색어 수와 제공 여부가 실제 목록과 다릅니다'; +} +const section = z.object({ totalCount: count, items: z.array(z.record(z.string(), z.unknown())) }); +const catalog = z.object({ + pages: section.extend({ items: z.array(z.object({ itemCode: text, itemName: text })).min(1) }), + issues: section, + exhibitions: section, +}); +export function validateCatalogSnapshot(payload: Record): string | null { + return catalog.safeParse(payload).success ? null : '카탈로그 상품과 기획전 응답이 없습니다'; +} diff --git a/scripts/ops/mcp-smoke.ts b/scripts/ops/mcp-smoke.ts index 95033797..899f301d 100644 --- a/scripts/ops/mcp-smoke.ts +++ b/scripts/ops/mcp-smoke.ts @@ -1,3 +1,8 @@ +import { + validateDaisoInventoryByName, + validateCatalogPopwords, + validateCatalogSnapshot, +} from './mcp-smoke-validation.js'; import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js'; /** * MCP 클라이언트 관점 smoke 테스트 @@ -42,6 +47,8 @@ export const MCP_SMOKE_TOOLS: Array<{ service: SmokeService; name: string }> = [ { service: 'daiso_jp', name: 'daiso_jp_find_stores' }, { service: 'gs25', name: 'gs25_search_products' }, { service: 'seveneleven', name: 'seveneleven_search_products' }, + { service: 'seveneleven', name: 'seveneleven_get_search_popwords' }, + { service: 'seveneleven', name: 'seveneleven_get_catalog_snapshot' }, { service: 'emart24', name: 'emart24_search_products' }, { service: 'opinet', name: 'opinet_get_average_prices' }, ]; @@ -107,29 +114,38 @@ function expectField( payload[fieldName] === expected ? null : `${fieldName} 값이 ${String(expected)}가 아닙니다`; } -function validateDaisoInventoryByName(payload: Record): string | null { - if (!isRecord(payload.summary) || typeof payload.summary.headline !== 'string') { - return '통합 조회 결과 summary.headline이 없습니다'; - } - - return null; -} - function validateJapanProduct(payload: Record): string | null { - const valid = payload.country === 'JP' && payload.currency === 'JPY' && - payload.id === '4550746075917' && typeof payload.name === 'string' && payload.name.trim().length > 0 && + const valid = + payload.country === 'JP' && + payload.currency === 'JPY' && + payload.id === '4550746075917' && + typeof payload.name === 'string' && + payload.name.trim().length > 0 && (typeof payload.onlineAvailable === 'boolean' || payload.onlineAvailable === null) && payload.storeInventorySupported === false; return valid ? null : '일본 상품 상세의 필수 필드 또는 판매 가능 여부가 올바르지 않습니다'; } -function validateJapanCollection(payload: Record, key: 'products' | 'stores'): string | null { +function validateJapanCollection( + payload: Record, + key: 'products' | 'stores', +): string | null { const items = payload[key]; - const valid = payload.country === 'JP' && Array.isArray(items) && items.length > 0 && - items.every((item) => isRecord(item) && - typeof item.id === 'string' && item.id.trim().length > 0 && - typeof item.name === 'string' && item.name.trim().length > 0 && - (key === 'products' ? item.currency === 'JPY' : typeof item.brand === 'string' && item.brand.trim().length > 0)); + const valid = + payload.country === 'JP' && + Array.isArray(items) && + items.length > 0 && + items.every( + (item) => + isRecord(item) && + typeof item.id === 'string' && + item.id.trim().length > 0 && + typeof item.name === 'string' && + item.name.trim().length > 0 && + (key === 'products' + ? item.currency === 'JPY' + : typeof item.brand === 'string' && item.brand.trim().length > 0), + ); return valid ? null : `일본 ${key}의 국가 또는 필수 필드가 올바르지 않습니다`; } @@ -176,6 +192,20 @@ export const MCP_SMOKE_SCENARIOS: McpSmokeScenario[] = [ args: { query: '커피', size: 1 }, validate: expectField('query', '커피'), }, + { + service: 'seveneleven', + label: '세븐일레븐 인기 검색어', + toolName: 'seveneleven_get_search_popwords', + args: { label: 'home' }, + validate: validateCatalogPopwords, + }, + { + service: 'seveneleven', + label: '세븐일레븐 카탈로그', + toolName: 'seveneleven_get_catalog_snapshot', + args: { limit: 2 }, + validate: validateCatalogSnapshot, + }, { service: 'emart24', label: '이마트24 상품 검색', @@ -237,7 +267,7 @@ export async function runMcpSmoke(deps: McpSmokeDeps = {}): Promise { const tools = await client.listTools(); const toolNames = new Set(tools.tools.map((tool) => tool.name)); - const missingTools = PUBLIC_TOOL_NAMES.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; @@ -251,11 +281,17 @@ 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)!; + 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: group.name, - arguments: { ...scenario.args, ...(route.service ? { service: route.service } : {}), ...(route.operation ? { operation: route.operation } : {}) }, + 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/tests/scripts/mcp-smoke-inventory.test.ts b/tests/scripts/mcp-smoke-inventory.test.ts new file mode 100644 index 00000000..93d1abea --- /dev/null +++ b/tests/scripts/mcp-smoke-inventory.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { MCP_SMOKE_SCENARIOS } from '../../scripts/ops/mcp-smoke.js'; +const inventory = () => ({ + summary: { headline: '재고 조회 결과' }, + selectedProduct: { id: 'p1', name: '수납박스' }, + storeInventory: { + inStockCount: 1, + outOfStockCount: 0, + unknownStockCount: 0, + stores: [{ storeCode: 's1', storeName: '강남역점', quantity: 5 }], + }, +}); +const scenario = (toolName: string) => MCP_SMOKE_SCENARIOS.find((s) => s.toolName === toolName)!; + +describe('실데이터 의미를 확인하는 MCP smoke', () => { + it('요약만 있는 응답을 정상 재고 조회로 통과시키지 않는다', () => { + expect( + scenario('daiso_find_inventory_by_name').validate({ summary: { headline: '검색 결과' } }), + ).not.toBeNull(); + }); + it('정상 수량과 품절을 통과시키고 미확인 수량만 있는 응답은 실패한다', () => { + const check = scenario('daiso_find_inventory_by_name').validate; + expect(check(inventory())).toBeNull(); + const zero = inventory(); + zero.storeInventory.stores[0].quantity = 0; + zero.storeInventory.inStockCount = 0; + zero.storeInventory.outOfStockCount = 1; + expect(check(zero)).toBeNull(); + expect(check({ ...zero, selectedProduct: null })).not.toBeNull(); + expect(check({ ...zero, storeInventory: { stores: [] } })).not.toBeNull(); + const unknown = { + ...zero, + storeInventory: { + inStockCount: 0, + outOfStockCount: 0, + unknownStockCount: 1, + stores: [{ storeCode: 's1', storeName: '강남역점', quantity: null }], + }, + }; + expect(check(unknown)).not.toBeNull(); + expect( + check({ ...zero, storeInventory: { ...zero.storeInventory, inStockCount: 8 } }), + ).not.toBeNull(); + }); + it('카탈로그 두 작업을 모두 호출 대상에 포함하고 빈 기본 응답을 거절한다', () => { + const popwords = scenario('seveneleven_get_search_popwords'); + const catalog = scenario('seveneleven_get_catalog_snapshot'); + expect(popwords).toBeDefined(); + expect(catalog).toBeDefined(); + expect(popwords.validate({ available: false, count: 0, keywords: [] })).toBeNull(); + expect(popwords.validate({ available: true, count: 1, keywords: ['커피'] })).toBeNull(); + expect(popwords.validate({})).not.toBeNull(); + expect(popwords.validate({ available: true, count: 1, keywords: [] })).not.toBeNull(); + expect(catalog.validate({})).not.toBeNull(); + const section = { totalCount: 1, items: [{ itemCode: '1', itemName: '상품' }] }; + expect( + catalog.validate({ + pages: section, + issues: section, + exhibitions: { totalCount: 0, items: [] }, + }), + ).toBeNull(); + expect( + catalog.validate({ + pages: { totalCount: 0, items: [] }, + issues: section, + exhibitions: section, + }), + ).not.toBeNull(); + }); +}); diff --git a/tests/scripts/mcp-smoke.test.ts b/tests/scripts/mcp-smoke.test.ts index 77d48128..ebbabf52 100644 --- a/tests/scripts/mcp-smoke.test.ts +++ b/tests/scripts/mcp-smoke.test.ts @@ -26,6 +26,8 @@ describe('runMcpSmoke', () => { 'daiso_jp_find_stores', 'gs25_search_products', 'seveneleven_search_products', + 'seveneleven_get_search_popwords', + 'seveneleven_get_catalog_snapshot', 'emart24_search_products', 'opinet_get_average_prices', ]); @@ -39,6 +41,8 @@ describe('runMcpSmoke', () => { 'daiso_jp', 'gs25', 'seveneleven', + 'seveneleven', + 'seveneleven', 'emart24', 'opinet', ]); @@ -49,27 +53,64 @@ describe('runMcpSmoke', () => { const listTools = vi.fn().mockResolvedValue({ tools: PUBLIC_TOOL_NAMES.map((name) => ({ name })), }); - 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: '수납박스', - summary: { headline: '수납박스 재고 조회 결과' }, - selectedProduct: { id: '1049516' }, + 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: '수납박스', + summary: { headline: '수납박스 재고 조회 결과' }, + selectedProduct: { id: '1049516', name: '수납박스' }, + storeInventory: { + inStockCount: 0, + outOfStockCount: 1, + unknownStockCount: 0, + stores: [{ storeCode: '1', storeName: '강남역점', quantity: 0 }], + }, + }, + daiso_jp_search_products: { + country: 'JP', + products: [{ id: '4550746075917', name: 'ボウル', currency: 'JPY' }], + }, + daiso_jp_get_product: { + country: 'JP', + id: '4550746075917', + name: 'ボウル', + currency: 'JPY', + onlineAvailable: true, + storeInventorySupported: false, + }, + daiso_jp_find_stores: { + country: 'JP', + stores: [{ id: '0001', name: '渋谷店', brand: 'DAISO' }], + }, + gs25_search_products: { keyword: '콜라', products: [{}] }, + seveneleven_get_search_popwords: { available: false, count: 0, keywords: [] }, + seveneleven_get_catalog_snapshot: { + pages: { totalCount: 1, items: [{ itemCode: '1', itemName: '상품' }] }, + issues: { totalCount: 0, items: [] }, + exhibitions: { totalCount: 0, items: [] }, + }, + seveneleven_search_products: { query: '커피', products: [{}] }, + emart24_search_products: { keyword: '커피', products: [{}] }, + opinet_get_average_prices: { provider: 'opinet', prices: [] }, + }; + + return Promise.resolve({ + content: [{ type: 'text', text: JSON.stringify(payloads[key]) }], + }); }, - daiso_jp_search_products: { country: 'JP', products: [{ id: '4550746075917', name: 'ボウル', currency: 'JPY' }] }, - daiso_jp_get_product: { country: 'JP', id: '4550746075917', name: 'ボウル', currency: 'JPY', onlineAvailable: true, storeInventorySupported: false }, - daiso_jp_find_stores: { country: 'JP', stores: [{ id: '0001', name: '渋谷店', brand: 'DAISO' }] }, - gs25_search_products: { keyword: '콜라', products: [{}] }, - seveneleven_search_products: { query: '커피', products: [{}] }, - emart24_search_products: { keyword: '커피', products: [{}] }, - opinet_get_average_prices: { provider: 'opinet', prices: [] }, - }; - - return Promise.resolve({ - content: [{ type: 'text', text: JSON.stringify(payloads[key]) }], - }); - }); + ); const exitCode = await runMcpSmoke({ createClient: async () => ({ listTools, callTool, close }), @@ -82,7 +123,7 @@ describe('runMcpSmoke', () => { expect(callTool).toHaveBeenCalledTimes(MCP_SMOKE_SCENARIOS.length); expect(callTool).toHaveBeenCalledWith({ name: 'search_products', - arguments: expect.objectContaining({ service:'gs25', keyword: '콜라' }), + arguments: expect.objectContaining({ service: 'gs25', keyword: '콜라' }), }); expect(close).toHaveBeenCalledOnce(); }); @@ -107,7 +148,7 @@ describe('runMcpSmoke', () => { expect(callTool).toHaveBeenCalledOnce(); expect(callTool).toHaveBeenCalledWith({ name: 'search_products', - arguments: expect.objectContaining({ service:'gs25', keyword: '콜라' }), + arguments: expect.objectContaining({ service: 'gs25', keyword: '콜라' }), }); expect(close).toHaveBeenCalledOnce(); }); @@ -150,7 +191,7 @@ describe('MCP 도구 오류 응답', () => { const exitCode = await runMcpSmoke({ service: 'gs25', createClient: async () => ({ - listTools: async () => ({ tools: PUBLIC_TOOL_NAMES.map(name=>({name})) }), + listTools: async () => ({ tools: PUBLIC_TOOL_NAMES.map((name) => ({ name })) }), callTool: async () => ({ isError: true, content: text === undefined ? [] : [{ type: 'text', text }], @@ -171,7 +212,7 @@ describe('MCP 도구 오류 응답', () => { const exitCode = await runMcpSmoke({ service: 'gs25', createClient: async () => ({ - listTools: async () => ({ tools: PUBLIC_TOOL_NAMES.map(name=>({name})) }), + listTools: async () => ({ tools: PUBLIC_TOOL_NAMES.map((name) => ({ name })) }), callTool: async () => ({ isError: false, content: [{ type: 'text', text }] }), close: async () => undefined, }), @@ -182,14 +223,28 @@ describe('MCP 도구 오류 응답', () => { }); }); -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(); - }); +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(); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index c9234504..1ca16223 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json'], // 커버리지 대상 파일 - include: ['scripts/relay/daiso-jp.ts', 'scripts/ops/health-check-retry.ts', 'src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/http-relay.ts', 'scripts/relay/convenience.ts', 'scripts/relay/gs25-session.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts', 'scripts/relay/http-lifecycle.ts', 'scripts/relay/transport-lifecycle.ts'], + include: ['scripts/ops/mcp-smoke-validation.ts', 'scripts/relay/daiso-jp.ts', 'scripts/ops/health-check-retry.ts', 'src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/http-relay.ts', 'scripts/relay/convenience.ts', 'scripts/relay/gs25-session.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts', 'scripts/relay/http-lifecycle.ts', 'scripts/relay/transport-lifecycle.ts'], // 커버리지 제외 파일 exclude: [