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サーバーを自作して社内データをAIクライアントから安全に検索する

0
Last updated at Posted at 2026-10-27

社内のチケットやナレッジをAIに参照させたい場合、データベースをそのままAIクライアントへ公開するのは危険です。検索条件の制御、アクセス権限、監査ログ、返却データの整形をどこで担保するかを設計する必要があります。

そこで今回は、Model Context Protocol(MCP)サーバーをTypeScriptで自作し、AIクライアントから社内チケットを検索できる構成を実装しました。

結論から言うと、MCPサーバーを「社内データへの安全な窓口」として配置し、AIには任意のSQLではなく、用途を限定した検索ツールだけを公開するのが扱いやすい方法です。

MCPサーバーの役割

AIクライアントから社内チケットを検索する処理全体の流れは次の通りです。

MCPは、AIクライアントと外部ツール・データを接続するためのプロトコルです。今回の構成では、次のように処理します。

AIクライアント
    ↓ MCP
社内MCPサーバー
    ↓
チケットAPI / データベース

MCPサーバーには、たとえば次のようなツールを登録できます。

  • チケットをキーワード検索する
  • 自分の所属部署のナレッジを取得する
  • 障害対応履歴を期間指定で検索する

重要なのは、AIからデータベースへ直接接続させないことです。MCPサーバー側で検索条件を検証し、ユーザーの権限に応じて返却範囲を制限します。

今回は動作確認しやすいように、社内チケットをメモリ上の配列で再現します。実運用では、この部分を社内APIやPostgreSQLなどに置き換えます。

プロジェクトを作成する

公式TypeScript SDKの現行構成では、Node.js 20以上とES Modulesを利用します。SDKは@modelcontextprotocol/serverパッケージから読み込みます。

mkdir internal-ticket-mcp
cd internal-ticket-mcp

npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx

mkdir src

src/index.tsを作成し、検索対象のデータとMCPサーバーを実装します。

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

type Ticket = {
  id: string;
  title: string;
  body: string;
  department: string;
  visibility: "public" | "department";
};

const tickets: Ticket[] = [
  {
    id: "INC-1001",
    title: "VPN接続が不安定",
    body: "特定の時間帯にVPN接続が切断される。ルーター再起動で一時的に復旧する。",
    department: "it",
    visibility: "department",
  },
  {
    id: "INC-1002",
    title: "経費精算画面の表示不具合",
    body: "経費精算の明細追加後、合計金額が更新されないことがある。",
    department: "経理",
    visibility: "department",
  },
  {
    id: "DOC-2001",
    title: "パスワードリセット手順",
    body: "社内ポータルのログイン画面からパスワードを再設定する手順。",
    department: "it",
    visibility: "public",
  },
];

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

  // 実運用ではホスト側の認証済みコンテキストから取得する
  const actorDepartment = process.env.MCP_USER_DEPARTMENT ?? "it";

  server.registerTool(
    "search_tickets",
    {
      description: "ユーザーが閲覧可能な社内チケットをキーワード検索する",
      inputSchema: z.object({
        query: z.string().min(1).max(100).describe("検索キーワード"),
        limit: z.number().int().min(1).max(10).default(5),
      }),
    },
    async ({ query, limit }) => {
      const normalizedQuery = query.trim().toLowerCase();

      const results = tickets
        .filter((ticket) => {
          const canRead =
            ticket.visibility === "public" ||
            ticket.department === actorDepartment;

          const matches =
            ticket.title.toLowerCase().includes(normalizedQuery) ||
            ticket.body.toLowerCase().includes(normalizedQuery);

          return canRead && matches;
        })
        .slice(0, limit);

      console.error(
        JSON.stringify({
          event: "ticket_search",
          department: actorDepartment,
          query: normalizedQuery,
          resultCount: results.length,
        }),
      );

      if (results.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: "閲覧可能な範囲に該当するチケットはありません。",
            },
          ],
        };
      }

      const text = results
        .map(
          (ticket) =>
            `[${ticket.id}] ${ticket.title}\n${ticket.body}`,
        )
        .join("\n\n");

      return {
        content: [{ type: "text", text }],
      };
    },
  );

  return server;
}

