📚

MCP(ModelContextProtcol)で図書館の蔵書を検索する

2024/12/03に公開

この記事は株式会社ガラパゴス(有志) Advent Calendar 2024
の7日目です

https://modelcontextprotocol.io/docs/first-server/python

お疲れ様です、波浪です。
非稼働日だっていうのにSlackの通知が来て、MCPでなんかやれよって圧をかけられたので急遽やってみることにしました。

ちょうど、子供に本をねだられていたので図書館の蔵書検索してくれるエージェントがいたらいいなーってお気持ちをMCPが実現してくれることを祈ってやってみます。
ところでこの手の芸、昔はマッシュアップって言ってた気がするんですけどあれはいつ頃廃れました?

図書館検索

さて、まず図書館APIを調達してきましょう。

https://calil.jp/doc/api_ref.html

上の方にある

アプリケーションキーの登録
APIを利用するには、アプリケーションキーの登録が必要です。 こちらのページでアプリケーションキーを申請してください。

のところからアプリケーションキー申請、お名前とメアドを登録したら

https://calil.jp/api/dashboard/
APIダッシュボードから
新しいアプリケーションの登録を行います。

登録できたらAPIキーが出るのでマルっとコピー。
雑に確かめてみましょう

curl "https://api.calil.jp/library?appkey=[APP_KEY]&pref=東京都&city=中野区&format=json"
Outputs

