Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,82 @@ theme:
date: green
```

### MCP server

`--mcp-port` を指定すると、メッセージ履歴を検索・取得するための MCP (Model Context Protocol) サーバーを `http://localhost:<port>/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!
Expand Down
51 changes: 42 additions & 9 deletions lib/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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");
Expand Down
33 changes: 28 additions & 5 deletions lib/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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);
Expand Down
69 changes: 69 additions & 0 deletions test/mcp_server_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading