iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

MCPサーバーを自作して動かしてみる

■はじめに

こんにちは!

iimonでエンジニアをしています「しらみず」です。

最近、いろんなMCPを接続して触ってみているのですが、自作できることがわかったので自作してみました。

「こんなことできるんだ」と思ってもらえて、興味が湧いたら自分でも作ってもらえるくらいを目指した入門記事です。

■MCP とは

MCP(Model Context Protocol)は、LLM(Claude など)に「外の世界の文脈」を渡すための共通プロトコルです。 Claude 単体では、GitHub や社内DB、今日の日付すら知りません。 MCP サーバーを 1 つ用意すると、Claude が必要なときにそのサーバーへ「PR 一覧をくれ」「今日の日付をくれ」と問い合わせ、返ってきた情報をもとに回答できるようになります。

  1. 接続時: MCP サーバーが「自分はこういうツールを持っています」という一覧(ツールの名前・説明・引数のスキーマ)を Claude に渡す(tools/list)。
  2. 依頼時: Claude はその説明文を読み、ユーザーの依頼に対して「今はこのツールを呼ぶべきだ」と 自分で判断 して呼び出す(tools/call)。
  3. 実行: サーバーは呼ばれて初めて外部リソースを叩き、結果を Claude に返す。Claude はそれをもとに回答する。