callback([{"libid": "108327", "formal": "著作権情報センター資料室", "short": "著作権情報センター", "systemid": "Special_Cric", "systemname": "著作権情報センター資料室", "libkey": "図書館", "category": "SPECIAL", "post": "164-0012", "tel": "03-5309-2421", "pref": "東京都", "city": "中野区", "address": "東京都中野区本町1-32-2 ハーモニータワー22階", "geocode": "139.683388,35.696237", "isil": "JP-1005134", "faid": null, "url_pc": "http://www.cric.or.jp/counsel/index.html#siryo"}, {"libid": "123466", "formal": "租税資料館図書室", "short": "租税資料館図書室", "systemid": "Special_Sozei", "systemname": "租税資料館", "libkey": "資料館", "category": "SPECIAL", "post": "164-0014", "tel": "03-5340-1131", "pref": "東京都", "city": "中野区", "address": "東京都中野区南台3-45-13", "geocode": "139.6647385,35.6851865", "isil": null, "faid": "FA024126", "url_pc": "https://www.sozeishiryokan.or.jp/"}, {"libid": "123975", "formal": "中野区立図書館みなみのライブラリー", "short": "みなみのライブラリー", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "みなみのL", "category": "SMALL", "post": "164-0013", "tel": "03-3381-7261", "pref": "東京都", "city": "中野区", "address": "東京都中野区弥生町4-27-11", "geocode": "139.670181,35.687576", "isil": null, "faid": null, "url_pc": "https://www.city.tokyo-nakano.lg.jp/dept/651500/d030353.html"}, {"libid": "104108", "formal": "中野区立上高田図書館", "short": "上高田図書館", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "上高田", "category": "MEDIUM", "post": "164-0002", "tel": "03-3319-5411", "pref": "東京都", "city": "中野区", "address": "東京都中野区上高田5-30-15", "geocode": "139.6751675,35.718717", "isil": "JP-1001028", "faid": null, "url_pc": "https://library.city.tokyo-nakano.lg.jp/"}, {"libid": "104109", "formal": "中野区立中央図書館", "short": "中央図書館", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "中央", "category": "MEDIUM", "post": "164-0001", "tel": "03-5340-5070", "pref": "東京都", "city": "中野区", "address": "東京都中野区中野2-9-7", "geocode": "139.6715989,35.704299", "isil": "JP-1001021", "faid": null, "url_pc": "https://library.city.tokyo-nakano.lg.jp/"}, {"libid": "124319", "formal": "中野区立中野東図書館", "short": "中野区立中野東図書館", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "中野東", "category": "MEDIUM", "post": "164-0011", "tel": "03-5937-3559", "pref": "東京都", "city": "中野区", "address": "東京都中野区中央1-41-2", "geocode": "139.683467,35.698285", "isil": null, "faid": null, "url_pc": "https://library.city.tokyo-nakano.lg.jp/"}, {"libid": "123976", "formal": "中野区立図書館中野第一ライブラリー", "short": "中野第一ライブラリー", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "中野第一L", "category": "SMALL", "post": "164-0012", "tel": "03-3372-8501", "pref": "東京都", "city": "中野区", "address": "東京都中野区本町3-16-1", "geocode": "139.6770203,35.6957532", "isil": null, "faid": null, "url_pc": "https://www.city.tokyo-nakano.lg.jp/dept/651500/d030422.html"}, {"libid": "104110", "formal": "中野区立南台図書館", "short": "南台図書館", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "南台", "category": "MEDIUM", "post": "164-0014", "tel": "03-3380-2661", "pref": "東京都", "city": "中野区", "address": "東京都中野区南台3-26-18", "geocode": "139.665703,35.685347", "isil": "JP-1001025", "faid": null, "url_pc": "https://library.city.tokyo-nakano.lg.jp/"}, {"libid": "104113", "formal": "中野区立江古田図書館", "short": "江古田図書館", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "江古田", "category": "MEDIUM", "post": "165-0022", "tel": "03-3319-9301", "pref": "東京都", "city": "中野区", "address": "東京都中野区江古田2-1-11", "geocode": "139.6690538,35.7248524", "isil": "JP-1001027", "faid": null, "url_pc": "https://library.city.tokyo-nakano.lg.jp/"}, {"libid": "123974", "formal": "中野区立図書館美鳩ライブラリー", "short": "美鳩ライブラリー", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "美鳩L", "category": "SMALL", "post": "165-0034", "tel": "03-3330-8160", "pref": "東京都", "city": "中野区", "address": "東京都中野区大和町4-26-5", "geocode": "139.644763,35.714547", "isil": null, "faid": null, "url_pc": "https://www.city.tokyo-nakano.lg.jp/dept/651500/d030421.html"}, {"libid": "104114", "formal": "中野区立野方図書館", "short": "野方図書館", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "野方", "category": "MEDIUM", "post": "165-0027", "tel": "03-3389-0214", "pref": "東京都", "city": "中野区", "address": "東京都中野区野方3-19-5", "geocode": "139.6543413,35.7174224", "isil": "JP-1001023", "faid": null, "url_pc": "https://library.city.tokyo-nakano.lg.jp/"}, {"libid": "104115", "formal": "中野区立鷺宮図書館", "short": "鷺宮図書館", "systemid": "Tokyo_Nakano", "systemname": "東京都中野区", "libkey": "鷺宮", "category": "MEDIUM", "post": "165-0032", "tel": "03-3337-1044", "pref": "東京都", "city": "中野区", "address": "東京都中野区鷺宮3-22-5", "geocode": "139.639777,35.723423", "isil": "JP-1001022", "faid": null, "url_pc": "https://library.city.tokyo-nakano.lg.jp/"}, {"libid": "113041", "formal": "こども教育宝仙大学図書館", "short": "図書館", "systemid": "Univ_Hosen", "systemname": "こども教育宝仙大学", "libkey": "図書館", "category": "UNIV", "post": "164-8631", "tel": "03-3365-0267", "pref": "東京都", "city": "中野区", "address": "東京都中野区中央2-33-26", "geocode": "139.678578,35.699695", "isil": "JP-1004109", "faid": "FA02009X", "url_pc": "https://www.hosen.ac.jp/student/library/"}, {"libid": "112852", "formal": "明治大学中野図書館", "short": "中野図書館", "systemid": "Univ_Meiji", "systemname": "明治大学", "libkey": "中野", "category": "UNIV", "post": "164-8525", "tel": "03-5343-8096", "pref": "東京都", "city": "中野区", "address": "東京都中野区中野4-21-1", "geocode": "139.659561,35.707016", "isil": "JP-1005897", "faid": null, "url_pc": "http://www.lib.meiji.ac.jp/use/nakano/"}, {"libid": "112443", "formal": "帝京平成大学中野キャンパスメディアライブラリーセンター", "short": "中野キャンパスメディアライブラリーセンター", "systemid": "Univ_Thu", "systemname": "帝京平成大学", "libkey": "中野", "category": "UNIV", "post": "164-8530", "tel": "03-5860-4731", "pref": "東京都", "city": "中野区", "address": "東京都中野区中野4-21-2", "geocode": "139.659126,35.707732", "isil": "JP-1006942", "faid": "FA025151", "url_pc": "http://tosho.thu.ac.jp/nakano/index.html"}, {"libid": "106200", "formal": "東京工芸大学中野図書館", "short": "中野図書館", "systemid": "Univ_T_Kougei", "systemname": "東京工芸大学", "libkey": "中野", "category": "UNIV", "post": "164-8678", "tel": "03-5371-2733", "pref": "東京都", "city": "中野区", "address": "東京都中野区本町2丁目9-5", "geocode": "139.679676,35.693436", "isil": "JP-1004155", "faid": "FA02262X", "url_pc": "http://www.t-kougei.ac.jp/library/"}]);%

