🎸

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

2024/12/08に公開

はじめに

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

MCPとは

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

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

プロジェクトの概要

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

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

開発環境の準備

Windows環境のセットアップ

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

  • Windows 10/11
  • Node.js v18以上
  • Visual Studio Code(推奨)

Node.jsのバージョンは以下のコマンドで確認できます:

node --version  # v18以上であること
npm --version

プロジェクトの作成

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

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形式で整形された検索結果を返却
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

インストール手順

npm install
npm run build

Claude Desktopでの設定

設定ファイルの場所:

  • MacOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%/Claude/claude_desktop_config.json

設定ファイルの内容:

{
  "mcpServers": {
    "duckduckgo-web-search": {
      "command": "/path/to/duckduckgo-web-search/build/index.js"
    }
  }
}

使用方法

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

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

デバッグについて

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

npm run inspector

まとめ

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

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

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

Discussion