🔨

MCPツールはAPIベースのツールを置き換えるのか?DenoでMCPサーバーを作ってみた

2025/03/21に公開
1

TL;DR

MCPツールはAPIベースのツールを置き換えない

MCPとは?

MCPは2024年末にAnthropicによって公開されたプロトコルで、プレスリリース内では「外部のデータやツールとAIアシスタントを接続するための新しい標準[1]」と説明されています。

MCPの公式サイトにも「AIアプリケーション用のUSB-Cポートのようなものと思ってください[2]」という記述があり、OpenAIの"Function calling"やAnthropicの"Tool use"といったプロプライエタリな規格を置き換え、"Write once, run anywhere"的な開発体験の実現を目的としているのがうかがえます。

DenoでMCPサーバーを作る

なんとClaude ProユーザーならClaude for Desktop経由でMCPツールを使い放題なので[3]、せっかくそんな機能があるならとLLM向け検索エンジンTavilyのAPIをMCP越しに呼び出すMCPサーバーを自作してみました。

実装にあたってはMCPサーバーの公式サンプルにあるBrave Searchを参考にしました。

Denoを採用しているのは標準ライブラリが充実していて依存関係を小さくできることや、TypeScriptコードを直接実行できるのでTypeScriptをJavaScriptにトランスパイルする手間がなく開発体験がよいといったメリットがあるためです。

プロジェクト構成

- /
    - src/
        - tavily-search.ts
    - .env
    - deno.json

deno.json

{
    "imports": {
        "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.7.0",
        "@std/dotenv": "jsr:@std/dotenv@^0.225.3"
    }
}

セットアップ

ツールの実行には以下のツール/APIキーが必要です。

  • Deno
  • TavilyのAPIキー
    • .envファイルを作ってTAVILY_API_KEY="tvly-dev-*****"と記載するか、システム全体の環境変数として設定しておいてください

DenoのセットアップとAPIキーの取得、tavily-search.tsの作成が終わったらdeno.jsonの内容をコピペしてからdeno installを実行するか、deno install npm:@modelcontextprotocol/sdk jsr:@std/dotenv@を実行して必要なパッケージをインストールしてください。

`tavily-search.ts`のコード全文
import { Server } from "npm:@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "npm:@modelcontextprotocol/sdk/server/stdio.js"
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from "npm:@modelcontextprotocol/sdk/types.js"
import "jsr:@std/dotenv/load"

const WEB_SEARCH_TOOL = {
    name: "tavily_web_search",
    description: "Performs a web search using the Tavily Search 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.",
    inputSchema: {
        type: "object",
        properties: {
            query: {
                type: "string",
                description: "Search query (max 400 chars, 50 words)",
            },
        },
        required: ["query"],
    },
} as const satisfies Tool

const server = new Server(
    {
        name: "tavlily-search",
        version: "1.0.0",
    },
    {
        capabilities: {
            tools: {},
        },
    }
)

const TAVILY_API_KEY = Deno.env.get("TAVILY_API_KEY")
if (!TAVILY_API_KEY) {
    console.error("TAVILY_API_KEY environment variable is required")
    Deno.exit(1)
}

function isWebSearchArgs(args: unknown): args is { query: string } {
    if (typeof args !== "object" || args === null) {
        return false
    }

    const { query } = args as { query: unknown }

    return typeof query === "string"
}

async function performWebSearch(query: string): Promise<string> {
    const response = await fetch("https://api.tavily.com/search", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "Authorization": `Bearer ${TAVILY_API_KEY}`,
        },
        body: JSON.stringify({
            query,
            search_depth: "advanced",
            include_raw_content: true,
        }),
    })

    if (!response.ok) {
        throw new Error(`Failed to perform web search: ${await response.text()}`)
    }

    const results = await response.json().then((data) => data.results)

    console.error(JSON.stringify(results, null, 2))

    const result = results.map((result: { title: string; url: string; raw_content: string }) => `[${result.title}](${result.url})\n\n${result.raw_content}`).join("\n\n---\n\n")

    return result
}

server.setRequestHandler(ListToolsRequestSchema, () => ({
    tools: [WEB_SEARCH_TOOL],
}))

server.setRequestHandler(CallToolRequestSchema, async (request) => {
    try {
        const { name, arguments: args } = request.params

        if (!args) {
            throw new Error("No arguments provided")
        }

        if (name === WEB_SEARCH_TOOL.name) {
            if (!isWebSearchArgs(args)) {
                throw new Error("Invalid arguments for tavily_web_search")
            }
            const { query } = args
            const result = await performWebSearch(query)
            return {
                content: [{ type: "text", text: result }],
                isError: false,
            }
        } else {
            return {
                content: [{ type: "text", text: `Unknown tool: ${name}` }],
                isError: true,
            }
        }
    } catch (err) {
        console.error(err)
        return {
            content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        }
    }
})

async function runServer() {
    const transport = new StdioServerTransport()
    await server.connect(transport)

    console.error("Server started")
}

runServer().catch((err) => {
    console.error(err)
    Deno.exit(1)
})

コード解説

1. ツールの定義

const WEB_SEARCH_TOOL = {
    name: "tavily_web_search",
    description: "Performs a web search using the Tavily Search API...",
    inputSchema: {
        type: "object",
        properties: {
            query: {
                type: "string",
                description: "Search query (max 400 chars, 50 words)",
            },
        },
        required: ["query"],
    },
} as const satisfies Tool

名前や説明、引数の形式といったツールの仕様を定義しています。

