ELW株式会社 テックブログ

リアルなログをそのままお届けします。

QuarkusでMCPサーバーを構築し、AI AgentとSlack botを連携させる

CTOの村上です。本記事では、Quarkusを使用してMCPサーバーを構築し、OpenAI Agent SDKを使ったAI Agentとの連携、さらにSlack botとしての実装までをプロトタイプとして作成しましたので、その解説をさせていただきます。なお、記載しているコードについては、Production用ではなく、記事のために省略・改変をしている旨をご了承ください。また、実装をもとにLLMに記事の土台を作成させ、手で加筆修正しました。


はじめに

全体アーキテクチャ

┌─────────────────────────────────────────────────────────────────────────┐
│                           ユーザーインターフェース                          │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐                 │
│  │   Web UI    │    │  Slack Bot  │    │   API       │                 │
│  └──────┬──────┘    └──────┬──────┘    └──────┬──────┘                 │
└─────────┼──────────────────┼──────────────────┼─────────────────────────┘
          │                  │                  │
          ▼                  ▼                  ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                        Agent Gateway (Node.js)                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                    OpenAI Agent SDK                              │   │
│  │  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐   │   │
│  │  │ Agent Runner │  │ Session Mgmt │  │ Query Rewrite Service│   │   │
│  │  └───────┬──────┘  └──────────────┘  └──────────────────────┘   │   │
│  └──────────┼──────────────────────────────────────────────────────┘   │
│             │ stdio                                                     │
│             ▼                                                           │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                    MCP Client                                    │   │
│  └───────┬─────────────────────────────────────────────────────────┘   │
└──────────┼──────────────────────────────────────────────────────────────┘
           │ JSON-RPC over stdio
           ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                      MCP Server (Quarkus/Kotlin)                        │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                    DriveSfaMcpService                            │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐  │   │
│  │  │ @Tool       │  │ @Tool       │  │ @Tool                   │  │   │
│  │  │search_      │  │get_account  │  │search_sales_activities  │  │   │
│  │  │accounts     │  │             │  │                         │  │   │
│  │  └─────────────┘  └─────────────┘  └─────────────────────────┘  │   │
│  └───────┬─────────────────────────────────────────────────────────┘   │
│          │                                                              │
│          ▼                                                              │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │              Business Logic Layer (Services, Repositories)       │   │
│  └───────┬─────────────────────────────────────────────────────────┘   │
└──────────┼──────────────────────────────────────────────────────────────┘
           │
           ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                        PostgreSQL Database                              │
└─────────────────────────────────────────────────────────────────────────┘

技術スタック

レイヤー 技術
MCP サーバー Quarkus 3.x + Kotlin + quarkus-mcp-server-stdio
Agent Gateway Node.js + TypeScript + OpenAI Agent SDK
Slack 連携 @slack/web-api
データベース PostgreSQL

MCP (Model Context Protocol) とは

MCPの主要な構成要素

┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐
│   MCP Host      │         │   MCP Client    │         │   MCP Server    │
│ (Claude, etc.)  │◄───────►│   (SDK)         │◄───────►│ (Your App)      │
└─────────────────┘         └─────────────────┘         └─────────────────┘
                                                                 │
                                                                 ▼
                                                        ┌─────────────────┐
                                                        │   Resources     │
                                                        │   Tools         │
                                                        │   Prompts       │
                                                        └─────────────────┘
  • Host: AIモデルを実行する環境(Claude Desktop、IDE拡張など)
  • Client: MCPサーバーと接続するためのコンポーネント
  • Server: ツールやリソースを提供するアプリケーション

なぜMCPを選んだか

  1. 標準化されたプロトコル: 独自のAPI設計が不要
  2. ツール定義の簡潔さ: アノテーションベースで直感的
  3. 多様なクライアント対応: Claude Desktop、VSCode拡張、カスタムAgentなど

MCPのトランスポート方式

MCPは2つのトランスポート方式をサポートしています。用途に応じて適切な方式を選択します。

1. stdio(標準入出力)

┌─────────────────┐      stdin/stdout       ┌─────────────────┐
│   MCP Client    │◄──────────────────────►│   MCP Server    │
│   (親プロセス)   │      JSON-RPC          │   (子プロセス)   │
└─────────────────┘                         └─────────────────┘

特徴:

  • MCPサーバーを子プロセスとして起動し、標準入出力で通信
  • ローカル環境での利用に最適
  • セットアップが簡単(ネットワーク設定不要)
  • プロセス間通信のため低レイテンシ

ユースケース

  • Claude Desktop との連携
  • ローカル開発環境
  • IDE拡張(VSCode、JetBrains等)
  • 本記事の実装(Agent Gateway → Quarkus MCP Server)

Quarkusでの実装:

// build.gradle.kts
implementation("io.quarkiverse.mcp:quarkus-mcp-server-stdio:1.7.2")