取れたみたいですね、 でも | jq . すると
parse error: Invalid numeric literal at line 1, column 10
おや? どうやらJSONとしてはぶっ壊れているようですね。

どうやら
curl "https://api.calil.jp/library?appkey=[APP_KEY]&pref=山梨県&city=甲府市&format=json&limit=3&callback=no"

callback=no と format=jsonがいるようです。注意しましょう

蔵書検索

さて、次は
https://api.calil.jp/check
の方を確かめてみます。
仕様を見るとsystemidが図書館の指定のようですね、
ふむふむ、横断検索は次に回して、駅前の県立図書館にあるかどうかだけ判定にして systemidを固定しときましょう Yamanashi_Pref に駅前の県立図書館は含まれているようです。

さらに検索したい図書はISBNで検索するみたいですね
書籍名からISBNにするAPIも必要そうです、後でつくりましょう。
とりあえず適当なISBNで動作を試してみます

~ ❯ curl "https://api.calil.jp/check?appkey=[APPKEY]&isbn=4834000826&systemid=Aomori_Pref&format=json"

callback({"session": "de750dce083666356d5e1418b94cc8af2fc1a3d3aed54bd752c4114fa2b24da5", "continue": 1, "books": {"4834000826": {"Aomori_Pref": {"status": "Running", "reserveurl": ""}}}});%

~ ❯ curl "https://api.calil.jp/check?session=[APPKEY]&format=json"

callback({"session": "de750dce083666356d5e1418b94cc8af2fc1a3d3aed54bd752c4114fa2b24da5", "continue": 0, "books": {"4834000826": {"Aomori_Pref": {"status": "OK", "libkey": {"県立": "貸出可"}, "reserveurl": ""}}}});%

なるほど、ではこのままClaudeに突っ込んでPythonコードにして変換してもらいましょう

PythonCode
import requests
import time

def check_library_availability(isbn, systemid):
    # 初回リクエスト
    initial_endpoint = "https://api.calil.jp/check"
    params = {
        "appkey": "*****************",
        "isbn": isbn,
        "systemid": systemid,
        "format": "json",
        "callback": "no"
    }
    
    response = requests.get(initial_endpoint, params=params)
    data = response.json()
    session = data["session"]
    
    # ステータスが確定するまでポーリング
    while data["continue"] == 1:
        time.sleep(1)  # 1秒待機
        
        polling_params = {
            "session": session,
            "format": "json",
            "callback": "no"
        }
        response = requests.get(initial_endpoint, params=polling_params)
        data = response.json()
    
    return data

