PyQGISは書けない、でもQGISを自動操作したい。Claude CodeとQGIS MCPに頼んでみた

PyQGISは書けない、でもQGISを自動操作したい。Claude CodeとQGIS MCPに頼んでみた

Claude CodeとQGIS MCPを使いPyQGISを自分で書かずにジオコーディング・ルート描画・バッファ解析・ボロノイ図を自然言語だけで完結させる方法と、試してわかった課題を紹介します。
2026.04.30

QGISで地図を開いてデータを眺めるのはできる。でも「半径500mバッファを作って重なる範囲を抽出する」となった途端、GUI操作が面倒くさい。自動化するにもPyQGISはわからない。そんな経験はないでしょうか?

PyQGISのドキュメントを手探りで読み、使うべきAPIや必要な引数を調べて、ようやくコードが動く……というサイクルを、Claude CodeとQGIS MCPに丸投げできないか試してみました。

以前、QGISでAmazon Location Serviceプラグインを使ったブログを書きました。

https://dev.classmethod.jp/articles/qgis-amazon-location-service-plugin/

今回はその環境をベースに、QGIS MCP を追加して、Claude Codeから直接QGISを操作してみます。

PyQGISとは?

PyQGIS とは、QGISをPythonで扱うためのAPIです。普段QGISでマウス操作している処理を、Pythonコードとして書けるようにしたもの、と考えるとわかりやすいです。たとえば、データの読み込み、条件による抽出、座標変換、空間演算、スタイル設定、地図の出力などを自動化できます。

https://docs.qgis.org/3.44/en/docs/pyqgis_developer_cookbook/index.html

QGIS MCPとは?

QGIS MCP は、Claude CodeなどのAIエージェントからQGIS Desktopを直接操作できるようにするModel Context Protocol(MCP)の実装です。

https://github.com/jjsantos01/qgis_mcp

仕組みはシンプルです。

Claude Codeがツールを呼び出すと、MCP経由でQGIS内のPyQGIS APIが実行されます。

前提条件

この記事では以下の環境を前提にしています。

  • macOS
  • QGIS 3.X がインストール済みであること
  • Python 3.10 以上が使えること
  • uv がインストール済みであること
  • Claude Codeがインストール済みであること
  • Amazon Location ServiceのAPIキーが取得済みであること(背景地図の表示に使用)

Amazon Location ServiceのAPIキー取得と、QGIS向けプラグインのセットアップは、以前のブログを参考にしてください。

https://dev.classmethod.jp/articles/qgis-amazon-location-service-plugin/

セットアップ

1. リポジトリをcloneする

Claude Codeを実行するターミナルで、任意のディレクトリにリポジトリをcloneします。

$ git clone https://github.com/jjsantos01/qgis_mcp.git

2. QGISプラグインのインストール

cloneしたディレクトリの qgis_mcp_plugin フォルダをQGIS Pluginフォルダにコピーします。
Macなら次のようなコマンドです。

$ cp -r qgis_mcp/qgis_mcp_plugin ~/Library/Application\ Support/QGIS/QGIS3/profiles/default/python/plugins

QGISを起動し、メニューから 「プラグイン」→「プラグインの管理とインストール」 を開きます。

「インストール済」タブで「QGIS MCP」を探し、チェックを入れて有効化します。

QGISプラグインマネージャでQGIS MCPを有効化する画面

インストール後、メニューバーに 「プラグイン」→「QGIS MCP」 が追加されます。

3. QGISでサーバーを起動する

QGISのメニューの 「プラグイン」→「QGIS MCP」→「QGIS MCP」 をクリックしてパネルを開き、 「Start Server」 ボタンをクリックします。

QGIS MCPプラグインのStart Serverを押す

4. Claude CodeにMCPサーバーを登録する

claude mcp add コマンドでMCPサーバーを登録します。/path/to/qgis_mcp は実際のclone先のパスに置き換えてください。

$ claude mcp add \
  -s project qgis \
  -- uv --directory /path/to/qgis_mcp/src/qgis_mcp run qgis_mcp_server.py

登録できたか確認します。「✓ Connected」になっていればOKです。

$ claude mcp list
qgis: uv --directory ./qgis_mcp/src/qgis_mcp run qgis_mcp_server.py - Connected

5. 疎通確認

Claude Codeを起動し、QGIS MCPとの接続を確認します。 pong: true が返ってくればOKです。

> QGISにpingを送って接続確認して

Claude CodeでQGIS MCPのpingツールを呼び出して接続確認している画面

Amazon Location Serviceの背景地図を用意する

ユースケースの前に、背景地図をQGISに追加しておきます。QGISを起動し、Amazon Location Serviceプラグインから 「Map」→「Standard」 を選択して背景レイヤーを追加してください。

Amazon Location Service Standardスタイルを追加した状態のQGIS

以降のユースケースは、この背景地図が表示された状態から始めます。

Amazon Location Service用のSkillを用意する

Claude CodeがQGIS上でAmazon Location Serviceを使う方法がわからず安定しなかったのでClaude Code用のSkillを作成して利用しています。
Skillの全容は、参考資料としてブログの最後に載せます。

やってみる:日比谷オフィス周辺マップをさわってみる

ここからは、弊社クラスメソッドの本社がある日比谷を題材に、Claude Codeへの指示だけでオフィス周辺マップをさわってみます。最寄り駅は霞ケ関駅・虎ノ門駅・内幸町駅の3駅です。

ポイント → ライン → ポリゴンと、GISの基本的なデータ型を一通り触りながら、QGIS MCPの各ツールがどう動くかを見ていきます。

ステップ1:本社と最寄り駅をポイントとして打つ

まずは地図上にポイントを落とすところから始めます。Amazon Location ServiceのPlaces APIを使って、4地点(本社+3駅)をジオコーディングし、ポイントレイヤーとしてClaude Codeに追加してもらいます。