クライアント側(Node.js)での接続:

const mcpServer = new MCPServerStdio({
  command: 'java',
  args: ['-jar', 'mcp-server.jar'],
  cwd: '/path/to/server',
});
await mcpServer.connect();

2. HTTP with SSE(Server-Sent Events)

┌─────────────────┐       HTTP POST         ┌─────────────────┐
│   MCP Client    │──────────────────────►│   MCP Server    │
│                 │                         │   (独立サーバー)  │
│                 │◄──────────────────────│                 │
└─────────────────┘       SSE Stream        └─────────────────┘

特徴:

  • HTTPベースでリモート通信が可能
  • MCPサーバーを独立したサービスとして運用
  • 複数クライアントからの同時接続に対応
  • ファイアウォール/プロキシを越えた通信が可能

ユースケース

Quarkusでの実装:

// build.gradle.kts
implementation("io.quarkiverse.mcp:quarkus-mcp-server-sse:1.7.2")

クライアント側での接続:

const mcpServer = new MCPServerSSE({
  url: '<https://mcp-server.example.com/sse>',
  headers: {
    'Authorization': 'Bearer your-token',
  },
});
await mcpServer.connect();

方式の比較

項目 stdio HTTP/SSE
通信方式 プロセス間(stdin/stdout) ネットワーク(HTTP)
サーバー起動 クライアントが子プロセスとして起動 独立したサービスとして常駐
適用範囲 ローカルのみ ローカル/リモート両対応
セットアップ 簡単 認証・HTTPS等の設定が必要
スケーラビリティ 1:1 1:N(複数クライアント対応可)
レイテンシ 低い ネットワーク依存
代表的な用途 Claude Desktop、IDE連携 Webサービス、マイクロサービス

本記事での選択

本記事ではstdio方式を採用しています。理由は以下の通りです:

  1. シンプルな構成: Agent GatewayがQuarkus MCPサーバーを子プロセスとして管理
  2. 低レイテンシ: プロセス間通信のため高速なツール実行が可能
  3. セキュリティ: ネットワーク経由のアクセスが不要(同一マシン内で完結)
  4. デプロイの容易さ: 1つのコンテナ/プロセスグループとしてデプロイ可能

ただし、将来的にMCPサーバーを独立したマイクロサービスとして運用したい場合は、HTTP/SSE方式への移行も検討できます。


QuarkusでMCPサーバーを構築する

依存関係の追加

まず、build.gradle.ktsにQuarkiverse MCP拡張を追加します:

dependencies {
    // MCP Server (stdio transport)
    implementation("io.quarkiverse.mcp:quarkus-mcp-server-stdio:1.7.2")
}

quarkus-mcp-server-stdioは、stdio(標準入出力)を使用してMCPプロトコルを実装する拡張です。これにより、子プロセスとしてMCPサーバーを起動し、JSON-RPC over stdioで通信できます。

ツールの定義

MCPサーバーの核となるのが「ツール」の定義です。@Toolアノテーションを使用して、AIが呼び出せる関数を定義します:

@ApplicationScoped
class DriveSfaMcpService {
    @Tool(description = "Search accounts by keyword")
    fun search_accounts(
        @ToolArg(description = "Search keyword") keyword: String?
    ): List<AccountResponseDto> {
        // 既存のサービスクラスを呼び出し、レスポンスを返す
    }

    @Tool(description = "Get account details by ID")
    fun get_account(
        @ToolArg(description = "Account ID") accountId: Long
    ): AccountResponseDto {
        // 既存のサービスクラスまたはリポジトリクラスを呼び出し、レスポンスを返す
    }

    @Tool(description = "Search sales activities")
    fun search_sales_activities(
        @ToolArg(description = "Search keyword") keyword: String?,
        @ToolArg(description = "Page number (default: 1)") page: Int?,
        @ToolArg(description = "Results per page (default: 20)") limit: Int?
    ): List<SalesActivityResponseDto> {
        // 既存のサービスクラスまたはリポジトリクラスを呼び出し、レスポンスを返す
    }

    @Tool(description = "Get current user's activities for a date")
    fun get_my_sales_activities(
        @ToolArg(description = "Date in YYYY-MM-DD format") date: String?
    ): List<SalesActivityMeResponseDto> {
        // 既存のサービスクラスまたはリポジトリクラスを呼び出し、レスポンスを返す
    }
}

ツール設計のポイント

  1. 明確なdescription: AIが適切なツールを選択できるよう、わかりやすい説明を記述
  2. 適切な引数設計: オプション引数にはデフォルト値を設定
  3. 戻り値の型: シリアライズ可能なDTO(Data Transfer Object)を返す

Agent Gatewayの実装

アーキテクチャ概要

