Timee Product Team Blog

タイミー開発者ブログ

Kotlin で MCP サーバー作ってみた

Android Chapter の tick-taku です。

この記事はTimee Product Advent Calendar 2025の15日目の記事です。

はじめに

2025年は AI コーディング・AI エージェント元年であり黎明期の渦中 と言っても過言ではない年だったと感じます。

各社からこぞって AI エージェントがリリースされ、その後も息つく暇もなくアップデートされてきました。単純なコード補完ではなくタスクを任せられるようになった昨今の AI エージェントは今ではすっかりコーディングの相棒です。

実際今年の DroidKaigi や iOSDC でも AI 関連のセッションが増えていた気がします。

そんな中 KotlinFest などで MCP サーバーを作った話を聞いて「この時代に生きてるんだから自分も触れておかなければ(使命感)(遅い)」と思い、せっかく1年も終わろうとしているのでいい機会だし触ってみようとまずは MCP サーバーを作ってみました。

ちなみにこの記事を書くにあたっても AI のサポートを最大限利用しています。時代ですねぇ。。。

Kotlin で MCP サーバー作ってみる

では実際に作成していきます。

ハンズオン的なドキュメントを公式が用意してくれているのでそちらも参考になります。

modelcontextprotocol.io

今回は timee-android で運用している LADR を提供する MCP サーバーを作成してみることにしました。LADR については こちら数日前にチームのリードエンジニアが取り組みについて投稿 しているためぜひご覧ください。

この記事では jar ファイルをローカルに作成し Claude Code と接続することを目標とします。

事前準備

以下のようなディレクトリ構成を想定します。

timee-ladr
├── ladr
│   ├── 0000-ladr-template.md
│   ├── ~~~
│   └── assets
└── mcp
    └── app

また、Kotlin で作成するので MCP Kotlin SDK を利用します。build.gradle はこちら。

dependencies {
    implementation("io.modelcontextprotocol:kotlin-sdk:0.7.7")
}

base {
    archivesName.set("ladr-mcp")
}

application {
    mainClass = "timee.ladr.AppKt"
    applicationName = "ladr-mcp"
}

Primitives

実装に移る前に MCP サーバーがコンテキストを提供するための重要な要素に触れておきます。

MCP には Resources, Tools, Prompts と呼ばれる要素があり、この記事で触れる Resources, Tools についてはそれぞれ以下のように定義されています。(Prompts は今回触れないため割愛します)

  • Resources: LLM に追加のコンテキストを提供する読み取り専用のデータまたはコンテンツ
  • Tools: LLM がアクションを実行するための関数のようなもの

今回作る MCP サーバーに当てはめると、LADR の md ファイルを Resources として提供し、LADR ファイルを新規作成したり更新したりする機能を Tools として提供することになります。

Server の初期化

まずは MCP サーバーを初期化します。

fun main() {
    val server = Server(
        serverInfo = Implementation(name = "ladr-mcp", version = "1.0.0"),
        options = ServerOptions(
            capabilities = ServerCapabilities(
                resources = ServerCapabilities.Resources(
                    subscribe = false,
                    listChanged = false
                ),
                tools = ServerCapabilities.Tools(
                    listChanged = false
                )
            )
        )
    ) { "timee-android 内の LADR を提供する MCP サーバー" }
}

ここで出てきた Capabilities とはこの MCP サーバーが LLM に対して何を提供しているかを示すものであり、今回の場合は Resources と Tools を提供していることを表しています。

Resources の Capabilities を示す data class の引数はそれぞれ以下のような意味を持っています。

プロパティ 説明
subscribe クライアントが特定リソースの変更を購読できるか
listChanged リソース一覧が変更されたときに通知を送るか

今回のサーバーは静的な LADR の md ファイルを提供することが目的なので基本的に更新系は false です。

Resource の登録

LADR のファイルを Resource に登録していきます。

今回は jar を作成し単独でローカルで動作させることを目指しているため、jar 内の resources/ 配下に md ファイルを格納し読み取ることにしました。この辺の実装は AI 頼りです。ありがとう🙏

fun main() {
    /** Server の初期化 */

    getLadrFilePathsFrom()?.forEach { filePath ->
        val fileName = filePath.substringAfterLast("/")
        server.addResource(
            uri = "ladr:///$fileName",
            name = fileName,
            description = "LADR: $fileName",
            mimeType = "text/markdown"
        ) { request ->
            ReadResourceResult(
                contents = listOf(
                    TextResourceContents(
                        uri = request.uri,
                        mimeType = "text/markdown",
                        text = readResourceFile(filePath) ?: "File not found: $fileName"
                    )
                )
            )
        }
    }
}

