diff --git a/.gitignore b/.gitignore index 53bba9c..7828e35 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ logs *.log npm-debug.log* +# Message logs / databases (may contain real Slack message data) +*.db +*.sqlite +*.sqlite3 +*.tsv + # Runtime data pids *.pid diff --git a/README.md b/README.md index 99ca81a..a1308ed 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,82 @@ theme: date: green ``` +### MCP server + +`--mcp-port` を指定すると、メッセージ履歴を検索・取得するための MCP (Model Context Protocol) サーバーを `http://localhost:/mcp` で起動します。Claude などの MCP クライアントから接続できます。 + +``` +$ slack-cli-stream --token xoxp-********** --log-sqlite ./slack.db --mcp-port 3737 +``` + +`mcp.port` は setting.yaml でも指定できます。 + +```yaml +mcp: + port: 3737 +``` + +> **Note (セキュリティ)**: MCP サーバーは認証を行いません。`post_to_stream` による表示注入・SQLite 書き込みや、保存済みメッセージ履歴の読み取りが、ポートに到達できる相手なら誰でも可能です。`localhost` バインドのまま利用し、`0.0.0.0` での公開やポートフォワードは避けてください。 + +提供ツール: + +- `search_messages` / `get_messages_by_channel` / `get_messages_by_date_range` / `get_thread_messages` — SQLite に保存した履歴の検索・取得(`--log-sqlite` 必須) +- `get_recent_messages` / `list_channels` — メモリ上のバッファ・チャンネル情報の取得 +- `post_to_stream` — **AIエージェントから任意のメッセージを slack-cli-stream のコンソールに表示**。Slack のライブメッセージと時系列でマージ表示され、`--log-sqlite` 有効時は SQLite にも記録されます。 + +#### post_to_stream(AIエージェントのメッセージ表示) + +Claude を loop 等でエージェント的に稼働させているとき、進捗・判断・通知などを Slack 経由ではなく直接このプログラムに流して、Slack メッセージと並べて眺められます。 + +引数: + +- `text`(必須): 表示する本文(改行可) +- `channel`(任意): 表示するチャンネル名相当ラベル(例: `agent-log`)。既定値 `claude` +- `user`(任意): 表示する発言ユーザー名相当ラベル(例: `claude`)。既定値 `claude` + +##### セットアップ手順 + +Claude を loop でエージェント的に動かし、その発言を slack-cli-stream に流す全体の流れです。 + +**1. ターミナルA: slack-cli-stream を MCP サーバー付きで起動** + +``` +$ slack-cli-stream --token xoxp-********** --log-sqlite ./slack.db --mcp-port 3737 +[MCP] Server listening on http://localhost:3737/mcp +``` + +**2. Claude Code に MCP サーバーを登録**(初回のみ) + +``` +$ claude mcp add --transport http slack-stream http://localhost:3737/mcp +``` + +登録できているかは `claude mcp list` で確認できます。 + +**3. ターミナルB: Claude を loop で起動し、流すよう指示する** + +``` +$ claude +> /loop 〜タスクの内容〜。進捗・重要な判断・完了時には slack-stream の + post_to_stream ツールを channel="agent-log" user="claude" を指定して + 1〜2行で流すこと。冗長な思考過程は流さない。 +``` + +恒久的に効かせたい場合は、プロジェクトの `CLAUDE.md` に同様の方針を書いておくとループごとに指示する必要がなくなります。 + +##### 表示イメージ + +ターミナルA には Slack のライブメッセージと Claude のメッセージが受信順(時系列)で混ざって表示されます。 + +``` +2026-06-20 21:36:01 | #general | hideack | デプロイお願いします +2026-06-20 21:36:53 | #agent-log | claude | テスト実行中… 58 passing +2026-06-20 21:37:10 | #agent-log | claude | デプロイ完了。本番反映を確認しました +2026-06-20 21:37:42 | #general | hideack | ありがとう! +``` + +`--log-sqlite` を有効にしていれば Claude の発言も `messages` テーブルに保存され、`search_messages` 等の MCP ツールや SQLite から後で横断検索できます(Slack 由来のメッセージと違い `slack_ts` は空なので、バックフィル処理には影響しません)。 + ## Contributing 1. Fork it! diff --git a/lib/core.js b/lib/core.js index 89a4cef..721fe86 100644 --- a/lib/core.js +++ b/lib/core.js @@ -107,20 +107,26 @@ let resolveChannelLabelKey = (channelId) => { core.display = (data, options) => { let name, channel; - if (util.users[data.user]) { - name = chalk[util.users[data.user].color](util.users[data.user].name); + if (data.synthetic) { + // Claude(AI)など、Slack外のソースから注入されたメッセージ。 + // data.channel / data.user は Slack ID ではなく任意のラベル文字列なので + // util.users / resolveChannelLabelDisplay による ID 解決はバイパスする。 + name = chalk.cyan(typeof data.user == "string" ? data.user : "-"); + channel = chalk.cyan(typeof data.channel == "string" ? data.channel : "-"); } else { - if (typeof data.user == "string") { + if (util.users[data.user]) { + name = chalk[util.users[data.user].color](util.users[data.user].name); + } else if (typeof data.user == "string") { name = chalk.white(data.user); } else { name = chalk.white("-"); } - } - if (typeof data.channel == "string") { - channel = resolveChannelLabelDisplay(data.channel); - } else { - channel = chalk.white("-"); + if (typeof data.channel == "string") { + channel = resolveChannelLabelDisplay(data.channel); + } else { + channel = chalk.white("-"); + } } data.lines.forEach((line) => { @@ -232,12 +238,39 @@ core.start = async (commander) => { setInterval(() => updateAppHeartbeat(sqliteDb), 60 * 1000); } + // Slack 以外のソース(Claude等のAIエージェント)から任意のメッセージを + // 表示パイプライン(コンソール表示 + バッファ + SQLite記録)へ注入する。 + // channel / user は Slack ID ではなく、そのまま表示する任意ラベル。 + core.postToStream = (text, opts = {}) => { + const channelLabel = opts.channel || "claude"; + const userLabel = opts.user || "claude"; + const fullLines = String(text).split("\n"); + const bufferKey = (channelLabel.startsWith("#") || channelLabel.startsWith("@")) + ? channelLabel + : "#" + channelLabel; + + const data = { + synthetic: true, + bufferKey: bufferKey, + lines: fullLines, + fullLines: fullLines, + time: moment(), + channel: bufferKey, + user: userLabel, + slackTs: null, + threadTs: null + }; + + core.display(data, { log: options.log, logSqlite: options.logSqlite }); + util.addMessageBuffer(data); + }; + const mcpPort = options.mcpPort ? parseInt(options.mcpPort, 10) : (util.mcp && util.mcp.port ? util.mcp.port : null); if (mcpPort) { - startMcpServer({ port: mcpPort, sqliteDb, util }); + startMcpServer({ port: mcpPort, sqliteDb, util, postToStream: core.postToStream }); } const {RTMClient} = require("@slack/client"); diff --git a/lib/mcp-server.js b/lib/mcp-server.js index 3bf064c..014eaeb 100644 --- a/lib/mcp-server.js +++ b/lib/mcp-server.js @@ -26,7 +26,7 @@ const formatObjectsResult = (items, summary) => { return { content: [{ type: "text", text }] }; }; -const registerTools = (server, sqliteDb, util) => { +const registerTools = (server, sqliteDb, util, postToStream) => { server.tool( "search_messages", "Full-text search across Slack message history stored in SQLite using FTS5. Requires --log-sqlite.", @@ -163,24 +163,47 @@ const registerTools = (server, sqliteDb, util) => { return formatRowsResult(rows, `get_thread_messages(${thread_ts}): ${rows.length} message(s)`); } ); + + server.tool( + "post_to_stream", + "Display a message from the AI agent in the slack-cli-stream console, merged " + + "chronologically with live Slack messages (and persisted to SQLite if enabled). " + + "Use this to surface progress, decisions, or notifications during an autonomous session.", + { + text: z.string().describe("Message body to display (newlines allowed)"), + channel: z.string().optional().describe("Channel label to show (Slackのチャンネル名相当, e.g. 'agent-log'). Default: 'claude'"), + user: z.string().optional().describe("Sender label to show (発言ユーザ名相当, e.g. 'claude'). Default: 'claude'"), + }, + ({ text, channel, user }) => { + if (typeof postToStream !== "function") { + return { + content: [{ type: "text", text: "post_to_stream is not available in this instance." }], + isError: true, + }; + } + postToStream(text, { channel, user }); + const ch = channel || "claude"; + return { content: [{ type: "text", text: `posted to "${ch}" as "${user || "claude"}"` }] }; + } + ); }; -const buildMcpServer = (sqliteDb, util) => { +const buildMcpServer = (sqliteDb, util, postToStream) => { const server = new McpServer({ name: "slack-cli-stream", version: require("../package.json").version, }); - registerTools(server, sqliteDb, util); + registerTools(server, sqliteDb, util, postToStream); return server; }; -const startMcpServer = ({ port, sqliteDb, util }) => { +const startMcpServer = ({ port, sqliteDb, util, postToStream }) => { const app = express(); app.use(express.json()); app.all("/mcp", async (req, res) => { try { - const server = buildMcpServer(sqliteDb, util); + const server = buildMcpServer(sqliteDb, util, postToStream); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); diff --git a/test/mcp_server_test.js b/test/mcp_server_test.js index 31490c6..a90539a 100644 --- a/test/mcp_server_test.js +++ b/test/mcp_server_test.js @@ -184,6 +184,75 @@ describe("MCPサーバーのテスト", () => { }); }); +describe("post_to_stream ツールのテスト", () => { + let server, port; + const calls = []; + + const mockUtil = { channels: {}, users: {}, buffer: {} }; + const postToStream = (text, opts) => { calls.push({ text, opts }); }; + + before((done) => { + server = startMcpServer({ port: 0, sqliteDb: null, util: mockUtil, postToStream }); + server.on("listening", () => { + port = server.address().port; + done(); + }); + }); + + after((done) => { + server.close(done); + }); + + it("post_to_streamがpostToStreamコールバックを引数付きで呼び出すこと", async () => { + calls.length = 0; + const res = await callTool(port, "post_to_stream", { + text: "進捗: タスク完了", + channel: "agent-log", + user: "claude", + }); + assert.isNotNull(res.body, "レスポンスボディが存在する"); + const isError = res.body && res.body.result && res.body.result.isError; + assert.isNotTrue(isError, "isErrorがtrueでないこと"); + assert.lengthOf(calls, 1, "postToStreamが1回呼ばれること"); + assert.equal(calls[0].text, "進捗: タスク完了", "textが渡ること"); + assert.equal(calls[0].opts.channel, "agent-log", "channelが渡ること"); + assert.equal(calls[0].opts.user, "claude", "userが渡ること"); + }); + + it("channel/user省略時もデフォルトで呼び出せること", async () => { + calls.length = 0; + const res = await callTool(port, "post_to_stream", { text: "hello" }); + const isError = res.body && res.body.result && res.body.result.isError; + assert.isNotTrue(isError, "isErrorがtrueでないこと"); + assert.lengthOf(calls, 1, "postToStreamが1回呼ばれること"); + assert.equal(calls[0].text, "hello", "textが渡ること"); + }); +}); + +describe("post_to_stream ツール (コールバック未設定) のテスト", () => { + let server, port; + const mockUtil = { channels: {}, users: {}, buffer: {} }; + + before((done) => { + server = startMcpServer({ port: 0, sqliteDb: null, util: mockUtil }); + server.on("listening", () => { + port = server.address().port; + done(); + }); + }); + + after((done) => { + server.close(done); + }); + + it("postToStream未設定時はエラーを返すこと", async () => { + const res = await callTool(port, "post_to_stream", { text: "hello" }); + assert.isNotNull(res.body, "レスポンスボディが存在する"); + const isError = res.body && res.body.result && res.body.result.isError; + assert.isTrue(isError, "isErrorがtrueであること"); + }); +}); + describe("MCPサーバー (SQLite無し) のテスト", () => { let server, port;