Agent Gatewayは、Node.js/TypeScriptで実装されたサービスで、以下の役割を担います:

  1. OpenAI Agent SDKによるLLM連携
  2. MCPサーバーとの通信
  3. セッション管理
  4. クエリ正規化

依存関係

{
  "dependencies": {
    "@openai/agents": "^0.3.0",
    "@slack/web-api": "^7.12.0",
    "express": "^4.21.1",
    "pino": "^9.7.0",
    "zod": "^3.24.1"
  }
}

Agent Runtimeの実装

import { Agent, Runner } from '@openai/agents';
import { OpenAIProvider } from '@openai/agents-openai';
import { InstrumentedMCPServer } from '../mcp/instrumented-server.js';

export class DriveSFAAgentRuntime {
  // MCPサーバーの設定
  private readonly mcpServer = new InstrumentedMCPServer({
    command: config.mcp.command,  // e.g., 'java'
    args: config.mcp.args,        // e.g., ['-jar', 'app.jar']
    cwd: config.mcp.cwd,
    env: config.mcp.env,
    timeout: config.mcp.clientTimeoutMs,
  });

  // Agentの定義
  private readonly agent = new Agent({
    name: 'DriveSFA Operator',
    instructions: config.agent.instructions,
    model: config.openai.model,  // e.g., 'gpt-4.1-mini'
    mcpServers: [this.mcpServer],
    outputType: 'text',
  });

  // Runnerの設定
  private readonly runner = new Runner({
    modelProvider: new OpenAIProvider({
      apiKey: config.openai.apiKey,
      baseURL: config.openai.baseUrl,
    }),
    model: config.openai.model,
  });

  async start() {
    await this.mcpServer.connect();
  }

  async handleChat(request: AgentChatRequest): Promise<AgentChatResponse> {
    // クエリの正規化
    const rewrittenMessages = await this.rewriteLastUserMessage(
      request.messages,
      { tenantId: request.tenantId, userId: request.userId }
    );

    // Agentの実行
    const result = await this.runner.run(
      this.agent,
      mapMessages(rewrittenMessages),
      {
        context: {
          tenantId: request.tenantId,
          userId: request.userId,
        },
        session: sessionEntry.session,
        maxTurns: config.agent.maxTurns,
      }
    );

    return {
      final: { role: 'assistant', content: result.finalOutput },
      usedTools: this.extractToolSummaries(result.newItems),
    };
  }
}

クエリ正規化サービス

ユーザーの自然言語クエリをSFAドメインに最適化された形式に変換します:

export class QueryRewriteService {
  private cache = new Map<string, CacheEntry>();

  async rewrite(
    query: string,
    context: { tenantId: string; userId: string }
  ): Promise<RewriteResult> {
    // キャッシュチェック
    const cacheKey = this.generateCacheKey(query, context);
    const cached = this.cache.get(cacheKey);
    if (cached && !this.isExpired(cached)) {
      return {
        original: query,
        rewritten: cached.value,
        wasRewritten: true,
        cacheHit: true
      };
    }

    // LLMによるクエリ正規化
    const rewritten = await this.callRewriteModel(query);

    // キャッシュに保存
    this.cache.set(cacheKey, {
      value: rewritten,
      timestamp: Date.now(),
    });

    return {
      original: query,
      rewritten,
      wasRewritten: query !== rewritten,
      cacheHit: false,
    };
  }
}

正規化のルール例:

  • 「取引先」「会社」「顧客」account
  • 「担当者」「コンタクト」account_contact
  • 「案件」「商談」project
  • 「今日」「昨日」 → ISO8601形式の日付

セッション管理

会話の継続性を保つため、セッションIDベースで会話履歴を管理します:

export class SessionStore {
  private sessions = new Map<string, SessionEntry>();

  constructor(
    private ttlMs: number = 30 * 60 * 1000,
    private cleanupMs: number = 5 * 60 * 1000 
  ) {
    setInterval(() => this.cleanup(), cleanupMs);
  }

  ensure(sessionId: string): SessionEntry {
    let entry = this.sessions.get(sessionId);
    if (!entry) {
      entry = { session: new AgentSession(), lastAccess: Date.now() };
      this.sessions.set(sessionId, entry);
    }
    entry.lastAccess = Date.now();
    return entry;
  }

  private cleanup() {
    const now = Date.now();
    for (const [key, entry] of this.sessions) {
      if (now - entry.lastAccess > this.ttlMs) {
        this.sessions.delete(key);
      }
    }
  }
}

システムプロンプト

Agentの振る舞いを定義するシステムプロンプト:

const DEFAULT_INSTRUCTIONS = `You are DRIVE SFA Agent, a bilingual assistant
that helps enterprise sales teams operate DRIVE SFA.

- Always respect tenant boundaries
- Use DriveSFA MCP tools when you need real data
- Summarize results in Japanese unless the user speaks English
- Never fabricate data

## Output Formatting
- Use natural, business-friendly language
- Hide technical details (MCP tools, API calls, field names)
- Present information in a clean, organized format
- Focus on actionable insights
`;

