MCPツールはAPIベースのツールを置き換えるのか?DenoでMCPサーバーを作ってみた
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がチャットの中で外部のアプリケーションを呼び出すことを想定しているので、あえて実用性皆無のおもしろツールとかを作ってみたいですね。
Discussion