はじめに
LINE WORKS には AI 議事録サービス「AiNote」があり、会議を録音すると文字起こしが作られ、テンプレートを選ぶと AI 要約も生成できます。2026 年 4 月のアップデートで外部連携 API が公開され、ノートの一覧・検索・取得がプログラムから行えるようになりました。
この API を MCP (Model Context Protocol) サーバーにして Claude に接続すると、次のような会話ができるようになります。
- 「先週の定例で決まったことを教えて」
- 「◯◯プロジェクトに関する会議を探して、参加者を一覧にして」
- 「田中さんが発言した箇所だけ抜き出して」
Claude が自分で「ノートを検索する」「要約を取る」「必要なら文字起こしを読む」という手順を選び、答えを返してきます。
質問文に「lineworks-ainote を使って」と付いているのは、撮影した環境に別の MCP サーバーも登録されていたためで、サーバーが 1 つだけの環境では不要です。
本記事では、この MCP サーバーを Python で 1 ファイルとして実装し、Claude Desktop と Claude Code から使えるようにするまでを説明します。コードは記事末尾のリポジトリにまとめてあります。
この記事で扱うこと
- AiNote API の認証 (OAuth 2.0)
- トークンの安全な保存
- MCP ツールの設計 (要約と文字起こしの分離、docstring による誘導)
- Claude Desktop / Claude Code への登録
前提
- LINE WORKS のテナントで AiNote が使えること。API は公開当初 Enterprise プラン限定でしたが、2026 年 8 月のアップデートで Team / Business プランでも利用できるようになりました。Developer Console でスコープに
ainote.readが選べれば利用できます - Developer Console でアプリを作成できること。一般メンバーにはこの権限がないことが多いので、管理者にアプリの作成か権限の付与を依頼する必要があります
- Python 3.12 以上と uv が入っていること
- Claude Desktop または Claude Code
- 会議の文字起こしを外部の LLM に送ることになるので、社内の AI 利用ルールや情報の取り扱い区分を事前に確認してください
動作確認は Windows 11 で行いました。macOS はキーチェーンで動く設計ですが未確認です。Linux や WSL では keyring のバックエンド (Secret Service など) がないとエラーになります。keyrings.alt を入れると動きますが平文保存になるので、その場合は保存先の扱いに注意してください。
AiNote API の全体像
使う API は 3 つです。エンドポイントの詳細やレスポンスの構造、エラーの実例は別記事 LINE WORKS API AiNote の議事録 (文字起こし・要約) を取得する にまとめたので、ここでは MCP サーバーの設計に関わる点だけ書きます。
| 用途 | エンドポイント |
|---|---|
| ノート一覧 | GET /users/me/ainote/notes |
| ノート検索 | GET /users/me/ainote/search?query=... |
| ノート取得 | GET /users/me/ainote/notes/{noteId} |
1. User Account 認証専用です。 ユーザーのノートを扱う API は Service Account 認証 (JWT) では呼べず、ユーザー本人がブラウザでログインして認可する OAuth 2.0 認可コードフローが必須です。MCP サーバーにこのフローを組み込む必要があります。
2. 読み取りだけなら ainote.read で足ります。 削除は実装しないので、最小権限のスコープを使います。
3. 検索だけレート制限が厳しいです。 通常の API はドメインあたり 240 requests/min ですが、ノート検索は 60 requests/min です。Claude が言い換えを繰り返して検索を連打すると簡単に超えるので、ツール側で呼び出し間隔を 1 秒以上あけ、docstring でも「同じ意図で言い換え検索を繰り返さない」と伝えます。それでも 429 が返ったときは、待つよう促すメッセージを Claude に返します。
設計方針
実装に入る前に、この MCP サーバーの設計で重視した 3 点を書いておきます。ここが本記事で一番伝えたい部分です。
要約と文字起こしを別のツールにする
ノート取得 API のレスポンスは、AI 要約 (summaries) と文字起こし全文 (scripts) の両方を含みます。手元で測ると 5 分 8 秒の会議で全体が約 11KB、うち文字起こしが 7.9KB、要約は 2.9KB でした。文字起こしは録音時間に比例して増えるので、1 時間の会議なら 100KB 近くになります。
これを「ノートの内容を取る」という 1 つのツールで返してしまうと、Claude が要約だけ知りたいときにも全文を受け取ることになり、トークンを浪費するうえ、応答も遅くなります。そこで次の 4 つに分けました。
| ツール | 返すもの | 返さないもの |
|---|---|---|
list_notes |
タイトル・作成日時・音声の長さ | 要約・文字起こし |
search_notes |
同上 | 同上 |
get_note_summary |
AI 要約・参加者・文字起こしのブロック数 | 文字起こし本文 |
get_note_transcript |
[時刻] 話者: 発言 に整形した全文。max_blocks で上限指定可 |
(なし) |
一覧と検索は「どのノートか」を特定するためだけの情報を返し、内容は一切含めません。内容を知るにはまず要約、足りなければ全文、という段階構造です。
docstring で Claude を誘導する
MCP ではツールの docstring がそのまま Claude への説明になります。上の段階構造を Claude に守らせるには、docstring に書いておくのが一番確実です。
@server.tool()
def get_note_summary(note_id: str) -> dict:
"""ノートの AI 要約と参加者を取得する。文字起こし全文は含まない。
ノートの内容を知りたい場合はまずこれを使う。要約で足りない場合にのみ
get_note_transcript で全文を取得する。
"""
こう書いておくと、Claude は「まず要約を見て、必要なら全文」という手順を自然に選びます。何も書かなければ、Claude はツール名と引数だけを手がかりに選ぶことになり、どちらを先に呼ぶかは運任せになります。実際に誘導が効いているかは「使ってみる」の節で確認します。
トークンをファイルに書かない
MCP サーバーはローカルで動くので、Refresh Token をどこかに保存する必要があります。よく見るのはホームディレクトリの JSON ファイルに平文で書く方法ですが、90 日有効なトークンを平文で置くのは避けたいところです。
今回は keyring ライブラリを使い、Windows なら資格情報マネージャー、macOS ならキーチェーンに保存します。Client ID / Client Secret も同じ場所に入れるので、設定ファイルや環境変数にシークレットを一切書かずに済みます。
これには副次的な利点もあります。Claude Desktop は Windows では MSIX パッケージアプリとして動いており、MCP サーバーの子プロセスにユーザー環境変数が渡らないことがあります。環境変数に依存しない設計にしておくと、この問題を最初から回避できます。
実装
ファイルは ainote_mcp.py の 1 つだけです。先頭に PEP 723 のインラインメタデータで依存関係を書いておくと、uv run が仮想環境の作成からパッケージの解決まで自動でやってくれます。
記事に載せるコードは要点が見えるように簡略化しています (エラー分岐やタイムアウト、入力チェックの一部を省いています)。完全版は末尾のリポジトリを参照してください。
# /// script
# requires-python = ">=3.12"
# dependencies = ["mcp>=2,<3", "httpx>=0.27", "keyring>=25"]
# ///
定数
AUTH_URL = "https://auth.worksmobile.com/oauth2/v2.0/authorize"
TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token"
API_BASE = "https://www.worksapis.com/v1.0"
SCOPE = "ainote.read"
REDIRECT_PORT = 8765
REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}/callback"
KEYRING_SERVICE = "lineworks-ainote-mcp"
REDIRECT_URI は後で Developer Console に登録するものと完全一致させる必要があります。末尾のスラッシュ 1 つ違っても認可が失敗します。
keyring への読み書き
import keyring
def _store(key: str, value: str) -> None:
keyring.set_password(KEYRING_SERVICE, key, value)
def _load(key: str) -> str | None:
return keyring.get_password(KEYRING_SERVICE, key)
def _save_tokens(tokens: dict) -> None:
_store("access_token", tokens["access_token"])
_store("expires_at", str(time.time() + int(tokens.get("expires_in", 3600))))
if tokens.get("refresh_token"):
# リフレッシュ時に新しい refresh_token が返ることがある (Refresh Token Rotation)
_store("refresh_token", tokens["refresh_token"])
保存するのは Client ID、Client Secret、Access Token、その期限、Refresh Token の 5 つです。リフレッシュの応答に refresh_token が含まれていたら上書きします。認証のドキュメントに「Refresh Token Rotation が ON の場合、新しい Refresh Token も発行される」とあるためです。
なお Windows の資格情報マネージャーは 1 エントリあたり 2,560 バイトの上限があります。トークンが長い場合に備えて、実際のコードでは _store / _load が値を 1,000 文字ごとに分割して保存しています (記事では省略しています)。
初回ログイン (認可コードフロー)
MCP サーバーは Claude から stdio で起動されるので、その中でブラウザを開いてログインさせるのは扱いにくいです。初回ログインはターミナルから明示的に実行する auth サブコマンドに分けました。
def login() -> None:
client_id = input("Client ID: ").strip()
client_secret = getpass.getpass("Client Secret: ")
state = secrets.token_urlsafe(16)
url = AUTH_URL + "?" + urlencode({
"client_id": client_id,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": SCOPE,
"state": state,
})
code = _receive_code(url, state)
tokens = _post_token(client_id, client_secret, {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
})
_store("client_id", client_id)
_store("client_secret", client_secret)
_save_tokens(tokens)
print("認証が完了しました。トークンは OS の資格情報ストアに保存されています。")
_receive_code はローカルに一時的な HTTP サーバーを立て、ブラウザを開き、リダイレクトで返ってくる認可コードを 1 回だけ受け取ります。
def _receive_code(auth_url: str, expected_state: str) -> str:
result: dict[str, str | None] = {}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
q = parse_qs(urlparse(self.path).query)
result["code"] = (q.get("code") or [None])[0]
result["state"] = (q.get("state") or [None])[0]
result["error"] = (q.get("error") or [None])[0]
result["error_description"] = (q.get("error_description") or [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write("認証が完了しました。このタブは閉じてください。".encode())
def log_message(self, *_): # 標準エラーへのアクセスログを止める
pass
with HTTPServer(("localhost", REDIRECT_PORT), Handler) as srv:
webbrowser.open(auth_url)
srv.handle_request() # 1 リクエストだけ処理して終了
if result.get("error"):
raise SystemExit(f"認可が拒否されました: {result['error']} {result.get('error_description') or ''}")
if result.get("state") != expected_state:
raise SystemExit("state が一致しません。認可をやり直してください。")
if not result.get("code"):
raise SystemExit("認可コードが取得できませんでした。")
return result["code"]
サーバーを bind してからブラウザを開く順序が重要です。逆にすると、リダイレクトが先に届いて接続拒否になることがあります。ユーザーが認可画面で拒否した場合は code ではなく error と error_description が返ってくるので、それも拾って表示します。
トークンエンドポイントへの POST は application/x-www-form-urlencoded です。httpx の data= で送れば自動でその形式になります。
def _post_token(client_id: str, client_secret: str, params: dict) -> dict:
data = {"client_id": client_id, "client_secret": client_secret, **params}
res = httpx.post(TOKEN_URL, data=data, timeout=30.0)
if res.status_code >= 400:
raise ToolError(f"トークン取得に失敗しました: HTTP {res.status_code} {res.text[:300]}")
return res.json()
Access Token の自動更新
MCP サーバー側は、保存済みの Access Token が有効ならそれを使い、期限が近ければ Refresh Token で更新します。Access Token の有効期限は 1 時間または 24 時間で、トークン取得時のレスポンスの expires_in に入っています。Refresh Token は 90 日です。
def get_access_token() -> str:
token, expires_at = _load("access_token"), _load("expires_at")
if token and expires_at and time.time() < float(expires_at) - 60:
return token
refresh = _load("refresh_token")
client_id, client_secret = _load("client_id"), _load("client_secret")
if not (refresh and client_id and client_secret):
raise ToolError(
"未認証です。ターミナルで `uv run ainote_mcp.py auth` を実行してください。"
)
tokens = _post_token(client_id, client_secret, {
"grant_type": "refresh_token",
"refresh_token": refresh,
})
_save_tokens(tokens)
return tokens["access_token"]
Refresh Token も切れていた場合 (90 日以上使わなかった場合) は _post_token がエラーを投げるので、auth をやり直してもらいます。
API 呼び出しとエラーの整形
def api_get(path: str, params: dict | None = None) -> dict:
clean = {k: v for k, v in (params or {}).items() if v is not None}
headers = {"Authorization": f"Bearer {get_access_token()}"}
try:
res = httpx.get(f"{API_BASE}/{path}", headers=headers, params=clean, timeout=30.0)
except httpx.RequestError as exc:
raise ToolError(f"LINE WORKS API に接続できません ({exc.__class__.__name__})") from exc
if res.status_code >= 400:
detail = ""
try:
body = res.json()
detail = f" [{body.get('code')}: {body.get('description')}]"
except ValueError:
pass
if res.status_code == 429:
raise ToolError(f"HTTP 429{detail} 呼び出し上限です。1 分ほど待ってください。")
raise ToolError(f"HTTP {res.status_code}{detail} -> {path}")
return res.json()
2 つポイントがあります。
-
想定したエラーは
ToolErrorで投げる。 MCP SDK 2.x ではToolErrorのメッセージだけがクライアント (Claude) に届きます。それ以外の例外はError executing tool xxxという情報のないエラーにまとめられてしまい、原因が分からなくなります -
API が返す
code/descriptionを必ず添える。 403 はスコープ不足でも権限不足でも返るので、API 自身の説明がないと切り分けができません
ツール定義
ここからが MCP サーバー本体です。MCPServer を作り、関数に @server.tool() を付けるだけでツールになります。型ヒントと docstring からスキーマが自動生成されます。
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
server = MCPServer("lineworks-ainote")
def _hms(milliseconds) -> str:
"""ミリ秒を H:MM:SS にする。audioDuration と発言の offset はミリ秒。"""
try:
total = int(milliseconds) // 1000
except (TypeError, ValueError):
return ""
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def _summarize(note: dict) -> dict:
"""一覧用。要約も文字起こしも含めない。"""
return {
"noteId": note.get("noteId"),
"title": note.get("title"),
"createdTime": note.get("createdTime"),
"duration": _hms(note.get("audioDuration")),
"language": note.get("recognitionLanguage"),
}
def _note_list(data: dict) -> dict:
notes = data.get("notes") or []
return {
"count": len(notes),
"notes": [_summarize(n) for n in notes],
"nextCursor": (data.get("responseMetaData") or {}).get("nextCursor"),
}
@server.tool()
def list_notes(count: int = 20, cursor: str | None = None) -> dict:
"""AiNote のノート一覧を新しい順に取得する。要約も文字起こしも含まない。
タイトル・作成日時・音声の長さのみを返す。内容を読むには、
ここで得た noteId を get_note_summary に渡す。
Args:
count: 取得件数。既定 20。
cursor: 前回の応答の nextCursor。次ページを取得する場合に指定する。
"""
return _note_list(api_get("users/me/ainote/notes", {"count": count, "cursor": cursor}))
@server.tool()
def search_notes(query: str, count: int = 20, cursor: str | None = None) -> dict:
"""AiNote のノートをキーワードで検索する。要約も文字起こしも含まない。
検索 API の呼び出し上限は 60 回/分と他より厳しい (ツール側で 1 秒間隔に制限する)。
同じ意図でキーワードを言い換えて繰り返し検索しないこと。
件数が足りない場合は cursor でページを進める。
Args:
query: 検索キーワード (必須)。
count: 取得件数。既定 20。
cursor: 前回の応答の nextCursor。
"""
if not query or not query.strip():
raise ToolError("query は必須です。検索キーワードを指定してください。")
_throttle_search() # 直前の検索から 1 秒未満なら待つ (実装は省略)
return _note_list(
api_get("users/me/ainote/search", {"query": query, "count": count, "cursor": cursor})
)
@server.tool()
def get_note_summary(note_id: str) -> dict:
"""ノートの AI 要約と参加者を取得する。文字起こし全文は含まない。
ノートの内容を知りたい場合はまずこれを使う。要約で足りない場合にのみ
get_note_transcript で全文を取得する。
要約はノートの作成者が AiNote 上でテンプレートを選んで生成するもの。
summaries が空の場合は作成者が要約を生成していないので、内容を知るには
get_note_transcript で全文を読む。
Args:
note_id: ノートID。list_notes または search_notes の結果から得る。
"""
note = api_get(f"users/me/ainote/notes/{note_id}")
return {
"noteId": note.get("noteId"),
"title": note.get("title"),
"createdTime": note.get("createdTime"),
"duration": _hms(note.get("audioDuration")),
"attendees": [
a.get("attendeeName") for a in (note.get("attendees") or []) if a.get("attendeeName")
],
"summaries": [
{
"summaryType": s.get("summaryType"),
"summaryName": s.get("summaryName"),
"content": s.get("content"),
}
for s in (note.get("summaries") or [])
],
"transcriptBlocks": len(note.get("scripts") or []),
}
@server.tool()
def get_note_transcript(note_id: str, max_blocks: int | None = None) -> dict:
"""ノートの文字起こし全文を取得する。
1 時間の会議で数十 KB になる。要約で足りる場合は get_note_summary を使うこと。
発言は `[時刻] 話者: 発言` の形に整形して返す。
Args:
note_id: ノートID。
max_blocks: 返す発言ブロックの上限。省略すると全件。
"""
note = api_get(f"users/me/ainote/notes/{note_id}")
scripts = note.get("scripts") or []
total = len(scripts)
if max_blocks and max_blocks > 0:
scripts = scripts[:max_blocks]
lines = [
f"[{_hms(b.get('startOffset'))}] {b.get('attendeeName') or '話者不明'}: "
f"{(b.get('text') or '').strip()}"
for b in scripts
]
return {
"noteId": note.get("noteId"),
"title": note.get("title"),
"duration": _hms(note.get("audioDuration")),
"totalBlocks": total,
"returnedBlocks": len(scripts),
"truncated": len(scripts) < total,
"transcript": "\n".join(lines),
}
get_note_summary が文字起こしのブロック数 (transcriptBlocks) を返しているのは意図的です。Claude はこの数を見て「全文を取るべきか、取るなら max_blocks をいくつにするか」を判断できます。
エントリポイント
def main() -> None:
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "auth":
login()
elif cmd == "logout":
logout() # 保存した資格情報をすべて削除
else:
server.run() # stdio で待ち受ける
if __name__ == "__main__":
main()
logout は保存済みの Client ID / Secret / トークンを削除するだけの小さなコマンドですが、別のテナントに切り替えるときや PC を手放すときに必要になるので付けています。
実装で踏んだ細かい点
API の癖 (時間がミリ秒単位、summary ではなく summaries を見る、query 必須、{userId} に me が使える、など) は API の記事の「注意事項」にまとめています。MCP サーバーの設計に影響した点だけ挙げます。
-
AI 要約は自動では付きません。 ノートの作成者が AiNote 上でテンプレートを選んで生成したときだけ
summariesに入ります。生成していないノートでは空配列になるので、get_note_summaryの docstring に「summariesが空なら作成者が要約を生成していないのでget_note_transcriptで全文を読む」と書き、Claude が「要約がない」で止まらないようにしています -
発言ブロックの
attendeeIdは null のことがあります。 話者の区別にはattendeeName(「参加者 1」など) を使い、それもなければ「話者不明」と表示します
Developer Console でのアプリ作成
- LINE WORKS Developer Console にログインし、「アプリの新規追加」でアプリを作ります
-
OAuth Scopes で
ainote.readを選びます -
Redirect URL に
http://localhost:8765/callbackを登録します。コードのREDIRECT_URIと一字一句合わせてください - 表示された Client ID と Client Secret を控えます。この 2 つは次の
authコマンドで入力し、資格情報ストアに保存されます
ダイアログの説明にあるとおり、契約プランによっては一部の Scope が表示されません。ここに ainote.read が出てこない場合は、そのテナントでは AiNote API が使えないということです。
初回ログイン
ターミナルで次を実行します。
uv run ainote_mcp.py auth
Client ID と Client Secret を聞かれるので入力すると、ブラウザが開いて LINE WORKS のログイン画面になります。ログインして許可すると、ブラウザに「認証が完了しました」と表示され、ターミナルにも次のように出ます。
認証が完了しました。トークンは OS の資格情報ストアに保存されています。
付与されたスコープ: ainote.read
Secret は getpass で読んでいるので画面に表示されません。Client ID は表示されるので、スクリーンショットを共有するときは注意してください。
これ以降、トークンは OS の資格情報ストアにあります。Windows なら「資格情報マネージャー」の「Windows 資格情報」に キー名.番号@lineworks-ainote-mcp という形のエントリが並んでいるはずです。
client_id.n が分割数、client_id.0 が 1 番目の断片です。1 つだけ lineworks-ainote-mcp とサービス名そのままのエントリがありますが、これは keyring ライブラリの仕様で、最後に書き込んだ値がこの名前で保存されます。読み出し時は両方を探すので動作に影響はありません。
なお実際の Refresh Token は 230 文字程度で、資格情報マネージャーの上限には余裕があります。分割保存は保険として入れているだけで、通常は断片が 1 つ (.0) だけになります。
資格情報マネージャーを開くには、ターミナルか Win + R で次を実行します。
control /name Microsoft.CredentialManager
Claude への登録
Claude Desktop
claude_desktop_config.json に次を追加します。Windows では Claude Desktop が uv を PATH から解決できないことがあるので、command にはフルパスを書きます。パスは where.exe uv で確認できます (公式インストーラなら %USERPROFILE%\.local\bin\uv.exe、winget なら %LOCALAPPDATA%\Microsoft\WinGet\Packages\...\uv.exe です)。
{
"mcpServers": {
"lineworks-ainote": {
"command": "C:\\Users\\<you>\\.local\\bin\\uv.exe",
"args": ["run", "--quiet", "C:\\path\\to\\ainote_mcp.py"]
}
}
}
--quiet は uv の進捗表示を抑えるためのものです。
Claude Desktop を完全に終了して再起動すると、ツール一覧に lineworks-ainote が現れます。
Claude Code
1 行です。
claude mcp add lineworks-ainote -- uv run --quiet /path/to/ainote_mcp.py
既定では現在のプロジェクトだけに登録されます。どのプロジェクトからも使いたい場合は -s user を付けます。
使ってみる
docstring での誘導が本当に効いているかを確かめるため、Claude Code をヘッドレスモード (claude -p) で起動し、性質の違う 3 つの質問を投げて、呼ばれたツールの順序を記録しました。
| 質問 | 呼ばれたツール |
|---|---|
| 「AiNote にある会議を一覧にして」 | list_notes |
| 「一番新しい会議で決まったことを教えて」 |
list_notes → get_note_summary
|
| 「一番新しい会議で、最初の発言者が最初に話した内容を 1 文で教えて」 |
list_notes → get_note_transcript
|
2 つ目の質問では要約だけで答えを組み立て、全文を取りに行っていません (冒頭の図がこのときの画面です)。3 つ目は発言そのものを問う質問なので、要約を経由せずに全文へ直行しています。各質問は 1 回ずつしか試していないので「常にこう動く」とは言えませんが、少なくともこの 3 ケースでは docstring に書いた「まず要約、足りなければ全文」の方針どおりに動きました。
3 つ目の質問で Claude は get_note_transcript を max_blocks=2 で呼んでいます。「最初の発言者の最初の発言」という質問から先頭 2 ブロックで足りると判断した値です。呼び出し後は応答の totalBlocks と truncated を見て、「全 25 ブロックのうち残りは読み込んでいない」と読み残しがあることを自分で説明しています。
まとめ
- AiNote API は User Account 認証専用なので、認可コードフローの実装が必須
- トークンは
keyringで OS の資格情報ストアに置き、設定ファイルにシークレットを書かない - 一覧・要約・全文をツールとして分け、docstring で「まず要約」と誘導することで、トークン消費と応答速度を両立する
- 想定エラーは
ToolErrorで投げ、API のcode/descriptionを添える
コード全体は以下のリポジトリにあります。
- GitHub: iwaohig/lineworks-ainote-mcp