Slack botとしての実装

Slack Appの設定

必要なScopes

# Bot Token Scopes
app_mentions:read    # メンション読み取り
channels:history     # チャンネル履歴読み取り
chat:write           # メッセージ送信
users:read           # ユーザー情報読み取り
users:read.email     # メールアドレス読み取り

Event Subscriptions

app_mention        # ボットへのメンション
message.channels   # チャンネルメッセージ

SlackServiceの実装

import { WebClient } from '@slack/web-api';

export class SlackService {
  private readonly web: WebClient;
  private readonly activeThreads: ActiveThreadStore;
  private botUserId?: string;

  constructor(
    private readonly runtime: DriveSFAAgentRuntime,
    private readonly config: SlackConfig
  ) {
    this.web = new WebClient(config.botToken);
    this.activeThreads = new ActiveThreadStore(30 * 60 * 1000); // 30分TTL
  }

  async init() {
    const auth = await this.web.auth.test();
    this.botUserId = auth.user_id;
  }

  /**
   * メンション処理 - スレッドをアクティブ化
   */
  private async processAppMention(event: SlackAppMentionEvent) {
    const prompt = this.stripBotMention(event.text);
    const threadTs = event.thread_ts ?? event.ts;

    // スレッドをアクティブ化(30分間)
    this.activeThreads.activate(event.channel, threadTs, event.user);

    await this.processChatRequest({
      channel: event.channel,
      threadTs,
      text: prompt,
      identity,
    });
  }

  /**
   * 通常メッセージ処理 - アクティブスレッド内のみ
   */
  private async processMessage(event: SlackMessageEvent) {
    // ボットメッセージは無視
    if (event.bot_id) return;

    // スレッド外は無視
    if (!event.thread_ts) return;

    // アクティブでないスレッドは無視
    if (!this.activeThreads.isActive(
      event.channel, event.thread_ts, event.user
    )) return;

    const identity = await this.resolveIdentity(event.user);
    await this.processChatRequest({
      channel: event.channel,
      threadTs: event.thread_ts,
      text: event.text,
      identity,
    });
  }

  /**
   * Agent呼び出し & 応答投稿
   */
  private async processChatRequest(params: ChatParams) {
    const sessionId = `slack:${params.channel}:${params.threadTs}`;

    const response = await this.runtime.handleChat({
      tenantId: params.identity.tenantId,
      userId: params.identity.userId,
      messages: [{ role: 'user', content: params.text }],
      session: sessionId,
      source: 'slack',
    });

    await this.web.chat.postMessage({
      channel: params.channel,
      thread_ts: params.threadTs,
      text: response.final.content,
    });
  }
}

スレッドベース会話の仕組み

export class ActiveThreadStore {
  private threads = new Map<string, ThreadEntry>();

  constructor(private ttlMs: number) {
    setInterval(() => this.cleanup(), 5 * 60 * 1000);
  }

  /**
   * スレッドをアクティブ化
   */
  activate(channel: string, threadTs: string, userId: string) {
    const key = `${channel}:${threadTs}:${userId}`;
    this.threads.set(key, {
      activatedAt: Date.now(),
      lastActivity: Date.now(),
    });
  }

  /**
   * スレッドがアクティブかチェック
   */
  isActive(channel: string, threadTs: string, userId: string): boolean {
    const key = `${channel}:${threadTs}:${userId}`;
    const entry = this.threads.get(key);
    if (!entry) return false;

    const isExpired = Date.now() - entry.lastActivity > this.ttlMs;
    if (isExpired) {
      this.threads.delete(key);
      return false;
    }

    entry.lastActivity = Date.now();
    return true;
  }
}

会話フロー

1. ユーザーがボットをメンション
   @bot 今月の商談を見せて
   └─► processAppMention()
       └─► スレッドをアクティブ化(30分間)
       └─► Agent実行 & 応答

2. 同じスレッド内での継続会話(メンション不要)
   もう少し詳しく
   └─► processMessage()
       └─► isActive() でスレッド確認
       └─► Agent実行 & 応答

3. 30分経過後
   今日の予定は?
   └─► processMessage()
       └─► isActive() = false
       └─► 無視(メンション必要)

まとめ

構築した全体像

本記事では、以下の3つのコンポーネントを連携させたAI Agentシステムを構築しました:

  1. Quarkus MCPサーバー: @Toolアノテーションでツールを定義し、SFAビジネスロジックをAIに公開
  2. Agent Gateway: OpenAI Agent SDKを使用してLLMとMCPサーバーを連携、クエリ正規化とセッション管理を実装
  3. Slack bot: スレッドベースの会話管理で、チャット体験を提供

参考リンク