> Amazon Location Serviceプラグインを使って、以下の4地点の座標を取得し、QGISにポイントレイヤーとして追加してください。
  - クラスメソッド本社(東京都港区西新橋1-1-1)
  - 霞ケ関駅
  - 虎ノ門駅
  - 内幸町駅
各ポイントに名前のラベルを表示してください。

Claude Codeは execute_code ツールでPyQGISコードを生成・実行します。

日比谷周辺に本社と3駅のポイント・ラベルが表示された地図

4地点が一発で地図に載りました。手動でやると「1地点ずつ検索 → ラベル設定」という手順が必要なところを、自然言語1リクエストで完了できました。

Claudeが生成したPyQGISコード
import sys
from qgis.core import (
    QgsApplication, QgsFeature, QgsField, QgsFields, QgsGeometry,
    QgsMarkerSymbol, QgsPalLayerSettings, QgsPointXY, QgsProject,
    QgsSimpleMarkerSymbolLayer, QgsSingleSymbolRenderer,
    QgsTextBufferSettings, QgsTextFormat, QgsVectorLayer,
    QgsVectorLayerSimpleLabeling,
)
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtGui import QColor

# --- セットアップ ---
PLUGIN_DIR = QgsApplication.qgisSettingsDirPath().rstrip("/") + "/python/plugins"
if PLUGIN_DIR not in sys.path:
    sys.path.insert(0, PLUGIN_DIR)

from location_service.utils.configuration_handler import ConfigurationHandler
from location_service.utils.external_api_handler import ExternalApiHandler

config = ConfigurationHandler()
region = config.get_setting("region_value")
apikey = config.get_setting("apikey_value")

if not region or not apikey:
    raise RuntimeError(f"API設定未完了: region={region!r}, apikey={'***' if apikey else '(empty)'}")

api = ExternalApiHandler()

# --- ジオコーディング ---
URL = f"https://places.geo.{region}.amazonaws.com/v2/search-text?key={apikey}"
BIAS = [139.7528, 35.6680]  # 虎ノ門駅付近(港区西新橋エリアのバイアス)

targets = [
    ("クラスメソッド本社", "東京都港区西新橋1-1-1 クラスメソッド本社"),
    ("霞ケ関駅",          "霞ケ関駅 東京"),
    ("虎ノ門駅",          "虎ノ門駅 東京"),
    ("内幸町駅",          "内幸町駅 東京"),
]

points = []
for label, query in targets:
    payload = {
        "Language": "ja",
        "MaxResults": 1,
        "QueryText": query,
        "BiasPosition": BIAS,
    }
    result = api.send_json_post_request(URL, payload)
    items = result.get("ResultItems", []) if result else []
    if items:
        pos = items[0]["Position"]  # [lon, lat]
        points.append((label, pos[0], pos[1]))
        print(f"Found: {label} -> lon={pos[0]:.6f}, lat={pos[1]:.6f}")
    else:
        raise RuntimeError(f"ジオコーディング失敗: {label}")

# --- メモリポイントレイヤー作成 ---
layer = QgsVectorLayer("Point?crs=EPSG:4326", "クラスメソッド本社・最寄駅", "memory")
provider = layer.dataProvider()
fields = QgsFields()
fields.append(QgsField("name", QVariant.String))
provider.addAttributes(fields)
layer.updateFields()

features = []
for name, lon, lat in points:
    f = QgsFeature(layer.fields())
    f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(lon, lat)))
    f.setAttributes([name])
    features.append(f)
provider.addFeatures(features)

# --- マーカースタイル(赤丸・白縁) ---
sym_layer = QgsSimpleMarkerSymbolLayer()
sym_layer.setShape(QgsSimpleMarkerSymbolLayer.Circle)
sym_layer.setColor(QColor(220, 50, 50))
sym_layer.setStrokeColor(QColor(255, 255, 255))
sym_layer.setStrokeWidth(0.5)
sym_layer.setSize(6.0)
symbol = QgsMarkerSymbol.createSimple({})
symbol.changeSymbolLayer(0, sym_layer)
layer.setRenderer(QgsSingleSymbolRenderer(symbol))

# --- ラベル設定(白バッファ付き) ---
label_settings = QgsPalLayerSettings()
label_settings.fieldName = "name"
label_settings.enabled = True
tf = QgsTextFormat()
tf.setSize(11)
tf.setColor(QColor("#1a1a1a"))
buf = QgsTextBufferSettings()
buf.setEnabled(True)
buf.setSize(1.0)
buf.setColor(QColor(255, 255, 255))
tf.setBuffer(buf)
label_settings.setFormat(tf)
layer.setLabelsEnabled(True)
layer.setLabeling(QgsVectorLayerSimpleLabeling(label_settings))

layer.triggerRepaint()
QgsProject.instance().addMapLayer(layer)
iface.setActiveLayer(layer)
iface.zoomToActiveLayer()

# --- 検証用出力 ---
print("--- VERIFY ---")
print(f"layer={layer.name()} feature_count={layer.featureCount()} crs={layer.crs().authid()}")
for f in layer.getFeatures():
    g = f.geometry().asPoint()
    print(f"  {f['name']}: lon={g.x():.6f}, lat={g.y():.6f}")

ステップ2:各駅から本社への徒歩ルートを引く

次に、3駅それぞれから本社までの徒歩ルートを引いて、駅ごとに色分けします。Amazon Location ServiceのRoutes APIを使ってルートラインを生成し、距離ラベルも付けます。

> 先ほど追加したレイヤーに霞ケ関駅・虎ノ門駅・内幸町駅のそれぞれから本社への徒歩ルートをAmazon Location Service プラグインで取得してください。
各ルートを駅ごとに異なる色(青・緑・オレンジ)で表示し、ルートの距離(km、小数点1桁)をラベルとして表示してください。

Claude CodeはRoutes APIを呼び出して各駅の座標からのルートラインを生成します。

