0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonでNotion MCPの検索結果からページを選び、本文を取得してみた

0
Posted at

はじめに

最初の記事では、検索結果からページIDをコピーして入力し、本文を取得しました。今回は検索結果を番号付きで表示し、番号を選ぶだけで本文を取得できるようにします。認証処理は前回の記事を参考にしています。

試した環境

項目 バージョン
Python 3.13.2
mcp 2.2.0
httpx2 2.13.1
pydantic 2.13.5

WindowsのGit Bashで、仮想環境内のPythonを使いました。接続先は https://mcp.notion.com/mcp です。

認証情報は AppData\Local\notion-mcp-python\auth.json に保存します。保存済みの情報が使える場合は再利用し、認証が必要な場合はブラウザを開きます。保存とコールバック受信の仕組みは、前回の記事で説明しています。

検索から本文取得までをつなぐ

利用できる検索ツールを確認する

session.list_tools() でツール一覧を取得し、検索用の notion-search と本文取得用の notion-fetch を確認します。notion-search がない接続では、notion-ai-search を探します。

ツールの説明と入力項目を表示する --list-tools オプションも用意しました。

venv/Scripts/python.exe notion_mcp_client_part3.py --list-tools

一覧に notion-get-tool-access があれば、検索前に呼び出します。一部の接続で事前確認が求められるためです。Notion MCPで利用できるツール

キーワードで検索し、結果を読み取る

検索語を query に渡し、コンテンツ検索を指定する query_type="internal" で呼び出します。

search_result = await session.call_tool(
    search_name, arguments={"query": query, "query_type": "internal"}
)
data = result_data(search_result)

result_data() は、structured_content の辞書を使うか、テキストのJSONを json.loads() で読み取ります。

検索結果からページを選ぶ

results からIDを持つページを取り出し、番号付きで表示します。

pages = [
    item for item in data["results"]
    if isinstance(item, dict) and item.get("type") == "page" and item.get("id")
]

for number, page in enumerate(pages, start=1):
    print(f"{number}. {page.get('title') or '(タイトルなし)'}")
    print(f"   ID: {page['id']}")
    if page.get("url"):
        print(f"   URL: {page['url']}")

選択した番号をリストの位置に対応させ、1 なら pages[0] を取得対象にします。不正な番号は再入力、空欄は終了としました。

入力待ちには asyncio.to_thread() を使い、MCPの通信を扱うイベントループを止めないようにしています。Pythonのasyncio.to_thread

選んだページの本文を取得する

選んだページのIDを notion-fetch に渡します。

fetched = result_data(
    await session.call_tool("notion-fetch", arguments={"id": page["id"]})
)

取得結果の text をそのまま表示します。本文のほか、<page> や <properties> などのメタ情報も含まれます。truncated が真の場合は一部省略の表示を出しますが、省略部分の追加取得は行っていません。

実行して確かめる

venv/Scripts/python.exe notion_mcp_client_part3.py
Notion MCP に接続しました。利用可能なツール: 49 件
検索キーワードを入力してください: テスト

===== NOTION SEARCH (notion-search) =====
query: テスト

1. テストページ
   ID: <PAGE_ID>
   URL: https://app.notion.com/p/<PAGE_ID>?pvs=204
取得するページの番号(空欄で終了): 1

===== NOTION FETCH: テストページ =====
タイトル: テストページ
URL: https://app.notion.com/p/<PAGE_ID>?pvs=204

===== PAGE CONTENT =====
Here is the result of "fetch" for the Page with URL https://app.notion.com/p/<PAGE_ID>?pvs=204 as of <FETCH_TIME>:
<page url="https://app.notion.com/p/<PAGE_ID>?pvs=204">
<ancestor-path></ancestor-path>
<properties>
{"title":"テストページ"}
</properties>
<iconMetadata>null</iconMetadata>
<content>
これはPythonからの接続を確認するテストページ
<empty-block/>
</content>
</page>

<content> の中に、確認用ページに書いた本文が返りました。

検索語と番号を引数で指定することもできます。番号はその実行で返った候補に対応するため、同じページを取得するとは限りません。

venv/Scripts/python.exe notion_mcp_client_part3.py --query テスト --select 1

まとめ

