MCP自作入門

# はじめに
皆さんこんにちは。muguです。

この記事は…

- Node.js で WebSocket サーバーを自作してみたい方
- ファイル操作やチャットコマンドができるサーバーを体験したい方
- 「MCP (Model Context Protocol) って何？」という初学者の方

に向けて **ダミーのサンプルプロジェクトを使い、
ゼロからセットアップ → 体験できる手順** をまとめています。

それでは早速学んでいきましょう！！

---

## MCP とは何か？

MCPは、Anthropic 社が提唱・オープンソース化した「AI（主に LLM）と外部データソースやツールを標準化された方法で接続するためのプロトコル」です。

- 例えるなら「AI アプリのための USB-C」
  → どんな AI アプリでも、どんなデータソースやツールでも、**MCP 準拠なら"つなぐだけ"で連携できる**
- 目的：
  - LLM（Claude 等）が「社内データ」「クラウドサービス」「ローカルファイル」「各種ツール」などに安全・双方向でアクセスできるようにする
  - これまでの"個別実装"による分断・非効率を解消し、**AI エコシステムの標準化・拡張性・セキュリティ**を実現

---

## 目次

- [前提条件](#前提条件)
- [プロジェクト構成](#プロジェクト構成)
- [セットアップ手順](#セットアップ手順)
- [主要ソースコード](#主要ソースコード)
- [起動方法](#起動方法)
- [コマンド例](#コマンド例)
- [よくあるエラーと対処法](#よくあるエラーと対処法)
- [セキュリティと運用メモ](#セキュリティと運用メモ)
- [まとめ](#まとめ)

---

## 前提条件

| ソフトウェア | バージョン目安        |
| :----------- | :---------------------- |
| Node.js      | v14 以上（LTS 推奨）    |
| npm          | Node.js に付属          |
| ターミナル   | Bash / PowerShell / CMD |

---

## プロジェクト構成

```
sample-app/
├── package.json
├── .env               # ※git 管理しない
├── workspace/         # ファイル操作対象
└── src/
    ├── main-server.js
    ├── main-client.js
    └── file-utils.js
```

---

## セットアップ手順

### 1. package.json と依存パッケージ

```json:package.json
{
  "name": "sample-app",
  "version": "1.0.0",
  "main": "src/main-server.js",
  "scripts": {
    "start": "node src/main-server.js"
  },
  "dependencies": {
    "dotenv": "^16.0.3",
    "express": "^4.18.2",
    "node-fetch": "^2.6.9",
    "ws": "^8.13.0"
  }
}
```

```sh
npm install
```

### 2. .env の作成

```env:.env
PORT=4000          # HTTP サーバー
WS_PORT=9000       # WebSocket サーバー
LLM_ENDPOINT=https://api.example.com/v1/generate-text
```

> `.env` は必ず `.gitignore` へ。公開リポジトリにアクセストークンを晒さないよう注意してください。

### 3. workspace ディレクトリ

```sh
mkdir workspace
```

---

## 主要ソースコード

### main-server.js

```js:src/main-server.js
const path      = require('path');
const express   = require('express');
const WebSocket = require('ws');
const fetch     = (...a) => import('node-fetch').then(({ default: f }) => f(...a));
const FileUtils = require('./file-utils');
require('dotenv').config();

const app     = express();
const port    = process.env.PORT    || 4000;
const wsPort  = process.env.WS_PORT || 9000;
const files   = new FileUtils();

/* =================================================
 * LLM モック呼び出し
 * ================================================= */
async function callLLM(prompt) {
  // 本番では fetch(process.env.LLM_ENDPOINT, { ... }) で呼び出す
  return `【AI 生成文】「${prompt}」に関する営業メール案です。\n– ここに本文 –`;
}

/* =================================================
 * コマンドハンドラ
 * ================================================= */
async function handleCommand({ content }) {
  const [cmd, ...rest] = content.trim().split(/\s+/);
  switch (cmd) {
    case '/help':
      return {
        type: 'help',
        content: `利用可能コマンド:\n/help   - このヘルプを表示\n/ls     - ファイル一覧\n/create <path> <text> - ファイル作成\n/read   <path>        - ファイル表示\n/edit   <path> <text> - ファイル更新\n/delete <path>        - ファイル削除\n/chat   <prompt>      - AI に依頼`,
        timestamp: new Date()
      };
    case '/ls':
      return await files.listFiles();
    case '/create':
      return await files.createFile(rest[0], rest.slice(1).join(' '));
    case '/read':
      return await files.readFile(rest[0]);
    case '/edit':
      return await files.editFile(rest[0], rest.slice(1).join(' '));
    case '/delete':
      return await files.deleteFile(rest[0]);
    case '/chat':
      return { type: 'ai', content: await callLLM(rest.join(' ')), timestamp: new Date() };
    default:
      return { type: 'error', content: `未知のコマンドです: ${cmd}`, timestamp: new Date() };
  }
}

/* =================================================
 * WebSocket サーバー
 * ================================================= */
const wss     = new WebSocket.Server({ port: wsPort });
const clients = new Set();

wss.on('connection', ws => {
  clients.add(ws);
  ws.send(JSON.stringify({
    type: 'system',
    content: 'MCP サーバーへようこそ！/help でコマンド一覧',
    timestamp: new Date()
  }));

  ws.on('message', async msg => {
    try {
      const data = JSON.parse(msg);
      const res  = await handleCommand(data);
      ws.send(JSON.stringify(res));
    } catch (e) {
      ws.send(JSON.stringify({ type: 'error', content: '解析エラー', timestamp: new Date() }));
    }
  });

  ws.on('close', () => clients.delete(ws));
  ws.on('error', () => clients.delete(ws));
});

/* =================================================
 * HTTP サーバー
 * ================================================= */
app.listen(port, () => {
  console.log(`HTTP      : http://localhost:${port}`);
  console.log(`WebSocket : ws://localhost:${wsPort}`);
});
```

---

### file-utils.js（ベースパス修正済み）

```js:src/file-utils.js
const fs   = require('fs').promises;
const path = require('path');

class FileUtils {
  constructor(baseDir = path.resolve(__dirname, '../workspace')) {
    this.baseDir = baseDir;
  }

  async createFile(rel, text) {
    const full = path.join(this.baseDir, rel);
    try {
      await fs.mkdir(path.dirname(full), { recursive: true });
      await fs.writeFile(full, text);
      return { success: true, message: `ファイルを作成しました: ${rel}` };
    } catch (e) {
      return { success: false, message: `ファイル作成エラー: ${e.message}` };
    }
  }

  async readFile(rel) {
    const full = path.join(this.baseDir, rel);
    try {
      const content = await fs.readFile(full, 'utf8');
      return { success: true, content };
    } catch (e) {
      return { success: false, message: `ファイル読み込みエラー: ${e.message}` };
    }
  }

  async editFile(rel, text) {
    const full = path.join(this.baseDir, rel);
    try {
      await fs.writeFile(full, text);
      return { success: true, message: `ファイルを更新しました: ${rel}` };
    } catch (e) {
      return { success: false, message: `ファイル編集エラー: ${e.message}` };
    }
  }

  async listFiles(rel = '') {
    const full = path.join(this.baseDir, rel);
    try {
      const items = await fs.readdir(full, { withFileTypes: true });
      return {
        success: true,
        files: items.map(i => ({ name: i.name, isDirectory: i.isDirectory(), path: path.join(rel, i.name) }))
      };
    } catch (e) {
      return { success: false, message: `ファイル一覧取得エラー: ${e.message}` };
    }
  }

  async deleteFile(rel) {
    const full = path.join(this.baseDir, rel);
    try {
      await fs.unlink(full);
      return { success: true, message: `ファイルを削除しました: ${rel}` };
    } catch (e) {
      return { success: false, message: `ファイル削除エラー: ${e.message}` };
    }
  }
}

module.exports = FileUtils;
```

---

### main-client.js（変更なし）

```js:src/main-client.js
const WebSocket = require('ws');
const readline = require('readline');

const ws = new WebSocket('ws://localhost:9000');

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

ws.on('open', () => {
  console.log('サーバーに接続しました');
  console.log('メッセージを入力してください（終了するには "exit" と入力）：');
  rl.on('line', input => {
    if (input.toLowerCase() === 'exit') {
      ws.close();
      rl.close();
      return;
    }
    const msg = { type: 'message', content: input, timestamp: new Date().toISOString() };
    ws.send(JSON.stringify(msg));
  });
});

ws.on('message', data => {
  try {
    const res = JSON.parse(data);
    console.log('\nサーバーからの応答:', res.content);
    console.log('\nメッセージを入力してください：');
  } catch (e) {
    console.error('メッセージ解析エラー:', e);
  }
});

ws.on('error', e => console.error('エラー:', e));
ws.on('close', () => {
  console.log('接続が切断されました');
  process.exit(0);
});
```

---

## 起動方法

```sh
# HTTP + WS サーバー
node src/main-server.js          # cd は不要

# 別ターミナルでクライアント
node src/main-client.js
```

---

## コマンド例

```sh
/help
/ls
/create test.txt これはテストファイルです
/read test.txt
/edit test.txt 内容を更新しました
/delete test.txt
/chat 株式会社サンプル 田中一郎 新規営業 ITサービス提案
```

---

## よくあるエラーと対処法

| 症状               | 原因・対策                                 |
| :----------------- | :----------------------------------------- |
| Cannot find module | npm install 実行漏れ                       |
| EADDRINUSE         | ポート競合 → .env で PORT を変更           |
| ファイル操作エラー | workspace/ が無い／権限不足                |
| コマンド無反応     | サーバー・クライアントのポート不一致を確認 |

---

## セキュリティと運用メモ

- `.env` を **絶対にリポジトリへコミットしない**
- WebSocket は本番環境で `wss://`（TLS 終端）を推奨
- 任意コマンド実行リスクがあるため **認証／認可** を実装する
- ロギングは winston などでファイル＋コンソールへ二重出力すると保守が楽

---

## まとめ

- MCP は "チャット = メソッド呼び出し" の発想でリアルタイム制御に強い
- Node.js + WebSocket で 約 100 行 の最小サーバーが作れる
- /help で自己完結する CLI 風コマンド体系が初心者学習に最適
- .env やポート管理で 環境依存を排除 するとチーム開発にも展開しやすい