3駅から本社への徒歩ルートが色分けして表示された地図

3本のルートが色分けされ、それぞれの距離がラベルで表示されました。「プロパティ → シンボロジ → 色・幅の設定」や「プロパティ → ラベル → 値」というGUI操作を省略できています。

Claudeが生成したPyQGISコード
import sys
from qgis.core import (
    QgsApplication, QgsFeature, QgsField, QgsFields, QgsGeometry,
    QgsPalLayerSettings, QgsPointXY, QgsProject,
    QgsSimpleLineSymbolLayer, QgsSingleSymbolRenderer,
    QgsSymbol, QgsTextBufferSettings, QgsTextFormat, QgsVectorLayer,
    QgsVectorLayerSimpleLabeling,
)
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtGui import QColor

# --- セットアップ ---
PLUGIN_DIR = QgsApplication.qgisSettingsDirPath().rstrip("/") + "/python/plugins"
if PLUGIN_DIR not in sys.path:
    sys.path.insert(0, PLUGIN_DIR)

from location_service.utils.configuration_handler import ConfigurationHandler
from location_service.utils.external_api_handler import ExternalApiHandler

config = ConfigurationHandler()
region = config.get_setting("region_value")
apikey = config.get_setting("apikey_value")

if not region or not apikey:
    raise RuntimeError(f"API設定未完了: region={region!r}, apikey={'***' if apikey else '(empty)'}")

api = ExternalApiHandler()
ROUTES_URL = f"https://routes.geo.{region}.amazonaws.com/v2/routes?key={apikey}"

# 前スクリプトで検証済みの座標
HQ_LON, HQ_LAT = 139.752760, 35.670160

# (駅名, 経度, 緯度, 色)
stations = [
    ("霞ケ関駅", 139.751930, 35.672420, QColor(0, 100, 220)),   # 青
    ("虎ノ門駅", 139.750050, 35.670170, QColor(34, 139, 34)),   # 緑
    ("内幸町駅", 139.755770, 35.670100, QColor(230, 120, 0)),   # オレンジ
]

for station_name, st_lon, st_lat, color in stations:
    payload = {
        "Origin": [st_lon, st_lat],
        "Destination": [HQ_LON, HQ_LAT],
        "TravelMode": "Pedestrian",   # Routes v2: Pedestrian(徒歩)
        "LegGeometryFormat": "Simple",
    }
    data = api.send_json_post_request(ROUTES_URL, payload)
    if not data:
        raise RuntimeError(f"ルート取得失敗: {station_name}")

    route0 = data.get("Routes", [{}])[0]

    # 距離(メートル)取得(Summary が 0 の場合は TravelSteps から合算)
    dist_m = route0.get("Summary", {}).get("Distance", 0)
    if not dist_m:
        dist_m = sum(
            step.get("Distance", 0)
            for leg in route0.get("Legs", [])
            for step in leg.get("TravelSteps", [])
        )
    dist_km_label = f"{round(dist_m / 1000, 1)} km"

    # ジオメトリ(LineString 配列)
    line_points = []
    for leg in route0.get("Legs", []):
        coords = leg.get("Geometry", {}).get("LineString", [])
        line_points.extend([QgsPointXY(c[0], c[1]) for c in coords])

    if not line_points:
        raise RuntimeError(f"ジオメトリなし: {station_name} - keys={list(route0.keys())}")

    print(f"{station_name}: {dist_km_label}, {len(line_points)} points")

    # ラインレイヤー作成
    layer = QgsVectorLayer("LineString?crs=EPSG:4326", f"ルート({station_name})", "memory")
    provider = layer.dataProvider()
    fields = QgsFields()
    fields.append(QgsField("station",    QVariant.String))
    fields.append(QgsField("dist_label", QVariant.String))
    provider.addAttributes(fields)
    layer.updateFields()

    feat = QgsFeature(layer.fields())
    feat.setGeometry(QgsGeometry.fromPolylineXY(line_points))
    feat.setAttributes([station_name, dist_km_label])
    provider.addFeatures([feat])

    # ラインスタイル
    sym_line = QgsSimpleLineSymbolLayer()
    sym_line.setColor(color)
    sym_line.setWidth(1.5)
    symbol = QgsSymbol.defaultSymbol(layer.geometryType())
    symbol.changeSymbolLayer(0, sym_line)
    layer.setRenderer(QgsSingleSymbolRenderer(symbol))

    # ラベル設定(ルートの色に合わせた文字色 + 白バッファ)
    ls = QgsPalLayerSettings()
    ls.fieldName = "dist_label"
    ls.enabled = True
    tf = QgsTextFormat()
    tf.setSize(10)
    tf.setColor(color)
    buf = QgsTextBufferSettings()
    buf.setEnabled(True)
    buf.setSize(1.0)
    buf.setColor(QColor(255, 255, 255))
    tf.setBuffer(buf)
    ls.setFormat(tf)
    layer.setLabelsEnabled(True)
    layer.setLabeling(QgsVectorLayerSimpleLabeling(ls))

    layer.triggerRepaint()
    QgsProject.instance().addMapLayer(layer)

print("--- DONE ---")

ステップ3:徒歩圏バッファの重なりを可視化する

各駅から徒歩10分(約800m)のバッファを作り、2駅以上の圏内が重なるエリアを赤くハイライトします。「どのあたりが複数駅からアクセスしやすいか」を空間的に可視化する、GISらしい操作です。

> 先ほどのレイヤーから駅のポイント(本社を除く3駅)だけを抽出して、各駅に半径800mのバッファを作成してください。
バッファは半透明で表示し、2つ以上のバッファが重なっているエリアだけを別レイヤーとして赤色でハイライトしてください。

3駅の800mバッファ(半透明円)と、重なりエリアが赤くハイライトされた地図

Claude Codeが自動でバッファを作ってくれて、重なりエリアが赤く表示され、本社がそのエリアに含まれていることが一目でわかります。

