8000 feat: 셸 없는 에이전트용 HTML 조회와 UTF-8 안내 제공 by hmmhmmhm · Pull Request #242 · 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
에이전트는 [짧은 시작 안내](https://mcp.aka.page/llms.txt)를 참고하세요. 무설치 요청에는 npm 설치나 전체 스키마 탐색이 필요하지 않습니다.
웹에서는 [공개 첫 화면](https://mcp.aka.page/)에서 시작할 수 있습니다. [검색·크롤러 안내와 배포 구조](docs/web-discovery.md)는 별도 문서에 정리했습니다.

웹 읽기만 가능한 채팅 에이전트는 공개 GET API에 `&format=html`을 추가해 결과를 읽을 수 있습니다. [첫 HTML 조회](https://mcp.aka.page/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html) · [사용 방법](docs/web-discovery.md).

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

| 연결 항목 | 값 |
Expand Down
10 changes: 10 additions & 0 deletions docs/web-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,13 @@ robots.txt는 hmart.app과 같은 방식으로 `User-agent: *`와 주요 AI 봇
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/)를 참조합니다.

## 웹 읽기만 가능한 채팅 에이전트

샌드박스나 셸 없이 URL만 읽을 수 있다면 [수납박스 HTML 조회](https://mcp.aka.page/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html)를 먼저 엽니다. 첫 화면과 llms.txt에도 이 링크를 안내합니다. 결과는 서버에서 생성하므로 JavaScript 실행이 필요 없습니다.

공개 `/api/*` GET 경로의 기존 파라미터에 `format=html`을 추가하면 같은 실제 조회 결과를 HTML로 제공합니다. `format`을 생략하면 기존 JSON 응답입니다. 예: `/api/daiso/stores?keyword=강남&format=html`, `/api/daiso/inventory?productId=실제상품ID&keyword=확인한매장명&lat=확인한위도&lng=확인한경도&format=html`. 상품 목록의 상세 링크도 HTML로 연결합니다. 다른 서비스의 필수 파라미터는 `/prompt`에서 확인합니다.

HTML은 원래 응답의 성공·오류와 HTTP 상태를 보존합니다. 상품 검색 결과를 매장 재고로 단정하거나 오류를 재고 0으로 해석하면 안 됩니다. POST와 MCP 응답 형식은 변경하지 않습니다. 웹 읽기 도구가 HTML 접근도 거부하는 경우에는 이 옵션만으로 도구 내부 문제를 해결하지 못합니다.

`llms.txt`와 `robots.txt`는 정적 Assets에서도 `text/plain; charset=utf-8`을 명시합니다. UTF-8 본문을 다른 인코딩으로 추측하는 클라이언트에서 한글이 깨지는 것을 방지합니다.
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { apiResultHtml } from './pages/apiResult.js';
import { withRouteRouting } from './utils/routeHealth.js';
import { refreshRouteChecks } from './utils/routeProbes.js';
import { registerRouteHealthRoutes } from './api/routes/routeHealthRoutes.js';
Expand Down Expand Up @@ -318,12 +319,10 @@ const handleRootMcpRequest = async (c: Context<{ Bindings: AppBindings }>) => {
await server.connect(transport);
return transport.handleRequest(c.req.raw, { parsedBody });
};

// Hono 앱 생성
const app = new Hono<{ Bindings: AppBindings }>();

app.use('*', diagnosticsMiddleware);

// CORS 설정
app.use(
'*',
Expand All @@ -343,6 +342,7 @@ app.use(
);

// Zyte 연동 공개 GET API의 일일 호출량을 IP별로 제한합니다.
app.use('/api/*', apiResultHtml);
app.use('/api/*', createDailyRateLimitMiddleware());

// 기본 정보 엔드포인트 (GET 요청만)
Expand Down
9 changes: 7 additions & 2 deletions src/pages/agentStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ export function buildAgentStartText(baseUrl: string): string {

공개 원격 MCP: https://mcp.aka.page
전송: Streamable HTTP · 인증: 없음 (사용자 API 키 불필요)
샌드박스·셸 없이 웹 읽기만 가능한 채팅 에이전트는 아래 HTML GET 주소를 먼저 여세요. npm 설치나 HTTP POST 실행이 필요 없습니다.
${baseUrl}/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html

다른 공개 GET API도 기존 파라미터에 format=html을 추가하면 실제 결과를 HTML로 읽을 수 있습니다. format을 생략하면 기존 JSON 응답입니다. 첫 결과의 상품 상세 링크도 HTML입니다. 오류는 HTTP 상태와 함께 표시되며 재고 없음으로 해석하면 안 됩니다.

전체 도구 목록이나 OpenAPI를 먼저 읽을 필요는 없습니다. 사용 가능한 실행 수단으로 아래 첫 조회를 실행하세요.

1. 원격 MCP 클라이언트가 있으면 공개 URL에 연결하세요. 표준 initialize → notifications/initialized 흐름을 사용하고 서버가 발급한 mcp-session-id를 전달합니다. 첫 도구는 daiso_search_products, 인자는 {"query":"수납박스","pageSize":3}입니다.
Expand All @@ -18,8 +23,8 @@ curl --max-time 20 -sS -N ${baseUrl}/ \\

응답은 SSE의 data: JSON-RPC 메시지입니다. result.isError 또는 error를 확인하고, 상품은 result.structuredContent.products에서 읽으세요. content[].text에도 상품 JSON이 있습니다. 오류를 상품 없음으로 해석하지 마세요.

3. HTTP GET만 가능하면 공개 REST GET을 사용하세요. 이것은 MCP 프로토콜 호출이 아닙니다.
${baseUrl}/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3
3. HTTP GET만 가능하면 공개 REST GET의 HTML 결과를 사용하세요. 이것은 MCP 프로토콜 호출이 아닙니다.
${baseUrl}/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html

실제 상품 결과의 id로 재고를 조회하세요. 예제 ID를 쓰거나 상품 검색 결과만으로 매장 재고를 단정하지 마세요.
상세 API와 서비스 안내: ${baseUrl}/prompt
Expand Down
47 changes: 47 additions & 0 deletions src/pages/apiResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/** 웹 읽기 에이전트용 공개 GET 결과 표시 */
import type { MiddlewareHandler } from 'hono';
import type { AppBindings } from '../api/response.js';

function escapeHtml(value: unknown): string {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#39;');
}

/** 모든 필드와 배열 순서를 보존하고 외부 문자열을 HTML로 해석하지 않습니다. */
function renderValue(value: unknown, productLinks: boolean): string {
if (value === null || typeof value !== 'object') return escapeHtml(value);
if (Array.isArray(value)) {
return value.length ? `<ol>${value.map(item => `<li>${renderValue(item, productLinks)}</li>`).join('')}</ol>` : '<p>빈 목록</p>';
}
return `<dl>${Object.entries(value).map(([key, item]) => {
const rendered = renderValue(item, productLinks);
const detail = productLinks && key === 'id' && (typeof item === 'string' || typeof item === 'number')
? ` <a href="/api/daiso/products/${encodeURIComponent(String(item))}?format=html">상품 상세</a>` : '';
return `<dt>${escapeHtml(key)}</dt><dd>${rendered}${detail}</dd>`;
}).join('')}</dl>`;
}

export const apiResultHtml: MiddlewareHandler<{ Bindings: AppBindings }> = async (c, next) => {
await next();
if (c.req.method !== 'GET' || c.req.query('format') !== 'html' || !c.res.headers.get('Content-Type')?.includes('application/json')) return;
let data: unknown;
try { data = await c.res.clone().json(); } catch { return; }
const url = new URL(c.req.url);
url.searchParams.delete('format');
const source = escapeHtml(url.pathname + url.search);
const failed = c.res.status >= 400 || (data as { success?: boolean } | null)?.success === false;
const title = failed ? '조회 실패' : '조회 성공';
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,follow"><title>${title} — 다이소 MCP</title>
<style>body{font-family:system-ui,sans-serif;max-width:900px;margin:32px auto;padding:0 20px;line-height:1.6;overflow-wrap:anywhere}dt{font-weight:600}dd{margin-bottom:12px}li{border-bottom:1px solid #ddd;padding:12px 0}a{color:#075bb5}</style></head><body><main><h1>${title}</h1>
<p>요청: <code>${source}</code> · HTTP ${c.res.status}</p><p>실제 API 조회 결과입니다. 오류를 상품 없음이나 재고 0으로 해석하지 마세요. 매장 재고는 상품 ID와 매장명 또는 사용자 위치를 확인한 뒤 별도로 조회하세요.</p>
${renderValue(data, url.pathname === '/api/daiso/products')}
<p><a href="${source}">동일 조회의 JSON 원본</a> · <a href="/">설치 없는 조회 안내</a> · <a href="/prompt">API 경로와 필수 파라미터 안내</a></p>
<p>다른 공개 GET 조회도 기존 파라미터에 <code>format=html</code>을 추가하면 HTML로 읽을 수 있습니다. 웹 읽기만으로 MCP 연결이 생기는 것은 아닙니다.</p></main></body></html>`;
const headers = new Headers(c.res.headers);
headers.set('Content-Type', 'text/html; charset=utf-8');
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'");
c.res = new Response(html, { status: c.res.status, headers });
// Hono는 응답 교체 시 기존 헤더를 병합하므로 교체 후 원본 표현의 헤더를 제거합니다.
for (const name of ['Content-Length', 'Content-Encoding', 'ETag', 'Last-Modified', 'Content-MD5', 'Digest']) c.res.headers.delete(name);
};
10 changes: 5 additions & 5 deletions src/pages/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function escapeHtml(value: string): string {
/** 배포마다 현재 레지스트리에서 정적 발견 자산을 생성합니다. */
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';
const firstQuery = '/api/daiso/products?q=%EC%88%98%EB%82%A9%EB%B0%95%EC%8A%A4&pageSize=3&format=html';
return {
'discovery.html': `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
Expand All @@ -22,16 +22,16 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools
<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><a href="${escapeHtml(firstQuery)}">다이소 수납박스 상품 3개 조회</a></p>
<p>위 링크는 셸·설치 없이 웹 읽기로 호출하는 공개 REST GET의 HTML 결과입니다. 다른 공개 GET 조회도 기존 파라미터에 <code>format=html</code>을 추가하세요. 검색한 상품의 실제 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>웹 읽기 기능으로 이 페이지를 읽는 것만으로 MCP 연결이 생기지는 않습니다. 샌드박스나 셸 없이 웹 읽기만 가능한 채팅 에이전트는 위 HTML 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>
Expand All @@ -46,6 +46,6 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools
'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',
'_headers': '/*\n Access-Control-Allow-Origin: *\n Cache-Control: public, max-age=300\n X-Content-Type-Options: nosniff\n/llms.txt\n Content-Type: text/plain; charset=utf-8\n/robots.txt\n Content-Type: text/plain; charset=utf-8\n',
};
}
74 changes: 74 additions & 0 deletions tests/pages/apiResult.test.ts
EF44
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { Hono } from 'hono';
import { apiResultHtml } from '../../src/pages/apiResult.js';
import type { AppBindings } from '../../src/api/response.js';
import app from '../../src/index.js';
import { buildDiscoveryAssets } from '../../src/pages/discovery.js';

function fixture() {
const a = new Hono<{ Bindings: AppBindings }>();
a.use('/api/*', apiResultHtml);
a.all('/api/daiso/products', c => c.json({ success: true, data: { products: [{ id: '123&"', name: '<script>alert(1)</script>', price: 1000 }, { id: 7 }, { id: { nested: true } }, { id: false }], nested: { null: null, flag: false, missing: [] } }, meta: { total: 1 } }, 200, { 'Cache-Control': 'public, max-age=30', 'ETag': 'json-tag', 'Content-Length': '1', 'Retry-After': '5' }));
a.get('/api/daiso/products/:id', c => c.json({ id: 7, other: {} }));
a.get('/api/error', c => c.json({ success: false, error: { code: 'UPSTREAM_DOWN', message: '조회 실패 <>&' } }, 503));
a.get('/api/null', c => c.json(null));
a.get('/api/false', c => c.json({ success: false }));
a.get('/api/no-type', () => new Response(new Uint8Array([65])));
a.get('/api/other-products', c => c.json([{ id: 7 }, { id: { nested: true } }, { id: false }]));
a.get('/api/string', c => c.json('문자열'));
a.get('/api/bad', c => c.body('broken', 200, { 'Content-Type': 'application/json' }));
a.get('/api/text', c => c.text('원문'));
return a;
}

describe('GET HTML 결과', () => {
it('기본 JSON과 POST 및 HEAD는 유지한다', async () => {
const a = fixture();
for (const [method, path] of [['GET','/api/daiso/products'], ['GET','/api/daiso/products?format=json'], ['POST','/api/daiso/products?format=html'], ['HEAD','/api/daiso/products?format=html']]) {
const res = await a.request(path, { method });
expect(res.headers.get('Content-Type')).toContain('application/json');
}
});
it('실제 데이터를 HTML로 표시하고 위험한 문자열을 escape하며 원본과 상세 링크를 만든다', async () => {
const res = await fixture().request('/api/daiso/products?q=%22%3C&pageSize=3&format=html');
const body = await res.text();
expect(res.headers.get('Content-Type')).toBe('text/html; charset=utf-8');
expect(res.headers.get('Cache-Control')).toBe('public, max-age=30');
expect(res.headers.get('Retry-After')).toBe('5');
expect(res.headers.get('ETag')).toBeNull();
expect(res.headers.get('Content-Length')).toBeNull();
expect(body).toContain('조회 성공'); expect(body).toContain('1000');
expect(body).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(body).not.toContain('<script');
expect(body).toContain('/api/daiso/products/123%26%22?format=html');
expect(body).toContain('q=%22%3C&amp;pageSize=3');
expect(body).toContain('null'); expect(body).toContain('false'); expect(body).toContain('빈 목록');
expect(body).toContain('total');
});
it('조회 오류의 상태와 내용을 보존하고 성공으로 표시하지 않는다', async () => {
const res = await fixture().request('/api/error?format=html');
expect(res.status).toBe(503);
const body = await res.text(); expect(body).toContain('조회 실패'); expect(body).toContain('UPSTREAM_DOWN'); expect(body).not.toContain('조회 성공');
});
it('JSON scalar도 표시하고 비JSON 또는 잘못된 JSON은 그대로 유지한다', async () => {
const a = fixture();
expect(await (await a.request('/api/string?format=html')).text()).toContain('문자열');
expect(await (await a.request('/api/text?format=html')).text()).toBe('원문');
expect(await (await a.request('/api/bad?format=html')).text()).toBe('broken');
expect(await (await a.request('/api/no-type?format=html')).text()).toBe('A');
expect(await (await a.request('/api/null?format=html')).text()).toContain('null');
expect(await (await a.request('/api/false?format=html')).text()).toContain('조회 실패');
expect(await (await a.request('/api/other-products?format=html')).text()).toContain('nested');
});
it('운영 앱에도 등록되어 기존 검증 오류를 HTML로 제공한다', async () => {
const res = await app.request('/api/daiso/products?format=html');
expect(res.status).toBe(400); expect(res.headers.get('Content-Type')).toContain('text/html');
expect(await res.text()).toContain('MISSING_QUERY');
});
it('셸 없는 에이전트에게 HTML GET을 안내하며 txt charset을 명시한다', () => {
const assets = buildDiscoveryAssets({ services: [], tools: [] });
expect(assets['discovery.html']).toContain('pageSize=3&amp;format=html');
expect(assets['llms.txt']).toContain('pageSize=3&format=html');
expect(assets['_headers']).toContain('/llms.txt\n Content-Type: text/plain; charset=utf-8');
});
});
Loading
0