From d8b5ad2a99e888084c5b8e56f92ca88f41a8db98 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 7 Oct 2026 14:37:49 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=84=A4=EC=B9=98=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20MCP=20=EC=9B=B9=20=EB=B0=9C=EA=B2=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=99=80=20=ED=81=AC=EB=A1=A4=EB=9F=AC=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 1 + docs/web-discovery.md | 36 ++++++++++++++++++++ package.json | 1 + scripts/ops/README.md | 1 + scripts/ops/generate-discovery.ts | 11 ++++++ src/index.ts | 22 +++++++----- src/pages/discovery.ts | 51 ++++++++++++++++++++++++++++ tests/app/app-info-pages.test.ts | 56 +++++++++++++++---------------- tests/pages/discovery.test.ts | 47 ++++++++++++++++++++++++++ tests/retirement.test.ts | 2 +- wrangler.toml | 8 +++++ 12 files changed, 199 insertions(+), 38 deletions(-) create mode 100644 docs/web-discovery.md create mode 100644 scripts/ops/generate-discovery.ts create mode 100644 src/pages/discovery.ts create mode 100644 tests/pages/discovery.test.ts diff --git a/.gitignore b/.gitignore index 1b389260..e8a1984a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ build/Release # Dependency directories node_modules/ +public-discovery/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) diff --git a/README.md b/README.md index d1b3e7dc..0ba970dc 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ 다이소·편의점 재고, 주변 장소, 주유소 가격, 영화 시간표를 AI에서 조회합니다. **공개 원격 MCP에 연결하면 로컬 설치·빌드·API 키 준비 없이 바로 사용할 수 있습니다.** 에이전트는 [짧은 시작 안내](https://mcp.aka.page/llms.txt)를 참고하세요. 무설치 요청에는 npm 설치나 전체 스키마 탐색이 필요하지 않습니다. +웹에서는 [공개 첫 화면](https://mcp.aka.page/)에서 시작할 수 있습니다. [검색·크롤러 안내와 배포 구조](docs/web-discovery.md)는 별도 문서에 정리했습니다. ## 원격 MCP로 바로 시작하기 diff --git a/docs/web-discovery.md b/docs/web-discovery.md new file mode 100644 index 00000000..fc21a62d --- /dev/null +++ b/docs/web-discovery.md @@ -0,0 +1,36 @@ +# 웹에서 다이소 MCP 발견하기 + +[공개 첫 화면](https://mcp.aka.page/)은 설치 없는 사용 방법과 첫 실제 조회 링크를 HTML로 제공합니다. 웹 읽기 기능만으로 MCP 도구가 자동 등록되는 것은 아닙니다. 원격 MCP 클라이언트, HTTP POST 또는 공개 REST GET 중 에이전트가 실행할 수 있는 방식을 선택합니다. + +- `/`: HTML 안내. `Accept: application/json`이면 기존 서버 정보 JSON. +- `/root.json`: 현재 서비스·도구 목록 JSON. API 클라이언트는 이 명시적 주소를 권장합니다. +- `/llms.txt`: 짧은 에이전트 시작 안내. +- `/prompt`: 전체 API 사용 안내. +- `/robots.txt`, `/sitemap.xml`: 검색과 AI 에이전트의 발견 안내. + +robots.txt는 hmart.app과 같은 방식으로 `User-agent: *`와 주요 AI 봇에 `Allow: /`를 명시합니다. 검색용 OAI-SearchBot, 사용자 요청용 ChatGPT-User, 학습용 GPTBot을 별도 그룹으로 표시합니다. robots 허용은 요청 제한이나 보안 검사를 우회하지 않습니다. + +## 배포와 운영 설정 + +`npm run build:discovery`는 현재 레지스트리에서 `public-discovery/`의 HTML·JSON·robots·sitemap·llms 자산을 다시 생성합니다. Wrangler의 `[build]`가 매 배포 전에 실행합니다. 생성물은 Git에 넣지 않습니다. 정적 Assets로 제공하며 SPA fallback은 사용하지 않습니다. + +**루트에 index.html을 추가하지 않습니다.** Assets가 루트 POST를 405로 처리할 수 있어 기존 MCP 호출이 막힙니다. HTML은 `/discovery.html`에 두고 다음 Cloudflare URL Rewrite 규칙으로 공개 GET/HEAD만 내부 연결합니다. + +```text +(http.host eq "mcp.aka.page" and http.request.method in {"GET" "HEAD"} and http.request.uri.path eq "/" and not any(http.request.headers["accept"][*] contains "application/json")) +``` + +작업은 경로를 `/discovery.html`로 정적 재작성하고 쿼리를 유지합니다. 브라우저 주소는 `/`로 남고 외부 호스트로 이동하지 않습니다. POST·DELETE·OPTIONS 및 JSON 클라이언트는 기존 Worker 경로를 사용합니다. workers.dev 등 이 규칙이 없는 도메인은 Worker의 HTML/JSON 응답으로 동작합니다. + +기존 `mcp.aka.page/`에서 R2 JSON으로 보내던 302 규칙은 제거 또는 교체해야 합니다. 과거 `mcp-root.aka.page/root.json` 링크는 현재 `https://mcp.aka.page/root.json`으로 연결해 오래된 별도 사본을 발견 경로에서 제외합니다. 앞으로 메타데이터는 레지스트리 한 곳에서 생성합니다. + +## 배포 후 확인 + +1. `/`가 외부 리디렉션 없이 HTTP 200 HTML인지 확인합니다. +2. `Accept: application/json`의 `/`와 `/root.json`의 목록이 일치하고 중단한 롯데마트가 없는지 확인합니다. +3. `/robots.txt`, `/sitemap.xml`, `/llms.txt`가 HTTP 200인지 확인합니다. +4. 루트 POST `tools/list`와 `tools/call`을 실제 실행합니다. GET 화면만으로 MCP 정상 동작을 주장하지 않습니다. +5. 없는 API 경로는 기존 404를 유지하는지 확인합니다. +6. 검색/웹 읽기 도구에서 새 HTML이 읽히는지 별도로 확인합니다. HTTP 200과 robots 허용만으로 도구 내부 캐시나 읽기 성공을 보장할 수 없습니다. + +OpenAI 검색·사용자 요청·학습 봇의 역할과 공개 IP는 [공식 봇 문서](https://developers.openai.com/api/docs/bots)를 참조합니다. Cloudflare 제공 방식은 [Assets 라우팅 문서](https://developers.cloudflare.com/workers/static-assets/routing/worker-script/)를 참조합니다. diff --git a/package.json b/package.json index 76f122ea..f27cc3fc 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "deploy": "wrangler deploy", "build": "tsc && npx tsx scripts/ops/generate-openapi.ts", "build:openapi": "npx tsx scripts/ops/generate-openapi.ts", + "build:discovery": "npx tsx scripts/ops/generate-discovery.ts", "cli:smoke": "npm run build && npx tsx scripts/ops/cli-smoke.ts", "mcp:smoke": "npx tsx scripts/ops/mcp-smoke.ts", "update:workers-chart": "npx tsx scripts/ops/update-workers-invocations-chart.ts", diff --git a/scripts/ops/README.md b/scripts/ops/README.md index 2a0a699d..c0d06ef4 100644 --- a/scripts/ops/README.md +++ b/scripts/ops/README.md @@ -5,6 +5,7 @@ - `mcp-smoke.ts`: 배포된 MCP 엔드포인트의 필수 도구와 대표 호출을 검증합니다. - `cli-smoke.ts`: 빌드된 CLI의 대표 사용자 흐름을 검증합니다. - `generate-openapi.ts`: 배포/패키징에 필요한 OpenAPI 산출물을 생성합니다. +- `generate-discovery.ts`: 현재 레지스트리에서 공개 HTML·JSON·robots·sitemap·llms 정적 자산을 생성합니다. [웹 발견 배포 절차](../../docs/web-discovery.md)를 참고하세요. - `workers-chart-data.ts`, `workers-chart-helpers.ts`, `update-workers-invocations-chart.ts`: Cloudflare Workers 호출량 차트를 갱신합니다. 운영 스크립트는 `npm run` 명령에서 참조되며, 입력과 실패 메시지는 자동화 로그만으로 원인 추적이 가능해야 합니다. diff --git a/scripts/ops/generate-discovery.ts b/scripts/ops/generate-discovery.ts new file mode 100644 index 00000000..57fdc16c --- /dev/null +++ b/scripts/ops/generate-discovery.ts @@ -0,0 +1,11 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { buildRootInfo } from '../../src/index.js'; +import { buildDiscoveryAssets } from '../../src/pages/discovery.js'; + +const directory = 'public-discovery'; +await rm(directory, { recursive: true, force: true }); +await mkdir(directory, { recursive: true }); +for (const [name, content] of Object.entries(buildDiscoveryAssets(buildRootInfo()))) { + await writeFile(`${directory}/${name}`, content, 'utf8'); +} +console.log('현재 레지스트리의 HTML·JSON·robots·sitemap·llms 정적 자산 생성 완료'); diff --git a/src/index.ts b/src/index.ts index 0736befb..29699c94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ import { createCompareService } from './services/compare/index.js'; import { createFeedbackService } from './services/feedback/index.js'; import { createAgentStartResponse } from './pages/agentStart.js'; import { createPromptResponse } from './pages/prompt.js'; +import { buildDiscoveryAssets } from './pages/discovery.js'; import { createFullOpenApiJsonResponse, createFullOpenApiYamlResponse, @@ -142,7 +143,7 @@ const createMcpServer = (bindings?: AppBindings) => { return server; }; -function buildRootInfo() { +export function buildRootInfo() { const registry = createRegistry(); const services = registry.getServicesInfo(); const allTools = registry.getAllToolNames(); @@ -170,16 +171,15 @@ function buildRootInfo() { totalTools: allTools.length, }; } - const ROOT_INFO_JSON = JSON.stringify(buildRootInfo()); const ROOT_INFO_BODY = new TextEncoder().encode(ROOT_INFO_JSON); +const ROOT_HTML_BODY = new TextEncoder().encode(buildDiscoveryAssets(buildRootInfo())['discovery.html']); const ROOT_INFO_HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': String(ROOT_INFO_BODY.byteLength), 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400', }; - function isRootInfoRequest(request: Request): boolean { if (request.method !== 'GET' && request.method !== 'HEAD') { return false; @@ -188,11 +188,14 @@ function isRootInfoRequest(request: Request): boolean { const url = new URL(request.url); return url.pathname === '/' && url.search === ''; } - -function createRootInfoResponse(method: string): Response { - return new Response(method === 'HEAD' ? null : ROOT_INFO_BODY, { +function createRootInfoResponse(request: Request): Response { + const json = request.headers.get('Accept')?.includes('application/json'); + const body = json ? ROOT_INFO_BODY : ROOT_HTML_BODY; + return new Response(request.method === 'HEAD' ? null : body, { status: 200, - headers: ROOT_INFO_HEADERS, + headers: { ...ROOT_INFO_HEADERS, + 'Content-Type': json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', + 'Content-Length': String(body.byteLength), Vary: 'Accept' }, }); } @@ -344,8 +347,9 @@ app.use('/api/*', createDailyRateLimitMiddleware()); // 기본 정보 엔드포인트 (GET 요청만) app.get('/', (c) => { - return c.body(ROOT_INFO_JSON, 200, ROOT_INFO_HEADERS); + return createRootInfoResponse(c.req.raw); }); +app.get('/root.json', (c) => c.body(ROOT_INFO_JSON, 200, ROOT_INFO_HEADERS)); // 루트 경로에서 MCP 요청 처리 (POST, DELETE) app.on(['POST', 'DELETE'], '/', handleRootMcpRequest); @@ -430,7 +434,7 @@ app.all('/mcp', handleMcpRequest); const worker = { fetch(request: Request, env: AppBindings, executionCtx: ExecutionContext) { if (isRootInfoRequest(request)) { - return createRootInfoResponse(request.method); + return createRootInfoResponse(request); } return withRouteRouting(env?.UPSTREAM_ROUTE_HEALTH, executionCtx?.waitUntil?.bind(executionCtx), diff --git a/src/pages/discovery.ts b/src/pages/discovery.ts new file mode 100644 index 00000000..f841b6db --- /dev/null +++ b/src/pages/discovery.ts @@ -0,0 +1,51 @@ +import { buildAgentStartText } from './agentStart.js'; + +const BASE = 'https://mcp.aka.page'; + +/** 레지스트리 설명이 HTML로 해석되지 않도록 이스케이프합니다. */ +function escapeHtml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + .replaceAll('"', '"').replaceAll("'", '''); +} + +/** 배포마다 현재 레지스트리에서 정적 발견 자산을 생성합니다. */ +export function buildDiscoveryAssets(info: { services: { name: string }[]; tools: string[] }): Record { + const services = info.services.map((service) => escapeHtml(service.name)).join(' · '); + const firstQuery = '/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3'; + return { + 'discovery.html': ` + +다이소 MCP — 설치 없이 상품·매장·재고 조회 + + + + +
+