Claudeが生成したPyQGISコード
import sys
from qgis.core import (
    QgsApplication, QgsCoordinateReferenceSystem, QgsCoordinateTransform,
    QgsFeature, QgsField, QgsFields, QgsGeometry, QgsPointXY, QgsProject,
    QgsSimpleFillSymbolLayer, QgsSingleSymbolRenderer,
    QgsSymbol, QgsVectorLayer,
)
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtGui import QColor

# 前スクリプトで検証済みの3駅座標
stations = [
    ("霞ケ関駅", 139.751930, 35.672420),
    ("虎ノ門駅", 139.750050, 35.670170),
    ("内幸町駅", 139.755770, 35.670100),
]

BUFFER_M = 800
SEGMENTS = 64

# CRS変換(正確なメートル計算のため EPSG:6677 を使用)
crs_4326 = QgsCoordinateReferenceSystem("EPSG:4326")
crs_6677 = QgsCoordinateReferenceSystem("EPSG:6677")
to_proj = QgsCoordinateTransform(crs_4326, crs_6677, QgsProject.instance())
to_geo  = QgsCoordinateTransform(crs_6677, crs_4326, QgsProject.instance())

# バッファジオメトリ計算(6677 でバッファ → 4326 に変換)
buffers = []
for name, lon, lat in stations:
    pt_proj = to_proj.transform(QgsPointXY(lon, lat))
    buf_proj = QgsGeometry.fromPointXY(pt_proj).buffer(BUFFER_M, SEGMENTS)
    buf_proj.transform(to_geo)   # 4326 に変換(in-place)
    buffers.append((name, buf_proj))
    print(f"{name}: buffer OK")

# --- 駅バッファレイヤー(水色・半透明) ---
buf_layer = QgsVectorLayer("Polygon?crs=EPSG:4326", "駅バッファ(800m)", "memory")
provider = buf_layer.dataProvider()
fields = QgsFields()
fields.append(QgsField("station", QVariant.String))
provider.addAttributes(fields)
buf_layer.updateFields()

features = []
for name, geom in buffers:
    f = QgsFeature(buf_layer.fields())
    f.setGeometry(QgsGeometry.fromWkt(geom.asWkt()))  # deep copy
    f.setAttributes([name])
    features.append(f)
provider.addFeatures(features)

fill = QgsSimpleFillSymbolLayer()
fill.setColor(QColor(0, 150, 200, 80))        # 水色・半透明(alpha=80/255)
fill.setStrokeColor(QColor(0, 100, 200))
fill.setStrokeWidth(0.5)
symbol = QgsSymbol.defaultSymbol(buf_layer.geometryType())
symbol.changeSymbolLayer(0, fill)
buf_layer.setRenderer(QgsSingleSymbolRenderer(symbol))
buf_layer.triggerRepaint()
QgsProject.instance().addMapLayer(buf_layer)
print(f"バッファレイヤー: feature_count={buf_layer.featureCount()}")

# --- 2駅以上のバッファが重なるエリア ---
# ペアワイズ交差のユニオン → 2+ 重複エリア
geoms = [g for _, g in buffers]
overlap_geom = None
for i in range(len(geoms)):
    for j in range(i + 1, len(geoms)):
        inter = geoms[i].intersection(geoms[j])
        if not inter.isEmpty():
            overlap_geom = inter if overlap_geom is None else overlap_geom.combine(inter)

if overlap_geom and not overlap_geom.isEmpty():
    # 面積計算(EPSG:6677 でメートル換算)
    ov_for_area = QgsGeometry.fromWkt(overlap_geom.asWkt())
    ov_for_area.transform(to_proj)
    area_km2 = ov_for_area.area() / 1e6
    print(f"重複エリア面積: {area_km2:.4f} km²")

    # 重複レイヤー
    ov_layer = QgsVectorLayer("Polygon?crs=EPSG:4326", "バッファ重複(2駅以上)", "memory")
    ov_layer.dataProvider().addAttributes([])
    ov_layer.updateFields()
    f = QgsFeature(ov_layer.fields())
    f.setGeometry(overlap_geom)
    ov_layer.dataProvider().addFeatures([f])

    ov_fill = QgsSimpleFillSymbolLayer()
    ov_fill.setColor(QColor(220, 50, 50, 150))    # 赤・半透明(alpha=150/255)
    ov_fill.setStrokeColor(QColor(180, 0, 0))
    ov_fill.setStrokeWidth(0.8)
    ov_symbol = QgsSymbol.defaultSymbol(ov_layer.geometryType())
    ov_symbol.changeSymbolLayer(0, ov_fill)
    ov_layer.setRenderer(QgsSingleSymbolRenderer(ov_symbol))
    ov_layer.triggerRepaint()
    QgsProject.instance().addMapLayer(ov_layer)
    print(f"重複レイヤー: feature_count={ov_layer.featureCount()}")
else:
    print("重複エリアなし(バッファが交差しない)")

print("--- DONE ---")

ステップ4:ボロノイ図で最寄り駅エリアを分割する

最後に、3駅のポイントからボロノイ図を生成します。ボロノイ図は「各地点から一番近い母点はどれか」で平面を分割するもので、商圏分析や施設の勢力圏の可視化によく使われます。

> 先ほどのレイヤーから駅のポイント(本社を除く3駅)を使って
ボロノイ図を作成してください。各ポリゴンを駅ごとに色分けして、駅名のラベルも表示してください。

3駅のボロノイ図。各駅の最寄りエリアが色分けして表示されている

ボロノイ図の境界線が「各駅から等距離のライン」になっており、どのエリアがどの駅に最も近いかが一目でわかります。本社がどの駅の最寄り圏に入っているかも確認できます。