# 使用例
result = check_library_availability("4834000826", "Aomori_Pref")
print(result)

あとは書籍からISBNを取得し直すAPIが必要ですね。
https://developers.google.com/books/docs/v1/using?hl=ja#PerformingSearch
今回はGoogleに頼ることにしましょう。

import requests
import time

url = "https://www.googleapis.com/books/v1/volumes"

params={
    "q":"intitle:ぐりとぐら",
}

result = requests.get(url, params=params)
json.loads(result.text)

上記コードを実行して、結果をまとめてClaudeに貼り付けてから、ISBNが欲しいですと呟いてコードにしてもらいます

PythonCode
# 結果から各本のISBNを取得
for book in json.loads(result.text)['items']:
    title = book['volumeInfo']['title']
    isbn = None
    
    # ISBNを探す
    if 'industryIdentifiers' in book['volumeInfo']:
        for identifier in book['volumeInfo']['industryIdentifiers']:
            if identifier['type'] == 'ISBN_13':
                isbn = identifier['identifier']
                break
            elif identifier['type'] == 'ISBN_10':
                isbn = identifier['identifier']
    
    print(f"タイトル:{title}")
    print(f"ISBN:{isbn}\n")

はい、これでISBNも取得できるようになりましたね、では本題のMCPに入っていきましょう。

MCP Server

https://qiita.com/sakasegawa/items/b091ad9931cea378099b

ちょうど逆瀬川さんの記事がアドベントカレンダーにあがっていたので上記を参考コードとして、先ほど作った図書館蔵書検索APIの実行コードとISBN検索コードをClaudeさんに渡していい感じに書いてくださいとお願いしたら以下のコードになりました。

server.py
import asyncio
from mcp.server.models import InitializationOptions
import mcp.types as types
from mcp.server import NotificationOptions, Server
import mcp.server.stdio
from pydantic import AnyUrl
import os
from dotenv import load_dotenv
import requests
import time
import logging
import json
# Load environment variables
load_dotenv()

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("library_mcp_server")

# API configuration
LIBRARY_APPKEY = os.getenv("LIBRARY_APPKEY")
if not LIBRARY_APPKEY:
    raise ValueError("LIBRARY_APPKEY environment variable required")


# MCPサーバーの初期化
server = Server("library_mcp_server")

# 動画検索結果を保持するリソース(サーバーの状態)
search_results = {}


def search_isbn(title: str):
    url = "https://www.googleapis.com/books/v1/volumes"

    params={
        "q":f"intitle:{title}",
        "maxResults":1
    }

    result = requests.get(url, params=params)
    # APIレスポンスからISBNを取得
    response_data = json.loads(result.text)
    isbn_info = response_data['items'][0]['volumeInfo']['industryIdentifiers']

    # ISBN-13を優先的に取得し、なければISBN-10を使用
    isbn = None
    for identifier in isbn_info:
        if identifier['type'] == 'ISBN_13':
            isbn = identifier['identifier']
            break
        elif identifier['type'] == 'ISBN_10':
            isbn = identifier['identifier']
    return isbn 




def search_library_collections(title: str, systemid: str="Yamanashi_Pref"):
    isbn = search_isbn(title)
    if isbn == None:
        return "書籍名から該当しそうな本がみつかりませんでした"
    
    # 初回リクエスト
    initial_endpoint = "https://api.calil.jp/check"
    params = {
        "appkey": LIBRARY_APPKEY,
        "isbn": isbn,
        "systemid": systemid,
        "format": "json",
        "callback": "no"
    }
    
    response = requests.get(initial_endpoint, params=params)
    data = response.json()
    session = data["session"]
    
    # ステータスが確定するまでポーリング
    while data["continue"] == 1:
        time.sleep(1)  # 1秒待機
        
        polling_params = {
            "session": session,
            "format": "json",
            "callback": "no"
        }
        response = requests.get(initial_endpoint, params=polling_params)
        data = response.json()
    
    return data