void serveStdio(createServer);
console.error("internal-ticket-mcp is running");

ここで検索ツールに渡せる値は、queryとlimitだけです。SQL文やテーブル名をAIに指定させていないため、意図しない全件取得を防ぎやすくなります。

また、標準出力はMCPのJSON-RPC通信に使われます。ログをconsole.logで出力すると通信を壊すため、ログはconsole.errorへ出力します。

MCP Inspectorで動作確認する

まず、サーバー単体が起動することを確認します。

$env:MCP_USER_DEPARTMENT="it"
npx tsx src/index.ts

プロセスは入力待ちの状態になります。これは異常ではなく、MCPクライアントからの接続を待っている状態です。

次に、別のターミナルからMCP Inspectorを起動します。

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

ブラウザでInspectorを開き、次の順で確認します。

  1. Connectを押す
  2. Toolsを開く
  3. search_ticketsを選ぶ
  4. queryにVPNを入力する
  5. 実行する

INC-1001が返れば成功です。

一方、MCP_USER_DEPARTMENTを経理に変更して同じ検索を行うと、IT部門向けの非公開チケットは返りません。

$env:MCP_USER_DEPARTMENT="経理"
npx @modelcontextprotocol/inspector npx tsx src/index.ts

このように、同じツールでもMCPサーバー側の権限判定によって結果を変えられます。Inspectorは、実際のAIクライアントを設定する前に、ツールの入力検証や返却内容を確認するのに便利でした。

AIクライアントへ接続する際の注意点

MCPクライアントには、サーバーを起動するコマンドと引数を登録します。設定形式はクライアントごとに異なりますが、考え方は次のようになります。

{
  "mcpServers": {
    "internal-ticket-search": {
      "command": "npx",
      "args": ["tsx", "C:/work/internal-ticket-mcp/src/index.ts"],
      "env": {
        "MCP_USER_DEPARTMENT": "it"
      }
    }
  }
}

ただし、環境変数を利用したサンプル認証は検証用です。実運用では、次の対策を追加します。

  • MCPサーバーを実行するOSユーザーを分離する
  • ユーザーの身元をクライアント入力ではなく認証済みコンテキストから取得する
  • 部署やロールごとに公開ツールを分ける
  • 検索結果に個人情報や秘密情報を含めない
  • ツール呼び出し、ユーザー、検索条件、件数を監査ログへ残す
  • 更新系ツールは読み取り系と分離し、明示的な確認を要求する

特に避けたいのは、execute_sql(sql)のような汎用ツールです。便利に見えますが、権限チェックや情報漏えい対策が各クエリに依存してしまいます。業務単位のツールとして設計し、サーバー側で許可する条件を決める方が安全です。

MCPのstdio接続では、AIクライアントがMCPサーバーを子プロセスとして起動します。複数ユーザーから共有するサーバーにする場合は、HTTP系のトランスポートや認証、セッション管理も検討します。詳しい接続方式はTypeScript SDKのサーバーガイドとstdioのガイドが参考になります。

まとめ

MCPサーバーを自作すると、社内データをAIクライアントから利用できる形にしつつ、データアクセスのルールをサーバー側へ集約できます。

今回の実装で確認できたポイントは次のとおりです。

  • registerToolでAIから呼び出せる機能を定義できる
  • Zodで入力値を検証できる
  • MCPサーバー側で部署・ロールによる絞り込みができる
  • MCP Inspectorでクライアント接続前に動作確認できる
  • 標準出力は通信専用にし、ログは標準エラー出力へ出す

まずは読み取り専用の検索ツールから始め、権限管理と監査ログを固めてから、必要に応じて更新系の操作を追加するのが現実的です。

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?