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を選んだか
MCPのトランスポート方式
MCPは2つのトランスポート方式をサポートしています。用途に応じて適切な方式を選択します。
1. stdio(標準入出力)
┌─────────────────┐ stdin/stdout ┌─────────────────┐ │ MCP Client │◄──────────────────────►│ MCP Server │ │ (親プロセス) │ JSON-RPC │ (子プロセス) │ └─────────────────┘ └─────────────────┘
特徴:
- MCPサーバーを子プロセスとして起動し、標準入出力で通信
- ローカル環境での利用に最適
- セットアップが簡単(ネットワーク設定不要)
- プロセス間通信のため低レイテンシ
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 └─────────────────┘
特徴:
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方式を採用しています。理由は以下の通りです:
- シンプルな構成: Agent GatewayがQuarkus MCPサーバーを子プロセスとして管理
- 低レイテンシ: プロセス間通信のため高速なツール実行が可能
- セキュリティ: ネットワーク経由のアクセスが不要(同一マシン内で完結)
- デプロイの容易さ: 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> { // 既存のサービスクラスまたはリポジトリクラスを呼び出し、レスポンスを返す } }
ツール設計のポイント
- 明確なdescription: AIが適切なツールを選択できるよう、わかりやすい説明を記述
- 適切な引数設計: オプション引数にはデフォルト値を設定
- 戻り値の型: シリアライズ可能なDTO(Data Transfer Object)を返す
Agent Gatewayの実装
アーキテクチャ概要
Agent Gatewayは、Node.js/TypeScriptで実装されたサービスで、以下の役割を担います:
依存関係
{ "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システムを構築しました:
- Quarkus MCPサーバー:
@Toolアノテーションでツールを定義し、SFAのビジネスロジックをAIに公開 - Agent Gateway: OpenAI Agent SDKを使用してLLMとMCPサーバーを連携、クエリ正規化とセッション管理を実装
- Slack bot: スレッドベースの会話管理で、チャット体験を提供