用語 役割
Host / Client Claude Code や Claude Desktop など。ユーザーと対話し、どのツールを呼ぶか判断して呼ぶ側
Server 今回自作する部分。呼ばれたら外部リソースを叩いて結果を返す受け身の側
Tool サーバーが提供する、Claude から呼べる個々の関数(例: get_today
tools/list 「どんな Tool があるか」をサーバーが Claude に伝える手続き
tools/call 「この Tool を実行して」と Claude がサーバーに依頼する手続き

■環境

今回の構成です。MCP の公式 SDK は複数言語ありますが、ここでは TypeScript を使います。

  • 言語:TypeScript
  • ランタイム:Node.js 20+
  • SDK:@modelcontextprotocol/sdk
  • スキーマ定義:zod
  • GitHub アクセス(後半):@octokit/rest
  • 動作確認に使う Host:Claude Code

■最小の MCP を作って動かす

まずは「今日の日付を返すだけ」の最小サーバーを作ります。

◆1. プロジェクト作成

mkdir mcp-sample && cd mcp-sample
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init

package.json"type": "module" を追加しておきます。

"rootDir": "./src",      // コメントアウトを外した
"outDir": "./dist",      // コメントアウトを外した(出力先を dist に固定)
"types": ["node"],       // [] → ["node"] に変更(これがエラーの直接の修正)

tsconfig.jsonを上のように変えておきます。

◆2. サーバー本体

src/index.ts を作ります。

やることは「サーバーを作る → ツールを 1 個登録する → 標準入出力でつなぐ」だけです。

#!/usr/bin/env node

// MCP サーバー本体を作るためのクラス
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
// サーバーと Claude(ホスト)を「標準入出力(stdio)」でつなぐための通信路(トランスポート)
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

// MCP サーバーのインスタンスを作る
const server = new McpServer({
    name: 'mcp-sample',
    version: '1.0.0',
});

// ツールを 1 個登録する。
server.registerTool(
    // 第1引数: ツールの一意な名前。Claude はこの名前を指定してツールを呼び出す。
    'get_today',
    // 第2引数: ツールのメタ情報。Claude が「このツールは何者で、いつ・どう呼ぶか」を判断する材料になる。
    {
        title: '今日の日付',
        description: '今日の日付を YYYY-MM-DD 形式で返す。',
        inputSchema: {},
    },
    // 第3引数: ツールが実際に呼ばれたときに動く処理(ハンドラ)。
    async () => {
        const today = new Date().toISOString().slice(0, 10);
        return {
            content: [{ type: 'text', text: today }],
        };
    }
);

// サーバーを起動する処理
const main = async (): Promise<void> => {
    // 標準入出力を使った通信路を用意する。
    const transport = new StdioServerTransport();
    // サーバーを通信路に接続する。これ以降、Claude からのリクエストを受け付け始める。
    await server.connect(transport);
    console.error('[mcp-sample] server started (stdio)');
};

main().catch((error) => {
    console.error('[mcp-sample] fatal error:', error);
    process.exit(1);
});

◆3. ビルド

npx tsc

dist/index.js が生成されます。

◆4. Claude Code に登録して動かす

claude mcp add mcp-sample -- node /絶対パス/mcp-sample/dist/index.js

登録できたら、Claude Code で聞いてみます。

「get_today ツールを使って今日の日付を教えて。」

Claude が get_today を呼び、返ってきた日付を答えてくれれば成功です。

たったこれだけで、自作した処理を Claude から呼べる ようになりました。

■同じサーバーに GitHub ツールを足す

MCP サーバーは 1 つの中にツールを何個でも登録できます

さっきの get_today の隣に、GitHub 用のツールを足していきます。

◆何を作るか

今日、自分がマージした PR」を GitHub から取ってきて、Claude に評価させ、評価結果と PR のリンクを出すところまでをゴールにします。

ツール 役割
list_my_merged_prs_today() 今日自分がマージした PR の一覧(番号・タイトル・URL)。引数なし
get_pr_detail(owner, repo, pull_number) PR の本文・diff(評価の材料)

Claude はこれらを 自分で順番に呼びます。 「今日マージした PR を評価して」と言うと、まず list_my_merged_prs_today で一覧を取り、各 PR について get_pr_detail を呼んで中身を読み、評価して、最後に一覧の url を使ってリンクを添える、という流れを自動で組み立てます。

◆環境構築

src/
├── index.ts    … ツールの登録(MCP の窓口)
└── github.ts   … GitHub API を叩くアクセス層

@octokit/rest(GitHub 公式の API クライアント)を追加します。

npm install @octokit/rest

◆GitHub アクセス層(src/github.ts

ここが「外部リソースを叩く」部分です。

検索 API で「今日、自分がマージした PR」を取得し、PR 詳細では評価材料(本文・diff)を返します。

// GitHub の公式 REST API クライアント。
import { Octokit } from '@octokit/rest';
//  外部コマンドを同期実行するための Node 標準 API
import { execFileSync } from 'node:child_process';

// 認証トークンの取得。
const resolveToken = (): string => {
         // 環境変数 GITHUB_TOKEN があればそれを使う
    const fromEnv = process.env.GITHUB_TOKEN;
    if (fromEnv) {
        return fromEnv;
    }

    // gh CLI がログイン済みなら、現在のトークンを標準出力に返してくれる。
    const fromGh = execFileSync('gh', ['auth', 'token'], {
        encoding: 'utf8',
    }).trim();

    if (!fromGh) {
        throw new Error(
            'GitHub の認証情報が見つかりません。`gh auth login` でログインするか、環境変数 GITHUB_TOKEN を設定してください。'
        );
    }

    return fromGh;
};

const octokit = new Octokit({ auth: resolveToken() });

// 巨大な文字列(主に diff)はトークンを食い潰すため、上限で打ち切る。
const truncate = (text: string, max: number): string =>
    text.length <= max ? text : `${text.slice(0, max)}\n…(truncated)`;

// 今日(UTC)の日付を YYYY-MM-DD で返す
const today = (): string => new Date().toISOString().slice(0, 10);

// 今日、自分がマージした PR の一覧(メタ情報のみ)を返す。
export const listMyMergedPrsToday = async () => {
    const day = today();
    // is:merged + author:@me + 今日マージ で絞る
    const q = `is:pr is:merged author:@me merged:${day}..${day}`;
         // ページネーションを自動処理し、全件をまとめて取得
    const result = await octokit.paginate(octokit.search.issuesAndPullRequests, {
        q,
        per_page: 100,
    });
    return result.map((item) => {
        // item.repository_url 例: <https://api.github.com/repos/owner/repo>
        const [owner, repo] = item.repository_url.split('/repos/')[1]?.split('/') ?? [];
        return {
            owner,
            repo,
            number: item.number,
            title: item.title,
            url: item.html_url, // ← 出力するリンクはこれを使う
        };
    });
};

// PR の詳細(評価の材料: 本文・diff)を返す。
export const getPrDetail = async (owner: string, repo: string, pullNumber: number) => {
         // 同じ PR に対して2回 API を呼んで並列取得
    const [pr, diff] = await Promise.all([
        octokit.pulls.get({ owner, repo, pull_number: pullNumber }), // 通常の JSON(タイトル・本文など)
        // mediaType: { format: 'diff' } を指定して、diff テキストを取得
        octokit.pulls.get({
            owner,
            repo,
            pull_number: pullNumber,
            mediaType: { format: 'diff' },
        }),
    ]);

    // diff リクエストは data が文字列で返る
    const diffText = diff.data as unknown as string;

    return {
        number: pr.data.number,
        title: pr.data.title,
        url: pr.data.html_url,
        body: pr.data.body ?? '',
        // 巨大 diff はトークンを食い潰すため上限を設ける
        diff: typeof diffText === 'string' ? truncate(diffText, 30000) : '',
    };
};

◆ツール登録(src/index.ts に追記)

最小サンプルの get_today と作りは同じです。

違いは 引数を zod で定義していること(get_pr_detail のみ)。

get_today はそのまま残し、その下に 2 つ追記します。

import { z } from 'zod';
import { listMyMergedPrsToday, getPrDetail } from './github.js';

// --- ツール: 今日マージした自分の PR 一覧 ---
server.registerTool(
    'list_my_merged_prs_today',
    {
        title: '今日マージした PR 一覧',
        description:
            '認証中ユーザーが今日マージした PR の一覧(番号・タイトル・URL)を返す。評価はせず一覧のみ。',
        inputSchema: {}, // 引数なし
    },
    async () => {
        const prs = await listMyMergedPrsToday();
        return {
            content: [{ type: 'text', text: JSON.stringify(prs, null, 2) }],
        };
    }
);

// --- ツール: PR の詳細(評価の材料) ---
server.registerTool(
    'get_pr_detail',
    {
        title: 'PR 詳細',
        description:
            'PR の本文と diff を返す。評価はしない。list_my_merged_prs_today の結果に含まれる owner / repo / number を渡す。',
        inputSchema: {
            owner: z.string().describe('リポジトリのオーナー名'),
            repo: z.string().describe('リポジトリ名'),
            pull_number: z.number().int().describe('PR 番号'),
        },
    },
    async ({ owner, repo, pull_number }) => {
        const detail = await getPrDetail(owner, repo, pull_number);
        return {
            content: [{ type: 'text', text: JSON.stringify(detail, null, 2) }],
        };
    }
);

◆認証(GITHUB_TOKEN)

GitHub API を叩くには Personal Access Token が必要です。

環境変数 GITHUB_TOKEN から読み込み、登録時に渡します。

# ビルドし直す
npx tsc

# 既存のmcpを削除する
claude mcp remove mcp-sample

# mcpを登録する
claude mcp add mcp-sample -e GITHUB_TOKEN="$(gh auth token)" -- node /絶対パス/mcp-sample/dist/index.js

# 確認
claude mcp list

◆動かす

Claude Code に「今日マージした自分の PR を評価して。評価結果と、その PR のリンクも一緒に出して。」と依頼してみます。

Claude は list_my_merged_prs_today → 各 PR に get_pr_detail の順で呼び、こんな形で返します。

今日マージした PR は 2 件でした。

1. #128 ログイン画面のバリデーション追加
   評価: 変更が小さくレビューしやすい。テストも追加されており○。
   <https://github.com/owner/repo/pull/128>

2. #131 PR テンプレートの更新
   評価: 説明は十分。ただし関連 issue へのリンクがあるとなお良い。
   <https://github.com/owner/repo/pull/131>

■最後に

MCPサーバーを自作することができることや、MCPがどのように動いているのかわかりとても勉強になりました。 便利なMCPが世の中の公開されているので、自作する機会は少ないかもですが、特定のプロジェクトだけで使えるMCPを作ってみるとおもしろいのかなーと思いました。

現在弊社ではエンジニアを募集しています!

この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!

iimon採用サイト / Wantedly / Green

最後まで読んでいただきありがとうございました!