はじめに
こんにちは。フォースタートアップス株式会社エンジニアの田畑です。
MCPサーバーは2024年11月に発表されてから約1年半が経ち、技術トレンドの移り変わりが速い昨今では「今さら感」を感じる方もいるかもしれません。
ただその分、情報もある程度出揃っており、これから学ぶにはちょうどよい題材・タイミングだと思いました。
これからもAIと上手く付き合っていくための第一歩として、本記事ではMCPを題材に、チュートリアルレベルではありますが実際に手を動かしながら、「ちょっと分かる」状態を目指していきます。
MCPとは?
簡単にいうと、AIと外部のデータを連携するための仕組みを標準化したものです。
PCと外部データを接続するUSBのように、誰でも同じように扱える「規格」と捉えるとイメージしやすいです。
参考:
What is the Model Context Protocol (MCP)?
MCPの全体構成
MCPは、主に「MCPホスト」「MCPクライアント」「MCPサーバー」の3つの要素から構成されています。
これらが連携することで、AIが外部のデータやツールをシームレスに利用できるようになります。
MCPの全体構成図
MCPホスト(Host)
ホストは、AIモデルを実行し、ユーザーと直接やり取りをするアプリ本体です。
例:Claude Desktop、Cursor、Visual Studio Codeなど
MCPクライアント(Client)
クライアントはホストの内部に組み込まれており、サーバーと通信を行うための「窓口」となる機能です。
ホストからの指示を受け取り、サーバーへリクエストを送ったり、サーバーからのレスポンスをホストへ返したりする役割を担います。
MCPサーバー(Server)
外部のデータソース(APIやデータベースなど)と直接つながり、MCPのルールに従ってAIにデータを提供する「橋渡し役」のプログラムです。
MCPという規格を利用することで、特定の機能やデータをAIから利用できるようになります。
たとえば、MCPサーバーを用意することで、AIが外部のAPIやデータベースにアクセスし、その結果をもとに回答できるようになります。
有名なものとしては、Slack MCP Server や GitHub MCP Server などがあります。
そしてこちらが、今回のメインテーマになります。
参考:
MCPの登場背景
そもそもMCPサーバーとは、何を目的として、何を解決するために登場したのでしょうか?以下は公式ドキュメントより抜粋した内容です。
最も高度なモデルでさえ、データとの連携が限られているという制約を抱えている。情報サイロやレガシーシステムに閉じ込められているため、新たなデータソースごとに独自のカスタム実装が必要となり、真に接続されたシステムの拡張は困難になっている。
MCPはこの課題に対処します。AIシステムとデータソースを接続するための普遍的でオープンな標準を提供し、断片化された統合を単一のプロトコルに置き換えます。その結果、AIシステムが必要なデータにアクセスするための、よりシンプルで信頼性の高い方法が実現します。
Introducing the Model Context Protocol
つまり、従来はAIが外部データと連携するたびに個別実装が必要で、拡張しづらいという課題がありました。
それを解決するために、AIとデータソースをつなぐ共通の仕組みとしてMCPが登場したようです。
MCPサーバーの構成
MCPサーバーは、主に「Tools」「Resources」「Prompts」の3つの機能から構成されます。
機能名
役割(ざっくり言うと?)
具体例
Tools(以下、ツール)
AIが外部に対してアクションを実行するための機能 (メイン機能)
・外部APIを呼び出して最新情報を取得する ・計算やデータ処理を行う ・データベースに情報を書き込む
Resources(以下、リソース)
AIに読ませる「読み取り専用のデータ」 (ユーザーが添付できる仮想ファイル)
・PC内のログファイル(error.log など) ・データベースのテーブル構造(スキーマ) ・システム全体を解説した仕様書テキスト
Prompts(以下、プロンプト)
よく使う指示をまとめた呼び出し可能なテンプレート
・「議事録を要約して」テンプレート ・「バグ報告を整理して」テンプレート ・「この内容をブログ記事にして」テンプレート
MCPサーバーの内部構成と処理フロー
図の処理の流れを順に追うと、以下の5つのステップになります。
ユーザーの入力と準備
ユーザーがAI(LLM)に対してチャットで質問や指示を行います。
このとき、必要に応じてMCPサーバーが提供する「指示テンプレート(プロンプト)」をユーザーが選択して指示を整形したり、「参考情報(リソース)」を付与して、AIに前提知識を与えることができます。
AIの判断(情報が足りるか?)
AIは、受け取った指示とリソースをもとに「この情報だけで回答できるか?」を判断します。情報が十分であれば、そのままツールを使わずに回答します。
一方で、ドキュメント検索など追加の情報が必要と判断した場合、次のステップに進みます。
MCPサーバーへのツール実行リクエスト
AIは、必要な情報を取得するために、MCPサーバーが提供する「ツール」を呼び出します(関数呼び出し)。
外部リソースへのアクセスとデータ取得
呼び出されたツールは、必要に応じて外部リソース(ドキュメントやAPIなど)にアクセスし、情報を取得します。
回答の生成と出力
取得したデータはMCPサーバーを通じてAIに返却されます。
AIは、元の質問・リソース・ツールの実行結果を統合し、最終的な回答を生成してユーザーに返します。
実際にMCPサーバーを作ってみる
一通り仕組みを理解したところで、実際にチュートリアルに沿ってMCPサーバーを作成します。
今回は、Railsにおけるクエリに関するドキュメントをデータソースとしたMCPサーバーを実装します。
実装の前提
なお、初期化や細かいセットアップについては本記事では扱いません。
詳細は公式チュートリアルをご参照ください。
動作確認環境
作成したMCPサーバーは、Claude Code Desktopをホストとして動作確認しています。
注意点
対象サイトの利用規約やrobots.txtを事前に確認し、スクレイピングの可否を確認してください
短時間に大量のリクエストを送らないようにし、適切な間隔でアクセスしてください
参考:公式チュートリアル
まずは全体像を把握するために、実装コード全体を掲載します。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" ;
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" ;
import { z } from "zod" ;
import * as cheerio from "cheerio" ;
const USER_AGENT = "rails-activerecord-mcp/1.0.0" ;
const QUERY_DOCS_URL = "https://guides.rubyonrails.org/active_record_querying.html" ;
const INTERNAL_RESOURCE_URI = "rails-activerecord://internal/guidelines" ;
const server = new McpServer({
name : "rails-activerecord" ,
version : "1.0.0" ,
} );
const queryKeywordSchema = z.object({
keyword : z.string().describe("クエリ設計に関する検索キーワード。例: N+1, includes, joins" ),
} );
const queryPromptArgsSchema = {
userQuestion : z.string().describe("ユーザーから受け取った質問文" ),
} ;
function buildInternalResourceText () {
return `【社内Railsクエリ・ガイドライン】
このドキュメントは、株式会社〇〇の社内ローカルルールです。
公式ドキュメントよりも、この社内ルールを最優先して回答してください。
【社内基本方針】
- N+1問題の解消時、当社のプロジェクトでは原則として \` includes \` ではなく \` eager_load \` を第一候補とすること。(※JOINを強制してクエリ数を1つに抑えるため)
- レコードの存在確認には \` present? \` ではなく、必ず \` exists? \` を使用すること。
※これら以外の一般的な仕様やメソッドの詳細について聞かれた場合は、適宜 \` get_query_docs \` ツールを使用して公式ドキュメントを検索してください。` ;
}
server.registerResource(
"internal_query_guidelines" ,
INTERNAL_RESOURCE_URI,
{
title : "Internal Query Guidelines" ,
description : "社内独自のRailsクエリ設計ガイドライン" ,
mimeType : "text/plain" ,
} ,
async () => ({
contents : [
{
uri : INTERNAL_RESOURCE_URI,
mimeType : "text/plain" ,
text : buildInternalResourceText(),
} ,
] ,
} )
);
server.registerTool(
"get_query_docs" ,
{
description : "ActiveRecordのクエリ設計に関する公式ドキュメントをキーワードで検索する" ,
inputSchema : queryKeywordSchema,
} ,
async ({ keyword } ) => {
const headers = {
"User-Agent" : USER_AGENT,
"Accept" : "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" ,
} ;
try {
const response = await fetch (QUERY_DOCS_URL, { headers } );
if (!response.ok) throw new Error (`Failed to fetch ${ QUERY_DOCS_URL} : ${ response.statusText} ` );
const rawHtml = await response.text();
const doc = cheerio.load(rawHtml);
const normalizedKeyword = keyword.toLowerCase ();
const sections: string [] = [] ;
doc("h3, h4" ).each((_ : number , el : any ) => {
const headingElement = doc(el);
const heading = headingElement.text();
const headingId = headingElement.attr("id" ) ?? "" ;
const headingAnchorHref = headingElement.find ("a" ).attr("href" ) ?? "" ;
const headingAnchorText = headingElement.find ("a" ).text();
let content = "" ;
let next = headingElement.next ();
while (next.length && !next.is("h3, h4" )) {
content += next.text() + " " ;
next = next.next ();
}
const searchableHeadingValues = [ heading, headingId, headingAnchorHref, headingAnchorText] .join (" " ).toLowerCase ();
if (searchableHeadingValues.includes (normalizedKeyword) || content.toLowerCase ().includes (normalizedKeyword)) {
sections.push (` ${ heading} \n ${ content} ` );
}
} );
const result = sections.length > 0 ? sections.join (" \n\n " ) : "該当する項目が見つかりませんでした" ;
return { content : [{ type : "text" as const , text : result }] } ;
} catch (error) {
console .error(error);
return { content : [{ type : "text" as const , text : "Failed to fetch documentation" }] } ;
}
}
);
server.registerPrompt(
"active_record_assistant" ,
{
title : "Active Record Assistant" ,
description : "ActiveRecordに関する質問に答えるための基本プロンプト" ,
argsSchema : queryPromptArgsSchema,
} ,
async ({ userQuestion } ) => ({
messages : [
{
role : "user" ,
content : {
type : "text" ,
text : [
"あなたは Rails / ActiveRecord のサポートアシスタントです。" ,
"まず internal_query_guidelines Resource を読み、社内ルールの有無を確認してください。" ,
"質問が Resource の内容だけで答えられる場合は、Tool を使わずに回答してください。" ,
"質問が一般仕様や詳細なメソッド挙動を必要とする場合のみ get_query_docs を使ってください。" ,
"回答は次の形式で出力してください。" ,
"1. 結論" ,
"2. 根拠" ,
"3. 使用した情報源: Resourceのみ / ResourceとTool" ,
"4. 社内ルールを参照した場合は、その該当箇所を1行で明記" ,
"" ,
`ユーザーの質問: ${ userQuestion} ` ,
] .join (" \n " ),
} ,
} ,
] ,
} )
);
async function main () {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch ((error ) => {
console .error("Fatal error in main():" , error);
process .exit(1 );
} );
上記の実装を、構成の観点からざっくり整理すると以下のようになります。
new McpServer()
server.registerResource()
server.registerTool()
server.registerPrompt()
server.connect(transport)
MCPサーバーの実装はシンプルで、サーバーを初期化し、リソース・ツール・プロンプトを登録したうえで起動する、という流れになっています。
1. サーバー設定
const USER_AGENT = "rails-activerecord-mcp/1.0.0" ;
const QUERY_DOCS_URL = "https://guides.rubyonrails.org/active_record_querying.html" ;
const INTERNAL_RESOURCE_URI = "rails-activerecord://internal/guidelines" ;
const server = new McpServer({
name : "rails-activerecord" ,
version : "1.0.0" ,
} );
const queryKeywordSchema = z.object({
keyword : z.string().describe("クエリ設計に関する検索キーワード。例: N+1, includes, joins" ),
} );
const queryPromptArgsSchema = {
userQuestion : z.string().describe("ユーザーから受け取った質問文" ),
} ;
const server = new McpServer({ ... }) でサーバーを初期化しています。name と version で基本的な情報を設定しています。それ以外は、参照するURLやリクエスト設定、入力スキーマなどの定義です。
2. 起動処理
async function main () {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch ((error ) => {
console .error("Fatal error in main():" , error);
process .exit(1 );
} );
サーバーの初期設定が完了したら、次にAIクライアントと通信し、サーバーを起動するための処理を記述します。(※ 実際のソースコードでは、この後に追加するリソースやツールの登録が完了した後、ファイルの最後に記述してください)
リソース・ツール・プロンプトの3つの機能を組み込んだMCPサーバーを起動し、AIクライアントと通信できる状態にします。
ここでは、トランスポート方式(通信方法)として StdioServerTransport() を指定しています。
MCPの通信方式は、大きく以下の2つに分けられます。
Streamable HTTP通信 :外部サーバーにデプロイし、ネットワーク越しにやり取りする方式
stdio通信(標準入出力) :ローカル環境で、AIクライアントと直接データをやり取りする方式
今回は、ローカル環境で手軽に動作させるために、stdio通信を利用しています。これにより、AIクライアントとMCPサーバーが直接やり取りできるようになります。
参考:
Transports
3. リソース
function buildInternalResourceText () {
return `【社内Railsクエリ・ガイドライン】
このドキュメントは、株式会社〇〇の社内ローカルルールです。
公式ドキュメントよりも、この社内ルールを最優先して回答してください。
【社内基本方針】
- N+1問題の解消時、当社のプロジェクトでは原則として \` includes \` ではなく \` eager_load \` を第一候補とすること。(※JOINを強制してクエリ数を1つに抑えるため)
- レコードの存在確認には \` present? \` ではなく、必ず \` exists? \` を使用すること。
※これら以外の一般的な仕様やメソッドの詳細について聞かれた場合は、適宜 \` get_query_docs \` ツールを使用して公式ドキュメントを検索してください。` ;
}
server.registerResource(
"internal_query_guidelines" ,
INTERNAL_RESOURCE_URI,
{
title : "Internal Query Guidelines" ,
description : "社内独自のRailsクエリ設計ガイドライン" ,
mimeType : "text/plain" ,
} ,
async () => ({
contents : [
{
uri : INTERNAL_RESOURCE_URI,
mimeType : "text/plain" ,
text : buildInternalResourceText(),
} ,
] ,
} )
);
リソースでは、LLMが参照するための静的な情報を定義します。今回はテキストをそのまま情報源として定義しましたが、テキストファイルや画像なども定義することが可能です。
テキストファイル・画像ファイルをリソースとして登録する場合は、それぞれ以下のように実装します。
import fs from "fs" ;
server.registerResource(
"system_error_log" ,
"file:///logs/error.log" ,
{
title : "System Error Log" ,
description : "システムのエラーログ" ,
mimeType : "text/plain" ,
} ,
async () => ({
contents : [
{
uri : "file:///logs/error.log" ,
mimeType : "text/plain" ,
text : fs.readFileSync("./logs/error.log" , "utf-8" ),
} ,
] ,
} )
);
server.registerResource(
"architecture_diagram" ,
"file:///docs/architecture.png" ,
{
title : "Architecture Diagram" ,
description : "システム構成図" ,
mimeType : "image/png" ,
} ,
async () => ({
contents : [
{
uri : "file:///docs/architecture.png" ,
mimeType : "image/png" ,
blob : fs.readFileSync("./docs/architecture.png" ).toString ("base64" ),
} ,
] ,
} )
);
Claude Code Desktopの場合、追加したリソースは、チャット入力欄の「+(添付)」アイコンから「コネクタ」を経由して手動で呼び出すことができます。
呼び出したリソースは、1つのテキストファイルとしてチャット欄に添付されます。さらに、そのファイルを開くと、コード内で定義した内容がそのまま含まれていることが確認できます。
このようにClaude Code Desktopでは、リソースは自動的に参照されるものではなく、ユーザーが必要に応じてAIに渡す「前提知識のファイル」であることが分かります。
そこで、実際にリソース(社内ガイドライン)を添付した状態で「N+1問題の解消方法」を質問してみました。その結果、AIはツールを使用せず、リソースの内容だけをもとに回答を生成しました。
このように、AIは提供された情報だけで十分と判断した場合、ツールを実行せずに回答します。
次に、リソースには含まれていない pluck メソッドについて聞いてみました。
するとAIは「手持ちの情報だけでは不十分」と判断し、get_query_docs ツールを実行して外部ドキュメントを参照したうえで回答を生成しました。
このように、AIはリソースとツールを状況に応じて使い分けながら回答を組み立てることがわかります。
補足
Claude Code Desktopでは、リソースを使用する際にユーザーが明示的に指定する必要があります。
一方で、他のAIホストでは、リソースの利用可否をクライアント側の実装で判断したり、状況に応じてAIが自動的に選択・利用するケースもあります。
参考:
Resources
4. ツール
server.registerTool(
"get_query_docs" ,
{
description : "ActiveRecordのクエリ設計に関する公式ドキュメントをキーワードで検索する" ,
inputSchema : queryKeywordSchema,
} ,
async ({ keyword } ) => {
const headers = {
"User-Agent" : USER_AGENT,
"Accept" : "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" ,
} ;
try {
const response = await fetch (QUERY_DOCS_URL, { headers } );
if (!response.ok) throw new Error (`Failed to fetch ${ QUERY_DOCS_URL} : ${ response.statusText} ` );
const rawHtml = await response.text();
const doc = cheerio.load(rawHtml);
const normalizedKeyword = keyword.toLowerCase ();
const sections: string [] = [] ;
doc("h3, h4" ).each((_ : number , el : any ) => {
const headingElement = doc(el);
const heading = headingElement.text();
const headingId = headingElement.attr("id" ) ?? "" ;
const headingAnchorHref = headingElement.find ("a" ).attr("href" ) ?? "" ;
const headingAnchorText = headingElement.find ("a" ).text();
let content = "" ;
let next = headingElement.next ();
while (next.length && !next.is("h3, h4" )) {
content += next.text() + " " ;
next = next.next ();
}
const searchableHeadingValues = [ heading, headingId, headingAnchorHref, headingAnchorText] .join (" " ).toLowerCase ();
if (searchableHeadingValues.includes (normalizedKeyword) || content.toLowerCase ().includes (normalizedKeyword)) {
sections.push (` ${ heading} \n ${ content} ` );
}
} );
const result = sections.length > 0 ? sections.join (" \n\n " ) : "該当する項目が見つかりませんでした" ;
return { content : [{ type : "text" as const , text : result }] } ;
} catch (error) {
console .error(error);
return { content : [{ type : "text" as const , text : "Failed to fetch documentation" }] } ;
}
}
);
まず全体の流れとしては、AIがユーザーの質問内容からキーワードを抽出し、そのキーワードをツールに渡します。ツールは受け取ったキーワードをもとに QUERY_DOCS_URL へリクエストを送り、該当する内容を取得して返す、という処理になっています。
このとき、コード上ではキーワード抽出の処理は実装していません。ツールの description や inputSchema をもとに、AIが質問文から適切なキーワードを判断し、自動的に引数として渡しています。
また、ページ全文をそのまま渡すと、不要な情報によってトークンを無駄に消費し、回答精度も下がってしまいます。そのため、h3 や h4 の見出し単位で内容を区切り、必要な部分のみを抽出してAIに渡しています。
MCPサーバーあるあるかもしれませんが、LLMが賢すぎてツールを使わずにドヤ顔で自分の知識から答えてきました。特に一般的な質問では、MCPサーバーを介さずに完結してしまうケースもあります。
そのため、MCPサーバーを使って回答してほしい場合は、明示的に「ツールを使って」と指示したり、「公式ドキュメントから引用して」と逃げ道をなくしてあげるのがよさそうです。
MCPサーバーの利用を明示的に指示すると、ちゃんとツールを使って答えてくれました。
5. プロンプト
server.registerPrompt(
"active_record_assistant" ,
{
title : "Active Record Assistant" ,
description : "ActiveRecordに関する質問に答えるための基本プロンプト" ,
argsSchema : queryPromptArgsSchema,
} ,
async ({ userQuestion } ) => ({
messages : [
{
role : "user" ,
content : {
type : "text" ,
text : [
"あなたは Rails / ActiveRecord のサポートアシスタントです。" ,
"まず internal_query_guidelines Resource を読み、社内ルールの有無を確認してください。" ,
"質問が Resource の内容だけで答えられる場合は、Tool を使わずに回答してください。" ,
"質問が一般仕様や詳細なメソッド挙動を必要とする場合のみ get_query_docs を使ってください。" ,
"回答は次の形式で出力してください。" ,
"1. 結論" ,
"2. 根拠" ,
"3. 使用した情報源: Resourceのみ / ResourceとTool" ,
"4. 社内ルールを参照した場合は、その該当箇所を1行で明記" ,
"" ,
`ユーザーの質問: ${ userQuestion} ` ,
] .join (" \n " ),
} ,
} ,
] ,
} )
);
MCPサーバーにおけるプロンプトは、あらかじめ用意した指示書のようなものです。リソースと同様にチャット画面から選択して適用でき、AIの振る舞いや回答方針をあらかじめ定義することができます。
プロンプトを選択すると、ユーザーの入力が求められます。
質問を入力すると、入力した指示が含まれたプロンプト全体が、1つのテキストファイルとして自動で作成・添付されます。
冒頭にAIとのやり取りがありますが、それ以降はプロンプトで定義した通り、「結論 → 根拠 → 情報源 → 社内ルール参照の有無」の順で回答が生成されていることが確認できます。
一方で、N+1問題についてはリソース内でルールとして記載しているのですが、うまく読み取ってもらえなかったみたいです。このあたりは、表現やプロンプト設計を工夫することで改善の余地がありそうでした。
終わりに
いかがでしたでしょうか。
本記事では、MCPサーバーの仕組みにフォーカスし、理解の解像度を上げることと、心理的なハードルを下げることを目的として、実際の実装例を交えながら紹介してきました。
本記事を通して、MCPサーバーについて「ちょっと分かる」状態になっていれば嬉しいです。