private fun getLadrFilePaths() = object {}.javaClass.protectionDomain?.codeSource?.let {
    JarFile(it.location.toURI().path).getLadrFiles()
}

private fun JarFile.getLadrFiles() = entries().asSequence()
    .filter { !it.isDirectory && it.name.startsWith("ladr/") && it.name.endsWith(".md") }
    .map { it.name }
    .sorted()
    .toList()

private fun readResourceFile(filePath: String) = object {}.javaClass.classLoader.getResourceAsStream(filePath)?.let { stream ->
    BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
}

Tool の登録

チームで運用している LADR のテンプレートに沿って登録しているため少し長いですが、addTool 内で input に対して output を定義するだけです。

JsonObject の用意が大変ですね…

fun main() {
    /** Server の初期化 */

    server.addTool(
        name = "create-ladr",
        description = "与えられたコンテキストから新しい LADR (Lightweight Architecture Decision Record) ドキュメントを作成します。テンプレートに基づいてMarkdown形式のLADRを生成します。",
        inputSchema = Tool.Input(
            properties = buildJsonObject {
                putJsonObject("title") {
                    put("type", "string")
                    put("description", "LADRのタイトル(何に対しての意思決定を行ったのかがわかるタイトル)")
                }
                putJsonObject("number") {
                    put("type", "string")
                    put("description", "LADRの通し番号(4桁のゼロ埋め、例: 0001, 0042)")
                }
                putJsonObject("status") {
                    put("type", "string")
                    put("description", "採用状況: Accepted(採用), Rejected(不採用), On holding(保留)のいずれか")
                    putJsonArray("enum") {
                        add(JsonPrimitive("Accepted"))
                        add(JsonPrimitive("Rejected"))
                        add(JsonPrimitive("On holding"))
                    }
                }
                putJsonObject("context") {
                    put("type", "string")
                    put("description", "文脈: 経緯(なぜこの意思決定が生まれたのか)、観点(判断した観点)、事情(検討した事情)、参考情報など")
                }
                putJsonObject("specification") {
                    put("type", "string")
                    put("description", "仕様、やること、やらないこと")
                }
                putJsonObject("design") {
                    put("type", "string")
                    put("description", "設計: 特筆すべき設計や設計意図")
                }
                putJsonObject("decision") {
                    put("type", "string")
                    put("description", "決定事項: 想定している運用や制約、再評価となる条件など")
                }
                putJsonObject("consequences") {
                    put("type", "string")
                    put("description", "結果: 意思決定の結果を評価したタイミングで追記")
                }
                putJsonObject("link") {
                    put("type", "string")
                    put("description", "関連するLADRへのリンク")
                }
            },
            required = listOf("title", "number", "context", "specification")
        )
    ) { request ->
        val args = request.arguments
        val ladrContent = buildString {
            val title = requireNotNull(args["title"]?.jsonPrimitive?.content)
            val number = requireNotNull(args["number"]?.jsonPrimitive?.content)
            appendLine("# $number-$title")
            appendLine()

            appendSection(
                title = "## Date - 日付",
                text = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
            )
            appendSection(
                title = "## Status - 採用状況",
                text = args["status"]?.jsonPrimitive?.content
            )
            appendSection(
                title = "## Context - 文脈",
                text = requireNotNull(args["context"]?.jsonPrimitive?.content)
            )
            appendSection(
                title = "## Specification - 仕様、やること、やらないこと",
                text = requireNotNull(args["specification"]?.jsonPrimitive?.content)
            )
            appendSection(
                title = "## Design - 設計",
                text = args["design"]?.jsonPrimitive?.content
            )
            appendSection(
                title = "## Decision - 決定事項",
                text = args["decision"]?.jsonPrimitive?.content
            )
            appendSection(
                title = "## Consequences - 結果",
                text = args["consequences"]?.jsonPrimitive?.content
            )
            appendSection(
                title = "## Link - 関連するLADRへのリンク",
                text = args["link"]?.jsonPrimitive?.content
            )
        }

        CallToolResult(content = listOf(TextContent(ladrContent)))
    }
}

private fun StringBuilder.appendSection(title: String, text: String?) = apply {
    appendLine(title)
    appendLine()
    if (text.isNullOrEmpty().not()) {
        appendLine(text)
        appendLine()
    }
}

本来はファイルを作成するところまでできるとよさそうなんですが、 LADR の性質上 jar の中にファイルを作成しても仕方なく、テンプレートに当てはめたテキストを返すのみとなっています。

Resource の登録と合わせて、本格的にチームで運用する場合はリポジトリをクローンしていることを前提に LADR を格納しているディレクトリの path を環境変数などで MCP サーバーに伝えるなどの方法もありそうですね。

Server の起動

最後に準備した Server を起動させます。

