Windows Claude Desktop用のDuckDuckGo検索サーバーを作ってみた

## はじめに

Windows環境でTypeScriptを使用して、Anthropicが先日発表したModel Context Protocol（MCP）は、AIアシスタントと外部システムを接続するための新しい標準プロトコルです。今回は、このMCPを使ってClaude Desktopから直接DuckDuckGo検索が行えるサーバーを作成してみました。

<blockquote class="twitter-tweet" data-media-max-width="800"><p lang="ja" dir="ltr">BraveのMCPサーバーを元にDuckDuckGoのMCPサーバーを作成してみた！<br>前回のデバッグの感じだと上手くいってるぽい。。。<br>＊分かりにくいので検索結果もちゃんと表示するようにしたい <a href="https://t.co/wQFy94GcQ7">https://t.co/wQFy94GcQ7</a> <a href="https://t.co/NJvZEeA2Nr">pic.twitter.com/NJvZEeA2Nr</a></p>&mdash; Maki@Sunwood AI Labs. (@hAru_mAki_ch) <a href="https://twitter.com/hAru_mAki_ch/status/1863075683540558035?ref_src=twsrc%5Etfw">December 1, 2024</a></blockquote> 

## MCPとは

Model Context Protocolは、AIアシスタントに外部データソースへのアクセスを提供するためのオープンな標準規格です。このプロトコルを使うことで：

- AIシステムと各種データソースを統一的に接続
- カスタム実装の手間を削減
- セキュアな双方向通信を実現

## プロジェクトの概要

今回作成したDuckDuckGo検索サーバーは以下の特徴を持っています：

- TypeScriptでの実装
- DuckDuckGo APIを利用した検索機能
- 検索結果のMarkdown形式での出力
- レート制限の実装

## 開発環境の準備

### Windows環境のセットアップ

開発環境として以下が必要です：

- Windows 10/11
- Node.js v18以上
- Visual Studio Code（推奨）

Node.jsのバージョンは以下のコマンドで確認できます：
```bash
node --version  # v18以上であること
npm --version
```

### プロジェクトの作成

MCPが提供する`create-typescript-server`ツールを使用して、プロジェクトの雛形を作成します：

```bash
npx @modelcontextprotocol/create-server duckduckgo-web-search
cd duckduckgo-web-search
```

このコマンドで必要な依存関係やTypeScriptの設定ファイルが自動的に生成されます。

## 実装のポイント

### プロジェクト構成

基本的なファイル構成は以下の通りです：

```
├─ src/
│  ├─ index.ts
├─ package.json
├─ tsconfig.json
```

### 主要な依存関係

- `@modelcontextprotocol/sdk`: MCPの実装に必要なSDK
- `node-fetch`: HTTP通信用
- `typescript`: 開発言語

### 検索機能の実装

検索機能は`duckduckgo_web_search`というツールとして実装しました。主な特徴：

- クエリと結果件数をパラメータとして受け付け
- DuckDuckGoのインスタントアンサーと関連トピックを取得
- Markdown形式で整形された検索結果を返却

```ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import fetch from "node-fetch";

// インターフェース定義
interface DuckDuckGoSearchArgs {
    query: string;
    count?: number;
}

interface DuckDuckGoResponse {
    AbstractText?: string;
    AbstractSource?: string;
    AbstractURL?: string;
    RelatedTopics: Array<{
        Text?: string;
        FirstURL?: string;
    }>;
}

const WEB_SEARCH_TOOL = {
    name: "duckduckgo_web_search",
    description: "Performs a web search using the DuckDuckGo API, ideal for general queries, news, articles, and online content. " +
        "Use this for broad information gathering, recent events, or when you need diverse web sources. " +
        "Supports content filtering and region-specific searches. " +
        "Maximum 20 results per request.",
    inputSchema: {
        type: "object",
        properties: {
            query: {
                type: "string",
                description: "Search query (max 400 chars)"
            },
            count: {
                type: "number",
                description: "Number of results (1-20, default 10)",
                default: 10
            }
        },
        required: ["query"],
    },
};

// Server implementation
const server = new Server({
    name: "example-servers/duckduckgo-search",
    version: "0.1.0",
}, {
    capabilities: {
        tools: {},
    },
});

const RATE_LIMIT = {
    perSecond: 1,
    perMonth: 15000
};

let requestCount = {
    second: 0,
    month: 0,
    lastReset: Date.now()
};

function checkRateLimit(): void {
    const now = Date.now();
    if (now - requestCount.lastReset > 1000) {
        requestCount.second = 0;
        requestCount.lastReset = now;
    }
    if (requestCount.second >= RATE_LIMIT.perSecond ||
        requestCount.month >= RATE_LIMIT.perMonth) {
        throw new Error('Rate limit exceeded');
    }
    requestCount.second++;
    requestCount.month++;
}

function isDuckDuckGoWebSearchArgs(args: unknown): args is DuckDuckGoSearchArgs {
    return (
        typeof args === "object" &&
        args !== null &&
        "query" in args &&
        typeof (args as DuckDuckGoSearchArgs).query === "string"
    );
}

async function performWebSearch(query: string, count: number = 10): Promise<string> {
    checkRateLimit();
    
    // DuckDuckGo search API endpoint
    const url = new URL('https://api.duckduckgo.com/');
    url.searchParams.set('q', query);
    url.searchParams.set('format', 'json');
    url.searchParams.set('no_html', '1');
    url.searchParams.set('no_redirect', '1');
    url.searchParams.set('skip_disambig', '1');
    
    const response = await fetch(url.toString());
    
    if (!response.ok) {
        throw new Error(`DuckDuckGo API error: ${response.status} ${response.statusText}\n${await response.text()}`);
    }

    const data = await response.json() as DuckDuckGoResponse;
    
    const results = [];
    
    // Add instant answer if available
    if (data.AbstractText) {
        results.push({
            title: data.AbstractSource || '',
            description: data.AbstractText,
            url: data.AbstractURL || ''
        });
    }

    // Add related topics
    const relatedTopics = data.RelatedTopics || [];
    for (let i = 0; i < Math.min(count - results.length, relatedTopics.length); i++) {
        const topic = relatedTopics[i];
        if (topic.Text && topic.FirstURL) {
            results.push({
                title: topic.Text.split(' - ')[0] || topic.Text,
                description: topic.Text,
                url: topic.FirstURL
            });
        }
    }

    // Markdown形式で検索結果を返す
    const formattedResults = results.map(r => {
        return `### ${r.title}