Claudeが生成したPyQGISコード
import processing
from qgis.core import (
    QgsCategorizedSymbolRenderer, QgsFeature, QgsField, QgsFields,
    QgsGeometry, QgsPalLayerSettings, QgsPointXY, QgsProject,
    QgsRendererCategory, QgsSimpleFillSymbolLayer, QgsSymbol,
    QgsTextBufferSettings, QgsTextFormat, QgsVectorLayer,
    QgsVectorLayerSimpleLabeling,
)
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtGui import QColor

# 前スクリプトで検証済みの3駅座標
stations = [
    ("霞ケ関駅", 139.751930, 35.672420),
    ("虎ノ門駅", 139.750050, 35.670170),
    ("内幸町駅", 139.755770, 35.670100),
]

# 駅ごとの色(半透明)
colors = {
    "霞ケ関駅": QColor(0, 100, 220, 180),
    "虎ノ門駅": QColor(34, 139, 34, 180),
    "内幸町駅": QColor(230, 120, 0, 180),
}

# --- 入力ポイントレイヤー作成 ---
pt_layer = QgsVectorLayer("Point?crs=EPSG:4326", "_voronoi_input", "memory")
provider = pt_layer.dataProvider()
fields = QgsFields()
fields.append(QgsField("name", QVariant.String))
provider.addAttributes(fields)
pt_layer.updateFields()

features = []
for name, lon, lat in stations:
    f = QgsFeature(pt_layer.fields())
    f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(lon, lat)))
    f.setAttributes([name])
    features.append(f)
provider.addFeatures(features)
print(f"入力ポイント: feature_count={pt_layer.featureCount()}")

# --- ボロノイ生成(BUFFER=50%でバウンディングボックスを拡張) ---
result = processing.run("qgis:voronoipolygons", {
    "INPUT": pt_layer,
    "BUFFER": 50,
    "OUTPUT": "memory:",
})
voronoi_layer = result["OUTPUT"]
voronoi_layer.setName("ボロノイ図(3駅)")
print(f"ボロノイ: feature_count={voronoi_layer.featureCount()}, fields={[f.name() for f in voronoi_layer.fields()]}")

# --- 分類シンボルレンダラー(駅ごとに色分け) ---
categories = []
for name, color in colors.items():
    fill = QgsSimpleFillSymbolLayer()
    fill.setColor(color)
    fill.setStrokeColor(QColor(80, 80, 80))
    fill.setStrokeWidth(0.5)
    symbol = QgsSymbol.defaultSymbol(voronoi_layer.geometryType())
    symbol.changeSymbolLayer(0, fill)
    categories.append(QgsRendererCategory(name, symbol, name))

renderer = QgsCategorizedSymbolRenderer("name", categories)
voronoi_layer.setRenderer(renderer)

# --- ラベル設定(駅名・白バッファ付き) ---
ls = QgsPalLayerSettings()
ls.fieldName = "name"
ls.enabled = True
tf = QgsTextFormat()
tf.setSize(11)
tf.setColor(QColor("#1a1a1a"))
buf = QgsTextBufferSettings()
buf.setEnabled(True)
buf.setSize(1.0)
buf.setColor(QColor(255, 255, 255))
tf.setBuffer(buf)
ls.setFormat(tf)
voronoi_layer.setLabelsEnabled(True)
voronoi_layer.setLabeling(QgsVectorLayerSimpleLabeling(ls))

voronoi_layer.triggerRepaint()
QgsProject.instance().addMapLayer(voronoi_layer)

# 検証
print("--- VERIFY ---")
for f in voronoi_layer.getFeatures():
    print(f"  {f['name']}: geom_type={f.geometry().type()}, valid={f.geometry().isGeosValid()}")
print("--- DONE ---")

やってみてわかったこと

うまくいったこと

  • 複数ステップの定型作業 が1リクエストで完結する。QGISがGUI操作できて便利ではあるものの、複数ステップを手作業していくのはやはりめんどくさい。これをClaude Codeが適切な順番で組み立て処理してくれるのは楽
  • ClaudeがPyQGISの利用方法を把握しており、自分でドキュメントを調べる手間が省けた
  • スタイリング(色分け・ラベル式)も自然言語で指定できた。ステップ2のルート色分けなど、GUIで1本ずつ設定すると面倒な作業が楽になる
  • 前のステップの結果を参照できる。「先ほどのレイヤーから駅だけ抽出して」のように、既存レイヤーを指定すれば続きの作業をスムーズに指示できた

つまづいたこと

  • Amazon Location Serviceプラグインの利用が不安定:Claude Codeはプラグインの利用方法がわからかった。そのため、Skill等でプラグインの使い方を明示する必要があった

「GISがわからなくてもOK」ではない

Claude Codeは「何をやりたいか」を伝えれば処理を組み立ててくれますが、「バッファとは何か」「投影座標系とはどういうときに使うか」といった GISの基礎概念 は依然として必要です。正しい指示を出すには、どんなデータがあって、どんな操作が存在するかを知っている必要があります。

私はGISにあまり詳しくないので、そもそもどう指示を出したら良いのかを考えるのが難しいです。

PyQGISを書く手間 」は大幅に省けます。「 GISを理解する必要 」はなくなりません。

注意点

  • QGIS MCPを利用すると、基本的にClaude CodeがQGIS上の情報を全て取得できます。APIキーや個人情報が含まれるレイヤーが含まれてる場合は注意してください
  • リポジトリのREADMEにも "Very powerful, but also be very cautious using this tool" と記載されています

おわりに

QGIS MCPを使って、PyQGISを自分で書かずにClaude Codeだけでオフィス周辺を操作してみました。

ポイントを打つ → ルートを引く → バッファで重なりを出す → ボロノイ図で最寄り駅エリアを分割する、という一連の流れが自然言語で完結するのは、思った以上に体験が良かったです。GISを業務で使っていてもPyQGISが苦手という方には特に刺さるツールだと思います。

