CUBE SUGAR CONTAINER

技術系のこと書きます。

Mac で llama.cpp をビルドして GPU の有効・無効によるパフォーマンスの違いを比べてみる

Mac で llama.cpp を使う場合、最も手っ取り早い方法は Homebrew からインストールすることだろう。 コマンドラインから brew install llama.cpp するだけで、GPU での演算が有効なバイナリが得られる。 一方で、GPU を有効にした場合と無効にした場合で、どれくらいパフォーマンスに影響があるのかは自明でない。 そこで、今回は自分で llama.cpp をビルドして両者を比べてみることにした 1

使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     26.1
BuildVersion:       25B78
$ uname -srm                                 
Darwin 25.1.0 arm64
$ sysctl machdep.cpu.brand_string
machdep.cpu.brand_string: Apple M2 Pro

もくじ

下準備

まずは、llama.cpp をビルドするのに使うパッケージなどを入れる。 Homebrew が入っていないときは、あらかじめインストールしておく。

$ brew install wget curl cmake libomp

次に llama.cpp のリポジトリをクローンしておく。

$ git clone https://github.com/ggml-org/llama.cpp.git
$ cd llama.cpp

llama.cpp で動かすモデルをダウンロードする。 特に何を使っても構わないが、大きなモデルだと環境によっては多少の時間がかかる。

$ wget -P /tmp https://huggingface.co/TheBloke/Llama-2-7B-GGUF/resolve/main/llama-2-7b.Q4_0.gguf

Homebrew でインストールした OpenMP を cmake が見つけられるように環境変数を設定する。

$ export OpenMP_ROOT=$(brew --prefix)/opt/libomp

ビルドする

GPU を有効にした状態と、無効にした状態でビルドする。 Mac の場合、GPU の有効・無効は GGML_METAL という設定項目で切り替えられる。

まずは GPU を有効にした状態から。 デフォルトで GPU が有効なので、特に明示的な指定は必要ない。

$ cmake \
  -B build_metal \
  -D BUILD_SHARED_LIBS=OFF
$ cmake \
  --build build_metal \
  --config Release \
  -j \
  --clean-first \
  --target llama-cli llama-bench llama-server

次に GPU を無効にした状態を。 こちらは GPU を無効にするために、明示的に -D GGML_METAL=OFF を指定する。

$ cmake \
  -B build_no_metal \
  -D GGML_METAL=OFF \
  -D BUILD_SHARED_LIBS=OFF
$ cmake \
  --build build_no_metal \
  --config Release \
  -j \
  --clean-first \
  --target llama-cli llama-bench llama-server

ビルドすると、各ビルドディレクトリの bin ディレクトリ以下にバイナリができる。

$ ls build_metal/bin
ggml-common.h       ggml-metal-impl.h   ggml-metal.metal    llama-bench     llama-cli       llama-server

ベンチマークする

それでは llama.cpp のベンチマークツールである llama-bench を使ってパフォーマンスを確認しよう。

まずは GPU を有効にしてビルドしたバイナリから。 -m オプションでベンチマークに使うモデルの GGUF ファイルを指定する。

$ ./build_metal/bin/llama-bench \
  -m /tmp/llama-2-7b.Q4_0.gguf
ggml_metal_device_init: tensor API disabled for pre-M5 and pre-A19 devices
ggml_metal_library_init: using embedded metal library
ggml_metal_library_init: loaded in 6.543 sec
ggml_metal_rsets_init: creating a residency set collection (keep_alive = 180 s)
ggml_metal_device_init: GPU name:   Apple M2 Pro
ggml_metal_device_init: GPU family: MTLGPUFamilyApple8  (1008)
ggml_metal_device_init: GPU family: MTLGPUFamilyCommon3 (3003)
ggml_metal_device_init: GPU family: MTLGPUFamilyMetal4  (5002)
ggml_metal_device_init: simdgroup reduction   = true
ggml_metal_device_init: simdgroup matrix mul. = true
ggml_metal_device_init: has unified memory    = true
ggml_metal_device_init: has bfloat            = true
ggml_metal_device_init: has tensor            = false
ggml_metal_device_init: use residency sets    = true
ggml_metal_device_init: use shared buffers    = true
ggml_metal_device_init: recommendedMaxWorkingSetSize  = 26800.60 MB
| model                          |       size |     params | backend    | threads |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| llama 7B Q4_0                  |   3.56 GiB |     6.74 B | Metal,BLAS |       8 |           pp512 |        390.39 ± 0.23 |
| llama 7B Q4_0                  |   3.56 GiB |     6.74 B | Metal,BLAS |       8 |           tg128 |         41.57 ± 0.02 |

build: db9783738 (7310)

上記に含まれるテーブルの 2 つの行が、特定の状況におけるベンチマークのスループットを示している。

まず、test カラムが pp512 となっている行は、プロンプト処理 (Prompt Processing) のスループットを示している。 プロンプト処理のスループットは、システムのピーク演算性能に影響を受けやすい。 示されている数字 (t/s) は、入力したトークン数を、プロンプトを入力してから最初のトークンが出力されるまでの時間で割ったもの。 この数字が大きいほど、TTFT (Time To First Token) が小さくなってユーザは快適に感じる。 TTFT は、モデルが長い入力を扱う場合に特に重要になる。 512 という数字は、ベンチマークで用いた入力トークン数を示している。 今回の環境では 390.39 t/s という結果が得られた。

次に test カラムが tg128 となっている行は、トークン生成 (Token Generation) のスループットを示している。 トークン生成のスループットは、システムのメモリ帯域幅に影響を受けやすい。 示されている数字 (t/s) は、出力されたトークン数を、最初のトークンが出力されてから最後のトークンが出力されるまでの時間で割ったもの。 この数字が大きいほど、TPOT (Time Per Output Token) が小さくなってユーザは快適に感じる。 TPOT は、モデルが長い出力を扱う場合に特に重要になる。 128 という数字は、ベンチマークで用いた出力トークン数を示している。 今回の環境では 41.57 t/s という結果が得られた。

ベンチマークの読み方が分かったところで、次は GPU を無効にしたバイナリでも同じことをやってみよう。

$ ./build_no_metal/bin/llama-bench \
  -m /tmp/llama-2-7b.Q4_0.gguf
| model                          |       size |     params | backend    | threads |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| llama 7B Q4_0                  |   3.56 GiB |     6.74 B | BLAS       |       8 |           pp512 |        127.29 ± 0.61 |
| llama 7B Q4_0                  |   3.56 GiB |     6.74 B | BLAS       |       8 |           tg128 |         28.72 ± 0.08 |

build: db9783738 (7310)

