5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPサーバーを5分で作る — TypeScript + @modelcontextprotocol/sdk の最小実装

5
Posted at

はじめに:自動化ジョブが33個あると、「今どうなってる?」に答えるだけで一苦労

当社(合同会社ジョインクラス)では、社内業務のほとんどを Claude Code と launchd で動かしています。2026年9月に棚卸ししたところ、ジョブは 33個 ありました。STATE.md には長いあいだ「17ジョブ」と書かれたままで、実態とずれていたんです。

ジョブごとの API コストは cost-tracker.json に貯めていますが、Claude Code に「今月どのジョブが一番お金を使ってる?」と聞くたびに、毎回 cat と jq を叩かせていました。そのぶんトークンも使いますし、聞き方次第で答えも変わります。

そこで、社内ファイルを決まった形で返すだけの MCP サーバーを作りました。この記事はその最小構成を、5分で再現できる手順にまとめたものです。

この記事で作るもの:

  • get_cost_summary — cost-tracker.json を集計し、コストの高い順にジョブを返す Tool
  • list_jobs — JOBS.md のテーブルからジョブ一覧を返す Tool
  • Claude Code への登録と、Inspector を使った動作確認

前提

  • Node.js 20 以上
  • Claude Code(claude コマンド)
  • @modelcontextprotocol/sdk 1.x(McpServer と registerTool を使います)

Step 1. プロジェクトを作る(1分)

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

package.json に次の2つを追記します。ESM にしないと SDK の import でつまずきます。

{
  "type": "module",
  "scripts": {
    "build": "tsc"
  }
}

tsconfig.json は最低限ここだけ合わせておけば動きます。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}

Step 2. 読み込むデータの形

当社の cost-tracker.json は月ごとに次の構造になっています(数値はサンプルです)。

{
  "month": "2026-10",
  "total_usd": 96.68,
  "jobs": {
    "dept-dev":       { "calls": 21, "usd": 11.14, "errors": 1, "cache_creation_tokens": 811202 },
    "dept-marketing": { "calls": 21, "usd": 9.78,  "errors": 1, "cache_creation_tokens": 706895 },
    "replenish":      { "calls": 52, "usd": 24.10, "errors": 0, "cache_creation_tokens": 1520331 }
  }
}

JOBS.md は Markdown のテーブルです。

| ジョブ | スケジュール | 役割 |
|---|---|---|
| auto-morning | 毎日 09:00 | 朝ダイジェスト生成 |
| auto-state-update | 毎日 18:00 | STATE.md 自動更新 |

Step 3. サーバー本体(2分)

src/index.ts 1ファイルで完結します。

src/index.ts
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// 読み込むディレクトリは環境変数で受け取る(パスをハードコードしない)
const SCRIPTS_DIR = process.env.COMPANY_SCRIPTS_DIR;
if (!SCRIPTS_DIR) {
  console.error("COMPANY_SCRIPTS_DIR is not set");
  process.exit(1);
}

type JobCost = { calls: number; usd: number; errors: number };
type CostTracker = { month: string; total_usd: number; jobs: Record<string, JobCost> };

const server = new McpServer({ name: "company-ops", version: "0.1.0" });

function text(value: unknown) {
  return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
}

server.registerTool(
  "get_cost_summary",
  {
    description: "今月のClaude API コストをジョブ別に集計し、コストの高い順に返す",
    inputSchema: { top: z.number().int().min(1).max(50).default(5) },
  },
  async ({ top }) => {
    const raw = await readFile(join(SCRIPTS_DIR, "cost-tracker.json"), "utf8");
    const tracker: CostTracker = JSON.parse(raw);

    const ranking = Object.entries(tracker.jobs)
      .map(([job, c]) => ({ job, usd: c.usd, calls: c.calls, errors: c.errors }))
      .sort((a, b) => b.usd - a.usd)
      .slice(0, top);

    return text({ month: tracker.month, total_usd: tracker.total_usd, ranking });
  },
);

server.registerTool(
  "list_jobs",
  {
    description: "JOBS.md に登録された自動化ジョブの一覧を返す。keyword で絞り込み可",
    inputSchema: { keyword: z.string().optional() },
  },
  async ({ keyword }) => {
    const markdown = await readFile(join(SCRIPTS_DIR, "JOBS.md"), "utf8");

    const jobs = markdown
      .split("\n")
      .filter((line) => line.startsWith("|") && !line.includes("---"))
      .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim().replace(/\*\*/g, "")))
      .filter(([name]) => name && name !== "ジョブ")
      .map(([name, schedule, role]) => ({ name, schedule, role }))
      .filter((job) => !keyword || JSON.stringify(job).includes(keyword));

    return text({ count: jobs.length, jobs });
  },
);

