diff --git a/README.md b/README.md
index 0ba970dc..e7b58c53 100644
--- a/README.md
+++ b/README.md
@@ -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로 바로 시작하기
| 연결 항목 | 값 |
diff --git a/docs/web-discovery.md b/docs/web-discovery.md
index fc21a62d..bcfdcdb2 100644
--- a/docs/web-discovery.md
+++ b/docs/web-discovery.md
@@ -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 본문을 다른 인코딩으로 추측하는 클라이언트에서 한글이 깨지는 것을 방지합니다.
diff --git a/src/index.ts b/src/index.ts
index 29699c94..4554ed41 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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';
@@ -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(
'*',
@@ -343,6 +342,7 @@ app.use(
);
// Zyte 연동 공개 GET API의 일일 호출량을 IP별로 제한합니다.
+app.use('/api/*', apiResultHtml);
app.use('/api/*', createDailyRateLimitMiddleware());
// 기본 정보 엔드포인트 (GET 요청만)
diff --git a/src/pages/agentStart.ts b/src/pages/agentStart.ts
index 98db9963..19cdbb92 100644
--- a/src/pages/agentStart.ts
+++ b/src/pages/agentStart.ts
@@ -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}입니다.
@@ -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
diff --git a/src/pages/apiResult.ts b/src/pages/apiResult.ts
new file mode 100644
index 00000000..35d8956e
--- /dev/null
+++ b/src/pages/apiResult.ts
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
+ .replaceAll('"', '"').replaceAll("'", ''');
+}
+
+/** 모든 필드와 배열 순서를 보존하고 외부 문자열을 HTML로 해석하지 않습니다. */
+function renderValue(value: unknown, productLinks: boolean): string {
+ if (value === null || typeof value !== 'object') return escapeHtml(value);
+ if (Array.isArray(value)) {
+ return value.length ? `
${value.map(item => `- ${renderValue(item, productLinks)}
`).join('')}
` : '빈 목록
';
+ }
+ return `${Object.entries(value).map(([key, item]) => {
+ const rendered = renderValue(item, productLinks);
+ const detail = productLinks && key === 'id' && (typeof item === 'string' || typeof item === 'number')
+ ? ` 상품 상세` : '';
+ return `- ${escapeHtml(key)}
- ${rendered}${detail}
`;
+ }).join('')}
`;
+}
+
+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 = `${title} — 다이소 MCP
+${title}
+요청: ${source} · HTTP ${c.res.status}
실제 API 조회 결과입니다. 오류를 상품 없음이나 재고 0으로 해석하지 마세요. 매장 재고는 상품 ID와 매장명 또는 사용자 위치를 확인한 뒤 별도로 조회하세요.
+${renderValue(data, url.pathname === '/api/daiso/products')}
+동일 조회의 JSON 원본 · 설치 없는 조회 안내 · API 경로와 필수 파라미터 안내
+다른 공개 GET 조회도 기존 파라미터에 format=html을 추가하면 HTML로 읽을 수 있습니다. 웹 읽기만으로 MCP 연결이 생기는 것은 아닙니다.
`;
+ 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);
+};
diff --git a/src/pages/discovery.ts b/src/pages/discovery.ts
index f841b6db..2f69f0b5 100644
--- a/src/pages/discovery.ts
+++ b/src/pages/discovery.ts
@@ -11,7 +11,7 @@ function escapeHtml(value: string): string {
/** 배포마다 현재 레지스트리에서 정적 발견 자산을 생성합니다. */
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';
+ 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': `
@@ -22,8 +22,8 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools
다이소 MCP
설치 없이 상품·매장·재고와 영화관 정보를 조회하세요. 사용자 API 키나 로그인 없이 쓸 수 있는 공개 서비스입니다.
-바로 조회하기
다이소 수납박스 상품 3개 조회
-위 링크는 공개 REST GET입니다. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.
+바로 조회하기
다이소 수납박스 상품 3개 조회
+위 링크는 셸·설치 없이 웹 읽기로 호출하는 공개 REST GET의 HTML 결과입니다. 다른 공개 GET 조회도 기존 파라미터에 format=html을 추가하세요. 검색한 상품의 실제 ID로 재고를 조회하세요. 상품 검색만으로 매장 재고를 단정하지 마세요.
에이전트에서 설치 없이 사용하기
원격 MCP 주소: ${BASE}
전송: Streamable HTTP · 인증: 없음
원격 MCP 클라이언트가 있으면 이 주소에 연결하고 daiso_search_products를 {"query":"수납박스","pageSize":3}으로 호출하세요.
HTTP POST나 셸이 있으면 아래 한 요청으로 첫 조회를 실행할 수 있습니다.
@@ -31,7 +31,7 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools
-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 링크를 사용하세요. 오류를 상품 없음으로 해석하지 마세요.
+웹 읽기 기능으로 이 페이지를 읽는 것만으로 MCP 연결이 생기지는 않습니다. 샌드박스나 셸 없이 웹 읽기만 가능한 채팅 에이전트는 위 HTML REST 링크를 바로 여세요. 오류를 상품 없음으로 해석하지 마세요.
짧은 시작 안내 · 전체 API 사용 안내 · 현재 서비스·도구 목록 JSON
@@ -46,6 +46,6 @@ export function buildDiscoveryAssets(info: { services: { name: string }[]; tools
'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',
+ '_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',
};
}
diff --git a/tests/pages/apiResult.test.ts b/tests/pages/apiResult.test.ts
new file mode 100644
index 00000000..22e5697f
--- /dev/null
+++ b/tests/pages/apiResult.test.ts
@@ -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: '', 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('<script>alert(1)</script>');
+ expect(body).not.toContain('