1
1

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サーバーを自作してLLMから社内APIを安全に呼び出す

1
Last updated at Posted at 2026-10-22

社内のチケット管理APIや検索APIを、LLMから自然言語で呼び出したい場面が増えてきました。以前は専用のチャット画面や、APIごとのラッパーを個別に作る必要がありました。

そこで今回は、MCP(Model Context Protocol)サーバーを自作し、LLMから社内APIを呼び出せるようにしました。

結論から言うと、MCPサーバーは「LLMに社内APIを直接公開するもの」ではなく、入力検証・権限確認・レスポンス整形を行う安全なアダプターとして実装するのがポイントです。

この記事では、TypeScript製のMCPサーバーを作り、社内チケット検索APIを呼び出すところまでを紹介します。現行のTypeScript SDK v2を使い、Node.js 20以上で確認しました。

MCPサーバーで何が変わるのか

LLMから社内APIを呼び出す処理全体の流れを図にすると次の通りです。

MCPでは、LLMが利用できる操作を「ツール」として公開します。

例えば、次のようなツールです。

  • search_internal_tickets: 社内チケットを検索する
  • get_ticket: チケットの詳細を取得する
  • create_ticket: チケットを作成する

LLMはツール名、説明、入力スキーマを見て、ユーザーの依頼に応じて適切なツールを呼び出します。

重要なのは、LLMに社内APIのURLや認証方式を教える必要がないことです。MCPサーバー側でAPI呼び出しを隠蔽し、LLMには必要な操作だけを公開できます。

今回は「キーワードで社内チケットを検索する」読み取り専用ツールを実装します。

プロジェクトを作成する

まず、プロジェクトを作成して依存パッケージをインストールします。

mkdir internal-api-mcp
cd internal-api-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
mkdir src

type=module は、SDKがES Modulesとして提供されているため必要です。

環境変数には社内APIの接続先とトークンを設定します。実際の運用では、トークンをソースコードやGit管理対象の設定ファイルに直接書かないようにします。

# macOS / Linux
export INTERNAL_API_BASE_URL="https://internal.example.local/api"
export INTERNAL_API_TOKEN="replace-with-secret"

# PowerShell
$env:INTERNAL_API_BASE_URL="https://internal.example.local/api"
$env:INTERNAL_API_TOKEN="replace-with-secret"

MCPツールを実装する

src/index.ts を作成します。

import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

const baseUrl = process.env.INTERNAL_API_BASE_URL;
const token = process.env.INTERNAL_API_TOKEN;

if (!baseUrl || !token) {
  throw new Error("INTERNAL_API_BASE_URL and INTERNAL_API_TOKEN are required");
}

type Ticket = {
  id: string;
  title: string;
  status: string;
  assignee?: string;
};

type SearchResponse = {
  items: Ticket[];
};

function createServer(): McpServer {
  const server = new McpServer({
    name: "internal-api",
    version: "1.0.0",
  });

  server.registerTool(
    "search_internal_tickets",
    {
      description:
        "社内チケットをキーワードで検索する。読み取り専用の操作。",
      inputSchema: z.object({
        query: z.string().min(1).max(100),
        limit: z.number().int().min(1).max(20).default(10),
      }),
    },
    async ({ query, limit }) => {
      const url = new URL(`${baseUrl}/tickets`);
      url.searchParams.set("q", query);
      url.searchParams.set("limit", String(limit));

      try {
        const response = await fetch(url, {
          method: "GET",
          headers: {
            Authorization: `Bearer ${token}`,
            Accept: "application/json",
          },
          signal: AbortSignal.timeout(5000),
        });

        if (!response.ok) {
          return {
            isError: true,
            content: [
              {
                type: "text",
                text: `社内APIの呼び出しに失敗しました: HTTP ${response.status}`,
              },
            ],
          };
        }

        const data = (await response.json()) as SearchResponse;

        const result = data.items.map((ticket) => ({
          id: ticket.id,
          title: ticket.title,
          status: ticket.status,
          assignee: ticket.assignee ?? "未割り当て",
        }));

        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(result, null, 2),
            },
          ],
        };
      } catch (error) {
        console.error("internal API request failed", error);

        return {
          isError: true,
          content: [
            {
              type: "text",
              text: "社内APIに接続できませんでした。時間を置いて再試行してください。",
            },
          ],
        };
      }
    }
  );

  return server;
}