一方で、Amazon Location Serviceプラグインのように外部プラグインを組み合わせる場合、エージェントが毎回正しいコードを生成できるとは限りません。そういったケースでは、正しい使い方をスキルとしてまとめておくことで安定性が大きく向上しました。MCPでツールを増やすだけでなく、スキルで知識を与えるアプローチも組み合わせると、エージェントの実用性がぐっと上がります。

今回はAmazon Location Serviceと組み合わせた例でしたが、シェープファイルや外部APIとの組み合わせも自由にできます。ぜひ試してみてください。

このブログがどなたかのお役に立てれば幸いです。

(参考)Amazon Location Service用スキル

SKILL.md全文
SKILL.md
---
name: amazon-location-service
description: >
  Amazon Location Service QGIS プラグインを PyQGIS スクリプトから使うスキル。
  ユーザーが「住所をジオコーディングしたい」「ルートを地図に表示したい」「Amazon Location Service で〜したい」
  「ALS で〜したい」「地点を検索して QGIS に追加したい」「駅からのルートを描きたい」と言ったら必ずこのスキルを使うこと。
  ジオコーディング・逆ジオコーディング・ルート計算・経由地ルート・バッファ解析・地図タイル・ポイント/ラインレイヤー作成をカバーする。
---

# Amazon Location Service × PyQGIS スキル

## 概要

このスキルは、QGIS にインストールされた `location_service` プラグインを PyQGIS スクリプトから
プログラマティックに呼び出すためのパターン集です。

---

## 0. 共通セットアップ(全スクリプト必須)

```python
import sys
from qgis.core import QgsApplication

PLUGIN_DIR = QgsApplication.qgisSettingsDirPath().rstrip("/") + "/python/plugins"
if PLUGIN_DIR not in sys.path:
    sys.path.insert(0, PLUGIN_DIR)

from location_service.utils.configuration_handler import ConfigurationHandler
from location_service.utils.external_api_handler import ExternalApiHandler

config = ConfigurationHandler()
region = config.get_setting("region_value")   # 例: "ap-northeast-1"
apikey = config.get_setting("apikey_value")   # API Key

if not region or not apikey:
    raise RuntimeError(f"API設定未完了: region={region!r}, apikey={'***' if apikey else '(empty)'}")

api = ExternalApiHandler()
```

`ConfigurationHandler` はシングルトンで、QSettings の `/location-service` グループから
`region_value``apikey_value` を読む。直接 QSettings を触らなくてよい。

`ExternalApiHandler``QgsNetworkAccessManager` をラップして、非同期I/Oを
`QEventLoop` で同期的に扱う。JSON POST 専用。
**エラー時は `RuntimeError` を raise する(None は返さない)。**
API 呼び出しは `try/except RuntimeError` でハンドリングすること。

---

## 1. ジオコーディング(Places API)

### 1-1. ExternalApiHandler を直接使う(推奨パターン)

```python
URL = f"https://places.geo.{region}.amazonaws.com/v2/search-text?key={apikey}"

targets = [
    ("地点A", "東京駅"),
    ("地点B", "新宿駅"),
]
BIAS = [139.752, 35.674]  # 対象エリア付近の経度・緯度でバイアスを設定(精度向上のため)

points = []
for label, query in targets:
    payload = {
        "Language": "ja",
        "MaxResults": 1,
        "QueryText": query,
        "BiasPosition": BIAS,  # 日本検索では必須。精度が大きく変わる
    }
    try:
        result = api.send_json_post_request(URL, payload)
    except RuntimeError as e:
        raise RuntimeError(f"API呼び出し失敗 ({label}): {e}")
    items = result.get("ResultItems", [])
    if items:
        pos = items[0]["Position"]  # [lon, lat]
        points.append((label, pos[0], pos[1]))
        print(f"Found: {label} -> lon={pos[0]:.6f}, lat={pos[1]:.6f}")
    else:
        raise RuntimeError(f"ジオコーディング失敗: {label}")
```

### 1-2. PlacesFunctions クラスを使う

```python
from location_service.functions.places import PlacesFunctions

pf = PlacesFunctions()
result = pf.search_text("東京駅", 139.766, 35.681)
# result["ResultItems"][i]["Position"] = [lon, lat]
# result["ResultItems"][i]["Title"] = 地名
# pf.add_point_layer(result) でそのままレイヤー追加も可能
```

**注意:** クラス利用時のデフォルト値は `Language=None`(API デフォルト)、`MaxResults=10`
日本語結果を1件だけ取りたい場合は ExternalApiHandler 直接利用(セクション 1-1)を使うこと。

---

## 2. 逆ジオコーディング(座標→住所)

プラグインの `PlacesFunctions` にはこの機能がないため、`ExternalApiHandler` で直接呼び出す。

```python
REVERSE_URL = f"https://places.geo.{region}.amazonaws.com/v2/reverse-geocode?key={apikey}"

payload = {
    "Language": "ja",
    "MaxResults": 1,
    "QueryPosition": [139.7671, 35.6812],  # [lon, lat]
}
try:
    result = api.send_json_post_request(REVERSE_URL, payload)
except RuntimeError as e:
    raise RuntimeError(f"逆ジオコーディング失敗: {e}")

items = result.get("ResultItems", [])
if items:
    addr = items[0].get("Address", {})
    label = addr.get("Label", "")
    print(f"住所: {label}")
```

---

## 3. ルート計算(Routes API)

### 3-1. RoutesFunctions クラスを使う

`TravelMode` はペイロードに未指定のため **API デフォルト(Car)** が適用される。
車ルートで十分な場合に使う。

```python
from location_service.functions.routes import RoutesFunctions

rf = RoutesFunctions()
data = rf.calculate_routes(
    st_lon, st_lat,   # 出発地(経度, 緯度)
    ed_lon, ed_lat,   # 目的地(経度, 緯度)
)
# data["Routes"][0]["Summary"]["Distance"]  → メートル
# data["Routes"][0]["Legs"][i]["Geometry"]["LineString"]  → [[lon,lat], ...]
```

