はじめに
LINE の未読がたまりがちなので、Claude に LINE を読ませて「未読の要約」と「自分っぽい返信の下書き」を作らせる MCP サーバーを自作しました。
初めて、このMCPを作ろうとした時に公式のMCPは公式ラインだけでAPIも同様に個人のアカウントは使えませんでした。Claudeに聞いてもできませんの一点張りでした。
しかし、Beeperというサービスを知ってあのサービスはLINEのメッセージをLINE for Chromeからひっぱって来てることを知って同じ方法を使えばMCPサーバーを立てられそうだと思いました。
できるようになったこと:
- 「LINE の未読を教えて」で、未読のトークを要約してくれる
- トークを読ませても既読がつかない
- 「〇〇に返信の下書き作って」で、自分の口調に寄せた返信を LINE の入力欄に入れてくれる
- 送信ボタンは自分で押す(送信機能は、あえて持たせていない)
LINE には個人アカウントのトークを扱える公式 API がないので、Chrome 拡張版 LINE の内部状態を Chrome DevTools Protocol(CDP)経由で読んでいます。この記事では、そこに至るまでに試したこととハマりどころをまとめます。
実装は Claude Code と対話しながら進めました。
この記事の方法は、LINE Chrome 拡張の非公式な内部構造に依存しています。拡張が更新されると動かなくなる可能性があります。また、LINE の利用規約を確認し、自分のアカウントでの個人利用の範囲にとどめてください。試す場合は自己責任でお願いします。
環境
- macOS(Apple Silicon)
- Google Chrome 153
- LINE Chrome 拡張 3.7.2
- Node.js 25
-
@modelcontextprotocol/sdk1.30 /puppeteer-core25 - クライアント: Claude Code、Claude Desktop
全体構成
MCP のツールは 4 つです。
| ツール | 内容 |
|---|---|
line_list_chats |
トーク一覧(新しい順・未読数つき)。名前での絞り込み、未読のみも可 |
line_read_chat |
トークの直近メッセージ。トーク名(部分一致)か ID で指定 |
line_reply_style |
自分の返信の癖のまとめと、その相手との直近のやりとり数件 |
line_draft_reply |
入力欄に返信の下書きを入れる(送信はしない) |
1. Claude in Chrome では拡張のページに入れない
最初は、Chrome 拡張版の Claude(Claude in Chrome)で LINE の画面を読ませればいいと考えていました。ところが、LINE 拡張のページ(chrome-extension://…/index.html)を開こうとすると拒否されます。
Can't interact with browser-internal or unparseable URLs.
拡張機能のページは、他の拡張からは操作できません。そこで、CDP で Chrome に直接つなぐ方針にしました。
2. 普段の Chrome に CDP でつなぐ
Chrome 136 以降、既定のプロファイルでは --remote-debugging-port が効かなくなっています(Cookie を盗むマルウェア対策)。
一方、最近の Chrome には chrome://inspect/#remote-debugging から「今動いている Chrome」のリモートデバッグを有効にする機能があります。有効にすると、プロファイルのディレクトリに DevToolsActivePort というファイルが書き出されるので、そこから接続先を組み立てます。
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer-core';
const file = path.join(os.homedir(), 'Library/Application Support/Google/Chrome/DevToolsActivePort');
const [port, wsPath] = (await fs.readFile(file, 'utf8')).trim().split('\n');
const browser = await puppeteer.connect({
browserWSEndpoint: `ws://127.0.0.1:${port}${wsPath}`,
defaultViewport: null,
});
// LINE 拡張のタブを探す
const LINE_URL = 'chrome-extension://ophjlpahpchlmihnnnihgmmeilfjmjjc/index.html';
const target = browser.targets().find((t) => t.url().startsWith(LINE_URL) && t.type() === 'page');
const page = await target.page();
Claude in Chrome が入れなかった拡張のページにも、CDP なら page.evaluate で入れます。
3. DOM ではなく Redux ストアを読む
最初は DOM から一覧やメッセージを読むつもりでした。ところが、クラス名が chatlistItem-module__chatlist_item__MOwxh のような CSS Modules のハッシュ付きで、更新ですぐ壊れそうです。
そこで React の fiber をたどってみると、Redux ストアが見つかりました。
// LINE 拡張のページ内で実行
const root = document.getElementById('root');
const key = Object.keys(root).find((k) => k.startsWith('__reactContainer$'));
let store;
const stack = [root[key]];
while (stack.length) {
const f = stack.pop();
if (!f) continue;
if (f.memoizedProps?.store?.getState) {
store = f.memoizedProps.store;
break;
}
if (f.sibling) stack.push(f.sibling);
if (f.child) stack.push(f.child);
}
Object.keys(store.getState());
// => ['auth', 'chat', 'common', 'contact', 'message', 'messageBox', 'room', 'talkChat', 'ui']
中身はおおむね次のとおりです。
-
messageBox.entities.messageBoxes:トーク一覧(未読数、最新メッセージの ID) -
message.entities.messages:メッセージ。復号済みの本文がtextに入っている -
contact.entities.contacts:友だちの表示名(自分で付けた名前はdisplayNameOverridden) -
talkChat.entities.talkChats:グループ名
トーク一覧は、これを組み立てるだけで作れます。
4. 既読をつけずに履歴を読む
履歴は保存されていない
ストアに全トークの履歴が入っているわけではありません。持っているのは、各トークの最新メッセージと、画面で開いたことのあるトークの履歴だけです。IndexedDB も見ましたが、入っていたのは E2EE の公開鍵だけで、メッセージは保存されていませんでした。
つまり、履歴を取るにはトークを開く必要があります。しかし、トークを開くと既読がつきます。Claude に読ませただけで既読がつくのは困ります。
既読がどこで送られているか
拡張のバンドル(static/js/main.js)を読むと、既読通知は TalkService/sendChatChecked という API でした。呼んでいるのはトーク画面コンポーネントの useEffect と、一覧の「既読にする」メニューだけです。
一方、履歴の取得は createAsyncThunk で作られた thunk に分かれていました。
// main.js(整形・抜粋。実際は minify されていて createAsyncThunk も別名)
DC = createAsyncThunk('message/fetchMessageLists', async ({ messageBoxIdList, messagesCount }, { dispatch }) => {
// getRecentMessagesV2 で取得 → 復号 → ストアに格納
});
jC = createAsyncThunk('message/fetchPrevMessageList', async ({ messageBoxId, endMessageId }, { dispatch }) => {
// getPreviousMessagesV2WithRequest でさかのぼって取得
});
この thunk を画面を開かずに直接 dispatch すれば、既読はつかないはずです。
モジュール内の thunk を [[Scopes]] から取り出す
問題は、この thunk がバンドルのモジュールスコープに閉じていて、外から参照できないことです。webpack の __webpack_require__.c(モジュールキャッシュ)も、本番ビルドでは有効になっていませんでした。
そこで、DevTools で関数を展開したときに見える [[Scopes]] を使いました。CDP の Runtime.getProperties なら、関数が閉じ込めている変数を取り出せます。React のコンポーネント関数は main.js のモジュールスコープを閉じ込めているので、そこから typePrefix を持つ関数(createAsyncThunk の action creator)を探します。
const cdp = await page.createCDPSession();
// fnId: fiber から集めた React コンポーネント関数の objectId
const { internalProperties } = await cdp.send('Runtime.getProperties', { objectId: fnId, ownProperties: false });
const scopes = internalProperties.find((p) => p.name === '[[Scopes]]');
const { result: scopeList } = await cdp.send('Runtime.getProperties', {
objectId: scopes.value.objectId,
ownProperties: true,
});
for (const s of scopeList.filter((p) => p.value?.description === 'Closure')) {
const { result: vars } = await cdp.send('Runtime.getProperties', { objectId: s.value.objectId, ownProperties: true });
const fns = vars.filter((v) => v.value?.type === 'function');
if (fns.length < 100) continue; // モジュールスコープは変数が多いので、それ以外は飛ばす
// 関数をまとめてページ側に渡し、thunk だけ window に置く
await cdp.send('Runtime.callFunctionOn', {
objectId: fnId,
functionDeclaration: `function (...fns) {
window.__lineThunks ??= {};
for (const f of fns) {
try { if (typeof f.typePrefix === 'string') window.__lineThunks[f.typePrefix] = f; } catch {}
}
}`,
arguments: fns.map((v) => ({ objectId: v.value.objectId })),
});
}
これで message/fetchMessageLists や contact/fetchContacts など 25 個の thunk が取れました。あとは普通に dispatch するだけです。
await store.dispatch(
window.__lineThunks['message/fetchMessageLists']({ messageBoxIdList: [chatId], messagesCount: 50 }),
);
本当に既読がつかないか確かめる
念のため、読み取り中の通信を page.on('request') で記録しました。未読 7 件のグループを 150 件分さかのぼったときの結果です。
getRecentMessagesV2 1
getPreviousMessagesV2WithRequest 2
getContactsV2 1 ← 友だちではないメンバーの名前の取得
sendChatChecked 0
sendChatChecked は一度も呼ばれず、読んだあとも未読数は 7 のままでした。
LINE for Chrome でさかのぼれる履歴は、ログインしてからおおむね直近 2 週間分でした。それより前は、サーバーから返ってきません。
5. 返信の下書きを入力欄に入れる
送信には message/enqueueTextMessage という thunk があり、技術的には送信もできます。ただ、送信は取り消しがききません。そのため、入力欄に下書きを入れるところまでにしました。
入力欄の中身もストアで管理されていて、普通の action で差し替えられます。
store.dispatch({
type: 'common/updateChatRoomEditorInput',
payload: { messageBoxId: chatId, input: { value: ['了解!明日10時に駅前で'] } },
});
入力欄は <textarea-ex> という独自要素(中は Shadow DOM の textarea)で、ストアの値がそのまま表示されます。トーク画面を開かないので、この時点でも既読はつきません。あとで自分がトークを開くと下書きが入っているので、確認して送信します。
入力欄に書きかけの文章があるときは上書きせずエラーを返し、ユーザーに確認してから overwrite: true で上書きする仕様にしています。
6. 自分の返信の癖を「学習」させる
モデル自体を再学習するのではなく、自分の過去の返信を Mac 内に貯めて、下書きのときに参考にさせる方式です。
集める
常駐プロセスが 30 分ごとに、自分の送信メッセージと、その直前の相手の発言(最大 3 件) だけを SQLite に保存します。データの置き方は次のようにしています。
- 相手の名前は保存しない(「相手」「メンバー」に置き換え)
- 保存先のディレクトリは
700、DB は600。iCloud の同期対象外の場所に置く - ログには件数とエラーだけを書き、本文は書かない
集計する(AI は使わない)
集計はただのコードで行います。1 通の長さの中央値、文末の記号(。 ! ? 〜 笑)の割合、敬語の割合、連投の割合、よく使う絵文字・語尾・短い返事を数えます。
絵文字は、Intl.Segmenter で書記素単位に分けて数えます。そうしないと、🙇♂️ が 🙇 と ♂ に分かれて集計されます。
const graphemes = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const IS_EMOJI = /\p{Extended_Pictographic}/u;
const emojisIn = (t) => [...graphemes.segment(t)].map((g) => g.segment).filter((g) => IS_EMOJI.test(g));
line_reply_style の出力はこんな感じです(数値ややりとりはダミーです)。
# あなたの返信の癖(Mac 内で集計)
## 全体
- 返信 500 回(メッセージ 800 件)
- 1 通の長さ: 中央値 8 文字 / 改行を含む 2%
- 連投(2 通以上に分けて送る): 40% / スタンプだけで返す: 3%
- 文末: 記号なし 70%、「!」 10%、「?」 8%、笑い(w/笑/草) 6%
- 敬語(です・ます等)を含む: 15% / 笑い表現を含む: 6%
- よく使う語尾: 〜よね(20) 〜った(18) 〜ない(15)
- 定番の短い返事: 「おけ」(8) 「了解」(6) 「ありがとう」(5)
## 「Aさん」への返信の傾向
- 1 通の長さ: 中央値 6 文字 / 敬語 0% / 笑い表現 12%
...
## 直近のやりとり(5 件)
相手: 明日の集合って何時だっけ?
自分: 10時! / 駅前ね
Claude には、下書きを書く前にこれを読んでから文体を合わせるよう、ツールの説明文で指示しています。同じ人でも、友だちには短文を連投し、就活の連絡には敬語で返すといった違いがはっきり出るので、相手ごとの傾向を分けて渡すのが効きました。
7. 「リモートデバッグを許可しますか」が毎回出る問題
ここで一番困りました。chrome://inspect/#remote-debugging で有効にした Chrome は、CDP の接続が来るたびに許可ダイアログを出します。
確認用スクリプトを走らせるたび、Claude Code のセッションを開くたび、自動収集が動くたびにダイアログが出て、とても使えません。
2 つの案を比べました。
-
LINE 専用の Chrome を別プロファイルで起動する:既定でないプロファイルなら
--remote-debugging-portが効くので、ダイアログは出ない。ただし LINE 拡張を入れ直し、ログインし直す必要があり、ウィンドウも普段の Chrome と分かれる - 接続を 1 本にまとめる:常駐プロセスが CDP の接続を持ち続け、MCP サーバーはそれを経由する
2 を採用しました。ダイアログは Chrome を起動したときの 1 回だけになります。
- 常駐プロセスは launchd の LaunchAgent で動かす
- MCP サーバーとは Unix ソケット(パーミッション
600)で話す -
DevToolsActivePortを 5 秒ごとに見張り、Chrome が起動したらすぐ接続する。ダイアログが Chrome 起動の直後に出るので、何の確認か分かりやすい - 許可を拒否された接続先には、Chrome が再起動するまで自動では再接続しない(ダイアログを連発しない)
// MCP サーバー側は Chrome に直接つながず、常駐プロセスを呼ぶだけ
server.registerTool(
'line_read_chat',
{
title: 'LINE トークを読む',
description: '指定したトークの直近のメッセージを古い順に返す。トーク画面を開かずに取得するので既読は付かない。',
inputSchema: {
chat: z.string().min(1).describe('トーク名または id'),
limit: z.number().int().min(1).max(500).optional(),
},
annotations: { readOnlyHint: true },
},
// call は Unix ソケット経由で常駐プロセスを呼ぶ関数。format(テキストへの整形)は省略
({ chat, limit }) => run(async () => format(await call('readChat', { chat, limit: limit ?? 30 }))),
);
その他のハマりどころ
page.evaluate に Infinity を渡すと null になる
「全件」のつもりで limit: Infinity を渡したら、一覧が空になりました。引数は JSON にシリアライズされるので、Infinity は null になり、slice(0, null) で 0 件です。大きな数値を渡すようにしました。
内部関数の取り出しが並行すると壊れる
自動収集と Claude からの呼び出しが重なると、thunk を取り出す処理が 2 本同時に走ります。すると、後から来た方が window.__lineThunks を空で上書きします。取り出しが同時に 1 つしか走らないよう、処理中の Promise を使い回すようにしました。
let bridgeReady = null;
bridgeReady ??= ensureBridge(page).finally(() => (bridgeReady = null));
await bridgeReady;
使ってみた
Claude Code で「LINE の未読を教えて」と頼むと、line_list_chats と line_read_chat を呼んで、次のように分類してまとめてくれます。
- 友だち・個人(返信が要りそうなもの)
- グループ
- 公式アカウント・宣伝
「〇〇に返信の下書き作って」と頼むと、line_reply_style で癖を確認してから line_draft_reply を呼びます。LINE を開くと入力欄に下書きが入っているので、確認して送信するだけです。
プライバシーについて
LINE には友だちのメッセージも含まれるので、データの流れは意識しておく必要があります。
- Claude に読ませた内容は Anthropic に送られます。Claude はクラウドで動いているので、これは避けられません。個人向けプラン(Free / Pro / Max)では、プライバシー設定で「学習に使うか」を選べます。OFF なら学習には使われず、保存期間は 30 日です(Claude Code のデータの扱い)。私は OFF にしました
-
/feedbackや/bug、セッション評価のあとの「会話の記録を送りますか?」で Yes を選ぶと、会話の記録が別途送られます。LINE を扱ったセッションでは使わないようにしています - 返信の履歴 DB と集計は Mac 内で完結させ、Claude に渡すのは「癖のまとめ」と「その相手との直近数件」(←自分はこれ無くしてますが、精度上げるなら保持すべき)だけにしています
- 完全にクラウドに出したくない場合は、下書きをローカル LLM(Ollama など)に書かせるべきです。私はOllamaで動かしてます
制約・注意点
- 公式 API ではないので、LINE 拡張の更新で壊れる可能性がある
- 履歴はおおむね直近 2 週間分まで
- 下書きは LINE 拡張のメモリ上にあるだけなので、LINE のウィンドウを閉じると消える
- ChatGPT からは使えない。ChatGPT はネット越しに届く MCP サーバーしか使えず、LINE の中身を外から読める入口を作ることになるので見送った
おわりに
「Claude in Chrome で読めばいい」と軽く始めましたが、拡張のページに入れないところから、CDP、React の fiber、[[Scopes]] からの thunk の取り出しと、思ったより深いところまで潜ることになりました。
特に効いた設計判断は次の 3 つです。
- DOM ではなくアプリ自身の状態(Redux ストア)と処理(thunk)を使うことで、表示の変化に強く、既読もつけずに済んだ
- 送信は人がやる。下書きまでにしておくと、安心して任せられる
- 口調の「学習」は、ローカルで数えるだけでもかなり効く
同じように LINE の未読に埋もれている人の参考になればうれしいです。