8000 fix: 설치 없는 MCP 웹 발견과 크롤러 안내 개선 by hmmhmmhm · Pull Request #241 · 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ build/Release

# Dependency directories
node_modules/
public-discovery/
jspm_packages/

# Snowpack dependency directory (https://snowpack.dev/)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
다이소·편의점 재고, 주변 장소, 주유소 가격, 영화 시간표를 AI에서 조회합니다.
**공개 원격 MCP에 연결하면 로컬 설치·빌드·API 키 준비 없이 바로 사용할 수 있습니다.**
에이전트는 [짧은 시작 안내](https://mcp.aka.page/llms.txt)를 참고하세요. 무설치 요청에는 npm 설치나 전체 스키마 탐색이 필요하지 않습니다.
웹에서는 [공개 첫 화면](https://mcp.aka.page/)에서 시작할 수 있습니다. [검색·크롤러 안내와 배포 구조](docs/web-discovery.md)는 별도 문서에 정리했습니다.

## 원격 MCP로 바로 시작하기

Expand Down
36 changes: 36 additions & 0 deletions docs/web-discovery.md
Original file line number Diff line number Diff line change
@@ -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/)를 참조합니다.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions scripts/ops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 명령에서 참조되며, 입력과 실패 메시지는 자동화 로그만으로 원인 추적이 가능해야 합니다.
Expand Down
11 changes: 11 additions & 0 deletions scripts/ops/generate-discovery.ts
Original file line number Diff line number Diff line change
@@ -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 정적 자산 생성 완료');
22 changes: 13 additions & 9 deletions src/index.ts
< 10000 td class="blob-code blob-code-addition js-file-line"> const body = json ? ROOT_INFO_BODY : ROOT_HTML_BODY;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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');
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' },
});
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down
51 changes: 51 additions & 0 deletions src/pages/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { buildAgentStartText } from './agentStart.js';

const BASE = 'https://mcp.aka.page';

/** 레지스트리 설명이 HTML로 해석되지 않도록 이스케이프합니다. */
function escapeHtml(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#39;');
}

/** 배포마다 현재 레지스트리에서 정적 발견 자산을 생성합니다. */
export function buildDiscoveryAssets(info: { services: { name: string }[]; tools: string[] }): Record<string, string> {
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': `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>다이소 MCP — 설치 없이 상품·매장·재고 조회</title>
<meta name="description" content="다이소, 편의점, 마트, 올리브영과 영화관을 조회하는 무료 공개 MCP. 설치나 사용자 API 키 없이 시작하세요.">
<meta name="robots" content="index,follow"><link rel="canonical" href="${BASE}/">
<link rel="alternate" type="text/plain" href="${BASE}/llms.txt" title="에이전트 시작 안내">
<style>body{font-family:system-ui,sans-serif;max-width:760px;margin:48px auto;padding:0 24px;line-height:1.7;color:#18222c;background:#fafafa}h1{line-height:1.3}a{color:#075bb5}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#edf1f5;padding:16px;border-radius:8px}code{font-size:.9em}section{margin:32px 0}footer{border-top:1px solid #d8dee5;padding-top:20px}</style>
</head><body><main>
<h1>다이소 MCP</h1><p>설치 없이 상품·매장·재고와 영화관 정보를 조회하세요. 사용자 API 키나 로그인 없이 쓸 수 있는 공개 서비스입니다.</p>
<section><h2>바로 조회하기</h2><p><a href="${firstQuery}">다이소 수납박스 상품 3개 조회</a></p>
<p>위 링크는 공개 REST GET입니다. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.</p></section>
<section><h2>에이전트에서 설치 없이 사용하기</h2><p>원격 MCP 주소: <code>${BASE}</code><br>전송: Streamable HTTP · 인증: 없음</p>
<p>원격 MCP 클라이언트가 있으면 이 주소에 연결하고 <code>daiso_search_products</code>를 <code>{"query":"수납박스","pageSize":3}</code>으로 호출하세요.</p>
<p>HTTP POST나 셸이 있으면 아래 한 요청으로 첫 조회를 실행할 수 있습니다.</p>
<pre><code>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}}}'</code></pre>
<p>웹 읽기 기능으로 이 페이지를 읽는 것만으로 MCP 연결이 생기지는 않습니다. GET만 가능한 에이전트는 위 REST 링크를 사용하세요. 오류를 상품 없음으로 해석하지 마세요.</p>
<p><a href="/llms.txt">짧은 시작 안내</a> · <a href="/prompt">전체 API 사용 안내</a> · <a href="/root.json">현재 서비스·도구 목록 JSON</a></p></section>
<section><h2>지원 서비스</h2><p>${services}</p></section>
<footer><a href="https://github.com/hmmhmmhm/daiso-mcp">GitHub 문서와 소스</a> · <a href="/privacy">개인정보 처리방침</a></footer>
</main></body></html>\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': '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
['/', '/llms.txt', '/prompt', '/root.json', '/privacy']
.map((path) => `<url><loc>${BASE}${path}</loc></url>`).join('\n') + '\n</urlset>\n',
'_headers': '/*\n Access-Control-Allow-Origin: *\n Cache-Control: public, max-age=300\n X-Content-Type-Options: nosniff\n',
};
}
Loading
Loading
0