PythonからNotion MCPでキーワード検索を行い、検索結果の中から取得するページを指定できるようにしました。候補を番号付きで表示し、選択したページのIDを notion-fetch に渡すことで、検索と本文取得をつないでいます。

実行結果では、「テスト」で検索して「テストページ」を選び、ページに書いた本文が返ることを確認しました。検索結果に含まれるタイトルやIDと、ページを取得して得られる本文を、それぞれの処理で扱う流れを確かめられました。

認証には前回の記事で扱った保存とコールバック受信の方式を使っています。今回は読み取りの範囲ですが、検索で候補を探し、その中から対象を指定して内容を読む、という操作をPythonで実装できました。

コード全文

notion_mcp_client_part3.py の全文です。

"""Notion MCP で認証し、検索結果から番号で選んだページを取得する。"""

import argparse
import asyncio
import json
import os
import tempfile
import webbrowser
from pathlib import Path
from urllib.parse import parse_qs, urlsplit

import httpx2
from pydantic import AnyUrl

from mcp import ClientSession
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.types import PaginatedRequestParams
from mcp.shared.auth import (
    OAuthClientInformationFull,
    OAuthClientMetadata,
    OAuthToken,
)


MCP_URL = "https://mcp.notion.com/mcp"
REDIRECT_URI = "http://localhost:3000/callback"
CALLBACK_TIMEOUT = 180


def auth_file_path() -> Path:
    # Windows ではユーザーごとの LocalAppData に置き、記事の作業フォルダには置かない。
    base = os.environ.get("LOCALAPPDATA")
    if base:
        return Path(base) / "notion-mcp-python" / "auth.json"
    return Path.home() / ".config" / "notion-mcp-python" / "auth.json"


class FileTokenStorage:
    """SDK の TokenStorage に必要な4メソッドをファイル保存で実装する。"""

    def __init__(self, path: Path) -> None:
        self.path = path
        if path.exists():
            try:
                self.data = json.loads(path.read_text(encoding="utf-8"))
                if not isinstance(self.data, dict):
                    raise ValueError("JSON の最上位がオブジェクトではありません")
            except (OSError, ValueError) as exc:
                raise RuntimeError(f"認証情報を読み込めません: {path}") from exc
        else:
            self.data = {}

    def _save(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        temporary_path = None
        try:
            with tempfile.NamedTemporaryFile(
                mode="w",
                encoding="utf-8",
                dir=self.path.parent,
                prefix="auth-",
                suffix=".tmp",
                delete=False,
            ) as temporary:
                temporary_path = Path(temporary.name)
                json.dump(self.data, temporary, ensure_ascii=False, indent=2)
                temporary.write("\n")
            if os.name != "nt":
                temporary_path.chmod(0o600)
            os.replace(temporary_path, self.path)
        finally:
            if temporary_path is not None:
                temporary_path.unlink(missing_ok=True)

    async def get_tokens(self) -> OAuthToken | None:
        value = self.data.get("tokens")
        return OAuthToken.model_validate(value) if value is not None else None

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.data["tokens"] = tokens.model_dump(mode="json")
        self._save()

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        value = self.data.get("client_info")
        return (
            OAuthClientInformationFull.model_validate(value)
            if value is not None
            else None
        )

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        self.data["client_info"] = client_info.model_dump(mode="json")
        self._save()


class LoopbackCallback:
    """認証が必要になったときだけ localhost でコールバックを受け取る。"""

    def __init__(self) -> None:
        self.server: asyncio.AbstractServer | None = None
        self.result: asyncio.Future[AuthorizationCodeResult] | None = None

    async def redirect(self, auth_url: str) -> None:
        if self.server is not None:
            raise RuntimeError("コールバックの待ち受けが既に開始されています")

        self.result = asyncio.get_running_loop().create_future()
        try:
            self.server = await asyncio.start_server(
                self._handle_request, host="localhost", port=3000
            )
        except OSError as exc:
            raise RuntimeError("localhost:3000 で待ち受けできません") from exc

        print("ブラウザで Notion の認証を完了してください。")
        if not webbrowser.open(auth_url):
            print(f"ブラウザが開かない場合は次のURLを開いてください:\n{auth_url}")

    async def callback(self) -> AuthorizationCodeResult:
        if self.result is None:
            raise RuntimeError("コールバックの待ち受けが開始されていません")
        try:
            return await asyncio.wait_for(self.result, timeout=CALLBACK_TIMEOUT)
        except TimeoutError as exc:
            raise RuntimeError("認証の待ち受けがタイムアウトしました") from exc
        finally:
            await self.close()

    async def close(self) -> None:
        if self.server is not None:
            self.server.close()
            await self.server.wait_closed()
            self.server = None

    async def _handle_request(
        self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
    ) -> None:
        status = "404 Not Found"
        message = "ページが見つかりません。"
        try:
            request = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5)
            request_line = request.split(b"\r\n", 1)[0].decode("ascii")
            method, target, _ = request_line.split(" ", 2)
            parsed = urlsplit(target)
            if method == "GET" and parsed.path == "/callback":
                params = parse_qs(parsed.query)
                if "error" in params:
                    status = "400 Bad Request"
                    message = "Notion の認証が完了しませんでした。ターミナルを確認してください。"
                    if self.result is not None and not self.result.done():
                        self.result.set_exception(
                            RuntimeError(f"認証エラー: {params['error'][0]}")
                        )
                elif params.get("code"):
                    status = "200 OK"
                    message = "認証を受け取りました。このタブを閉じてください。"
                    if self.result is not None and not self.result.done():
                        self.result.set_result(
                            AuthorizationCodeResult(
                                code=params["code"][0],
                                state=params.get("state", [None])[0],
                                iss=params.get("iss", [None])[0],
                            )
                        )
                else:
                    status = "400 Bad Request"
                    message = "認証コードが見つかりません。"
        except (UnicodeError, ValueError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, TimeoutError):
            status = "400 Bad Request"
            message = "リクエストを読み取れませんでした。"

        body = message.encode("utf-8")
        response = (
            f"HTTP/1.1 {status}\r\n"
            "Content-Type: text/plain; charset=utf-8\r\n"
            "Cache-Control: no-store\r\n"
            "Connection: close\r\n"
            f"Content-Length: {len(body)}\r\n\r\n"
        ).encode("ascii") + body
        try:
            writer.write(response)
            await writer.drain()
        finally:
            writer.close()
            await writer.wait_closed()