**注意:** `rf.add_line_layer(data)``MajorRoadLabels` が空の場合に `NameError` になるバグがある。
レイヤー追加はセクション 3-4 のパターンを使うこと。

### 3-2. ExternalApiHandler で徒歩ルートを取得(推奨)

`TravelMode` を指定するには `ExternalApiHandler` を直接使う。
**`"Walking"` は無効(400 Bad Request)。必ず `"Pedestrian"` を使うこと。**

```python
ROUTES_URL = f"https://routes.geo.{region}.amazonaws.com/v2/routes?key={apikey}"

payload = {
    "Origin":            [st_lon, st_lat],
    "Destination":       [ed_lon, ed_lat],
    "TravelMode":        "Pedestrian",   # ← "Walking" は Bad Request になる
    "LegGeometryFormat": "Simple",       # ← 必須。省略すると Geometry が FlexiblePolyline 文字列で返り LineString が空になる
}
try:
    data = api.send_json_post_request(ROUTES_URL, payload)
except RuntimeError as e:
    raise RuntimeError(f"ルート計算失敗: {e}")

route0 = data.get("Routes", [{}])[0]

# 距離(メートル)取得(フォールバック付き)
dist_m = route0.get("Summary", {}).get("Distance", 0)
if not dist_m:
    dist_m = sum(
        step.get("Distance", 0)
        for leg in route0.get("Legs", [])
        for step in leg.get("TravelSteps", [])
    )

# ジオメトリ
line_points = []
for leg in route0.get("Legs", []):
    coords = leg.get("Geometry", {}).get("LineString", [])
    line_points.extend([QgsPointXY(c[0], c[1]) for c in coords])
```

有効な TravelMode 値(Routes v2 API): `"Car"` / `"Truck"` / `"Pedestrian"` / `"Bicycle"` / `"Motorcycle"`

### 3-3. 経由地(Waypoints)を使ったルート計算

3点以上を経由するルートは `Waypoints` パラメータで指定する。

```python
ROUTES_URL = f"https://routes.geo.{region}.amazonaws.com/v2/routes?key={apikey}"

payload = {
    "Origin":            [st_lon, st_lat],
    "Destination":       [ed_lon, ed_lat],
    "Waypoints": [
        {"Position": [wp1_lon, wp1_lat]},
        {"Position": [wp2_lon, wp2_lat]},
    ],
    "TravelMode":        "Car",
    "LegGeometryFormat": "Simple",
}
try:
    data = api.send_json_post_request(ROUTES_URL, payload)
except RuntimeError as e:
    raise RuntimeError(f"経由ルート計算失敗: {e}")

# Legs は Origin→WP1, WP1→WP2, WP2→Destination の順で返る
line_points = []
for leg in data["Routes"][0].get("Legs", []):
    coords = leg.get("Geometry", {}).get("LineString", [])
    line_points.extend([QgsPointXY(c[0], c[1]) for c in coords])
```

### 3-4. ラインレイヤーへの変換パターン(距離ラベル付き)

`dist_label` フィールドに `"X.X km"` 形式の文字列を持たせ、ライン上にラベル表示する。
複数ルートをループする場合は `color` を外から渡す(セクション 3-2 のデータを前提)。

```python
from qgis.core import (
    QgsFeature, QgsField, QgsFields, QgsGeometry, QgsPalLayerSettings,
    QgsPointXY, QgsProject, QgsSimpleLineSymbolLayer, QgsSingleSymbolRenderer,
    QgsSymbol, QgsTextBufferSettings, QgsTextFormat, QgsVectorLayer,
    QgsVectorLayerSimpleLabeling,
)
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtGui import QColor

# dist_m, line_points, color はセクション 3-2 で取得済みの想定
dist_km_label = f"{round(dist_m / 1000, 1)} km"

layer = QgsVectorLayer("LineString?crs=EPSG:4326", "ルート", "memory")
provider = layer.dataProvider()
fields = QgsFields()
fields.append(QgsField("station",    QVariant.String))
fields.append(QgsField("dist_label", QVariant.String))  # "0.4 km" 形式
provider.addAttributes(fields)
layer.updateFields()

feat = QgsFeature(layer.fields())
feat.setGeometry(QgsGeometry.fromPolylineXY(line_points))
feat.setAttributes(["駅名", dist_km_label])
provider.addFeatures([feat])

# ラインスタイル
sym_line = QgsSimpleLineSymbolLayer()
sym_line.setColor(color)   # QColor を外から渡す
sym_line.setWidth(1.5)
symbol = QgsSymbol.defaultSymbol(layer.geometryType())
symbol.changeSymbolLayer(0, sym_line)
layer.setRenderer(QgsSingleSymbolRenderer(symbol))

# 距離ラベル(ラインの色に合わせた文字色 + 白バッファ)
ls = QgsPalLayerSettings()
ls.fieldName = "dist_label"
ls.enabled = True
tf = QgsTextFormat()
tf.setSize(10)
tf.setColor(color)
buf = QgsTextBufferSettings()
buf.setEnabled(True)
buf.setSize(1.0)
buf.setColor(QColor(255, 255, 255))
tf.setBuffer(buf)
ls.setFormat(tf)
layer.setLabelsEnabled(True)
layer.setLabeling(QgsVectorLayerSimpleLabeling(ls))

layer.triggerRepaint()
QgsProject.instance().addMapLayer(layer)
print(f"ルート追加: {dist_km_label}, {len(line_points)} points")
```

---

## 4. 地図タイル(Maps API)

### 4-1. MapsFunctions クラスを使う

```python
from location_service.functions.maps import MapsFunctions

mf = MapsFunctions()
mf.add_xyz_tile_layer("Standard")
```

利用可能なスタイル名: `"Standard"` / `"Monochrome"` / `"Hybrid"` / `"Satellite"`

### 4-2. ExternalApiHandler を使わず直接 QgsRasterLayer で追加