다이소 MCP

설치 없이 상품·매장·재고와 영화관 정보를 조회하세요. 사용자 API 키나 로그인 없이 쓸 수 있는 공개 서비스입니다.

+

바로 조회하기

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

+

위 링크는 공개 REST GET입니다. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.

+

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

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

+

원격 MCP 클라이언트가 있으면 이 주소에 연결하고 daiso_search_products를 {"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}}}'
+

웹 읽기 기능으로 이 페이지를 읽는 것만으로 MCP 연결이 생기지는 않습니다. GET만 가능한 에이전트는 위 REST 링크를 사용하세요. 오류를 상품 없음으로 해석하지 마세요.

+

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

+

지원 서비스

${services}

+ +
\n`, + 'root.json': JSON.stringify(info, null, 2) + '\n', + 'llms.txt': buildAgentStartText(BASE), + 'robots.txt': '# 공개 검색 및 AI 에이전트의 읽기를 허용합니다.\n' + + ['*', 'ChatGPT-User', 'OAI-SearchBot', 'GPTBot', 'CCBot', 'anthropic-ai', 'Claude-Web', + 'Claude-User', 'Claude-SearchBot', 'Google-Extended', 'PerplexityBot', 'Perplexity-User', 'Applebot-Extended'] + .map((bot) => `User-agent: ${bot}\nAllow: /\n`).join('\n') + + `\nSitemap: ${BASE}/sitemap.xml\n# 에이전트 안내: ${BASE}/llms.txt\n`, + 'sitemap.xml': '\n\n' + + ['/', '/llms.txt', '/prompt', '/root.json', '/privacy'] + .map((path) => `${BASE}${path}`).join('\n') + '\n\n', + '_headers': '/*\n Access-Control-Allow-Origin: *\n Cache-Control: public, max-age=300\n X-Content-Type-Options: nosniff\n', + }; +} diff --git a/tests/app/app-info-pages.test.ts b/tests/app/app-info-pages.test.ts index 0cf03d90..b24a8420 100644 --- a/tests/app/app-info-pages.test.ts +++ b/tests/app/app-info-pages.test.ts @@ -11,7 +11,7 @@ setupFetchMock(mockFetch); describe('GET /', () => { it('서버 정보를 반환한다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); expect(res.status).toBe(200); @@ -27,7 +27,7 @@ describe('GET /', () => { }); it('Worker fetch 엔트리에서 루트 정보를 빠른 경로로 반환한다', async () => { - const res = await app.fetch(new Request('https://mcp.aka.page/')); + const res = await app.fetch(new Request('https://mcp.aka.page/', { headers: { Accept: 'application/json' } })); expect(res.status).toBe(200); expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); @@ -54,7 +54,7 @@ describe('GET /', () => { }); it('다이소 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const daisoService = data.services.find((s: { id: string }) => s.id === 'daiso'); @@ -63,7 +63,7 @@ describe('GET /', () => { }); it('올리브영 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const oliveyoungService = data.services.find((s: { id: string }) => s.id === 'oliveyoung'); @@ -72,7 +72,7 @@ describe('GET /', () => { }); it('CU 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const cuService = data.services.find((s: { id: string }) => s.id === 'cu'); @@ -81,7 +81,7 @@ describe('GET /', () => { }); it('GS25 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const gs25Service = data.services.find((s: { id: string }) => s.id === 'gs25'); @@ -90,7 +90,7 @@ describe('GET /', () => { }); it('이마트24 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const emart24Service = data.services.find((s: { id: string }) => s.id === 'emart24'); @@ -99,7 +99,7 @@ describe('GET /', () => { }); it('종료된 롯데마트 서비스는 등록하지 않는다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const lotteMartService = data.services.find((s: { id: string }) => s.id === 'lottemart'); @@ -107,7 +107,7 @@ describe('GET /', () => { }); it('메가박스 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const megaboxService = data.services.find((s: { id: string }) => s.id === 'megabox'); @@ -116,7 +116,7 @@ describe('GET /', () => { }); it('CGV 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const cgvService = data.services.find((s: { id: string }) => s.id === 'cgv'); @@ -125,7 +125,7 @@ describe('GET /', () => { }); it('롯데시네마 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const lotteCinemaService = data.services.find((s: { id: string }) => s.id === 'lottecinema'); @@ -134,7 +134,7 @@ describe('GET /', () => { }); it('세븐일레븐 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const sevenElevenService = data.services.find((s: { id: string }) => s.id === 'seveneleven'); @@ -143,7 +143,7 @@ describe('GET /', () => { }); it('장소 검색 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const placesService = data.services.find((s: { id: string }) => s.id === 'places'); @@ -152,7 +152,7 @@ describe('GET /', () => { }); it('개발자 요청 서비스가 등록되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); const feedbackService = data.services.find((s: { id: string }) => s.id === 'feedback'); @@ -161,7 +161,7 @@ describe('GET /', () => { }); it('다이소 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('daiso_search_products'); @@ -171,7 +171,7 @@ describe('GET /', () => { }); it('올리브영 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('oliveyoung_find_nearby_stores'); @@ -179,7 +179,7 @@ describe('GET /', () => { }); it('CU 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('cu_find_nearby_stores'); @@ -187,7 +187,7 @@ describe('GET /', () => { }); it('GS25 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('gs25_find_nearby_stores'); @@ -196,7 +196,7 @@ describe('GET /', () => { }); it('이마트24 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('emart24_find_nearby_stores'); @@ -205,7 +205,7 @@ describe('GET /', () => { }); it('종료된 롯데마트 도구들은 포함하지 않는다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).not.toContain('lottemart_find_nearby_stores'); @@ -213,7 +213,7 @@ describe('GET /', () => { }); it('메가박스 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('megabox_find_nearby_theaters'); @@ -222,7 +222,7 @@ describe('GET /', () => { }); it('CGV 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('cgv_find_theaters'); @@ -231,7 +231,7 @@ describe('GET /', () => { }); it('롯데시네마 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('lottecinema_find_nearby_theaters'); @@ -240,7 +240,7 @@ describe('GET /', () => { }); it('세븐일레븐 도구들이 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('seveneleven_search_products'); @@ -250,28 +250,28 @@ describe('GET /', () => { }); it('장소 검색 도구가 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('places_search_nearby'); }); it('개발자 요청 제출 도구가 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('submit_developer_request'); }); it('통합 비교 도구가 포함되어 있다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.tools).toContain('compare_products'); }); it('엔드포인트 정보를 포함한다', async () => { - const res = await app.request('/'); + const res = await app.request('/', { headers: { Accept: 'application/json' } }); const data = await res.json(); expect(data.endpoints).toBeDefined(); diff --git a/tests/pages/discovery.test.ts b/tests/pages/discovery.test.ts new file mode 100644 index 00000000..49645d55 --- /dev/null +++ b/tests/pages/discovery.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import app from '../../src/index.js'; +import { buildDiscoveryAssets } from '../../src/pages/discovery.js'; + +describe('공개 발견 자산', () => { + it('브라우저 GET은 HTML이고 JSON 클라이언트와 명시적 메타데이터 경로는 JSON이다', async () => { + const res = await app.fetch(new Request('https://mcp.aka.page/')); + expect(res.headers.get('Content-Type')).toContain('text/html'); + 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(41); + }); + 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(41); + 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('/api/daiso/products?q='); + expect(assets['discovery.html']).toContain('https://mcp.aka.page/'); + expect(assets['discovery.html']).not.toContain('lottemart'); + expect(assets['discovery.html']).not.toContain('Zyte'); + expect(assets['discovery.html']).not.toContain(' { + const assets = buildDiscoveryAssets({ services: [], tools: [] }); + for (const bot of ['*', 'ChatGPT-User', 'OAI-SearchBot', 'GPTBot', 'Claude-User', 'PerplexityBot']) { + expect(assets['robots.txt']).toContain(`User-agent: ${bot}\nAllow: /`); + } + expect(assets['robots.txt']).toContain('Sitemap: https://mcp.aka.page/sitemap.xml'); + expect(assets['sitemap.xml']).toContain('https://mcp.aka.page/'); + expect(assets['sitemap.xml']).toContain('https://mcp.aka.page/llms.txt'); + expect(assets['_headers']).toContain('Access-Control-Allow-Origin: *'); + }); + + it('레지스트리 문자열을 HTML 문법으로 해석하지 않는다', () => { + const assets = buildDiscoveryAssets({ services: [{ name: '"&\'>' }], tools: [] }); + expect(assets['discovery.html']).toContain('<a>"&'>'); + expect(assets['discovery.html']).not.toContain(''); + }); +}); diff --git a/tests/retirement.test.ts b/tests/retirement.test.ts index 4ed69ea7..af482f13 100644 --- a/tests/retirement.test.ts +++ b/tests/retirement.test.ts @@ -18,7 +18,7 @@ it('롯데마트 경로는 업스트림 호출 없이 종료를 알린다', asyn } }); it('롯데마트는 지원 서비스와 MCP 도구에 없다', async () => { - const body = await (await app.request('/')).json(); + const body = await (await app.request('/root.json')).json(); expect(body.tools.some((name: string) => name.startsWith('lottemart_'))).toBe(false); }); it.each(['lottemartFindStores', 'lottemartSearchProducts'])('기존 action %s도 업스트림 없이 종료를 알린다', async (action) => { diff --git a/wrangler.toml b/wrangler.toml index 9862745a..6f3e1380 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -3,6 +3,14 @@ main = "src/index.ts" compatibility_date = "2025-01-23" compatibility_flags = ["nodejs_als"] +[build] +command = "npm run build:discovery" + +[assets] +directory = "./public-discovery" +not_found_handling = "none" +html_handling = "none" + [vars] HEALTH_CHECK_BASE_URL = "https://daiso-mcp.hmmhmmhm.workers.dev"