void serveStdio(createServer);
console.error("internal-api MCP server started");

registerTool には、ツール名、説明、入力スキーマ、ハンドラーを渡します。入力スキーマにはZodを使うため、LLMに公開するJSON Schemaと、実行前の入力検証を一つの定義で管理できます。

例えば query が空文字列だったり、limit が21以上だったりする場合、APIを呼び出す前に拒否されます。

また、MCPのstdio通信では標準出力がプロトコル用に使われます。そのため、ログ出力には console.log ではなく console.error を使います。標準出力にログを混ぜると、JSON-RPC通信が壊れることがあります。

ローカルで動作確認する

まず、単体でサーバーを起動します。

npx tsx src/index.ts

stdio型のMCPサーバーは、クライアントから接続されるまで入力を待ち続けます。起動しても何も表示されないように見えますが、異常ではありません。

MCP Inspectorを使うと、実際のLLMホストを用意せずにツールを確認できます。

npx @modelcontextprotocol/inspector npx tsx src/index.ts

ブラウザでInspectorを開き、接続後に Tools から search_internal_tickets を選びます。

{
  "query": "認証エラー",
  "limit": 5
}

社内APIのレスポンスが、MCPのテキストコンテンツとして返れば成功です。

LLMクライアントから接続する場合は、クライアントごとの設定ファイルに起動コマンドを登録します。設定の形式は製品によって異なりますが、概念的には次のようになります。

{
  "mcpServers": {
    "internal-api": {
      "command": "npx",
      "args": [
        "tsx",
        "C:\\work\\internal-api-mcp\\src\\index.ts"
      ],
      "env": {
        "INTERNAL_API_BASE_URL": "https://internal.example.local/api",
        "INTERNAL_API_TOKEN": "${INTERNAL_API_TOKEN}"
      }
    }
  }
}

接続後は、例えば次のように依頼できます。

未対応の「認証エラー」チケットを5件検索して、担当者とステータスを一覧にして

LLMは公開されたツールの説明をもとに、検索ツールを呼び出します。

社内利用で意識したセキュリティ

MCPを追加しただけで、社内APIが安全になるわけではありません。今回の実装では、次の点を意識しました。

まず、読み取り専用のツールから始めました。作成・更新・削除系の操作は、誤操作の影響が大きいため、必要性と承認フローを確認してから追加します。

次に、入力値を必ず制限します。文字数、件数、許可する値をスキーマで定義し、URLやSQLの断片をそのまま受け取らないようにします。

認証トークンはLLMへのレスポンスに含めません。MCPサーバーだけがトークンを保持し、LLMには検索結果だけを返します。

さらに、API側でも認可を行う必要があります。MCPサーバーのツール定義はセキュリティ境界ではありません。利用者ごとの権限、対象データの範囲、監査ログは、社内APIまたはその手前の認証基盤でも検証します。

最後に、タイムアウトとエラー処理を入れます。社内APIが応答しない場合にMCPサーバーが無期限に待つと、LLM側の処理も止まってしまいます。

まとめ

今回は、TypeScript SDKを使ってMCPサーバーを自作し、LLMから社内チケット検索APIを呼び出しました。

MCPサーバーをAPIの薄い中継にするのではなく、次の責務を持たせると運用しやすくなります。

  • LLMが理解しやすいツール名と説明を付ける
  • Zodで入力値を検証する
  • 認証情報をLLMに渡さない
  • タイムアウトとエラーを処理する
  • 社内API側でも認可と監査を行う

最初は検索や参照などの読み取り専用ツールから始めるのがおすすめです。実際に動かしてみると、既存APIを大きく変更せずに、自然言語による社内データ活用の入口を作れることが分かりました。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?