await server.connect(new StdioServerTransport());
console.error("company-ops MCP server running on stdio");

ビルドします。

npm run build

Step 4. Claude Code に登録する(1分)

claude mcp add company-ops \
  -e COMPANY_SCRIPTS_DIR=/path/to/.company/scripts \
  -- node /absolute/path/to/company-mcp/dist/index.js

claude mcp list
# company-ops: node /absolute/path/.../dist/index.js - ✓ Connected

チーム全員で使いたいなら、リポジトリ直下に .mcp.json を置きます(claude mcp add --scope project でも生成できます)。

.mcp.json
{
  "mcpServers": {
    "company-ops": {
      "command": "node",
      "args": ["./tools/company-mcp/dist/index.js"],
      "env": { "COMPANY_SCRIPTS_DIR": "./.company/scripts" }
    }
  }
}

あとは Claude Code で「今月コストが高いジョブ上位3つは?」と聞けば、get_cost_summary が top: 3 で呼ばれます。

Step 5. 検証方法(1分)

MCP Inspector で単体確認する

Claude Code を経由させずに、サーバー単体で Tool を叩けます。

COMPANY_SCRIPTS_DIR=/path/to/.company/scripts \
  npx @modelcontextprotocol/inspector node dist/index.js

ブラウザが開いたら、Tools タブで list_jobs を選び、keyword: 毎日 で実行してください。JSON が返ってくれば成功です。

Claude Code 側で確認する

セッション内で /mcp を打つと、接続状態と Tool の一覧が表示されます。ここに failed と出たら、次の落とし穴を疑ってください。

ハマりどころ

1. console.log を使うと壊れる
stdio トランスポートでは、stdout がそのまま JSON-RPC の通信路になります。デバッグ用に console.log を1行入れるだけで、クライアントがパースに失敗して切断されます。ログは必ず console.error(stderr)に出してください。

2. 相対パスが解決されない
claude mcp add で node dist/index.js のように相対パスを渡すと、起動時のカレントディレクトリ次第で Cannot find module になります。ローカル登録では絶対パスを使うのが確実です。

3. "type": "module" の書き忘れ
SDK は .../server/mcp.js のように拡張子付きのサブパスで import する設計です。CommonJS のままだと解決に失敗します。

4. ビルドし忘れ
src/index.ts を直しても、dist/ を作り直さないと Claude Code は古いコードのまま動きます。修正したら npm run build を実行し、/mcp から再接続してください。開発中は npx tsc --watch を回しておくと楽です。

5. 書き込み系 Tool は最初から作らない
当社では以前、AI が出力した営業メールをそのまま自動送信し、敬語がおかしいメールが取引先に届いたことがあります。それ以来「draft → 承認 → 実行」のパイプラインを徹底しています。MCP でも最初は 読み取り専用の Tool だけにしておき、書き込みや外部送信が必要になったら「ドラフトファイルを作るだけ」の Tool から始めるのが安全です。

MCP化で得られること

  • 「今月のコストは?」への答えが毎回同じ形で返るようになった(jq のワンライナーを AI に考えさせる必要がない)
  • 生ファイルを丸ごとコンテキストに読ませなくなったぶん、トークン消費が減った
  • ジョブ数のように実態とずれやすい数字も、ファイルから毎回その場で数えるので、古い記載に引きずられない

おわりに

MCP サーバーは「AI に何でもさせる仕組み」ではありません。僕は 「AI に見せる窓口を、こちらで決めておく仕組み」 だと考えています。当社には「AIは実行に使う。判断は人間。」というルールがあり、MCP の Tool 設計はそれをコードにする作業そのものでした。

今回のコードは100行足らずです。まずは手元の JSON や Markdown を1つ選び、読み取り専用の Tool を1本作ってみてください。

もっと深く知りたい方へ

Resources や Prompts の使い分け、HTTP トランスポートでのリモート公開、認証、テスト戦略までを体系的にまとめた書籍を出しています。

📘 『Claude Code × MCP サーバー開発入門』
👉 書籍一覧: https://zenn.dev/joinclass?tab=books

5
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?