今度は、同じテスト項目でもスループットが大きく落ちている。 プロンプト処理では 127.29 t/s と、GPU を有効にした場合に比べて約 32.6% の結果となった。 トークン生成では 28.72 t/s と、GPU を有効にした場合と比べて約 69% の結果となった。

考察

GPU を無効にしたバイナリでは、プロンプト処理とトークン生成のいずれもスループットが落ちている。 ただし、受ける影響の度合いは両者で異なっており、プロンプト処理の方がより大きくスループットを落としている。

これは、前述した通りそれぞれの処理が何処に主なボトルネックを持つかに由来するものと考えられる。 プロンプト処理の主なボトルネックは演算である。 したがって、GPU の有無に影響を受けやすい。 一方で、トークン生成の主なボトルネックはメモリの帯域である。 したがって、特にユニファイドメモリアーキテクチャの Mac では GPU の有無に影響を受けにくいのだろう。

まとめ

今回は Mac を使って llama.cpp をビルドして GPU の有効・無効によるパフォーマンスの違いを比べてみた。 GPU を無効にした場合には、プロンプト処理とトークン生成のいずれもスループットは落ちた。 一方で、スループットの落ちる程度は両者で差が見られることが分かった。



  1. どちらかというと、比べるための方法をメモしておく意味合いが強い

Raspberry Pi のファイルシステムを RAM ディスクで運用する

一般的に Raspberry Pi は SD カードにファイルシステムを構築して動作させることが多い。 ただ、SD カードには書き込み回数に制限があるため、製品やワークロードによっては寿命に到達するリスクがある。 また、SD カードに固有の問題ではないものの、不意の電源断などでファイルシステムが破損するリスクもある。

上記のようなリスクを低減するためにファイルシステム全体を RAM ディスクで運用する方法が考えられる。 SD カードのファイルシステムを読み取り専用にした上で、そこからの差分をオンメモリのファイルシステム (tmpfs) で読み書きする。 こうしておくと、普段は SD カードへの書き込みが生じないため SD カードの書き込み回数を消費しない。 また、SD カードのファイルシステムは読み取り専用なので、電源断が生じても破損するリスクを下げられる。

デメリットとして、当然のことながらオンメモリで管理している差分は電源が切れると揮発してしまう。 つまり、RAM ディスクを有効にしている間に書き込んだログや設定は消えてしまう。

Raspberry Pi OS (Trixie) にはファイルシステム全体を RAM ディスクで運用するための機能が公式で用意されている。 今回はその使い方を見ていく。

使った環境は次のとおり。 筐体は Raspberry Pi 5 のメモリが 8GB のモデルになる。

