duckduckgo-mcp-server
# DuckDuckGo MCP
DuckDuckGo 웹 검색과 웹페이지 본문 추출을 제공하는 MCP 서버입니다. API 키 없이 DuckDuckGo HTML 엔드포인트를 사용하며, 검색 결과와 정제된 페이지 본문을 LLM이 바로 소비할 수 있는 형태로 반환합니다.
> 이 저장소는 [nickclyde/duckduckgo-mcp-server](https://github.com/nickclyde/duckduckgo-mcp-server)를 **Goover MCP Hub** 배포용으로 포크·수정한 버전입니다. 원본은 transport 설정을 CLI 인자로만 받았고, 컨테이너 배포 시 Host 헤더 검증(421)과 SSE 스트리밍 응답에서 각각 문제가 있었습니다. 이 저장소에서 환경변수 기반 설정을 추가하고 네 가지 배포 차단 이슈를 수정했습니다.
## 기본 정보
| 항목 | 내용 |
|---|---|
| MCP 명칭 | DuckDuckGo MCP (`ddg-search`) |
| 원본 저장소 | https://github.com/nickclyde/duckduckgo-mcp-server |
| 언어/런타임 | Python 3.10+ (3.14까지 테스트), `mcp.server.fastmcp.FastMCP` |
| Transport | stdio(원본) + sse + **streamable HTTP** — 전부 환경변수로 설정 가능(신규) |
| 인증 | 없음 — DuckDuckGo HTML 엔드포인트 스크래핑, 키 불필요 |
| 로컬 상태 | **없음 — PVC 불필요.** rate limiter만 인메모리로 동작 |
| 도구 수 | 2개 |
| 버전 | 0.6.1 |
## 소개
**English**
> DuckDuckGo MCP provides web search and webpage content extraction without requiring any API key. It scrapes DuckDuckGo's HTML endpoint and returns results formatted for LLM consumption, along with a fetch tool that strips navigation, headers, footers, scripts, and styles to return clean readable text with pagination support. Built-in sliding-window rate limiting protects both tools. SafeSearch level and default region are fixed at server startup by the operator and cannot be changed by an AI assistant. An optional browser backend uses curl_cffi's Chrome TLS impersonation to pass fingerprint-based bot filters. Outbound fetches are guarded against SSRF by default.
**한글**
> DuckDuckGo MCP는 API 키 없이 웹 검색과 웹페이지 본문 추출을 제공하는 MCP입니다. DuckDuckGo HTML 엔드포인트를 스크래핑해 LLM이 바로 쓸 수 있는 형태로 결과를 반환하고, 본문 추출 도구는 내비게이션·헤더·푸터·스크립트·스타일을 제거한 정제 텍스트를 페이지네이션과 함께 돌려줍니다. 두 도구 모두 슬라이딩 윈도우 방식의 rate limit이 적용됩니다. SafeSearch 수준과 기본 지역은 운영자가 서버 기동 시 고정하며 AI 어시스턴트가 변경할 수 없습니다. 선택적 브라우저 백엔드는 curl_cffi의 Chrome TLS 지문 위장으로 봇 필터를 통과합니다. 외부 URL 접근은 기본적으로 SSRF 가드가 적용됩니다.
## 제공 도구 (2개)
| 도구 | 시그니처 | 설명 |
|---|---|---|
| `search` | `(query, max_results=10, region="")` | DuckDuckGo 웹 검색. 제목·URL·요약을 포함한 결과 목록 반환. 분당 30회 제한 |
| `fetch_content` | `(url, start_index=0, max_length=8000, backend=None)` | 웹페이지 본문 추출. 비본문 요소 제거 후 정제 텍스트 반환, 페이지네이션 지원. 분당 20회 제한 |
프롬프트/리소스는 제공하지 않는 순수 tool 기반 MCP입니다.
`region`은 호출별로 `us-en`, `cn-zh`, `jp-ja`, `de-de`, `fr-fr`, `wt-wt` 등으로 지정할 수 있고, 비워두면 서버 기본값을 씁니다.
> **SSRF 보호:** `fetch_content`는 기본적으로 loopback, 사설(RFC1918), link-local(`169.254.169.254` 클라우드 메타데이터 포함), reserved, multicast, unspecified 주소로 해석되는 URL을 거부하며 리다이렉트 홉마다 재검증합니다. `http`/`https`만 허용합니다. 내부 호스트 접근이 필요한 신뢰된 배포에서는 `DDG_ALLOW_PRIVATE_URLS=1`로 해제할 수 있습니다. 자세한 내용은 [SECURITY.md](SECURITY.md)를 참고하세요.
## 원본 대비 변경 사항
### 1. transport 설정을 환경변수로 받지 못함
원본은 `--transport` / `--host` / `--port`를 **CLI 인자로만** 받았습니다(`os.getenv()`로 읽는 건 `DDG_*` 계열뿐). Rancher처럼 컨테이너 Arguments를 넣기 어려운 환경에서 기동할 수 없었습니다.
`TRANSPORT` / `HOST` / `PORT` 환경변수를 fallback으로 추가했습니다. `DDG_` 접두사가 없는 이유는 이 자리에 있던 이전 Node.js 구현과의 호환 때문입니다.
env는 argparse `default=`가 아니라 **`parse_args()` 이후에** 해석합니다. 원본의 "host/port가 주어졌는데 transport가 stdio면 종료" 가드를 그대로 살리기 위해서입니다. `default=os.getenv("HOST")`로 넣으면 환경에 `HOST`가 떠 있기만 해도 stdio 실행이 즉시 죽습니다.
또한 argparse는 **default 값을 `choices`로 검증하지 않고**, 원본의 transport 분기에는 `else`가 없었습니다. 그래서 `TRANSPORT=http` 같은 오타가 들어오면 아무 로그 없이 exit 0으로 끝나 원인 파악이 어려웠습니다. 명시적 검증과 `else` 방어선을 추가했습니다.
```
$ TRANSPORT=http python -m duckduckgo_mcp_server.server
error: Invalid TRANSPORT value(s) ['http']; choose from stdio, sse, streamable-http
```
`TRANSPORT`는 콤마 구분 멀티 값(`sse,streamable-http`)도 받습니다.
### 2. Host allow-list를 켜면 localhost가 차단됨
컨테이너 배포 시 외부 도메인으로 오는 요청이 `421 Misdirected Request: Invalid Host header`로 거부되는 문제는, 원본에 이미 있던 `DDG_ALLOWED_HOSTS`로 해결됩니다.
문제는 그 다음이었습니다. FastMCP에 명시적 `TransportSecuritySettings`를 넘기면 SDK의 localhost 기본값(`127.0.0.1:*`, `localhost:*`, `[::1]:*`)을 **통째로 덮어씁니다.** 그래서 프록시 호스트를 allow-list에 넣는 순간 로컬 접근이 전부 막혀, 도커 healthcheck나 로컬 프로브가 조용히 죽었습니다.
localhost 패턴을 병합하도록 수정했습니다. 부수적으로 `DDG_ALLOWED_ORIGINS`만 설정하면 `allowed_hosts`가 빈 리스트가 되어 **모든** Host가 421이 되던 문제도 함께 해결됐습니다.
```
DDG_ALLOWED_HOSTS=example.goover.ai:33284 로 기동 시
Host: example.goover.ai:33284 -> 200
Host: localhost:8000 -> 200 (수정 전 421)
Host: 127.0.0.1:8000 -> 200 (수정 전 421)
Host: attacker.example.com -> 421 (차단 유지)
```
> SDK의 Host 매칭은 **정확히 일치**하거나 뒤에 `:*`가 붙은 **포트 와일드카드**만 처리합니다. 바에 `*`를 넣어도 "모든 호스트 허용"이 되지 않고 Host 헤더가 문자 그대로 `*`일 때만 매칭됩니다. 전체 허용이 필요하면 `DDG_DISABLE_DNS_REBINDING_PROTECTION=1`을 쓰세요.
### 3. blocking HTTP 클라이언트가 SSE 응답을 읽지 못함
Hub가 blocking `HttpURLConnection`으로 호출하는데, `streamable-http`의 POST 응답이 SSE 스트림이라 두 가지 증상이 발생했습니다.
1. `{"content":[{"type":"text","text":""}],"isError":false}` — 첫 SSE 청크(중간 notification)만 읽고 스트림 종료로 오판
2. `java.net.SocketException: Unexpected end of file from server` — chunked/SSE 파싱 실패
독립적인 두 스위치를 추가했고, **둘 다 기본 off**입니다.
- `DDG_JSON_RESPONSE=1` — POST 응답을 SSE 프레임 없는 단일 `application/json` 바디로 반환
- `DDG_DISABLE_PROGRESS_NOTIFICATIONS=1` — `ctx.info`/`ctx.error`를 MCP 통신 대신 서버 로그로 보냄
실측 결과 **notification 억제만으로는 증상 2번이 해결되지 않습니다.** 이벤트 수만 줄고 SSE 프레임 자체는 남기 때문입니다.
| 조합 | Content-Type | `event:` 프레임 |
|---|---|---|
| 기본 (둘 다 off) | `text/event-stream` | 3 |
| `DDG_DISABLE_PROGRESS_NOTIFICATIONS=true` | `text/event-stream` | 1 |
| `DDG_JSON_RESPONSE=1` | **`application/json`** | 0 |
| 둘 다 | `application/json` | 0 |
`json_response`는 `mcp.streamable_http_app()` 호출 **이전에** 설정해야 합니다 — FastMCP가 첫 호출에 세션 매니저를 만들어 캐시하기 때문입니다.
억제해도 메시지는 서버 로그에 남고, 에러 내용은 각 도구의 **반환값**에도 들어있어 클라이언트가 실패를 놓치지 않습니다.
### 4. Docker 이미지에 curl_cffi 누락
원본 Dockerfile이 `pip install .` 만 실행해 `[browser]` extra를 빠뜨렸습니다. 그런데 검색 백엔드 기본값은 `auto`라서, curl_cffi가 없으면 DuckDuckGo의 TLS 지문 차단(HTTP 202/403) 시 fallback이 동작하지 못하고 안내 메시지만 반환했습니다. 특히 한글 쿼리에서 재현되던 "결과 없음" 증상의 원인입니다.
```dockerfile
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir ".[browser]"
```
### 참고 — 함께 정리한 항목
`src/duckduckgo_mcp_server/__init__.py`의 `__version__`이 `0.1.1`로 하드코딩되어 `pyproject.toml`의 `0.6.1`과 어긋나 있었습니다. 설치된 배포판 메타데이터에서 읽도록 바꿔 이중 출처를 없앴습니다.
## 환경변수
기동 시 1회 읽으며, 요청별로는 반영되지 않습니다.
### Transport (신규)
| 변수 | CLI 플래그 | 값 | 기본값 |
|---|---|---|---|
| `TRANSPORT` | `--transport` | `stdio` / `sse` / `streamable-http`, 콤마 구분 멀티 값 가능 | `stdio` |
| `HOST` | `--host` | HTTP transport 바인드 주소 | `127.0.0.1` |
| `PORT` | `--port` | HTTP transport 바인드 포트 | `8000` |
CLI 플래그가 환경변수보다 우선합니다.
### 검색 동작
| 변수 | 값 | 기본값 |
|---|---|---|
| `DDG_SAFE_SEARCH` | `STRICT`(kp=1) / `MODERATE`(kp=-1) / `OFF`(kp=-2) | `MODERATE` |
| `DDG_REGION` | `us-en`, `cn-zh`, `jp-ja`, `wt-wt` 등. 비우면 DuckDuckGo 기본 동작 | (없음) |
| `DDG_SEARCH_BACKEND` | `auto` / `httpx` / `curl` | `auto` |
### 네트워크 / 보안
| 변수 | CLI 플래그 | 설명 |
|---|---|---|
| `DDG_ALLOWED_HOSTS` | `--allowed-hosts` | 허용 Host 헤더 목록(콤마 구분). `host`, `host:port`, `host:*` 지원. localhost 패턴은 자동 병합 |
| `DDG_ALLOWED_ORIGINS` | `--allowed-origins` | 허용 Origin 헤더 목록 |
| `DDG_DISABLE_DNS_REBINDING_PROTECTION` | `--disable-dns-rebinding-protection` | Host/Origin 검증 전체 해제. allow-list 사용을 권장 |
| `DDG_ALLOW_PRIVATE_URLS` | `--allow-private-urls` | `fetch_content`의 SSRF 가드 해제 |
| `DDG_CA_CERTS` | `--ca-certs` | TLS 검증용 PEM CA 번들 경로. TLS 가로채기 프록시 뒤에서 필요 (httpx는 `SSL_CERT_FILE`을 더 이상 읽지 않음) |
| `DDG_SSL_VERIFY=0` | `--no-ssl-verify` | TLS 인증서 검증 전체 해제. 비권장 |
### 클라이언트 호환 (신규)
| 변수 | CLI 플래그 | 설명 |
|---|---|---|
| `DDG_JSON_RESPONSE` | `--json-response` | streamable-http POST 응답을 단일 `application/json`으로. `sse` transport에는 무효 |
| `DDG_DISABLE_PROGRESS_NOTIFICATIONS` | — | 진행상황 notification을 MCP 통신 대신 서버 로그로. 모든 transport에 적용 |
## 실행 방법
### stdio (원본 방식, 그대로 유지)
```bash
uvx duckduckgo-mcp-server
```
Claude Desktop 설정 (`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"ddg-search": {
"command": "uvx",
"args": ["duckduckgo-mcp-server"],
"env": {
"DDG_SAFE_SEARCH": "STRICT",
"DDG_REGION": "cn-zh"
}
}
}
}
```
Claude Code:
```bash
claude mcp add ddg-search uvx duckduckgo-mcp-server
```
### streamable HTTP (신규, Goover MCP Hub 배포용)
```bash
# CLI 인자로
uvx duckduckgo-mcp-server --transport streamable-http --host 0.0.0.0 --port 8000
# 환경변수만으로 (Arguments를 넣기 어려운 환경)
TRANSPORT=streamable-http HOST=0.0.0.0 PORT=8000 uvx duckduckgo-mcp-server
```
### 검색 백엔드 (봇 차단 우회)
DuckDuckGo의 검색 엔드포인트는 httpx의 TLS 지문을 차단해 빈 **HTTP 202**를 반환할 수 있습니다(User-Agent와 무관하게 JA3/TLS 핸드셰이크를 봅니다). `curl` 백엔드는 curl_cffi로 Chrome 핸드셰이크를 위장해 이를 통과합니다.
| 값 | 동작 | `[browser]` 필요 |
|---|---|---|
| `httpx` | 경량 async HTTP | 아니오 |
| `curl` | curl_cffi Chrome TLS 위장 | 예 |
| `auto` | httpx 먼저, 차단 감지 시 curl로 재시도 | 예 |
검색은 기본값이 `auto`, `fetch_content`는 기본값이 `httpx`이며 호출별 `backend` 인자로 덮어쓸 수 있습니다.
```bash
uv pip install "duckduckgo-mcp-server[browser]"
```
Docker 이미지에는 이미 포함되어 있습니다.
## Docker
### Dockerfile
```dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY . /app
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir ".[browser]"
ENTRYPOINT ["python", "-m", "duckduckgo_mcp_server.server"]
CMD []
```
### 로컬 빌드 및 스모크 테스트
```bash
docker build --no-cache --platform linux/amd64 -t duckduckgo-mcp:latest .
docker run -d --name duckduckgo-mcp-test -p 8069:8000 \
-e TRANSPORT=streamable-http \
-e HOST=0.0.0.0 \
-e PORT=8000 \
-e DDG_REGION=wt-wt \
-e DDG_SAFE_SEARCH=OFF \
-e DDG_ALLOWED_HOSTS=example.goover.ai:33284,example.goover.ai:*,example.goover.ai \
-e DDG_JSON_RESPONSE=1 \
-e DDG_DISABLE_PROGRESS_NOTIFICATIONS=true \
duckduckgo-mcp:latest
curl -s -X POST http://localhost:8069/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
```
`DDG_ALLOWED_HOSTS`에 세 가지 형태를 모두 넣은 이유는 클라이언트가 Host 헤더에 포트를 붙이는지 확실하지 않기 때문입니다. `example.goover.ai`와 `example.goover.ai:33284`는 서로 다른 값이라 매칭되지 않습니다.
검증 완료 항목:
- `initialize` — 환경변수만으로 기동, 정상 응답
- `tools/list` — `search`, `fetch_content` 2개 정상 반환
- `tools/call`(`search`) — 영문·한글 쿼리 모두 성공, 5회 연속 빠른 호출에도 202 없음
- `tools/call`(`fetch_content`) — 실제 페이지 본문 추출 성공
- Host 헤더 4종 프로브 — 허용 호스트·localhost·127.0.0.1은 200, 미등록 호스트는 421
- 응답 형식 4개 조합 — `DDG_JSON_RESPONSE` 유무에 따라 `application/json` / `text/event-stream` 정상 전환
## 개발
```bash
uv sync # 의존성 설치
uv run duckduckgo-mcp-server # 실행
mcp dev src/duckduckgo_mcp_server/server.py # MCP Inspector
uv run python -m pytest src/duckduckgo_mcp_server/ -v # 전체 테스트 (106개)
uv run ruff check . # 린트 (CI quality 잡과 동일)
```
CI는 GitHub Actions로 Python 3.10–3.14에서 pytest를 돌리고, `ruff check`(blocking)와 `pip-audit`(non-blocking)를 실행합니다.
## 이 포크만의 특징적인 사항
- 원본은 stdio 전용 사용을 전제로 문서화되어 있었고, HTTP transport 관련 설정이 CLI 인자에만 노출되어 컨테이너 배포 시 기동 자체가 어려웠습니다.
- 잘못된 `TRANSPORT` 값이 **아무 로그 없이 exit 0**으로 끝나던 실패 모드를 제거했습니다. argparse가 default 값을 `choices`로 검증하지 않는다는 점이 원인이었습니다.
- Host allow-list 설정이 SDK의 localhost 기본값을 덮어써 로컬 프로브를 조용히 차단하던 버그를 수정했습니다. 이 문제는 allow-list를 켜기 전에는 드러나지 않습니다.
- blocking HTTP 클라이언트 호환은 notification 억제가 아니라 **응답 형식 자체**(`json_response`)를 바꿔야 해결된다는 점을 실측으로 확인하고 두 스위치를 모두 제공합니다.
- 로컬 상태가 전혀 없어 PVC가 필요 없고, 인증·API 키도 필요 없어 자격증명 관리 이슈가 없습니다.
- 근본 원인 참고: Hub의 HTTP 클라이언트가 SSE 스트리밍을 정식 지원하는 스택(Spring `WebClient` 등)으로 교체되기 전까지, progress notification을 보내는 다른 MCP를 붙일 때마다 같은 문제가 재발할 수 있습니다. 3번 항목은 서버 쪽 우회입니다.
## 라이선스
원본 저장소([nickclyde/duckduckgo-mcp-server](https://github.com/nickclyde/duckduckgo-mcp-server))의 MIT 라이선스를 따릅니다 (Copyright (c) 2025 Nick Clyde). 재배포·상업적 사용 전 [LICENSE](LICENSE) 파일을 확인하시기 바랍니다.
TDQS
Scored across 2 tools
The two tools are completely orthogonal: 'search' queries the web for results, while 'fetch_content' retrieves and cleans the text of a specific URL. There is zero overlap in purpose or arguments.
Both tool names use imperative lowercase-with-underscores style. 'search' is a simple verb, and 'fetch_content' follows the verb_noun pattern; they are consistent in style and tone.
With only 2 tools, the server is minimal but not thin—it covers the two core actions for a DuckDuckGo search MCP: searching and fetching content. A third tool like 'get_suggestions' might be nice, but the current count is reasonable for the stated purpose.
The pair supports a complete workflow of searching and then reading result pages, with pagination on fetch. Missing advanced features like result pagination beyond 20 or related searches, but these are minor gaps that do not block typical use cases.