```python
from qgis.core import QgsProject, QgsRasterLayer

tile_url = f"https://als.dayjournal.dev/{region}/Standard/{{z}}/{{x}}/{{y}}?APIkey={apikey}"
layer_url = f"type=xyz&url={tile_url}&zmin=0&zmax=18"
layer = QgsRasterLayer(layer_url, "背景地図", "wms")
QgsProject.instance().addMapLayer(layer)
```

---

## 5. ポイントレイヤー作成パターン

```python
from qgis.core import (
    QgsFeature, QgsField, QgsFields, QgsGeometry, QgsMarkerSymbol,
    QgsPalLayerSettings, QgsPointXY, QgsProject, QgsSimpleMarkerSymbolLayer,
    QgsSingleSymbolRenderer, QgsTextBufferSettings, QgsTextFormat,
    QgsVectorLayer, QgsVectorLayerSimpleLabeling,
)
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtGui import QColor

layer = QgsVectorLayer("Point?crs=EPSG:4326", "地点", "memory")
provider = layer.dataProvider()
fields = QgsFields()
fields.append(QgsField("name", QVariant.String))
provider.addAttributes(fields)
layer.updateFields()

features = []
for name, lon, lat in points:
    f = QgsFeature(layer.fields())
    f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(lon, lat)))
    f.setAttributes([name])
    features.append(f)
provider.addFeatures(features)

# マーカースタイル
sym_layer = QgsSimpleMarkerSymbolLayer()
sym_layer.setShape(QgsSimpleMarkerSymbolLayer.Circle)
sym_layer.setColor(QColor(0, 124, 191))
sym_layer.setStrokeColor(QColor(255, 255, 255))
sym_layer.setStrokeWidth(0.5)
sym_layer.setSize(5.0)
symbol = QgsMarkerSymbol.createSimple({})
symbol.changeSymbolLayer(0, sym_layer)
layer.setRenderer(QgsSingleSymbolRenderer(symbol))

# ラベル(白バッファ付き)
label_settings = QgsPalLayerSettings()
label_settings.fieldName = "name"
label_settings.enabled = True
tf = QgsTextFormat()
tf.setSize(11)
tf.setColor(QColor("#1a1a1a"))
buf = QgsTextBufferSettings()
buf.setEnabled(True)
buf.setSize(1.0)
buf.setColor(QColor(255, 255, 255))
tf.setBuffer(buf)
label_settings.setFormat(tf)
layer.setLabelsEnabled(True)
layer.setLabeling(QgsVectorLayerSimpleLabeling(label_settings))

layer.triggerRepaint()
QgsProject.instance().addMapLayer(layer)
iface.zoomToActiveLayer()
```

---

## 6. バッファ解析(正確なメートル計算)

日本国内で正確な距離計算をするには **EPSG:6677(日本平面直角座標系IX)** を使う。
EPSG:4326 のままバッファすると度単位になり精度が出ない。

```python
from qgis.core import (
    QgsCoordinateReferenceSystem, QgsCoordinateTransform,
    QgsGeometry, QgsPointXY, QgsProject,
)

crs_4326 = QgsCoordinateReferenceSystem("EPSG:4326")
crs_6677 = QgsCoordinateReferenceSystem("EPSG:6677")
to_proj = QgsCoordinateTransform(crs_4326, crs_6677, QgsProject.instance())
to_geo  = QgsCoordinateTransform(crs_6677, crs_4326, QgsProject.instance())

BUFFER_M = 800
SEGMENTS = 64

for name, lon, lat in stations:
    pt_proj = to_proj.transform(QgsPointXY(lon, lat))
    geom_proj = QgsGeometry.fromPointXY(pt_proj)
    buf_proj = geom_proj.buffer(BUFFER_M, SEGMENTS)  # メートル単位
    buf_proj.transform(to_geo)                        # WGS84 に戻す
    # buf_proj をポリゴンフィーチャとしてレイヤーへ追加
```

---

## 7. API エンドポイント早見表

| 機能 | URL |
|------|-----|
| テキスト検索(ジオコーディング) | `https://places.geo.{region}.amazonaws.com/v2/search-text?key={apikey}` |
| 逆ジオコーディング | `https://places.geo.{region}.amazonaws.com/v2/reverse-geocode?key={apikey}` |
| ルート計算 | `https://routes.geo.{region}.amazonaws.com/v2/routes?key={apikey}` |
| XYZタイル(地図背景) | `https://als.dayjournal.dev/{region}/{style}/{z}/{x}/{y}?APIkey={apikey}` |

---

## 8. よくあるエラーと対処

| エラー | 原因 | 対処 |
|--------|------|------|
| `region=None / apikey=None` | プラグインの設定が保存されていない | QGIS の Location Service プラグイン GUI で region と API Key を設定して保存 |
| `ResultItems` が空 | クエリがヒットしない | `BiasPosition` を東京付近(`[139.752, 35.674]`)に設定。クエリに「東京」を付加 |
| バッファが異様に小さい/大きい | EPSG:4326 のまま度単位でバッファ | EPSG:6677 に変換してからメートルでバッファ(セクション6参照) |
| ルートの `Distance` が 0 | `Summary` キー構造の違い | `Legs[i].TravelSteps[0].Distance` を合算するフォールバックを実装 |
| ルート API が 400 Bad Request | `TravelMode: "Walking"` は無効値 | `"Pedestrian"` に変更する(セクション 3-2 参照) |
| `RoutesFunctions.add_line_layer()``NameError` | `MajorRoadLabels` が空リストの場合 `roadname` 未定義 | `add_line_layer()` は使わず、セクション 3-4 のパターンでレイヤーを自作する |
| `RuntimeError: Network error occurred` | API キー無効・リージョン不一致・ネットワーク障害 | `ExternalApiHandler` はエラー時に `RuntimeError` を raise する。`try/except RuntimeError` でハンドリングすること |

この記事をシェアする

関連記事