$ cat /etc/*-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
VERSION_ID="13"
VERSION="13 (trixie)"
VERSION_CODENAME=trixie
DEBIAN_VERSION_FULL=13.2
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
$ uname -srm
Linux 6.12.47+rpt-rpi-2712 aarch64

もくじ

RAM ディスクを有効にする

RAM ディスクを有効にするには、管理者権限で raspi-config コマンドを実行する。

$ sudo raspi-config

以下のように選択肢を選んでいく。

4 Performance Options

P2 Overlay File System Enable/disable read-only file system

Would you line the overlay file system to be enabled?

<はい>

The overlay file system is enabled.

<了解>

Would you like the boot partition to be write-protected?

どちらでも良いが安全側に倒すのであれば <はい>

The boot partition is read-only.

<了解>

<Finish>

Would you like to reboot now?

<はい>

ここまででデバイスが再起動する。

動作を確認する

再起動後にファイルシステムの状態を確認すると /overlayroot のファイルシステムで 4GB の容量になっている。

$ df -h
ファイルシス   サイズ  使用  残り 使用% マウント位置
udev             3.9G     0  3.9G    0% /dev
tmpfs            1.6G   15M  1.6G    1% /run
/dev/mmcblk0p2   117G  8.4G  104G    8% /media/root-ro
tmpfs-root       4.0G  4.6M  4.0G    1% /media/root-rw
overlayroot      4.0G  4.6M  4.0G    1% /
tmpfs            4.0G  368K  4.0G    1% /dev/shm
tmpfs            5.0M   48K  5.0M    1% /run/lock
tmpfs            1.0M     0  1.0M    0% /run/credentials/systemd-journald.service
tmpfs            4.0G   16K  4.0G    1% /tmp
...

/etc/fstab を見ると、どのように実現されているかが分かりやすい。

$ grep overlay /etc/fstab 
#  This fstab is for overlayroot. The real one can be found at
#      sudo overlayroot-chroot
proc /proc proc defaults 0 0 # overlayroot:fs-virtual
PARTUUID=edd367c7-01 /boot/firmware vfat defaults,ro 0 2 # overlayroot:fs-unsupported
/media/root-ro/ / overlay lowerdir=/media/root-ro/,upperdir=/media/root-rw/overlay/,workdir=/media/root-rw/overlay-workdir/_ 0 1

OverlayFS を使って、次のようにディレクトリを重ね合わせている。

  • lowerdir
    • /media/root-ro/
  • upperdir
    • /media/root-rw/overlay/,
  • workdir
    • /media/root-rw/overlay-workdir/_

マウントされている状態を確認すると、/media/root-ro が SD カードのファイルシステムになっている。 ro フラグがついているので読み取り専用でマウントされている。

$ mount | grep root-ro
/dev/mmcblk0p2 on /media/root-ro type ext4 (ro,relatime)
overlayroot on / type overlay (rw,relatime,lowerdir=/media/root-ro,upperdir=/media/root-rw/overlay,workdir=/media/root-rw/overlay-workdir/_,uuid=on)

/media/root-rw の方は tmpfs で作成されたファイルシステムになっている。 こちらは rw フラグなので読み書きができる。

$ mount | grep root-rw
tmpfs-root on /media/root-rw type tmpfs (rw,relatime)
overlayroot on / type overlay (rw,relatime,lowerdir=/media/root-ro,upperdir=/media/root-rw/overlay,workdir=/media/root-rw/overlay-workdir/_,uuid=on)

以上のように、SD カードのファイルシステムをベースにして、その上に tmpfs のファイルシステムを重ねているようだ。

RAM ディスクを無効にする

RAM ディスクの状態を解除したいときは、有効にしたときと逆をすれば良い。

つまり、管理者権限で raspi-config を実行する。

$ sudo raspi-config

そして Would you line the overlay file system to be enabled?<いいえ> を選択すれば良い。

デバイスを再起動すれば RAM ディスクの状態が解除されている。

参考

なお、OverlayFS 自体への理解は以下が参考になるかもしれない。

blog.amedama.jp

いじょう。


Raspberry Pi OS (Trixie) で NIC に固定 IP アドレスを付与する

Raspberry Pi OS (Trixie) で NIC に固定 IP アドレスを付与する方法をメモしておく。 どうやら Bookworm 以降は Network Manager を使ってネットワークを設定するようになったらしい。

www.raspberrypi.com

公式のドキュメントには、固定 IP アドレスを付与する方法として DHCP サーバで MAC アドレスにアドレスを対応させる方法が書いてある。 それはそれとして、端末側で完結した形で固定 IP アドレスを設定したい。

使った環境は次のとおり。

$ cat /etc/*-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
VERSION_ID="13"
VERSION="13 (trixie)"
VERSION_CODENAME=trixie
DEBIAN_VERSION_FULL=13.2
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
$ uname -srm
Linux 6.12.47+rpt-rpi-2712 aarch64

もくじ

NIC の状態を確認する

ひとまず NIC の状態を確認する。 有線 LAN に eth0、無線 LAN に wlan0 が使えることが分かる。

$ ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether XX:XX:XX:XX:XX:XX brd ff:ff:ff:ff:ff:ff
3: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DORMANT group default qlen 1000
    link/ether XX:XX:XX:XX:XX:XX brd ff:ff:ff:ff:ff:ff

nmcli を使って設定する

今回は nmcli コマンドを使ってターミナルから設定していく。

まずはイーサネットに設定された connection があるか確認する。

$ nmcli connection show | grep -i ethernet

もし既存の connection が不要なときは nmcli connection delete で削除する。

$ sudo nmcli connection delete <connection-name>

新たに eth0 を使う connection を作成する。 以下では connection に wire という名前をつけている。

$ sudo nmcli connection add type ethernet con-name wire ifname eth0
$ nmcli connection show | grep ethernet
wire               80f2a4d0-e95a-4644-af7d-f4d50b437bd0  ethernet  --

connection に固定で付与したい IPv4 アドレスを ipv4.addresses に指定する。

$ sudo nmcli connection modify wire ipv4.addresses "172.16.X.X/16"

connection を down / up して設定を反映する。 もし有線 LAN 経由で操作しているときは接続が切れてしまうので注意する。

$ sudo nmcli connection down wire
$ sudo nmcli connection up wire

NIC の状態を確認すると、指定したアドレスが付与されている。 それとは別に DHCP でもアドレスが付与されているが、特に不都合もないので気にしないでおく。

$ ip address show eth0 
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether XX:XX:XX:XX:XX:XX brd ff:ff:ff:ff:ff:ff
    inet 172.16.X.X/16 brd 172.16.255.255 scope global noprefixroute eth0
       valid_lft forever preferred_lft forever
    inet 172.16.X.Y/16 brd 172.16.255.255 scope global secondary dynamic noprefixroute eth0
       valid_lft 259196sec preferred_lft 259196sec
    inet6 XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX/64 scope global dynamic noprefixroute 
       valid_lft 2591989sec preferred_lft 604789sec
    inet6 fe80::XXXX:XXXX:XXXX:XXXX/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever

いじょう。


Python: uv を使って手軽にフリースレッド版の CPython を試す

CPython 3.14 から、フリースレッドモード (Free-threaded mode) が正式にサポートされた 1。 フリースレッドモードでは、従来の CPython に存在した GIL (Global Interpreter Lock) の制約が無くなる。 GIL の制約が無くなると、これまでは基本的にマルチプロセスを使っていた並列処理がマルチスレッドでも書けるようになる。

ただし、フリースレッドモードにはいくつか注意点がある。 まず、CPython のバージョン 3.14 ではフリースレッドモードがデフォルトで無効になっている。 有効にするには、CPython をビルドするときにオプション (--disable-gil) で明示的に指定しなければいけない 2。 そして、フリースレッドモードが有効なときはプログラムのシングルスレッド性能が 5 ~ 10% 程度低下する。 つまり、並列処理は書きやすくなる一方で逐次処理は少し遅くなる。

今回は uv 3 を使うことで手軽にフリースレッドモードが有効な CPython を試してみる。 フリースレッドモードが有効な CPython が、本当にマルチスレッドで CPU コアを使い切れるのかを確認する。

使った環境は次のとおり。

$ sw_vers      
ProductName:        macOS
ProductVersion:     15.7.1
BuildVersion:       24G231
$ sysctl machdep.cpu.brand_string
machdep.cpu.brand_string: Apple M3
$ sysctl hw.logicalcpu
hw.logicalcpu: 8
$ uv -V
uv 0.9.2 (Homebrew 2025-10-10)

もくじ

下準備

まずは uv と GNU time をインストールしておく。 CPU の状況を観測する手段が何かしらあれば GNU time は無くても構わない。

$ brew install uv gnu-time

uv でインストールする Python で、フリースレッドモードが有効なビルドには名前の末尾に t がつく。 なので、まずはフリースレッドモードが有効な CPython 3.14 である 3.14t をインストールしよう。

$ uv python install 3.14t

比較対象として、フリースレッドモードが無効な通常の CPython 3.14 もインストールしておこう。

$ uv python install 3.14

サンプルコード

次に、それぞれのビルドで実行するサンプルコードを示す。 サンプルコードでは CPU の論理コア数よりも 1 少ない数のスレッドを起動してビジーループさせる。 GIL の制約が無ければ、このプログラムで CPU 1 コアよりも多いリソースを消費できるはず。

import threading
import os
import sys
import time


def _busy_loop(thread_id):
    """ビジーループする関数"""
    print(f"starting thread: {thread_id}")
    while True:
        pass


def main():
    # CPU 論理コア数より 1 少ない数のスレッドを起動する
    num_threads = os.cpu_count() - 1
    for i in range(num_threads):
        thread = threading.Thread(target=_busy_loop, args=(i,))
        # 親スレッドが止まったら子スレッドも停止する
        thread.daemon = True
        # スレッドを起動する
        thread.start()

    # メインスレッドはスリープさせる
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        sys.exit(0)


if __name__ == "__main__":
    main()

フリースレッドモードが無効なビルドで実行した場合

まずは通常の、フリースレッドモードが無効なビルドから試してみよう。

uv run コマンドに --script オプションを渡して先ほどのサンプルコードを実行する。 同時に --python オプションで実行する Python 実行環境を選ぶ。 このとき、末尾に t のつかない、フリースレッドモードが無効なビルドを指定する。 gtime コマンドを先頭につけることで、プログラムが消費する CPU のリソースに関する情報を出力できる。

$ gtime uv run --script --python 3.14 busyloop.py 
starting thread: 0
starting thread: 1
starting thread: 2
starting thread: 3
starting thread: 4
starting thread: 5
starting thread: 6

実行しているのは 8 論理 CPU コアを積んだマシンなので 7 つのスレッドが走る。 その上で、リソースモニターなどを確認すると CPU 1 コア分のリソースが消費されているはず。 これは、同時に実行されるカーネルスレッドが GIL によって 1 つに制限されているため。

$ gtime uv run --script --python 3.14 busyloop.py 
starting thread: 0
starting thread: 1
starting thread: 2
starting thread: 3
starting thread: 4
starting thread: 5
starting thread: 6
^C5.06user 0.04system 0:05.14elapsed 99%CPU (0avgtext+0avgdata 15312maxresident)k
0inputs+0outputs (929major+1467minor)pagefaults 0swaps

しばらく実行して満足したら Ctrl-C でプログラムの実行を止めよう。 上記の gtime コマンドの出力にも 99%CPU という表記が確認できる。

フリースレッドモードが有効なビルドで実行した場合

次は先ほどと同じことをフリースレッドモードが有効なビルドで試す。 違いは --python の引数の末尾に t をつけるだけ。

$ gtime uv run --script --python 3.14t busyloop.py
starting thread: 0
starting thread: 1
starting thread: 2
starting thread: 3
starting thread: 4
starting thread: 5
starting thread: 6
^C29.49user 0.04system 0:04.31elapsed 684%CPU (0avgtext+0avgdata 18992maxresident)k
0inputs+0outputs (1002major+1707minor)pagefaults 0swaps

今度はビジーループする 7 つのスレッドで CPU 7 コア分のリソースが消費されるはず。 上記の gtime コマンドの出力にも 684%CPU という表記が確認できる。

まとめ

今回は uv を使ってフリースレッドモードが有効な CPython 3.14 を動作させてみた。 uv は Python の実行環境まで管理できるので、こういった場面で便利だと感じられる。

なお、フリースレッドモードを公式にサポートしたバージョンの CPython 3.14 は 2025-10-07 にリリースされたばかり。 そのため、フリースレッドモードが有効なビルドを実用する上では、サードパーティー製のパッケージ群の対応を待つ時間が必要なはず。

また、将来的には CPython でフリースレッドモードがデフォルトで有効になる「フェーズ 3」も計画されている 4。 マルチスレッドで並列処理が書けるのは本当に嬉しくて、実用的になるはずの将来が楽しみで仕方がない。


batt で MacBook のバッテリー充電を制御する

リチウムイオンバッテリーは、充電量が 0% か 100% に近い状態で使い続けると劣化しやすいことが知られている。 また、充放電のサイクルが増えると少しずつではあるが着実に劣化していく。 リチウムイオンバッテリーが劣化すると、製品の設計上の容量よりも電気を蓄える力が落ちて駆動時間が短くなる。

したがって、MacBook のバッテリーの寿命を延ばすためには、次のような状態にしたい。

  • バッテリーが劣化しにくい充電量を維持する
    • 特定の充電量に到達したら充電を停止する
  • バッテリーの充放電サイクルを増やさない
    • 使うときは充電ケーブルから給電し続ける (バッテリーの出力を使わない)

しかし、残念ながら上記は macOS の標準的な機能では実現することが難しい。

なお、現在の macOS にも標準で「バッテリー充電の最適化」という機能はある。 この機能を有効にすると、たまに充電が 80% でしばらく止まることがある。 しかし、この機能を使ったとしてもユーザが明示的に充電を制御することはできない。 充電ケーブルをつないだままにしていると、そのうち充電量が 100% になってしまう。

そこで、今回は batt というアプリケーションを紹介する。 このアプリケーションを使うと MacBook で明示的にバッテリーの充電を制御できるようになる。

github.com

なお、類似のアプリケーションとしては battery 1 というのもある。

使った環境は次のとおり。

$ sw_vers 
ProductName:        macOS
ProductVersion:     15.7.1
BuildVersion:       24G231
$ batt version
v0.5.3 9c9e5e3ef4b1edd93d050aaddd5f9b91ddb6c1ac

もくじ

下準備

まず、Homebrew を使って batt をインストールする。

$ brew install batt

batt のサービスを起動する。 このとき管理者権限が必要なので sudo(8) をつける。

$ sudo brew services start batt

サービスが起動すると batt コマンドが使えるようになる。

$ batt status       
Charging status:
  Allow charging: ✔ (refreshes can take up to 2 minutes)
    Your Mac will charge, but you are not plugged in yet.
  Use power adapter: ✔
    Your Mac will use power from the wall (to operate or charge), if it is plugged in.

Battery status:
  Current charge: 57%
  State: discharging
  Full capacity: 4556 mAh
  Charge rate: -9.8 W
  Voltage: 11.65 V

Battery configuration:
  Upper limit: 80%
  Lower limit: 78%
  Prevent idle-sleep when charging: ✔
  Disable charging before sleep if charge limit is enabled: ✔
  Prevent system-sleep when charging: ✘
  Allow non-root users to access the daemon: ✘
  Control MagSafe LED: ✘

バッテリーの充電量に上限を設ける

まずは、最も一般的なユースケースと考えられるバッテリーの充電量に上限を設定する方法について。 これには batt limit コマンドを使う。 何処まで充電するかをパーセンテージで引数に指定する。

$ batt limit 70

実行すると、次のように充電量の上限が反映される。

$ batt status        
Charging status:
  Allow charging: ✔ (refreshes can take up to 2 minutes)
    Your Mac will charge, but you are not plugged in yet.
  Use power adapter: ✔
    Your Mac will use power from the wall (to operate or charge), if it is plugged in.

Battery status:
  Current charge: 57%
  State: discharging
  Full capacity: 4558 mAh
  Charge rate: -3.3 W
  Voltage: 11.70 V

Battery configuration:
  Upper limit: 70%
  Lower limit: 68%
  Prevent idle-sleep when charging: ✔
  Disable charging before sleep if charge limit is enabled: ✔
  Prevent system-sleep when charging: ✘
  Allow non-root users to access the daemon: ✘
  Control MagSafe LED: ✘

なお、電源が切れている状態では制限が効かない点には注意が必要になる。

バッテリーの充電を一時的に停止する

その他に、一時的にバッテリーの充電を停止することもできる。 これには batt adapter コマンドを使う。

まず、デフォルトではアダプタが有効になっている。 現在の状況は batt adapter status コマンドで確認できる。

$ batt adapter status
INFO[2025-10-13T17:24:37+09:00] power adapter is enabled

ここで、batt adapter disable を実行すると、充電が停止する。

$ batt adapter disable
INFO[2025-10-13T17:26:34+09:00] daemon responded: "ok"                       
INFO[2025-10-13T17:26:34+09:00] successfully disabled power adapter

確認すると、次のようにアダプタが無効になっている。

$ batt adapter status 
INFO[2025-10-13T17:26:56+09:00] power adapter is disabled

batt adapter enable コマンドを実行すると、充電が再開する。

$ batt adapter enable
INFO[2025-10-13T17:27:08+09:00] daemon responded: "ok"                       
INFO[2025-10-13T17:27:08+09:00] successfully enabled power adapter

batt による制御を停止する

また、batt による制御を止めたいときは次のように batt disable すれば良い。

$ batt disable

いじょう。


Android: GitHub にある Obsidian のデータを Termux + GitHub CLI で同期する

最近、情報を記録するのに Obsidian を使い始めた。 ただ、使い始める上で問題が一つあった。 それは、複数のデバイスでデータを同期する方法について。 Obsidian は、基本的にローカルのファイルシステムで Markdown を管理する。 そのため、複数のデバイスでデータを同期したい場合には、その方法を考える必要がある。 公式には Obsidian Sync という同期のためのサービスがあるものの月額で料金がかかる。 まずはもうちょっと気軽に始めたかったので、それ以外の選択肢を検討し始めた。

そして、ひとまず GitHub にプライベートリポジトリを作って管理する方法に落ち着いた。 このやり方には、次のような利点があると思う。

  • 複数の異なるプラットフォームのデバイスで同期しやすい
  • ファイルの世代管理ができる

今回は、そのやり方で Android を使ってデータ (Vault) を同期する方法について記録しておく。 Web で事例を調べると、デバイスに SSH の秘密鍵を置くやり方が多かった。 今回は、代わりに GitHub CLI を使って PAT を発行する。 ターミナルエミュレータには Termux というアプリを使ってみた。

もくじ

下準備

まずは Android に Termux のアプリをインストールする。

play.google.com

同様に Obsidian のアプリもインストールする。

play.google.com

Termux で GitHub にログインする

Termux のアプリを開いたら GitHub CLI をインストールする。

$ pkg install gh

インストールしたら GitHub CLI で GitHub にログインする。 これには gh auth login コマンドを使う。

$ gh auth login

対話的にログインとセットアップのやり方を聞かれる。

Where do you use GitHub?
> GitHub.com

What is your preferred protocol for Git operations on this host?
> HTTPS

How would you linke to authenticate GitHub CLI?
> Login with a web browser

Login with a web browser を選択したら、ブラウザで以下の URL にアクセスする。

github.com

そして、Termux の方に表示されているワンタイムコードを入力する。

First copy your one-time code: XXXX-XXXX

入力したら Termux 経由のアクセスを承認する。

ローカルのファイルシステムを操作できるようにする

デフォルトで Termux はデバイスのファイルシステムにアクセスできない。 そこで、アクセスできるようにセットアップする。

そのために termux-setup-storage コマンドを実行する。

$ termux-setup-storage

コマンドを実行するとホームディレクトリに storage というシンボリックリンクができる。

$ ls
storage

この storage からデバイスのファイルシステムにアクセスできる。

リポジトリをクローンする

すでに既存の Obsidian 用のリポジトリがあるときは、そのリポジトリをデバイスのファイルシステムにクローンする。

たとえば storage/documents の下に repos みたいなディレクトリを作る。

$ mkdir -p storage/documents/repos
$ cd storage/documents/repos

作成したディレクトリで gh repo clone コマンドを実行してリポジトリをクローンする。 <username> には自身のアカウント名、<repo-name> にはリポジトリの名前を入れる。

$ gh repo clone <username>/<repo-name>

あるいは、まだリポジトリがないときは gh repo create コマンドで対話的に作成する。

$ gh repo create

もちろん、リポジトリを作る作業はパソコンなど操作性に優れた環境を使った方が楽だろう。

クローンしたリポジトリを Obsidian から使う

リポジトリはデバイスのファイルシステムにクローンされている。 そのため、あとは Obsidian からそのディレクトリを vault として指定するだけで利用できる。

Obsidian を開いたら「Open folder as vault」を選択して、先ほどクローンしたディレクトリを指定する。

Git プラグインをセットアップする

このままでも Termux から操作すればリポジトリを管理できる。 しかし、Obsidian の方からコミットやプッシュできた方が便利なので設定していく。

まずは Obsidian のアプリの設定を開いて「Community plugins」に移動する。 デフォルトでは Community plugins は無効になっているため、まずは有効にする。

そして Community plugins の「Browse」を選択する。 一覧の中から「Git」プラグインを探してインストールする。 インストール直後にはプラグインが無効になっているため有効にする。

有効にしたらプラグインのオプションを開く。 下の方にスクロールして「Authentication/commit author」の項目を埋めていく。

まず、「Username」や「Auther name」は GitHub のアカウント名を入れれば良い。

「Author email」は、GitHub の Web サイトを確認していれる。 GitHub の Web サイトをブラウザで開いたら「Settings > Emails」に移動する。 下の方に「Keep my email addresses private」という項目があるので、無効になっているときは有効にする。 その上で、記載されている <random>+<username>@users.noreply.github.com 的なフォーマットのメールアドレスをコピーする。 コピーした内容を、Obsidian の方の「Author email」に入れる。

「Password/Parsonal acccess token」は GitHub CLI で発行する。 Termux の方に戻って以下のコマンドを実行する。

$ gh auth token

表示されたトークンをコピーして「Password/Parsonal acccess token」にペーストする。

以上で Git プラグインをセットアップできた。

Git プラグインからファイルをプッシュする

次に Git プラグインが正常に動作することを確認する。

Obsidian のアプリの右下のハンバーガーメニューから「Open Git source control」を開く。 まずはダウンロードする感じのボタンを押下して Git リポジトリを Pull できることを確認する。 うまくいかない場合にはアプリを開き直したり、パソコンから一旦リポジトリにプッシュしたりしてみよう。 Pull できれば、Commit や Push についても問題なくできるはず。

まとめ

今回は Android で GitHub にある Obsidian の Vault を同期する方法について書いた。

自分で MCP (Model Context Protocol) を喋ってみる

Model Context Protocol (MCP) は、LLM が外部のシステムから情報を得る、または操作するのに用いられるプロトコルのひとつ。 昨今の扱われ方からすると、今後のデファクトとなりつつあるように感じられる。

今回は、そんな MCP がどういったプロトコルなのか仕様を読みながら自分で喋ってみることにした。 詳しくは後述するものの、MCP はあくまで JSON-RPC 2.0 をベースにしたプロトコルに過ぎない。 そのため、LLM とは関係なく独立して扱うことができる。

使った環境は次のとおり。 MCP Server と Client の実装には MCP 公式の Python SDK を用いた。

$ sw_vers
ProductName:        macOS
ProductVersion:     15.6
BuildVersion:       24G84
$ uname -srm                      
Darwin 24.6.0 arm64
$ uv --version                    
uv 0.8.4 (Homebrew 2025-07-30)

もくじ

MCP について

本題に入る前に、まずは MCP の概要についてざっくりと説明していく。

なお、特に断りがない限りは執筆の時点で最新のバージョン (2025-06-18) のプロトコル仕様を元にしている。 プロトコルの仕様は、以下の Web サイトで確認できる。

modelcontextprotocol.io

アーキテクチャ

MCP のアーキテクチャには、次のようなコンポーネントが登場する。

  • MCP Server
  • MCP Client
  • MCP Host

まず、MCP Server は実際にツールを実行したりデータを提供したりするコンポーネントになっている。 そのため、MCP Server が MCP の主役といっていい。 そして MCP Client は MCP Server に対してツールの実行やデータの提供を依頼する。

MCP Host は LLM やそれを利用するアプリケーションとほぼイコールと考えて良い。 MCP Host には MCP Server (+Client) を登録する。 登録した MCP Server が提供する機能を、MCP Host の LLM が使えるようになる。 具体例を挙げると、Claude Desktop や LM Studio が MCP Host として動作する。 例として挙げた MCP Host には MCP Client の機能が同梱されている。

機能

MCP Server が提供する機能には、次のような種類がある。

  • Tools
  • Resources
  • Prompts

Tools は読んで字のごとくツールの呼び出しに使用する。 LLM はツールを呼び出すことで外部から情報を得たり、外部の世界に何らかの副作用を生じさせたりする。 たとえば外部の何らかの API の呼び出しや、データベースへのクエリの発行、計算の実行など。 これが MCP のユースケースとしてはイメージしやすいと思う。

参考: Tools - Model Context Protocol

Resources は URI を使って識別される何らかのリソースの読み取りに使用する。 LLM はリソースを読み取ることで後続の推論に必要な情報を得る。 分かりやすいところでいうとファイルシステムの読み取りがある。 たとえば file:///read/to/path のような URI を読み取ることで、そのファイルの内容が得られる。

参考: Resources - Model Context Protocol

Prompts は、LLM の推論に用いるプロンプトを得るのに使用する。 この機能は LLM が直接利用するのではなく、MCP Host やそれを操作するユーザが主に利用する機能らしい。 MCP Server から提供されたプロンプトを使って、ユーザが LLM に指示を出すイメージか。

参考: Prompts - Model Context Protocol

なお、 MCP Client も提供する機能がいくつかあるが、ここでは扱わない。

メッセージ

プロトコルとしての MCP は、メッセージングに JSON-RPC 2.0 を用いる。 つまり、MCP Server と Client の間では JSON-RPC 2.0 のメッセージがやり取りされる。

JSON-RPC 2.0 は、その名の通り JSON を表現に使った RPC (Remote Procedure Call) になっている。 仕様は以下の Web サイトで確認できる。

www.jsonrpc.org

メッセージとしては、次の 3 つが定義されている。

  • Requests
  • Responses
  • Notifications

上記で Requests と Responses は対になっている。 機能の要求が Requests で、それに対する応答が Responses になる。 Notifications は一方通行の通知で、Server と Client のいずれも他方に送信することがある。

参考: Overview - Model Context Protocol

トランスポート

MCP Server と Client の間で JSON-RPC 2.0 をやり取りするための通信路をトランスポートという。 仕様には次の 3 つのトランスポートが存在する。

  • stdio
  • Streamable HTTP
  • ​Custom

stdio は文字通り stdio (標準入出力) を使って MCP Server と Client が通信する。 典型的には MCP Server の機能がローカルホストで完結する場合や、開発の用途で用いられる。

Streamable HTTP も文字通り HTTP を使って MCP Server と Client が通信する。 この場合、MCP Server はイコール HTTP サーバになる。 MCP Server から Client に対して非同期にメッセージを送りたいときは、オプションで SSE (Server-Sent Events) を用いる。

Custom は独自に実装したトランスポートになる。 具体例を挙げると、WebSocket や UNIX ドメインソケットでメッセージをやり取りするような場合だろう。 その場合は MCP Server と Client の両方が、その独自のトランスポートに対応している必要がある。

参考: Transports - Model Context Protocol

なお、今回はトランスポートとして stdio だけを使う。

下準備

これで、必要な前提知識は大体説明できた。

次は実際に MCP Server と MCP Client を書いて動かしてみよう。 ただし、最初から自分で MCP を喋ることはしない。 まずは、Server と Client がそれぞれちゃんと動くことを確認する。

下準備として Homebrew で uv をインストールしておく。

$ brew install uv

Tools

まずは Tools から見ていこう。

MCP Server

公式の Python SDK を使って実装した MCP Server のサンプルコードを次に示す。 この MCP Server では add()server_time() という 2 つの Tools を実装している。 add は引数を取って足し算をする。 server_time は引数なしでサーバの時刻を返す。

# /// script
# requires-python = ">=3.9"
# dependencies = [
#     "mcp[cli]",
# ]
# ///

"""サンプルの Tools を実装した MCP サーバ"""

from datetime import datetime

from mcp.server.fastmcp import FastMCP

mcp = FastMCP()


@mcp.tool(description="足し算をするツールです")
def add(a: int, b: int) -> int:
    return a + b


@mcp.tool(description="サーバのシステム時刻を返すツールです")
def server_time() -> str:
    now = datetime.now()
    return now.isoformat(sep=" ", timespec="seconds")


if __name__ == "__main__":
    mcp.run(transport="stdio")

MCP Client

続いて、同じように公式の SDK で実装した MCP Client のサンプルコードを以下に示す。 まずは公式の SDK を使って振る舞いを確認する。 サンプルコードは次のような流れになっている。

  • セッションを初期化する
  • 使用できる Tools のリストを得る
  • Tool を呼び出す
# /// script
# requires-python = ">=3.9"
# dependencies = [
#     "mcp[cli]",
# ]
# ///

"""サンプルの Tools を実装した MCP サーバを呼び出す MCP クライアント"""

import asyncio

from mcp import ClientSession
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client

server_params = StdioServerParameters(
    command="uv",
    args=[
        # MCP サーバのスクリプトが別のディレクトリにある場合は以下で指定する
        # "--directory",
        # "/path/to/server/script/directory",
        "run",
        "toolmcpserver.py",
    ],
)


async def main():
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # セッションを初期化する
            await session.initialize()

            # 利用できるツールの一覧を得る
            items = await session.list_tools()
            print("Available tools:", [item.name for item in items.tools])

            # ツール (add) を呼び出す
            response = await session.call_tool("add", {"a": 1, "b": 2})
            result = response.structuredContent.get("result")
            print("Tool (add) result:", result)

            # ツール (server_time) を呼び出す
            response = await session.call_tool("server_time")
            result = response.structuredContent.get("result")
            print("Tool (server_time) result:", result)


if __name__ == "__main__":
    asyncio.run(main())

それでは、実装した MCP Client のスクリプトを uv で実行してみよう。

$ uv run --script toolmcpclient.py
[07/27/25 00:55:04] INFO     Processing request of type ListToolsRequest                                               server.py:625
Available tools: ['add', 'server_time']
                    INFO     Processing request of type CallToolRequest                                                server.py:625
Tool (add) result: 3
                    INFO     Processing request of type CallToolRequest                                                server.py:625
Tool (server_time) result: 2025-07-27 00:55:04

ちゃんと MCP Server から処理に対応した結果が得られている。

自分で喋ってみる

続いては今回の本題である MCP を自分で喋ってみることにしよう。 要するに、先ほどの MCP Client を自分が代わりにやる。

それには、実装した MCP Server を uv で実行する。

$ uv run --script toolmcpserver.py

これで MCP Server と stdio トランスポートで会話できる。 stdio トランスポートの場合、それぞれのメッセージは改行で区切られる。

まずはクライアント側から initialize メソッドを呼び出す。 このメッセージで、クライアント側が対応しているプロトコルのバージョンや機能などの情報をサーバへ伝える。 なお、id は単に Requests と Responses の対応を取るためのもので、別に連番になっている必要はない。

> { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "Human", "title": "Human Powered Client", "version": "0.0.1" }}}

また、実際に試す際には先頭の > は必要ない。 これは分かりやすさのためにつけている。 以降はクライアントからサーバへのメッセージには > をつける。 また、サーバからクライアントへのメッセージには < をつける。

すると、initialize メソッドに対する返答がサーバから返ってくる。 このメッセージには、サーバが対応しているプロトコルのバージョンや機能の情報が含まれている。

< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"FastMCP","version":"1.12.2"}}}

なお、サーバ側が出力するログの情報も本来は一緒に表示されるがここでは省略する。

次にクライアントからサーバに対して初期化が完了した旨の通知を送る。 これは先述した Notifications なので、一方通行であってサーバからの返答は無い。

> { "jsonrpc": "2.0", "method": "notifications/initialized" }

ちなみに、初期化しないまま後続のメッセージをやり取りしようとすると次のようなエラーメッセージになる。

< {"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Invalid request parameters","data":""}}

次に MCP Server が提供する Tools の一覧を得る。 これには tools/list メソッドを呼び出す。

> { "jsonrpc": "2.0", "id": 2, "method": "tools/list" }

すると、利用できる Tools の一覧が返答として得られる。 ここには説明や、呼び出す際の引数の情報などが含まれている。

< {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"add","description":"足し算をするツールです","inputSchema":{"properties":{"a":{"title":"A","type":"integer"},"b":{"title":"B","type":"integer"}},"required":["a","b"],"title":"addArguments","type":"object"},"outputSchema":{"properties":{"result":{"title":"Result","type":"integer"}},"required":["result"],"title":"addOutput","type":"object"}},{"name":"server_time","description":"サーバのシステム時刻を返すツールです","inputSchema":{"properties":{},"title":"server_timeArguments","type":"object"},"outputSchema":{"properties":{"result":{"title":"Result","type":"string"}},"required":["result"],"title":"server_timeOutput","type":"object"}}]}}

実際に Tools を呼び出すには tools/call メソッドを使う。 ここでは引数のない server_time を呼んでいる。

> { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "server_time" }}

呼び出すと、次のようにサーバ側で処理された結果が返ってくる。

< {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"2025-07-27 01:23:07"}],"structuredContent":{"result":"2025-07-27 01:23:07"},"isError":false}}

引数があるものについては次のように arguments をつけて呼び出す。

> { "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "add", "arguments": { "a": 1, "b": 2 }}}

結果については先ほどと変わらない。

< {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"3"}],"structuredContent":{"result":3},"isError":false}}

これで先ほどの MCP Client と同じ流れを自分で体験できた。

Resources

次は Resources についても同じ流れを試す。

MCP Server

次の MCP Server のコードでは greeting()echo() という 2 つの Resources を実装している。 greeting は定数の文字列を返す。 echo は URI に埋め込まれた内容をそのまま返す。

# /// script
# requires-python = ">=3.9"
# dependencies = [
#     "mcp[cli]",
# ]
# ///

"""サンプルの Resources を実装した MCP サーバ"""

from mcp.server.fastmcp import FastMCP

mcp = FastMCP()


@mcp.resource("greeting://", description="挨拶をするリソースです")
def greeting():
    return "Hello, World!"


@mcp.resource("echo://{message}", description="エコーを返すリソースです")
def echo(message: str) -> str:
    return message


if __name__ == "__main__":
    mcp.run(transport="stdio")

MCP Client

対応する MCP Client のサンプルコードを以下に示す。 Tools と同様に以下の流れになっている。

  • 初期化する
  • 利用できる Resources のリストを得る
  • Resources を読み取る

なお、URI に何らかの情報が含まれる Resources は Resource Templates として別物になっている。

# /// script
# requires-python = ">=3.9"
# dependencies = [
#     "mcp[cli]",
# ]
# ///

"""サンプルの Resources を実装した MCP サーバを呼び出す MCP クライアント"""

import asyncio

from mcp import ClientSession
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client

server_params = StdioServerParameters(
    command="uv",
    args=[
        # "--directory",
        # "/path/to/server/script/directory",
        "run",
        "resourcemcpserver.py",
    ],
)


async def main():
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # セッションを初期化する
            await session.initialize()

            # 利用できるリソースの一覧を得る
            items = await session.list_resources()
            print("Available resources:", [item.name for item in items.resources])

            # 利用できるリソーステンプレートの一覧を得る
            items = await session.list_resource_templates()
            print("Available resource templates:", [item.name for item in items.resourceTemplates])

            # リソース (greeting) を呼び出す
            response = await session.read_resource("greeting://")
            print("Resource (greeting) response:", response)

            # リソーステンプレート (echo) を呼び出す
            response = await session.read_resource("echo://hello")
            print("Resource (echo) response:", response)


if __name__ == "__main__":
    asyncio.run(main())

実装した MCP Client を実行してみよう。

$ uv run --script resourcemcpclient.py
[08/04/25 03:43:45] INFO     Processing request of type ListResourcesRequest                                           server.py:625
Available resources: ['greeting']
                    INFO     Processing request of type ListResourceTemplatesRequest                                   server.py:625
Available resource templates: ['echo']
                    INFO     Processing request of type ReadResourceRequest                                            server.py:625
Resource (greeting) response: meta=None contents=[TextResourceContents(uri=AnyUrl('greeting://'), mimeType='text/plain', meta=None, text='Hello, World!')]
                    INFO     Processing request of type ReadResourceRequest                                            server.py:625
Resource (echo) response: meta=None contents=[TextResourceContents(uri=AnyUrl('echo://hello'), mimeType='text/plain', meta=None, text='hello')]

ちゃんと Resources のリストの取得や読み取りができている。

自分で喋ってみる

次は、先ほどと同様に自分が MCP Client となって Server とやり取りしてみよう。

uv を使って MCP Server のスクリプトを起動する。

$ uv run --script resourcemcpserver.py

まずは Tools の場合と同様に初期化する。

> { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "Human", "title": "Human Powered Client", "version": "0.0.1" }}}
< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"FastMCP","version":"1.12.2"}}}
> { "jsonrpc": "2.0", "method": "notifications/initialized" }

次に利用できる Resources の一覧を resources/list メソッドで得る。

> { "jsonrpc": "2.0", "id": 2, "method": "resources/list" }
< {"jsonrpc":"2.0","id":2,"result":{"resources":[{"name":"greeting","uri":"greeting://","description":"挨拶をするリソースです","mimeType":"text/plain"}]}}

同様に、利用できる Resource Templates の一覧を resources/templates/list で得る。

> { "jsonrpc": "2.0", "id": 3, "method": "resources/templates/list" }
< {"jsonrpc":"2.0","id":3,"result":{"resourceTemplates":[{"name":"echo","uriTemplate":"echo://{message}","description":"エコーを返すリソースです"}]}}

greeting:// の Resources を resources/read メソッドで読み取る。

> { "jsonrpc": "2.0", "id": 4, "method": "resources/read", "params": { "uri": "greeting://" }}
< {"jsonrpc":"2.0","id":4,"result":{"contents":[{"uri":"greeting://","mimeType":"text/plain","text":"Hello, World!"}]}}

結果として定数の文字列が返ってきた。

同様に echo:// の Resource Templates を読み取る。 読み取る場合のメソッドは Resources と Resource Templates で違いはないようだ。

> { "jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": { "uri": "echo://hello" }}
< {"jsonrpc":"2.0","id":5,"result":{"contents":[{"uri":"echo://hello","mimeType":"text/plain","text":"hello"}]}}

Prompts

次は Prompts でも同じ流れを確認する。

MCP Server

以下に Prompts を実装した MCP Server のサンプルコードを示す。 サンプルコードでは summarize というプロンプトを実装している。

# /// script
# requires-python = ">=3.9"
# dependencies = [
#     "mcp[cli]",
# ]
# ///

"""サンプルの Prompts を実装した MCP サーバ"""

from mcp.server.fastmcp import FastMCP

mcp = FastMCP()


@mcp.prompt(description="要約に使うプロンプトです")
def summarize(topic: str) -> str:
    return f"{topic}について分かりやすく要約してください"


if __name__ == "__main__":
    mcp.run(transport="stdio")

MCP Client

対応する MCP Client のサンプルコードを以下に示す。 Tools や Resources と同様に以下の流れになっている。

  • 初期化する
  • 利用できる Prompts のリストを得る
  • Promts を得る
# /// script
# requires-python = ">=3.9"
# dependencies = [
#     "mcp[cli]",
# ]
# ///

"""サンプルの Prompts を実装した MCP サーバを呼び出す MCP クライアント"""

import asyncio

from mcp import ClientSession
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client

server_params = StdioServerParameters(
    command="uv",
    args=[
        # "--directory",
        # "/path/to/server/script/directory",
        "run",
        "promptmcpserver.py",
    ],
)


async def main():
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # セッションを初期化する
            await session.initialize()

            # 利用できるプロンプトの一覧を得る
            items = await session.list_prompts()
            print("Available prompts:", [item.name for item in items.prompts])

            # プロンプト (summarize) を呼び出す
            response = await session.get_prompt("summarize", {"topic": "生命、宇宙、そして万物についての究極の疑問の答え"})
            print("Prompts (summarize) response:", response)


if __name__ == "__main__":
    asyncio.run(main())

上記を uv で実行してみよう。

$ uv run --script promptmcpclient.py
[08/04/25 04:12:23] INFO     Processing request of type ListPromptsRequest                                                                               server.py:625
Available prompts: ['summarize']
                    INFO     Processing request of type GetPromptRequest                                                                                 server.py:625
Prompts (summarize) response: meta=None description='要約に使うプロンプトです' messages=[PromptMessage(role='user', content=TextContent(type='text', text='生命、宇宙、そして万物についての究極の疑問の答えについて分かりやすく要約してください', annotations=None, meta=None))]

クライアントからの入力に基づいてプロンプトが得られている。

自分で喋ってみる

次は先ほどと同様に自分が MCP Client となって Server とやり取りしてみよう。

uv を使って MCP Server のスクリプトを起動する。

$ uv run --script promptmcpserver.py

まずは初期化する。

> { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "Human", "title": "Human Powered Client", "version": "0.0.1" }}}
< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"FastMCP","version":"1.12.2"}}}
> { "jsonrpc": "2.0", "method": "notifications/initialized" }

利用できるプロンプトの一覧を得る。

> { "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }
< {"jsonrpc":"2.0","id":2,"result":{"prompts":[{"name":"summarize","description":"要約に使うプロンプトです","arguments":[{"name":"topic","required":true}]}]}}

プロンプトの summarize を得る。

> { "jsonrpc": "2.0", "id": 3, "method": "prompts/get", "params": { "name": "summarize", "arguments": { "topic": "生命、宇宙、そして万物についての究極の疑問の答え" }}}
< {"jsonrpc":"2.0","id":3,"result":{"description":"要約に使うプロンプトです","messages":[{"role":"user","content":{"type":"text","text":"生命、宇宙、そして万物についての究極の疑問の答えについて分かりやすく要約してください"}}]}}

入力した内容が埋め込まれたプロンプトが得られた。

まとめ

今回は Model Context Protocol (MCP) をプロトコルという側面から扱った。 MCP は JSON-RPC 2.0 をベースにしたプロトコルで、Server / Client / Host という 3 つのコンポーネントが登場する。 今回はその中で Server と Client を実装した上で、Client になりきって自分で MCP を喋ってみた。 実際に自分でサーバとメッセージをやり取りすることで、どのようなプロトコルなのか理解を深めることができた。