2. 検索機能の実装

async function performWebSearch(query: string): Promise<string> {
    const response = await fetch("https://api.tavily.com/search", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "Authorization": `Bearer ${TAVILY_API_KEY}`,
        },
        body: JSON.stringify({
            query,
            search_depth: "advanced",
            include_raw_content: true,
        }),
    })

    if (!response.ok) {
        throw new Error(`Failed to perform web search: ${await response.text()}`)
    }

    const results = await response.json().then((data) => data.results)

    console.error(JSON.stringify(results, null, 2))

    const result = results.map((result: { title: string; url: string; raw_content: string }) => `[${result.title}](${result.url})\n\n${result.raw_content}`).join("\n\n---\n\n")

    return result
}

APIから返ってきた検索結果の間にMarkdownのセパレータを挟んで単一の文字列に結合しているだけの単純な実装ですが、重要なのはAPIへのリクエスト時に含まれているオプションです。

search_depth: "advanced"は通常より検索クエリと関連性の高い結果を返すように、include_raw_content: trueは検索結果と同時にWebページ全体の内容を返すように指定するオプションです。search_depth: "advanced"は指定すると検索結果が返ってくるのに多少時間がかかりますが、より高精度な検索結果が得られるようになり、結果としてLLMの回答精度の向上も見込めるため、リクエストに含めることをおすすめします。

検索結果をconsole.errorで出力しているのはMCPの仕様上StdioServerTransportを利用したMCPサーバー上でconsole.logを使うと標準出力に書き込まれたメッセージがクライアントへのレスポンスとして扱われてしまうためです。公式の実装サンプルでもログをconsole.errorで標準エラー出力に書き込むワークアラウンドがとられています。

ちなみに:この関数はTavilyのJavaScript SDKを使えば3倍くらい短く書けるのですが、できるだけ依存関係をシンプルに保ちたかったのでfetchで実装しています。ここはわたしの個人的な好みなので、基本的にはSDKを使うほうがいいと思います。

3. リクエストハンドラの設定

server.setRequestHandler(ListToolsRequestSchema, () => ({
    tools: [WEB_SEARCH_TOOL],
}))

server.setRequestHandler(CallToolRequestSchema, async (request) => {
    try {
        const { name, arguments: args } = request.params
        
        if (name === WEB_SEARCH_TOOL.name) {
            const { query } = args
            const result = await performWebSearch(query)
            return {
                content: [{ type: "text", text: result }],
                isError: false,
            }
        } else {
            return {
                content: [{ type: "text", text: `Unknown tool: ${name}` }],
                isError: true,
            }
        }
    } catch (err) {
        console.error(err)
        return {
            content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
            isError: true,
        }
    }
})

MCPサーバーが対応するツールの一覧を返すListToolsRequestSchemaと実際のツール呼び出しに対応するCallToolRequestSchemaの2種類のリクエストハンドラを設定しています。

戻り値をResultモナドに似たオブジェクトで包むことで関数の実行結果とエラーを共通化されたインターフェースで扱えるような仕様になっているのがスマートですね。

MCPツールの登録

Claude for DesktopでMCPツールを実行する前に、設定ファイル(claude_desktop_config.json)を編集して今回作成したツールを登録してください。

{
    "mcpServers": {
        "tavily_search": {
            "command": "{absolute path of deno}",
            "args": [
                "--allow-read",
                "--allow-env",
                "--allow-net",
                "--env-file={absolute path of this repo}/.env",
                "{absolute path of this repo}/src/tavily-search.ts"
            ]
        }
    }
}

{absolute path of deno}などのプレースホルダーは実際のパスに置き換えてください。またTavilyのAPIキーをシステム全体の環境変数として登録した場合は"--env-file={absolute path of this repo}/.env"の行は不要です。

実行してみる

いま一番アツいコンセプトのVibe Codingについて聞いてみました。

単純に質問しただけではMCPツールを使ってくれないようです。今度はツールを使うように頼んでみます。

MCPツールで検索した結果をもとに回答してくれました。念のためファクトチェックも行いましたが、回答に誤りはありませんでした。

まとめ:MCPツールはAPIベースのツールを置き換えるのか?

APIがLLMをアプリケーションに組み込む手段なのに対してMCPはLLMにできることを拡張する手段であり、MCPツールがAPIベースのツールを置き換えることはないというのがわたしの見解です。

一方で、APIを利用するアプリケーションを拡張する手段としてMCPが使われる可能性は十分にあると思います。例えばわたしが先日開発したChatGPT、Claude、Gemini、Gemma(Ollama)が同時に閲覧・書き込みできるLLMのためのSNSではStructured OutputやTool useを駆使して各LLMに共通の関数を呼び出してもらうのに非常に苦しむことになったのですが、こうした苦しみもMCPがあれば解決できるはずです。

前述のとおりAPIを利用して画像の文字認識や議事録の要約といったLLMを組み込んだアプリケーションと違ってMCPはLLMがチャットの中で外部のアプリケーションを呼び出すことを想定しているので、あえて実用性皆無のおもしろツールとかを作ってみたいですね。

脚注
  1. "a new standard for connecting AI assistants to the systems where data lives, including content repositories, business tools, and development environments." ↩︎

  2. "Think of MCP like a USB-C port for AI applications." ↩︎

  3. FreeプランユーザーでもMCPツールの機能は使えるが、チャット機能そのものの利用可能枠が小さいので使い放題とはいかない ↩︎

1

Discussion