From 2d501e8c47c15646995df32a73f407d0f4ec76ac Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 7 Oct 2026 15:11:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=B9=20=EC=9D=BD=EA=B8=B0=EC=9A=A9?= =?UTF-8?q?=20HTML=20=EC=A1=B0=ED=9A=8C=EC=99=80=20UTF-8=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/web-discovery.md | 10 +++++ src/index.ts | 4 +- src/pages/agentStart.ts | 9 ++++- src/pages/apiResult.ts | 47 ++++++++++++++++++++++ src/pages/discovery.ts | 10 ++--- tests/pages/apiResult.test.ts | 74 +++++++++++++++++++++++++++++++++++ 7 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 src/pages/apiResult.ts create mode 100644 tests/pages/apiResult.test.ts 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 => `
  1. ${renderValue(item, productLinks)}
  2. `).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

지원 서비스

${services}

@@ -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(' { + 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&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'); + }); +});