${r.description}

🔗 [記事を読む](${r.url})
`;
    }).join('\n\n');

    // 検索結果の前にサマリーを追加
    return `# DuckDuckGo 検索結果
${query} の検索結果（${results.length}件）

---

${formattedResults}
`;
}

// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [WEB_SEARCH_TOOL],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
    try {
        const { name, arguments: args } = request.params;
        if (!args) {
            throw new Error("No arguments provided");
        }

        switch (name) {
            case "duckduckgo_web_search": {
                if (!isDuckDuckGoWebSearchArgs(args)) {
                    throw new Error("Invalid arguments for duckduckgo_web_search");
                }
                const { query, count = 10 } = args;
                const results = await performWebSearch(query, count);
                return {
                    content: [{ type: "text", text: results }],
                    isError: false,
                };
            }
            default:
                return {
                    content: [{ type: "text", text: `Unknown tool: ${name}` }],
                    isError: true,
                };
        }
    }
    catch (error) {
        return {
            content: [
                {
                    type: "text",
                    text: `Error: ${error instanceof Error ? error.message : String(error)}`,
                },
            ],
            isError: true,
        };
    }
});

async function runServer() {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("DuckDuckGo Search MCP Server running on stdio");
}

runServer().catch((error) => {
    console.error("Fatal error running server:", error);
    process.exit(1);
});

```

### レート制限の実装

APIの適切な利用のため、以下のレート制限を実装：

- 1秒あたり1リクエスト
- 月間15,000リクエスト

## インストールと設定

### 必要な環境

- Node.js
- npm

### インストール手順

```bash
npm install
npm run build
```

### Claude Desktopでの設定

設定ファイルの場所：
- MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%/Claude/claude_desktop_config.json`

設定ファイルの内容：
```json
{
  "mcpServers": {
    "duckduckgo-web-search": {
      "command": "/path/to/duckduckgo-web-search/build/index.js"
    }
  }
}
```

## 使用方法

Claude Desktopで以下のように使用できます：

1. サーバーを起動
2. Claudeに検索クエリを入力
3. Markdown形式で整形された検索結果を受け取り

## デバッグについて

MCPサーバーは標準入出力を使用して通信を行うため、デバッグが難しい場合があります。MCPインスペクターを使用することで、効率的なデバッグが可能です：

```bash
npm run inspector
```

<blockquote class="twitter-tweet" data-media-max-width="800"><p lang="ja" dir="ltr">BraveのMCPサーバーを元にDuckDuckGoのMCPサーバーを作成中④<br>inspectorで自作MCPサーバーデバッグしてみたら。。。ちゃんと動いてた。。。問題はDuckDuckGo側の検索エンジンが問題ぽいな。。。<br>＊このinspector使ったデバッグ方法もどこかで展開します！ <a href="https://t.co/fprABNw3i0">https://t.co/fprABNw3i0</a> <a href="https://t.co/VGlCd8aEJ8">pic.twitter.com/VGlCd8aEJ8</a></p>&mdash; Maki@Sunwood AI Labs. (@hAru_mAki_ch) <a href="https://twitter.com/hAru_mAki_ch/status/1863074859267567809?ref_src=twsrc%5Etfw">December 1, 2024</a></blockquote> 

## まとめ

MCPを使用することで、比較的簡単にClaude Desktopに外部検索機能を追加することができました。このような拡張性は、AIアシスタントの活用範囲を大きく広げる可能性を秘めています。

今回の実装は基本的な機能に留まっていますが、MCPの可能性を探るための良い出発点になったと考えています。

<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>