本来 MCP サーバーのトランスポートの方式はいくつかありますが、今回はローカルに build した jar との接続を想定しているのでドキュメント通りSTDIO形式で起動します。

fun main() {
    /** Server の初期化 */

    val transport = StdioServerTransport(
        inputStream = System.`in`.asSource().buffered(),
        outputStream = System.out.asSink().buffered()
    )
    runBlocking {
        val session = server.createSession(transport)
        val done = Job()
        server.onClose {
            done.complete()
        }
        done.join()
    }
}

以上でサーバーの実装は完了です🎉

動作確認

作成したサーバーが正しくレスポンスを返してくるか検証しましょう。MCP の検証には MCP Inspector が提供されています。

MCP Inspector を起動する前に以下のコマンドでアプリケーションを用意します。

./gradlew installDist

app/build/install/ 以下に作成されるので、作成されたアプリケーションの path を Inspector の起動時に指定します。

npx @modelcontextprotocol/inspector mcp/app/build/install/ladr-mcp/bin/ladr-mcp

ブラウザが起動するので左ペインの Connect を実行すると正しく作成できていれば成功します。

List Resources を実行すると登録された Resource の一覧が確認できます。

MCPInspecter/Resources

また、Tools タブ内で List Tools で登録された Tool の一覧が確認できます。

右側のペインに inputSchema に指定した property を入力でき、実行するとレスポンスが返ってきます。enum で指定した status はドロップダウンで選択できるようになっていて便利ですね。

MCPInspecter/Tools

Claude Code との接続

では最後に Claude Code と接続して LADR が確認できるか試してみます。

接続の前に jar ファイルを build します。ただし、普通に jar ファイルを build すると resource ファイルなどは格納されないため fatJar(shadowJar) を作成する必要があります。

これは自分が Server Side Kotlin の知見に乏しいので AI に聞いて作成 task を用意してもらいましたが、もっといい方法があるかもしれません。

fatJar 作成の gradle task

tasks.register<Jar>("fatJar") {
    archiveClassifier.set("all")
    archiveVersion.set("")
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE

    manifest {
        attributes["Main-Class"] = "timee.ladr.AppKt"
    }
    
    from(sourceSets.main.get().output)
    
    dependsOn(configurations.runtimeClasspath)
    from({
        configurations.runtimeClasspath.get()
            .filter { it.name.endsWith("jar") }
            .map { zipTree(it) }
    })
}

jar ファイルが作成できたら ~/.claude.json に設定を追加します。

"mcpServers": {
  "ladr-mcp": {
    "type": "stdio",
    "command": "java",
    "args": [
      "-jar",
      "/path/to/timee-ladr/mcp/app/build/libs/ladr-mcp-all.jar"
    ],
    "env": {}
  }
}

Claude Code を起動して /mcp list を確認して connected になっていたら成功です。試しに timee-android が LADR を採用した経緯を聞いてみると readMcpResource を利用してまとめてくれました。うちの Claude お姉ちゃんは優秀です。

ClaudeCodeによるLADRのサマライズ

最後に

「MCP ってなんかすごいやつでいい感じになんかやってくれるんだな〜」くらいのイメージでいましたが、実際に作って触れてみるとなるほどコンテキストを取り扱うためのものなんだなということがよく分かりました。MCP がいい感じにしてくれるのではなく、コンテキスト接続のプロトコルなのであくまで外部からデータを与えたり作成したりするためのものであり、いい感じにしてくれるのはエージェントだと身をもって理解できました。

秩序という意味でも “AI における USB-TypeC” とはよく言ったものです。

となると次は AI エージェントの作成にチャレンジしてみるしかありませんね。次回は Koog を利用して今回作成した MCP サーバーを利用していい感じに LADR を作成してくれる AI エージェントを試してみようと思います。最近は KMP も熱く、全てが Kotlin で書けるのでもう全部 Kotlin でいいじゃんですね🤗

実際に手を動かしてみることで学びにはなりましたが GitHub のリポジトリに上がっているものなら GitHub CLI で済みそうな気がするので、社内 DB などよりクローズドなデータなら MCP サーバーとして用意する価値があるのかもしれません。とは言え、そんなデータをエージェントにくわせていいのかは…🤔

社内データを活用するために MCP サーバーを自作しておくと色々と捗りそうですが、一方で MCP サーバーはコンテキストを消費するのであんまり追加してもエージェントが処理しきれなくて意味がないと言う主張もあるそうです。

これからますます便利になっていくであろう AI エージェント界隈、今後の進化を楽しみながら注視していきたいですね!


タイミーでは日々の業務に積極的に AI を活用しています。ご興味があればぜひお話ししましょう!

プロダクト採用サイトTOP

カジュアル面談申込はこちら