def result_text(result) -> str:
    return "\n".join(
        content.text for content in result.content if content.type == "text"
    )


def result_data(result) -> dict:
    """構造化データ、またはテキストに含まれる JSON を読み取る。"""
    if result.is_error:
        raise RuntimeError(f"Notion のツールがエラーを返しました:\n{result_text(result)}")
    if isinstance(result.structured_content, dict):
        return result.structured_content
    for content in result.content:
        if content.type != "text":
            continue
        try:
            data = json.loads(content.text)
        except ValueError:
            continue
        if isinstance(data, dict):
            return data
    raise RuntimeError(f"結果を JSON として読み取れませんでした:\n{result_text(result)}")


async def prompt(message: str) -> str:
    # MCP の通信を動かしたまま、ターミナルからの入力を待つ。
    return (await asyncio.to_thread(input, message)).strip()


async def select_page(pages: list[dict], selection: int | None) -> dict | None:
    if selection is not None:
        if not 1 <= selection <= len(pages):
            raise RuntimeError(f"--select は 1〜{len(pages)} の範囲で指定してください。")
        return pages[selection - 1]
    while True:
        value = await prompt("取得するページの番号(空欄で終了): ")
        if not value:
            return None
        if value.isdecimal() and 1 <= int(value) <= len(pages):
            return pages[int(value) - 1]
        print(f"1〜{len(pages)} の番号を入力してください。")


