0
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サーバーを自作して社内APIをClaude Codeから叩く ― TypeScriptで作る業務特化MCPの実装チュートリアル

0
Posted at

結論:社内APIをClaude Codeから直接呼べるMCPサーバーは60分で作れる

公式MCPサーバーだけでは足りない。社内の独自APIをClaude Codeからシームレスに呼び出せるMCPサーバーを、ゼロから60分で作ります。

この記事を読むと、以下のことができるようになります。

  • 社内のREST APIをClaude Codeの自然言語から呼び出せるMCPサーバーをTypeScriptで自作できる
  • ツール定義・リソース定義・エラーハンドリングの実装パターンを理解できる
  • 本番運用に向けた認証・レート制限・ログ設計の勘所がわかる

環境・前提条件

項目 バージョン/要件
Node.js v20 以上
TypeScript 5.x
@modelcontextprotocol/sdk 1.x(2025年6月時点の最新)
Claude Code 最新版(CLI)
社内API(想定) REST API(JSON)、Bearer Token認証

社内APIとして、以下のようなユーザー管理APIが存在する想定で進めます。

GET  https://internal-api.example.com/users/:id
POST https://internal-api.example.com/users/search

1. MCPサーバーのアーキテクチャ概要

MCP(Model Context Protocol)とは

MCPは、AIアシスタント(Claude Codeなど)が外部のツールやデータソースにアクセスするための標準プロトコルです。MCPサーバーを自作することで、Claude Codeから社内APIを自然言語で叩けるようになります。

stdio vs SSE ― トランスポートの選び方

MCPには2つのトランスポート方式があります。

項目 stdio SSE(HTTP)
通信方式 標準入出力 HTTP Server-Sent Events
起動方式 Claude Codeがプロセスを起動 常駐サーバーとして稼働
適したケース ローカル開発・個人利用 チーム共有・リモートサーバー
セットアップ難度 低い やや高い
Claude Codeとの相性 ◎ 公式推奨 ○ 対応済み

結論:Claude Codeで使うなら、まずstdioトランスポートから始めましょう。 本記事でもstdioを採用します。

2. 環境構築とMCP SDKのセットアップ

プロジェクト初期化

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

tsconfig.json の設定

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

package.json に追記

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts"
  },
  "bin": {
    "mcp-internal-api": "./dist/index.js"
  }
}

ディレクトリ構成

mcp-internal-api/
├── src/
│   ├── index.ts          # エントリポイント
│   ├── tools/
│   │   ├── getUser.ts    # ツール定義: ユーザー取得
│   │   └── searchUsers.ts # ツール定義: ユーザー検索
│   ├── resources/
│   │   └── userProfile.ts # リソース定義
│   └── lib/
│       └── apiClient.ts   # 社内APIクライアント
├── tsconfig.json
└── package.json

3. ステップバイステップ実装

