Upgrade to Pro — share decks privately, control downloads, hide ads and more …

フルKotlinで作る! MCPサーバー、AIエージェント、UIまで一気 通貫したAIエージェ...

Avatar for Shuzo Takahashi Shuzo Takahashi
November 01, 2025
210

フルKotlinで作る! MCPサーバー、AIエージェント、UIまで一気 通貫したAIエージェントシステム

Avatar for Shuzo Takahashi

Shuzo Takahashi

November 01, 2025
Tweet

Transcript

  1. 今回話すこと Kotlinで以下を全部一気通貫で作る話 MCPサーバー (公式MCP SDK + Ktor) AIエージェント (Koog) UI

    (Compose+Jetpack系ライブラリ) 今回はUIデザイン〜アプリ実装までのプロセスを包括的にサポートする、チャット 形式のAIエージェントを題材にします ドメイン知識、デザインデータやコードを参照し、チャット形式でユーザーの質問 に回答するシステムを例にします 3 / 29
  2. MCPツールのスキーマの実装 Kotlinらしく実装したいので、ピュアなデータクラスでスキーマを表現 それを内部的にプロトコルに準拠したJsonObjectに変換する。 # 例:FigmaからVariable(色などのトークン定義)の情報を取得する # ツールのdescriptionなどはアノテーションで定義できるように実装。 @Serializable data class

    FigmaGetVariablesInput( @ToolParam(description = "FigmaファイルのURL") val figmaUrl: String ) @Serializable data class FigmaGetVariablesOutput( @ToolParam(description = "Figmaファイルの変数情報") val variables: FigmaVariablesResponse, @ToolParam(description = "取得したファイルキー") val fileKey: String, @ToolParam(description = "取得日時") val retrievedAt: String ) MCPサーバーの実装 8 / 29
  3. MCPツールのロジックの実装 MCPツールを表す基底クラス(自分で)を定義し、各ツールはそれを実装する設計 class FigmaGetVariablesTool( private val figmaRepository: FigmaRepository ) :

    MCPTool<FigmaGetVariablesInput, FigmaGetVariablesOutput>() { override val toolSchema = FigmaGetVariablesToolSchema override suspend fun doExecute(params: FigmaGetVariablesInput): Result<FigmaGetVariablesOutput> { return figmaRepository.getFigmaVariablesByUrl(params.figmaUrl).map { variablesResponse -> val fileKey = figmaRepository.extractFileKey(params.figmaUrl) FigmaGetVariablesOutput( variables = variablesResponse, fileKey = fileKey ?: "", retrievedAt = Instant.now().toString() ) } } } MCPサーバーの実装 9 / 29
  4. MCPツールのロジックの実装 # 基底クラスの定義(注:SDKのコードではありません) abstract class MCPTool<TInput : Any, TOutput :

    Any> { protected abstract suspend fun doExecute(params: TInput): Result<TOutput> # 実行結果を取得してCallToolResult(SDKに定義されている)に変換する。 suspend fun execute(request: CallToolRequest): CallToolResult = try { val inputSerializer = serializer(inputClass.java) val params = Json.decodeFromJsonElement(inputSerializer, request.arguments) as TInput // 実装されたロジックを実行 val result = doExecute(params) result.fold( onSuccess = { output -> // 出力をJSONにシリアライズ val outputSerializer = serializer(outputClass.java) val outputJson = Json.encodeToJsonElement(outputSerializer, output) // CallToolResultとして返す CallToolResult( content = listOf(TextContent(outputJson.toString())), isError = false ) }, onFailure = { error -> /*エラーのとき*/} ) } catch (e: SerializationException) { /*エラーハンドリング*/ } } MCPサーバーの実装 10 / 29
  5. MCPツールの登録 // 複数のツールをまとめて登録する関数 private fun registerTools(server: Server, tools: List<MCPTool<*, *>>)

    { tools.forEach { tool -> server.addTool( name = tool.toolSchema.name, description = tool.toolSchema.description, inputSchema = tool.inputSchema, handler = tool::execute ) } } MCPサーバーの実装 11 / 29
  6. 基本的な実装 LLMのモデルや利用可能ツール、ワークフローのグラフなどを設定する AIAgent.run() で実行する val agent = AIAgent( executor =

    simpleGoogleAIExecutor(apiKey), systemPrompt = "あなたはアプリ開発/デザインの専門家です。定められた情報を参照し、ツールを使用してユーザーのリクエストに回答してください。", llmModel = GoogleModels.Gemini2_5Pro // その他必要に応じて設定 ) val result = agent.run("Repositoryの実装方針について教えて") println(result) AIエージェントの実装 16 / 29
  7. AIAgentServiceの実装 AIAgent/AIAgentServiceにはインプットとアウトプットを型で指定できる。 // 例として返答結果を受け取るFlowを定義 val agentResultFlow = MutableSharedFlow<String>() // String以外にも独自のクラスを定義可能

    val agentService: GraphAIAgentService<String, String> = AIAgentService( promptExecutor = simpleGoogleAIExecutor(apiKey), systemPrompt = "あなたはアプリ開発/デザインの専門家です。定められた情報を参照し、ツールを使用してユーザーのリクエストに回答してください。", llmModel = GoogleModels.Gemini2_5Pro // その他設定 ) // ---- 以下、入力の都度実行する --- val agent = agentService.createManagedAgent(id = agentId) // 会話を継続的にするには、同一のIDを使用する launch { val result = agent.run("Repositoryの実装方針について教えて") agentResultFlow.emit(result) } AIエージェントの実装 18 / 29
  8. ツールの実装と登録 AIエージェントが実行可能な機能は「ツール」として実装・登録する。 任意のMCPサーバーのツールも同じ用に登録することができる。 // MCPサーバーと接続してToolRegistryに val httpClient = HttpClient() val

    baseUrl = "http://$host:$port" val transport = SseClientTransport(httpClient, baseUrl) val client = Client( clientInfo = Implementation( name = "mcp-client-name", version = "1.1.0" ) ) client.connect(transport) val toolRegistry = McpToolRegistryProvider.fromClient(mcpClient = client) // AIAgentServiceの作成 AIAgentService( toolRegistry = toolRegistry, // 中略 ) AIエージェントの実装 19 / 29
  9. ストリーミング AIAgentから実行途中のレスポンスをストリーミング(Flow)で受け取ることが できる。 クライアントサイドが結果を受け取る「ツール」を登録しておき、Flowでの受信毎 にツール呼び出しを行うことで、クライアントサイドに応答を伝える。 // node定義の中 llm.writeSession { updatePrompt

    { system("これまで収集した情報に元に、ユーザーのリクエストに正確かつ詳細に回答してください。") } val responseStream = requestLLMStreaming() // ストリームを収集し、各テキストチャンクをリアルタイムで送信します responseStream.collect { frame -> if (frame is StreamFrame.Append && frame.text.isNotEmpty()) { responseBuilder.append(frame.text) // ユーザーに応答結果を送るツールの呼び出し callTool(...) } } } AIエージェントの実装 22 / 29
  10. 会話履歴の永続化 interface PersistenceStorageProviderを継承して、 AgentCheckpointData を保 持する仕組みを実装する。 今回は永続化はKMP対応してるJetpack DataStoreを使用する。 それをAIAgentServiceの作成時にinstallすることで、自動的に保存される。 //

    storageProviderをインスタンス化しておく AIAgentService( // その他設定... installFeatures = { install(Persistence) { storage = storageProvider enableAutomaticPersistence = true rollbackStrategy = RollbackStrategy.MessageHistoryOnly } }, ) AIエージェントの実装 24 / 29
  11. 会話履歴の永続化:ハマったこと 同一のagentIdで会話を再開しようとしても、会話履歴が保持されていないという 状況が発生。 対処法 install時に rollbackStrategy = RollbackStrategy.MessageHistoryOnly にしてお く。

    Defaultだと、会話以外のメタ情報も全て保持するが、以前のセッションでエージ ェントを破棄した(TombStone)状態としてマークされるため、会話履歴も復元 できない。 MessageHistoryOnly にすることで、会話履歴のみを復元し、会話を継続させるこ とができる。 AIエージェントの実装 25 / 29