async def search_and_fetch(session: ClientSession, tools: dict, args) -> None:
    search_name = next(
        (name for name in ("notion-search", "notion-ai-search") if name in tools),
        None,
    )
    if search_name is None or "notion-fetch" not in tools:
        raise RuntimeError("検索ツールまたは notion-fetch が見つかりません。")

    # 接続先によっては、検索前にアクセス情報の確認を求める。
    if "notion-get-tool-access" in tools:
        access = await session.call_tool("notion-get-tool-access", arguments={})
        result_data(access)

    query = args.query
    if query is None:
        query = await prompt("検索キーワードを入力してください: ")
    if not query.strip():
        print("検索キーワードが空欄のため終了します。")
        return

    print(f"\n===== NOTION SEARCH ({search_name}) =====")
    print(f"query: {query}")
    search_result = await session.call_tool(
        search_name, arguments={"query": query, "query_type": "internal"}
    )
    data = result_data(search_result)
    if not isinstance(data.get("results"), list):
        raise RuntimeError(
            "検索結果に results の配列がありません:\n"
            + json.dumps(data, ensure_ascii=False, indent=2)
        )
    pages = [
        item for item in data["results"]
        if isinstance(item, dict) and item.get("type") == "page" and item.get("id")
    ]
    for notice in data.get("notices", []):
        print("検索時のお知らせ:", notice)
    if not pages:
        print("取得できる Notion ページが見つかりませんでした。")
        return

    print(f"\n===== SEARCH RESULTS: {len(pages)} 件 =====")
    for number, page in enumerate(pages, start=1):
        print(f"{number}. {page.get('title') or '(タイトルなし)'}")
        print(f"   ID: {page['id']}")
        if page.get("url"):
            print(f"   URL: {page['url']}")

    page = await select_page(pages, args.select)
    if page is None:
        print("終了します。")
        return

    print(f"\n===== NOTION FETCH: {page.get('title') or page['id']} =====")
    fetched = result_data(
        await session.call_tool("notion-fetch", arguments={"id": page["id"]})
    )
    print("タイトル:", fetched.get("title", page.get("title", "")))
    if fetched.get("url"):
        print("URL:", fetched["url"])
    print("\n===== PAGE CONTENT =====")
    text = fetched.get("text")
    if not isinstance(text, str):
        raise RuntimeError(
            "取得結果に text がありません:\n"
            + json.dumps(fetched, ensure_ascii=False, indent=2)
        )
    print(text)
    if fetched.get("truncated"):
        print("\n本文の一部が省略されています(truncated=true)。")


async def main(args) -> None:
    storage = FileTokenStorage(auth_file_path())
    callback = LoopbackCallback()
    oauth = OAuthClientProvider(
        server_url=MCP_URL,
        client_metadata=OAuthClientMetadata(
            client_name="Python Notion MCP Client",
            redirect_uris=[AnyUrl(REDIRECT_URI)],
            grant_types=["authorization_code", "refresh_token"],
            response_types=["code"],
            token_endpoint_auth_method="none",
        ),
        storage=storage,
        redirect_handler=callback.redirect,
        callback_handler=callback.callback,
    )
    try:
        async with httpx2.AsyncClient(
            auth=oauth, timeout=httpx2.Timeout(30.0, read=300.0)
        ) as http_client:
            async with streamable_http_client(
                MCP_URL, http_client=http_client
            ) as (read, write):
                async with ClientSession(read, write) as session:
                    await session.initialize()
                    tools = {}
                    cursor = None
                    while True:
                        listed = await session.list_tools(
                            params=PaginatedRequestParams(cursor=cursor) if cursor else None
                        )
                        tools.update((tool.name, tool) for tool in listed.tools)
                        cursor = listed.next_cursor
                        if not cursor:
                            break
                    print(f"Notion MCP に接続しました。利用可能なツール: {len(tools)} 件")
                    if args.list_tools:
                        for name in (
                            "notion-search", "notion-ai-search",
                            "notion-get-tool-access", "notion-fetch",
                        ):
                            if name in tools:
                                tool = tools[name]
                                print(f"\n===== {name} =====")
                                print(tool.description)
                                print(json.dumps(tool.input_schema, ensure_ascii=False, indent=2))
                        return
                    await search_and_fetch(session, tools, args)
    finally:
        await callback.close()


def parse_args():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--query", help="検索キーワード(省略時は対話入力)")
    parser.add_argument("--select", type=int, help="取得する候補の番号(省略時は対話入力)")
    parser.add_argument("--list-tools", action="store_true", help="検索・取得ツールの仕様を表示して終了")
    return parser.parse_args()


if __name__ == "__main__":
    try:
        asyncio.run(main(parse_args()))
    except (KeyboardInterrupt, EOFError):
        print("\n終了しました。")

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?