3-1. 社内APIクライアント(src/lib/apiClient.ts

まず、社内APIとの通信を担当するクライアントを作ります。

// src/lib/apiClient.ts

export interface User {
  id: number;
  name: string;
  email: string;
  department: string;
  role: string;
}

export interface SearchResult {
  users: User[];
  total: number;
}

export class InternalApiClient {
  private baseUrl: string;
  private token: string;

  constructor() {
    this.baseUrl = process.env.INTERNAL_API_URL ?? "https://internal-api.example.com";
    this.token = process.env.INTERNAL_API_TOKEN ?? "";

    if (!this.token) {
      console.error("WARNING: INTERNAL_API_TOKEN is not set");
    }
  }

  private async request<T>(path: string, options?: RequestInit): Promise<T> {
    const url = `${this.baseUrl}${path}`;
    const response = await fetch(url, {
      ...options,
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${this.token}`,
        ...options?.headers,
      },
    });

    if (!response.ok) {
      throw new Error(
        `API Error: ${response.status} ${response.statusText} - ${await response.text()}`
      );
    }

    return response.json() as Promise<T>;
  }

  async getUser(userId: number): Promise<User> {
    return this.request<User>(`/users/${userId}`);
  }

  async searchUsers(query: string, department?: string): Promise<SearchResult> {
    const params = new URLSearchParams({ q: query });
    if (department) params.set("department", department);
    return this.request<SearchResult>(`/users/search?${params}`);
  }
}

3-2. ツール定義(src/tools/getUser.ts

MCPの「ツール」は、Claude Codeが能動的に呼び出す関数です。

// src/tools/getUser.ts
import { z } from "zod";
import { InternalApiClient } from "../lib/apiClient.js";

// 入力スキーマをZodで定義
export const GetUserSchema = z.object({
  userId: z.number().describe("取得したいユーザーのID"),
});

export type GetUserInput = z.infer<typeof GetUserSchema>;

export async function getUserHandler(
  input: GetUserInput,
  client: InternalApiClient
) {
  try {
    const user = await client.getUser(input.userId);
    return {
      content: [
        {
          type: "text" as const,
          text: JSON.stringify(user, null, 2),
        },
      ],
    };
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error";
    return {
      content: [
        {
          type: "text" as const,
          text: `ユーザー取得に失敗しました: ${message}`,
        },
      ],
      isError: true,
    };
  }
}

3-3. ツール定義(src/tools/searchUsers.ts

// src/tools/searchUsers.ts
import { z } from "zod";
import { InternalApiClient } from "../lib/apiClient.js";

export const SearchUsersSchema = z.object({
  query: z.string().describe("検索キーワード(名前・メールアドレスなど)"),
  department: z
    .string()
    .optional()
    .describe("部署名でフィルタ(例: 'エンジニアリング')"),
});

export type SearchUsersInput = z.infer<typeof SearchUsersSchema>;

export async function searchUsersHandler(
  input: SearchUsersInput,
  client: InternalApiClient
) {
  try {
    const result = await client.searchUsers(input.query, input.department);

    if (result.users.length === 0) {
      return {
        content: [
          {
            type: "text" as const,
            text: `「${input.query}」に該当するユーザーは見つかりませんでした。`,
          },
        ],
      };
    }

    return {
      content: [
        {
          type: "text" as const,
          text: `${result.total}件のユーザーが見つかりました:\n${JSON.stringify(result.users, null, 2)}`,
        },
      ],
    };
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error";
    return {
      content: [
        {
          type: "text" as const,
          text: `ユーザー検索に失敗しました: ${message}`,
        },
      ],
      isError: true,
    };
  }
}

3-4. リソース定義(src/resources/userProfile.ts

MCPの「リソース」は、Claude Codeがコンテキストとして読み込むデータです。ツールとの違いを意識しましょう。

// src/resources/userProfile.ts
import { InternalApiClient } from "../lib/apiClient.js";

export async function readUserProfile(
  userId: number,
  client: InternalApiClient
) {
  const user = await client.getUser(userId);
  return {
    contents: [
      {
        uri: `internal://users/${userId}/profile`,
        mimeType: "application/json",
        text: JSON.stringify(user, null, 2),
      },
    ],
  };
}

3-5. エントリポイント(src/index.ts

すべてを統合するメインファイルです。

#!/usr/bin/env node
// src/index.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { InternalApiClient } from "./lib/apiClient.js";
import {
  GetUserSchema,
  getUserHandler,
} from "./tools/getUser.js";
import {
  SearchUsersSchema,
  searchUsersHandler,
} from "./tools/searchUsers.js";
import { readUserProfile } from "./resources/userProfile.js";

// APIクライアントの初期化
const apiClient = new InternalApiClient();

// MCPサーバーの作成
const server = new McpServer({
  name: "internal-api",
  version: "1.0.0",
});

// ── ツール登録 ──────────────────────────────
server.tool(
  "get_user",
  "社内ユーザーをIDで取得します。ユーザーの名前・メール・部署・役職が返ります。",
  GetUserSchema.shape,
  async (input) => getUserHandler(input, apiClient)
);

server.tool(
  "search_users",
  "社内ユーザーをキーワードで検索します。名前やメールアドレスで部分一致検索できます。",
  SearchUsersSchema.shape,
  async (input) => searchUsersHandler(input, apiClient)
);

// ── リソース登録 ─────────────────────────────
server.resource(
  "user-profile",
  "internal://users/{userId}/profile",
  { description: "社内ユーザーのプロフィール情報" },
  async (uri) => {
    const match = uri.pathname.match(/\/users\/(\d+)\/profile/);
    if (!match) throw new Error("Invalid URI format");
    const userId = parseInt(match[1], 10);
    return readUserProfile(userId, apiClient);
  }
);

// ── サーバー起動 ─────────────────────────────
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Internal API MCP Server is running on stdio");
}

main().catch((error) => {
  console.error("Server failed to start:", error);
  process.exit(1);
});

ポイント: console.error を使うのは、stdioトランスポートではstdoutがMCPプロトコルの通信路として使われるためです。ログ出力は必ずstderrに流しましょう。

4. 実装パターンの整理

ツール定義のベストプラクティス

守るべき4つの原則を挙げます。

  1. descriptionを丁寧に書く ― Claude CodeがどのツールをいつAIが選択するかは、このdescriptionが判断材料になります
  2. Zodスキーマで.describe()を全フィールドに付ける ― AIが引数の意味を正しく理解できます
  3. エラー時はisError: trueを返す ― Claude Codeがエラーを認識してリトライや説明ができます
  4. レスポンスはJSON文字列をテキストで返す ― 構造化データもtype: "text"で返すのがMCPの標準パターンです

ツール vs リソース ― 使い分け

観点 ツール(Tool) リソース(Resource)
呼び出し主体 AIが判断して呼ぶ ユーザーまたはAIがコンテキストとして読む
副作用 あってよい(POST/PUT/DELETE) 読み取り専用
典型的な用途 API呼び出し、データ操作 設定ファイル、プロフィール情報の参照

5. Claude Codeへの登録・動作確認・デバッグ

ビルドと登録

# ビルド
npm run build

# Claude Codeにプロジェクトスコープで登録
claude mcp add internal-api -- node /absolute/path/to/mcp-internal-api/dist/index.js

# 環境変数付きで登録する場合
claude mcp add internal-api \
  -e INTERNAL_API_URL=https://internal-api.example.com \
  -e INTERNAL_API_TOKEN=your-secret-token \
  -- node /absolute/path/to/mcp-internal-api/dist/index.js

登録確認

# 登録済みMCPサーバー一覧
claude mcp list

# 出力例:
# internal-api: node /path/to/dist/index.js (project)

動作確認

Claude Codeを起動して、自然言語で試してみましょう。

> ユーザーID 42の情報を取得して

> エンジニアリング部の田中さんを検索して

> ID 100番のユーザープロフィールを見せて

デバッグのコツ

① MCP Inspectorを使う

npx @modelcontextprotocol/inspector node dist/index.js

ブラウザでMCPサーバーのツール一覧確認やテスト呼び出しが可能です。開発中はこれを常に開いておくと効率が上がります。

② stderrログを確認する

# Claude Codeのデバッグモードでstderrを表示
claude --mcp-debug

③ よくあるトラブルと対処

症状 原因 対処
ツールが表示されない パスが間違っている / ビルドしていない claude mcp listでパスを確認、npm run buildを実行
spawn ENOENT nodeのパスが見つからない 絶対パスでnodeを指定する
接続後すぐ切れる サーバー起動時にstdoutに出力している console.logconsole.error に変更
引数が正しく渡されない Zodスキーマの.describe()が不足 各フィールドに説明を追加

6. 本番運用に向けた設計

認証の強化

// 環境変数からトークンを取得(必須チェック付き)
function getRequiredEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    console.error(`ERROR: Environment variable ${key} is required`);
    process.exit(1);
  }
  return value;
}

