8000 fix: PlayMCP 입력 계약과 운영 검증 보강 by hmmhmmhm · Pull Request #301 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 하위호환

Expand Down
48 changes: 48 additions & 0 deletions scripts/ops/mcp-smoke-validation.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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, unknown>): 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, unknown>): string | null {
return catalog.safeParse(payload).success ? null : '카탈로그 상품과 기획전 응답이 없습니다';
}
76 changes: 56 additions & 20 deletions scripts/ops/mcp-smoke.ts
8000
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
im 8000 port {
validateDaisoInventoryByName,
validateCatalogPopwords,
validateCatalogSnapshot,
} from './mcp-smoke-validation.js';
import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js';
/**
* MCP 클라이언트 관점 smoke 테스트
Expand Down Expand Up @@ -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' },
];
Expand Down Expand Up @@ -107,29 +114,38 @@ function expectField(
payload[fieldName] === expected ? null : `${fieldName} 값이 ${String(expected)}가 아닙니다`;
}

function validateDaisoInventoryByName(payload: Record<string, unknown>): string | null {
if (!isRecord(payload.summary) || typeof payload.summary.headline !== 'string') {
return '통합 조회 결과 summary.headline이 없습니다';
}

return null;
}

function validateJapanProduct(payload: Record<string, unknown>): 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. 'boolean' || payload. null) &&
payload.storeInventorySupported === false;
return valid ? null : '일본 상품 상세의 필수 필드 또는 판매 가능 여부가 올바르지 않습니다';
}

function validateJapanCollection(payload: Record<string, unknown>, key: 'products' | 'stores'): string | null {
function validateJapanCollection(
payload: Record<string, unknown>,
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}의 국가 또는 필수 필드가 올바르지 않습니다`;
}

Expand Down Expand Up @@ -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 상품 검색',
Expand Down Expand Up @@ -237,7 +267,7 @@ export async function runMcpSmoke(deps: McpSmokeDeps = {}): Promise<number> {

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;
Expand All @@ -251,11 +281,17 @@ export async function runMcpSmoke(deps: McpSmokeDeps = {}): Promise<number> {
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);
Expand Down
4 changes: 2 additions & 2 deletions src/core/compact/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down
56 changes: 48 additions & 8 deletions src/core/compact/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ToolRegistration>): ToolInputSchema {
const fields = new Map<string, z.ZodType[]>();
const descriptions = new Map<string, Set<string>>();
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<string>();
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] : []))),
];
Expand All @@ -45,11 +58,12 @@ function mergedInputs(group: CompactGroup, legacy: Map<string, ToolRegistration>
];
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[] {
Expand Down Expand Up @@ -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(', ')}. 이 옵션의 조건을 충족했다고 안내하지 마세요.`,
},
],
};
},
};
});
Expand Down
17 changes: 15 additions & 2 deletions src/services/daiso/tools/searchProducts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface SearchProductsArgs {
query: string;
page?: number;
pageSize?: number;
includeSoldOut?: boolean;
}

/**
Expand Down Expand Up @@ -60,13 +61,14 @@ export async function fetchProducts(
* 제품 검색 핸들러
*/
async function searchProducts(args: SearchProductsArgs): Promise<McpToolResponse> {
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,
Expand All @@ -75,6 +77,11 @@ async function searchProducts(args: SearchProductsArgs): Promise<McpToolResponse
totalCount,
count: products.length,
products,
...(!includeSoldOut
? {
note: '현재 페이지에서 품절 상품을 제외했습니다. totalCount는 필터 전 전체 검색 건수입니다.',
}
: {}),
};

return {
Expand All @@ -93,6 +100,12 @@ export function createSearchProductsTool(): ToolRegistration {
description: '다이소 제품을 검색합니다. 키워드로 제품을 검색할 수 있습니다.',
inputSchema: {
query: z.string().describe('검색할 제품명 또는 키워드'),
includeSoldOut: z
.boolean()
.optional()
.describe(
'품절 상품 포함 여부 (기본값: true). false이면 현재 페이지에서 품절 상품을 제외합니다.',
),
page: positiveIntegerSchema.optional().default(1).describe('페이지 번호 (기본값: 1)'),
pageSize: positiveIntegerSchema
.optional()
Expand Down
Loading
Loading
0