# ツールをリストするエンドポイント
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
    """
    ツールのリストを提供する。
    """
    return [
        types.Tool(
            name="search-library-collections",
            description="Search library collections.",
            inputSchema={
                "type": "object",
                "properties": {
                    "title": {
                        "type": "string",
                        "description": "BOOK TITLE"
                    },
                },
                "required": ["title"]
            }
        )
    ]

# ツールを実行するエンドポイント
@server.call_tool()
async def handle_call_tool(
    name: str, arguments: dict
) -> list[types.TextContent | types.EmbeddedResource]:
    """
    ツールを実行して蔵書検索を行う。
    """
    if name != "search-library-collections":
        raise ValueError(f"Unknown tool: {name}")

    # 引数を取得
    title = arguments.get("title")

    # 蔵書検索を実行
    result = search_library_collections(title)

    # 結果をリソースに保存
    search_results[title] = result

    # クライアントに結果を返す
    return [
        types.TextContent(
            type="text",
            text=f"Search results for '{title}':\n{result}"
        )
    ]

# リソースをリストするエンドポイント
@server.list_resources()
async def handle_list_resources() -> list[types.Resource]:
    """
    検索結果をリソースとしてリストする。
    """
    return [
        types.Resource(
            uri=AnyUrl(f"search-library-collections://{title}"),
            name=f"Search Results for '{title}'",
            description=f"Search results for title: '{title}'",
            mimeType="text/plain",
        )
        for title in search_results
    ]

# リソースを読み取るエンドポイント
@server.read_resource()
async def handle_read_resource(uri: AnyUrl) -> str:
    """
    指定された検索クエリの結果を返す。
    """
    title = uri.path.lstrip("/")
    if title not in search_results:
        raise ValueError(f"No results found for title: {title}")

    # 結果を整形して返す
    result = search_results[title]
    return result
# メイン関数
async def main():
    async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="library_mcp_server",
                server_version="0.0.1",
                capabilities=server.get_capabilities(
                    notification_options=NotificationOptions(),
                    experimental_capabilities={}
                )
            )
        )

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

で、サーバーできたしほな繋ごって公式資料見ると
claude_desktop_config.jsonを更新って書いてあるけど、どこにあるか書いてないんですよね。
いやどこやねん?ざっと検索すると
https://note.com/npaka/n/ncd797acdad06
天下のnpakaさんが ~/Library/Application Support/Claude/claude_desktop_config.json だよって書いてくれてるので ここにファイルをtouchして書いていきます。

{
    "mcpServers": {
      "library_collection_search": {
        "command": "uv",
        "args": [
          "--directory",
          "/Users/hello/work/1202_mcp/library_mcp_server/src/library_mcp_server/",
          "run",
          "server.py"
        ],
        "env": {
          "LIBRARY_APPKEY": "************************"
        }
      }
    }
  }

まあ、見ての通りuvコマンドで run したいだけっぽいのでパスとファイル名を指定しておきます。
envのAPPKEY指定は .envに書いてあるんだから不要だと思いますがクイックスタートに従って一応つけておきます。

Claude.appを起動すると


こんな感じのツールアイコンが追加されていて、押すとアクティブなMCPサーバーが確認できます。

もしこのアイコンがない場合はどこかで落ちてるので

/Users/[ユーザー名]/Library/Logs/Claude/
以下のログを調べてください、ちなみに僕の場合は requestsがimportできない〜!で落ちてました。

さて、ここまできたらあとは会話を開始するだけですね。


こんな感じで、アクセスするけどいいんだな???って許可申請が来るので許可しましょう

はい、できました。意外と簡単でしたね。

未完成な部分として、図書館APIさんは利用したあと linker.calil.jp へのリンクを表示しないといけないのでそこらへんの対応と、あとは特定の図書館だけでなく横断検索とかできるとよさそうですね。

以上、ご清聴ありがとうございました。

株式会社ガラパゴス(有志)

Discussion