const token = getRequiredEnv("INTERNAL_API_TOKEN");

Claude Code側の登録時に-eフラグで環境変数を渡すか、.claude/ディレクトリ内の設定ファイルで管理できます。トークンをソースコードにハードコードしないことが鉄則です。

レート制限

社内APIに負荷をかけすぎないための簡易リミッターの例です。

class RateLimiter {
  private timestamps: number[] = [];

  constructor(
    private maxRequests: number = 30,
    private windowMs: number = 60_000
  ) {}

  async check(): Promise<void> {
    const now = Date.now();
    this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs);
    if (this.timestamps.length >= this.maxRequests) {
      throw new Error(
        `Rate limit exceeded: max ${this.maxRequests} requests per ${this.windowMs / 1000}s`
      );
    }
    this.timestamps.push(now);
  }
}

// APIクライアントで使用
const limiter = new RateLimiter(30, 60_000);

async function requestWithLimit<T>(fn: () => Promise<T>): Promise<T> {
  await limiter.check();
  return fn();
}

ログ設計

stdioトランスポートではstdoutが使えないため、ログは全てstderrに出力します。

function log(level: "INFO" | "WARN" | "ERROR", message: string, meta?: object) {
  const entry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    ...meta,
  };
  console.error(JSON.stringify(entry));
}

// 使用例
log("INFO", "Tool called", { tool: "get_user", input: { userId: 42 } });

本格的に運用する場合は、stderrのログをファイルやログ管理サービスにリダイレクトする構成を検討してください。

まとめ

  • MCPサーバーは@modelcontextprotocol/sdkzodだけで自作できる。 stdioトランスポートを使えば、Claude Codeとの連携設定はコマンド1つで完了します
  • ツール定義のdescription.describe()がAIの判断精度を左右する。 日本語で丁寧に書くことで、Claude Codeが適切なタイミングで正しい引数を渡してくれます
  • 本番運用では認証・レート制限・ログの3点を初期設計に含める。 stdoutに何も出さない(stderrに出す)というstdioトランスポート特有の制約も忘れずに

参考リンク

0
0
0

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
0
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?