はじめに:自動化ジョブが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/sdk1.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ファイルで完結します。
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 でも生成できます)。
{
"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