社内のチケット管理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を大きく変更せずに、自然言語による社内データ活用の入口を作れることが分かりました。