slack-task-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "slack-task-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP Server for Slack task management with Claude Code",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "slack-task-mcp": "./src/cli.js"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/ignission/mcp-slack-task.git"
16
+ },
17
+ "homepage": "https://github.com/ignission/mcp-slack-task#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/ignission/mcp-slack-task/issues"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "slack",
27
+ "claude",
28
+ "task-management",
29
+ "model-context-protocol"
30
+ ],
31
+ "author": "Ignission G.K.",
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.25.1",
35
+ "@slack/web-api": "^7.13.0",
36
+ "open": "^10.1.0",
37
+ "zod": "^4.2.1"
38
+ }
39
+ }
package/src/auth.js ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * OAuth 認証モジュール
5
+ *
6
+ * Cloudflare Workers を使った OAuth 認証フロー
7
+ */
8
+
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs/promises";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import open from "open";
14
+
15
+ // 定数
16
+ const DATA_DIR = path.join(os.homedir(), ".slack-task-mcp");
17
+ const CREDENTIALS_FILE = path.join(DATA_DIR, "credentials.json");
18
+ const AUTH_TIMEOUT = 5 * 60 * 1000; // 5分
19
+ const POLL_INTERVAL = 2000; // 2秒
20
+
21
+ // OAuth Worker URL
22
+ const OAUTH_WORKER_URL =
23
+ process.env.OAUTH_WORKER_URL || "https://slack-task-mcp-oauth.ignission.workers.dev";
24
+
25
+ /**
26
+ * データディレクトリを初期化
27
+ */
28
+ async function initDataDir() {
29
+ try {
30
+ await fs.mkdir(DATA_DIR, { recursive: true });
31
+ } catch (_err) {
32
+ // 既に存在する場合は無視
33
+ }
34
+ }
35
+
36
+ /**
37
+ * credentials.json を読み込み
38
+ */
39
+ export async function loadCredentials() {
40
+ try {
41
+ const data = await fs.readFile(CREDENTIALS_FILE, "utf-8");
42
+ return JSON.parse(data);
43
+ } catch (_err) {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * credentials.json を保存
50
+ */
51
+ async function saveCredentials(credentials) {
52
+ await initDataDir();
53
+ await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 });
54
+ }
55
+
56
+ /**
57
+ * credentials.json を削除
58
+ */
59
+ async function deleteCredentials() {
60
+ try {
61
+ await fs.unlink(CREDENTIALS_FILE);
62
+ return true;
63
+ } catch (_err) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * セッション ID を生成
70
+ */
71
+ function generateSessionId() {
72
+ return crypto.randomBytes(16).toString("hex");
73
+ }
74
+
75
+ /**
76
+ * Worker にポーリングしてトークンを取得
77
+ */
78
+ async function pollForToken(sessionId) {
79
+ const pollUrl = `${OAUTH_WORKER_URL}/poll?session_id=${sessionId}`;
80
+
81
+ const response = await fetch(pollUrl);
82
+ const data = await response.json();
83
+
84
+ return data;
85
+ }
86
+
87
+ /**
88
+ * OAuth 認証フローを実行
89
+ * Cloudflare Workers を使用
90
+ */
91
+ export async function authenticate(options = {}) {
92
+ const noBrowser = options.noBrowser || false;
93
+
94
+ // セッション ID を生成
95
+ const sessionId = generateSessionId();
96
+
97
+ // Worker の認証 URL を生成
98
+ const authUrl = `${OAUTH_WORKER_URL}/auth?session_id=${sessionId}`;
99
+
100
+ console.log("🔐 Slack OAuth 認証を開始します...");
101
+ console.log("");
102
+
103
+ if (noBrowser) {
104
+ console.log("以下の URL をブラウザで開いてください:");
105
+ console.log("");
106
+ console.log(authUrl);
107
+ } else {
108
+ console.log("ブラウザで Slack ログイン画面を開いています...");
109
+ try {
110
+ await open(authUrl);
111
+ } catch (_err) {
112
+ console.log("");
113
+ console.log("ブラウザを自動で開けませんでした。以下の URL を手動で開いてください:");
114
+ console.log("");
115
+ console.log(authUrl);
116
+ }
117
+ }
118
+
119
+ console.log("");
120
+ console.log("認証が完了するまで待機中...");
121
+
122
+ // ポーリング開始
123
+ const startTime = Date.now();
124
+
125
+ while (Date.now() - startTime < AUTH_TIMEOUT) {
126
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
127
+
128
+ try {
129
+ const result = await pollForToken(sessionId);
130
+
131
+ if (result.status === "success") {
132
+ // credentials を保存
133
+ const credentials = {
134
+ access_token: result.access_token,
135
+ token_type: result.token_type,
136
+ scope: result.scope,
137
+ user_id: result.user_id,
138
+ team_id: result.team_id,
139
+ team_name: result.team_name,
140
+ created_at: result.created_at,
141
+ };
142
+
143
+ await saveCredentials(credentials);
144
+
145
+ console.log("");
146
+ console.log("✅ 認証が完了しました!");
147
+ console.log(` ワークスペース: ${credentials.team_name}`);
148
+ console.log(` トークンは ${CREDENTIALS_FILE} に保存されました`);
149
+
150
+ return true;
151
+ }
152
+
153
+ if (result.status === "error") {
154
+ console.error("");
155
+ console.error(`❌ 認証エラー: ${result.error}`);
156
+ return false;
157
+ }
158
+
159
+ // pending の場合は継続
160
+ process.stdout.write(".");
161
+ } catch (_err) {
162
+ // ネットワークエラーは無視して継続
163
+ process.stdout.write("x");
164
+ }
165
+ }
166
+
167
+ console.error("");
168
+ console.error("❌ 認証がタイムアウトしました(5分)");
169
+ return false;
170
+ }
171
+
172
+ /**
173
+ * 認証状態を表示
174
+ */
175
+ export async function showStatus() {
176
+ const credentials = await loadCredentials();
177
+
178
+ console.log("📋 認証状態");
179
+ console.log("");
180
+
181
+ if (!credentials) {
182
+ console.log("状態: ❌ 未認証");
183
+ console.log("");
184
+ console.log("`npx slack-task-mcp auth` を実行して認証してください");
185
+ return;
186
+ }
187
+
188
+ console.log("状態: ✅ 認証済み");
189
+ console.log(`ユーザー ID: ${credentials.user_id}`);
190
+ console.log(`ワークスペース: ${credentials.team_name} (${credentials.team_id})`);
191
+ console.log(`認証日時: ${credentials.created_at}`);
192
+ console.log(`スコープ: ${credentials.scope}`);
193
+ }
194
+
195
+ /**
196
+ * ログアウト
197
+ */
198
+ export async function logout() {
199
+ const credentials = await loadCredentials();
200
+
201
+ if (!credentials) {
202
+ console.log("ℹ️ 認証情報はありません");
203
+ return true;
204
+ }
205
+
206
+ const deleted = await deleteCredentials();
207
+
208
+ if (deleted) {
209
+ console.log("✅ ログアウトしました");
210
+ console.log(` ${CREDENTIALS_FILE} を削除しました`);
211
+ return true;
212
+ } else {
213
+ console.error("❌ ログアウトに失敗しました");
214
+ return false;
215
+ }
216
+ }
package/src/cli.js ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI エントリーポイント
5
+ *
6
+ * npx slack-task-mcp [command] [options]
7
+ */
8
+
9
+ import { authenticate, logout, showStatus } from "./auth.js";
10
+
11
+ // コマンドライン引数を解析
12
+ const args = process.argv.slice(2);
13
+ const command = args[0];
14
+ const subCommand = args[1];
15
+
16
+ // オプションを解析
17
+ function parseOptions(args) {
18
+ const options = {};
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i] === "--no-browser") {
21
+ options.noBrowser = true;
22
+ }
23
+ }
24
+ return options;
25
+ }
26
+
27
+ // ヘルプを表示
28
+ function showHelp() {
29
+ console.log(`
30
+ Slack Task MCP Server
31
+
32
+ Usage:
33
+ npx slack-task-mcp [command] [options]
34
+
35
+ Commands:
36
+ auth Slack OAuth 認証を開始
37
+ auth status 認証状態を表示
38
+ auth logout ログアウト
39
+ (なし) MCP サーバーとして起動
40
+
41
+ Options:
42
+ --no-browser ブラウザを自動で開かない
43
+ --help, -h ヘルプを表示
44
+
45
+ Examples:
46
+ npx slack-task-mcp auth
47
+ npx slack-task-mcp auth status
48
+ npx slack-task-mcp auth logout
49
+ npx slack-task-mcp auth --no-browser
50
+ `);
51
+ }
52
+
53
+ // メイン処理
54
+ async function main() {
55
+ // ヘルプ
56
+ if (args.includes("--help") || args.includes("-h")) {
57
+ showHelp();
58
+ process.exit(0);
59
+ }
60
+
61
+ // auth コマンド
62
+ if (command === "auth") {
63
+ if (subCommand === "status") {
64
+ await showStatus();
65
+ process.exit(0);
66
+ } else if (subCommand === "logout") {
67
+ const success = await logout();
68
+ process.exit(success ? 0 : 1);
69
+ } else {
70
+ // 認証フロー
71
+ const options = parseOptions(args);
72
+ const success = await authenticate(options);
73
+ process.exit(success ? 0 : 1);
74
+ }
75
+ }
76
+
77
+ // コマンドなし → MCP サーバー起動
78
+ if (!command) {
79
+ // MCP サーバーをインポートして起動
80
+ await import("./index.js");
81
+ } else {
82
+ console.error(`❌ 不明なコマンド: ${command}`);
83
+ console.error("");
84
+ console.error("ヘルプを表示するには: npx slack-task-mcp --help");
85
+ process.exit(1);
86
+ }
87
+ }
88
+
89
+ main().catch((err) => {
90
+ console.error(`❌ エラー: ${err.message}`);
91
+ process.exit(1);
92
+ });
package/src/index.js ADDED
@@ -0,0 +1,938 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Slack Task MCP Server
5
+ *
6
+ * Claude Code用のSlackタスク管理MCPサーバー
7
+ * - Slackスレッドの取得
8
+ * - タスクのJSON永続化
9
+ */
10
+
11
+ import fs from "node:fs/promises";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { WebClient } from "@slack/web-api";
17
+ import { z } from "zod";
18
+ import { loadCredentials } from "./auth.js";
19
+
20
+ // データ保存先
21
+ const DATA_DIR = path.join(os.homedir(), ".slack-task-mcp");
22
+ const TASKS_FILE = path.join(DATA_DIR, "tasks.json");
23
+
24
+ // ============================================
25
+ // analyze_request 用 Zodスキーマ
26
+ // ============================================
27
+
28
+ const UnclearPointSchema = z.object({
29
+ question: z.string().max(200).describe("確認すべき質問"),
30
+ impact: z.string().max(200).describe("この点が不明だと何が困るか"),
31
+ suggested_options: z.array(z.string()).optional().describe("想定される選択肢"),
32
+ });
33
+
34
+ const NextActionSchema = z.object({
35
+ action: z.string().max(200).describe("具体的なアクション内容"),
36
+ estimated_time: z.number().min(1).max(30).describe("推定所要時間(分)"),
37
+ reason: z.string().nullable().optional().describe("なぜこれが最初のアクションなのか"),
38
+ });
39
+
40
+ const PrioritySchema = z.enum(["high", "medium", "low"]);
41
+
42
+ const AnalysisResultSchema = z.object({
43
+ purpose: z.string().max(500).describe("依頼の目的(1文で言語化)"),
44
+ deliverable: z.string().nullable().optional().describe("成果物"),
45
+ deadline: z.string().nullable().optional().describe("期限"),
46
+ unclear_points: z.array(UnclearPointSchema).describe("不明点のリスト"),
47
+ confirmation_message: z.string().nullable().optional().describe("確認メッセージ案"),
48
+ next_action: NextActionSchema.describe("ネクストアクション"),
49
+ priority: PrioritySchema.describe("優先度"),
50
+ });
51
+
52
+ // ============================================
53
+ // draft_reply 用 Zodスキーマ
54
+ // ============================================
55
+
56
+ const TaskTypeSchema = z.enum(["report", "confirm", "request"]);
57
+ const ToneSchema = z.enum(["formal", "casual"]);
58
+ const ChangeTypeSchema = z.enum(["structure", "simplify", "clarify", "tone", "logic", "add"]);
59
+
60
+ const ChangeSchema = z.object({
61
+ type: ChangeTypeSchema.describe("変更の種類"),
62
+ description: z.string().max(200).describe("変更内容の説明"),
63
+ reason: z.string().max(200).describe("変更の理由"),
64
+ });
65
+
66
+ const ReplyStructureSchema = z.object({
67
+ conclusion: z.string().max(500).describe("結論(何を伝えたいか)"),
68
+ reasoning: z.string().nullable().optional().describe("根拠(なぜそう言えるか)"),
69
+ action: z.string().nullable().optional().describe("アクション"),
70
+ });
71
+
72
+ const EditedReplySchema = z.object({
73
+ task_type: TaskTypeSchema.describe("タスクタイプ"),
74
+ after: z.string().describe("添削後のテキスト"),
75
+ structure: ReplyStructureSchema.describe("構造化された返信"),
76
+ changes: z.array(ChangeSchema).describe("変更ポイント"),
77
+ tone: ToneSchema.describe("適用されたトーン"),
78
+ });
79
+
80
+ // ============================================
81
+ // search_slack 用 Zodスキーマ
82
+ // ============================================
83
+
84
+ const _SearchParamsSchema = z.object({
85
+ query: z.string().min(1).describe("検索クエリ(Slack検索構文対応: from:@user, in:#channel等)"),
86
+ count: z.number().min(1).max(100).optional().describe("最大件数(デフォルト10)"),
87
+ channel: z.string().optional().describe("チャンネル名で絞り込み(#なし)"),
88
+ });
89
+
90
+ // Slack クライアント(User Token使用)
91
+ let slackClient = null;
92
+
93
+ /**
94
+ * データディレクトリを初期化
95
+ */
96
+ async function initDataDir() {
97
+ try {
98
+ await fs.mkdir(DATA_DIR, { recursive: true });
99
+ } catch (_err) {
100
+ // 既に存在する場合は無視
101
+ }
102
+ }
103
+
104
+ /**
105
+ * タスクデータを読み込み
106
+ */
107
+ async function loadTasks() {
108
+ try {
109
+ const data = await fs.readFile(TASKS_FILE, "utf-8");
110
+ return JSON.parse(data);
111
+ } catch (_err) {
112
+ return { tasks: [] };
113
+ }
114
+ }
115
+
116
+ /**
117
+ * タスクデータを保存
118
+ */
119
+ async function saveTasks(data) {
120
+ await fs.writeFile(TASKS_FILE, JSON.stringify(data, null, 2));
121
+ }
122
+
123
+ /**
124
+ * SlackのURLからチャンネルIDとメッセージTSを抽出
125
+ */
126
+ function parseSlackUrl(url) {
127
+ // https://xxx.slack.com/archives/C12345678/p1234567890123456
128
+ // https://xxx.slack.com/archives/C12345678/p1234567890123456?thread_ts=1234567890.123456
129
+ const archivesMatch = url.match(/archives\/([A-Z0-9]+)\/p(\d+)/);
130
+ if (archivesMatch) {
131
+ const channel = archivesMatch[1];
132
+ const tsRaw = archivesMatch[2];
133
+ // p1234567890123456 -> 1234567890.123456
134
+ const ts = `${tsRaw.slice(0, 10)}.${tsRaw.slice(10)}`;
135
+
136
+ // thread_tsがある場合
137
+ const threadMatch = url.match(/thread_ts=([\d.]+)/);
138
+ const threadTs = threadMatch ? threadMatch[1] : ts;
139
+
140
+ return { channel, ts, threadTs };
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * スレッドのメッセージを取得(ページネーション対応)
148
+ */
149
+ async function getThreadMessages(channel, threadTs) {
150
+ if (!slackClient) {
151
+ throw new Error(
152
+ "Slack認証されていません。`npx slack-task-mcp auth` を実行して認証してください。",
153
+ );
154
+ }
155
+
156
+ const allMessages = [];
157
+ let cursor;
158
+
159
+ // ページネーションで全メッセージを取得
160
+ do {
161
+ const result = await slackClient.conversations.replies({
162
+ channel,
163
+ ts: threadTs,
164
+ limit: 200,
165
+ cursor,
166
+ });
167
+
168
+ if (!result.ok) {
169
+ throw new Error(`Slack API error: ${result.error}`);
170
+ }
171
+
172
+ if (result.messages) {
173
+ allMessages.push(...result.messages);
174
+ }
175
+
176
+ cursor = result.response_metadata?.next_cursor;
177
+ } while (cursor);
178
+
179
+ return allMessages;
180
+ }
181
+
182
+ /**
183
+ * ユーザー情報を取得
184
+ */
185
+ async function getUserInfo(userId) {
186
+ if (!slackClient) return { name: userId, real_name: userId };
187
+
188
+ try {
189
+ const result = await slackClient.users.info({ user: userId });
190
+ return result.user || { name: userId, real_name: userId };
191
+ } catch {
192
+ return { name: userId, real_name: userId };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Slackメッセージを検索
198
+ */
199
+ async function searchSlackMessages(query, count = 10) {
200
+ if (!slackClient) {
201
+ throw new Error("Slack client not initialized");
202
+ }
203
+
204
+ const result = await slackClient.search.messages({
205
+ query: query,
206
+ count: count,
207
+ sort: "timestamp",
208
+ sort_dir: "desc",
209
+ });
210
+
211
+ if (!result.ok) {
212
+ throw new Error(`Slack API error: ${result.error}`);
213
+ }
214
+
215
+ return {
216
+ messages: result.messages?.matches || [],
217
+ total: result.messages?.total || 0,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * 検索結果をMarkdown形式にフォーマット
223
+ */
224
+ async function formatSearchResults(messages, total, _requestedCount) {
225
+ if (messages.length === 0) {
226
+ return "🔍 該当するメッセージはありません";
227
+ }
228
+
229
+ const userCache = {};
230
+ const lines = [];
231
+
232
+ // ヘッダー
233
+ const remaining = total - messages.length;
234
+ if (remaining > 0) {
235
+ lines.push(`## 🔍 検索結果 (${messages.length}件 / 全${total}件)\n`);
236
+ } else {
237
+ lines.push(`## 🔍 検索結果 (${messages.length}件)\n`);
238
+ }
239
+
240
+ // 各メッセージ
241
+ for (let i = 0; i < messages.length; i++) {
242
+ const msg = messages[i];
243
+
244
+ // ユーザー名を取得(キャッシュ)
245
+ let userName = msg.user || msg.username || "不明";
246
+ if (msg.user && !userCache[msg.user]) {
247
+ const userInfo = await getUserInfo(msg.user);
248
+ userCache[msg.user] = userInfo.real_name || userInfo.name || msg.user;
249
+ }
250
+ if (msg.user) {
251
+ userName = userCache[msg.user];
252
+ }
253
+
254
+ // タイムスタンプを日時に変換
255
+ const timestamp = new Date(parseFloat(msg.ts) * 1000).toLocaleString("ja-JP");
256
+
257
+ // チャンネル名
258
+ const channelName = msg.channel?.name || "DM";
259
+
260
+ lines.push("---\n");
261
+ lines.push(`### ${i + 1}. #${channelName} - ${timestamp}`);
262
+ lines.push(`**${userName}**`);
263
+ lines.push(msg.text || "(内容なし)");
264
+ lines.push(`📎 ${msg.permalink}`);
265
+ lines.push("");
266
+ }
267
+
268
+ // 残りの件数
269
+ if (remaining > 0) {
270
+ lines.push("---\n");
271
+ lines.push(`💡 他に ${remaining} 件の結果があります。\`count\` パラメータで件数を増やせます。`);
272
+ }
273
+
274
+ // 使い方ヒント
275
+ lines.push("\n💡 スレッド全体を見るには: `get_slack_thread` に📎のURLを渡してください");
276
+
277
+ return lines.join("\n");
278
+ }
279
+
280
+ /**
281
+ * メッセージをフォーマット
282
+ */
283
+ async function formatMessages(messages) {
284
+ const formatted = [];
285
+ const userCache = {};
286
+
287
+ for (const msg of messages) {
288
+ // ユーザー名を取得(キャッシュ)
289
+ let userName = msg.user;
290
+ if (msg.user && !userCache[msg.user]) {
291
+ const userInfo = await getUserInfo(msg.user);
292
+ userCache[msg.user] = userInfo.real_name || userInfo.name || msg.user;
293
+ }
294
+ userName = userCache[msg.user] || msg.user;
295
+
296
+ // タイムスタンプを日時に変換
297
+ const timestamp = new Date(parseFloat(msg.ts) * 1000).toLocaleString("ja-JP");
298
+
299
+ formatted.push({
300
+ user: userName,
301
+ text: msg.text,
302
+ timestamp,
303
+ ts: msg.ts,
304
+ });
305
+ }
306
+
307
+ return formatted;
308
+ }
309
+
310
+ // MCPサーバーを作成
311
+ const server = new McpServer({
312
+ name: "slack-task-mcp",
313
+ version: "1.0.0",
314
+ });
315
+
316
+ // ツール: Slackスレッドを取得
317
+ server.tool(
318
+ "get_slack_thread",
319
+ "SlackスレッドのURLからメッセージを取得します",
320
+ {
321
+ url: z
322
+ .string()
323
+ .describe(
324
+ "SlackスレッドのURL(例: https://xxx.slack.com/archives/C12345678/p1234567890123456)",
325
+ ),
326
+ },
327
+ async ({ url }) => {
328
+ const parsed = parseSlackUrl(url);
329
+ if (!parsed) {
330
+ return {
331
+ content: [
332
+ { type: "text", text: "無効なSlack URLです。archives形式のURLを指定してください。" },
333
+ ],
334
+ };
335
+ }
336
+
337
+ const { channel, threadTs } = parsed;
338
+ const messages = await getThreadMessages(channel, threadTs);
339
+ const formatted = await formatMessages(messages);
340
+
341
+ // 読みやすい形式でテキスト化
342
+ const text = formatted.map((m) => `[${m.timestamp}] ${m.user}:\n${m.text}`).join("\n\n---\n\n");
343
+
344
+ return {
345
+ content: [
346
+ {
347
+ type: "text",
348
+ text: `## スレッド内容 (${formatted.length}件のメッセージ)\n\n${text}`,
349
+ },
350
+ ],
351
+ };
352
+ },
353
+ );
354
+
355
+ // ツール: タスクを保存
356
+ server.tool(
357
+ "save_task",
358
+ "タスクを保存します",
359
+ {
360
+ title: z.string().describe("タスクのタイトル"),
361
+ purpose: z.string().describe("タスクの目的"),
362
+ steps: z
363
+ .array(
364
+ z.object({
365
+ text: z.string().describe("ステップの内容"),
366
+ estimate_min: z.number().describe("推定時間(分)"),
367
+ }),
368
+ )
369
+ .describe("タスクのステップ"),
370
+ source_url: z.string().optional().describe("元のSlack URL"),
371
+ },
372
+ async ({ title, purpose, steps, source_url }) => {
373
+ await initDataDir();
374
+ const data = await loadTasks();
375
+
376
+ const task = {
377
+ id: Date.now().toString(),
378
+ title,
379
+ purpose,
380
+ steps: steps.map((s, i) => ({
381
+ order: i + 1,
382
+ text: s.text,
383
+ estimate_min: s.estimate_min,
384
+ status: "pending",
385
+ })),
386
+ source_url,
387
+ status: "active",
388
+ created_at: new Date().toISOString(),
389
+ };
390
+
391
+ data.tasks.push(task);
392
+ await saveTasks(data);
393
+
394
+ return {
395
+ content: [
396
+ {
397
+ type: "text",
398
+ text: `✅ タスクを保存しました\n\nID: ${task.id}\nタイトル: ${title}\nステップ数: ${steps.length}`,
399
+ },
400
+ ],
401
+ };
402
+ },
403
+ );
404
+
405
+ // ツール: タスク一覧を取得
406
+ server.tool("list_tasks", "保存されているタスクの一覧を取得します", {}, async () => {
407
+ await initDataDir();
408
+ const data = await loadTasks();
409
+
410
+ if (data.tasks.length === 0) {
411
+ return {
412
+ content: [{ type: "text", text: "📋 タスクはありません" }],
413
+ };
414
+ }
415
+
416
+ const activeTasks = data.tasks.filter((t) => t.status === "active");
417
+
418
+ if (activeTasks.length === 0) {
419
+ const archivedCount = data.tasks.filter((t) => t.status === "archived").length;
420
+ const message =
421
+ archivedCount > 0
422
+ ? `📋 アクティブなタスクはありません(アーカイブ: ${archivedCount}件)\n\n💡 過去のタスクは search_tasks で検索できます`
423
+ : "📋 タスクはありません";
424
+ return {
425
+ content: [{ type: "text", text: message }],
426
+ };
427
+ }
428
+
429
+ const text = activeTasks
430
+ .map((task) => {
431
+ const completedSteps = task.steps.filter((s) => s.status === "done").length;
432
+ const totalSteps = task.steps.length;
433
+ const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
434
+
435
+ const stepsText = task.steps
436
+ .map((s) => {
437
+ const checkbox = s.status === "done" ? "☑️" : "☐";
438
+ const stepText = s.status === "done" ? `~~${s.text}~~` : s.text;
439
+ return ` ${checkbox} ${s.order}. ${stepText} (${s.estimate_min}分)`;
440
+ })
441
+ .join("\n");
442
+
443
+ const sourceUrlText = task.source_url ? `\n📎 元スレッド: ${task.source_url}` : "";
444
+
445
+ return `### ${task.title}\n進捗: ${completedSteps}/${totalSteps} (${progress}%)${sourceUrlText}\n\n${stepsText}`;
446
+ })
447
+ .join("\n\n---\n\n");
448
+
449
+ return {
450
+ content: [{ type: "text", text: `## 📋 タスク一覧 (${activeTasks.length}件)\n\n${text}` }],
451
+ };
452
+ });
453
+
454
+ // ツール: タスクを検索(アーカイブ含む)
455
+ server.tool(
456
+ "search_tasks",
457
+ "キーワードや日付でタスクを検索します(アーカイブ済みタスクも含む)",
458
+ {
459
+ keyword: z.string().optional().describe("検索キーワード(タイトル・目的・ステップ内容を検索)"),
460
+ status: z
461
+ .enum(["all", "active", "archived"])
462
+ .optional()
463
+ .describe("ステータスでフィルタ(デフォルト: all)"),
464
+ days: z.number().optional().describe("過去N日以内に作成/完了したタスク"),
465
+ },
466
+ async ({ keyword, status = "all", days }) => {
467
+ await initDataDir();
468
+ const data = await loadTasks();
469
+
470
+ if (data.tasks.length === 0) {
471
+ return {
472
+ content: [{ type: "text", text: "📋 タスクはありません" }],
473
+ };
474
+ }
475
+
476
+ let results = data.tasks;
477
+
478
+ // ステータスフィルタ
479
+ if (status !== "all") {
480
+ results = results.filter((t) => t.status === status);
481
+ }
482
+
483
+ // 日付フィルタ
484
+ if (days) {
485
+ const cutoff = new Date();
486
+ cutoff.setDate(cutoff.getDate() - days);
487
+ results = results.filter((t) => {
488
+ const taskDate = new Date(t.completed_at || t.created_at);
489
+ return taskDate >= cutoff;
490
+ });
491
+ }
492
+
493
+ // キーワード検索
494
+ if (keyword) {
495
+ const lowerKeyword = keyword.toLowerCase();
496
+ results = results.filter((t) => {
497
+ const searchText = [t.title, t.purpose, ...t.steps.map((s) => s.text)]
498
+ .join(" ")
499
+ .toLowerCase();
500
+ return searchText.includes(lowerKeyword);
501
+ });
502
+ }
503
+
504
+ if (results.length === 0) {
505
+ return {
506
+ content: [{ type: "text", text: "🔍 条件に一致するタスクはありません" }],
507
+ };
508
+ }
509
+
510
+ // 新しい順にソート
511
+ results.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
512
+
513
+ const text = results
514
+ .map((task) => {
515
+ const statusIcon = task.status === "active" ? "🔵" : "📦";
516
+ const dateStr = task.completed_at
517
+ ? `完了: ${new Date(task.completed_at).toLocaleDateString("ja-JP")}`
518
+ : `作成: ${new Date(task.created_at).toLocaleDateString("ja-JP")}`;
519
+
520
+ const stepsText = task.steps
521
+ .map((s) => {
522
+ const checkbox = s.status === "done" ? "☑️" : "☐";
523
+ return ` ${checkbox} ${s.order}. ${s.text}`;
524
+ })
525
+ .join("\n");
526
+
527
+ const sourceUrlText = task.source_url ? `\n📎 ${task.source_url}` : "";
528
+
529
+ return `### ${statusIcon} ${task.title}\n${dateStr}${sourceUrlText}\n\n${stepsText}`;
530
+ })
531
+ .join("\n\n---\n\n");
532
+
533
+ return {
534
+ content: [{ type: "text", text: `## 🔍 検索結果 (${results.length}件)\n\n${text}` }],
535
+ };
536
+ },
537
+ );
538
+
539
+ // ツール: ステップを完了にする
540
+ server.tool(
541
+ "complete_step",
542
+ "タスクのステップを完了にします",
543
+ {
544
+ task_id: z.string().optional().describe("タスクID(省略時は最初のアクティブタスク)"),
545
+ step_number: z.number().describe("完了するステップ番号"),
546
+ },
547
+ async ({ task_id, step_number }) => {
548
+ await initDataDir();
549
+ const data = await loadTasks();
550
+
551
+ // タスクを検索
552
+ let task;
553
+ if (task_id) {
554
+ task = data.tasks.find((t) => t.id === task_id);
555
+ } else {
556
+ task = data.tasks.find((t) => t.status === "active");
557
+ }
558
+
559
+ if (!task) {
560
+ return {
561
+ content: [{ type: "text", text: "❌ タスクが見つかりません" }],
562
+ };
563
+ }
564
+
565
+ // ステップを完了に
566
+ const step = task.steps.find((s) => s.order === step_number);
567
+ if (!step) {
568
+ return {
569
+ content: [{ type: "text", text: `❌ ステップ ${step_number} が見つかりません` }],
570
+ };
571
+ }
572
+
573
+ step.status = "done";
574
+ step.completed_at = new Date().toISOString();
575
+
576
+ // 全ステップ完了ならタスクをアーカイブ
577
+ const allDone = task.steps.every((s) => s.status === "done");
578
+ if (allDone) {
579
+ task.status = "archived";
580
+ task.completed_at = new Date().toISOString();
581
+ }
582
+
583
+ await saveTasks(data);
584
+
585
+ // 次のステップを取得
586
+ const nextStep = task.steps.find((s) => s.status !== "done");
587
+
588
+ let responseText = `✅ ステップ ${step_number} を完了しました!\n\n~~${step.text}~~`;
589
+
590
+ if (allDone) {
591
+ responseText += `\n\n🎉 タスク「${task.title}」を全て完了しました!(アーカイブ済み)`;
592
+ } else if (nextStep) {
593
+ responseText += `\n\n📌 次のステップ: ${nextStep.order}. ${nextStep.text} (${nextStep.estimate_min}分)`;
594
+ }
595
+
596
+ return {
597
+ content: [{ type: "text", text: responseText }],
598
+ };
599
+ },
600
+ );
601
+
602
+ // ============================================
603
+ // ツール: 依頼を分析
604
+ // ============================================
605
+
606
+ /**
607
+ * 優先度をアイコン付きラベルに変換
608
+ */
609
+ function formatPriority(priority) {
610
+ const map = {
611
+ high: "🔴 高(他の人をブロック/期限近い)",
612
+ medium: "🟡 中(今日〜今週中)",
613
+ low: "🟢 低(いつでもいい)",
614
+ };
615
+ return map[priority] || priority;
616
+ }
617
+
618
+ /**
619
+ * 分析結果をMarkdown形式にフォーマット
620
+ */
621
+ function formatAnalysisResult(analysis) {
622
+ const lines = [];
623
+
624
+ // ヘッダー
625
+ lines.push("## 依頼の分析\n");
626
+
627
+ // 把握した内容
628
+ lines.push("### 把握した内容");
629
+ lines.push(`- **目的**: ${analysis.purpose}`);
630
+ if (analysis.deliverable) {
631
+ lines.push(`- **成果物**: ${analysis.deliverable}`);
632
+ }
633
+ if (analysis.deadline) {
634
+ lines.push(`- **期限**: ${analysis.deadline}`);
635
+ }
636
+ lines.push(`- **優先度**: ${formatPriority(analysis.priority)}`);
637
+ lines.push("");
638
+
639
+ // 不明点
640
+ if (analysis.unclear_points && analysis.unclear_points.length > 0) {
641
+ lines.push("### 不明点");
642
+ for (const point of analysis.unclear_points) {
643
+ lines.push(`- ❓ **${point.question}**`);
644
+ lines.push(` - 影響: ${point.impact}`);
645
+ if (point.suggested_options && point.suggested_options.length > 0) {
646
+ lines.push(` - 選択肢: ${point.suggested_options.join(" / ")}`);
647
+ }
648
+ }
649
+ lines.push("");
650
+ } else {
651
+ lines.push("### 不明点");
652
+ lines.push("なし(依頼内容は明確です)");
653
+ lines.push("");
654
+ }
655
+
656
+ // 確認メッセージ案
657
+ if (analysis.confirmation_message) {
658
+ lines.push("### 確認メッセージ案");
659
+ lines.push(`「${analysis.confirmation_message}」`);
660
+ lines.push("");
661
+ }
662
+
663
+ // ネクストアクション
664
+ lines.push("### ネクストアクション");
665
+ const na = analysis.next_action;
666
+ lines.push(`📌 **${na.action}(${na.estimated_time}分)**`);
667
+ if (na.reason) {
668
+ lines.push(` 理由: ${na.reason}`);
669
+ }
670
+
671
+ return lines.join("\n");
672
+ }
673
+
674
+ server.tool(
675
+ "analyze_request",
676
+ "Slackスレッドの依頼を分析し、目的・不明点・確認メッセージ案・ネクストアクションを構造化して返す",
677
+ {
678
+ thread_content: z.string().describe("分析対象のSlackスレッド内容(get_slack_threadの出力)"),
679
+ thread_url: z.string().optional().describe("SlackスレッドのURL(参照用)"),
680
+ analysis: AnalysisResultSchema.describe("Claudeが生成した分析結果"),
681
+ },
682
+ async ({ thread_content, thread_url, analysis }) => {
683
+ try {
684
+ // 分析結果をフォーマット
685
+ const formatted = formatAnalysisResult(analysis);
686
+
687
+ return {
688
+ content: [
689
+ {
690
+ type: "text",
691
+ text: formatted,
692
+ },
693
+ ],
694
+ };
695
+ } catch (err) {
696
+ return {
697
+ content: [
698
+ {
699
+ type: "text",
700
+ text: `❌ 分析結果の処理中にエラーが発生しました: ${err.message}`,
701
+ },
702
+ ],
703
+ };
704
+ }
705
+ },
706
+ );
707
+
708
+ // ============================================
709
+ // ツール: 返信を添削
710
+ // ============================================
711
+
712
+ /**
713
+ * タスクタイプをラベルに変換
714
+ */
715
+ function formatTaskType(taskType) {
716
+ const map = {
717
+ report: "📝 報告",
718
+ confirm: "❓ 確認",
719
+ request: "🙏 依頼",
720
+ };
721
+ return map[taskType] || taskType;
722
+ }
723
+
724
+ /**
725
+ * トーンをラベルに変換
726
+ */
727
+ function formatTone(tone) {
728
+ const map = {
729
+ formal: "丁寧",
730
+ casual: "カジュアル",
731
+ };
732
+ return map[tone] || tone;
733
+ }
734
+
735
+ /**
736
+ * 変更タイプをラベルに変換
737
+ */
738
+ function formatChangeType(changeType) {
739
+ const map = {
740
+ structure: "構造化",
741
+ simplify: "簡潔化",
742
+ clarify: "明確化",
743
+ tone: "トーン調整",
744
+ logic: "論理補強",
745
+ add: "追加",
746
+ };
747
+ return map[changeType] || changeType;
748
+ }
749
+
750
+ /**
751
+ * 添削結果をMarkdown形式にフォーマット
752
+ */
753
+ function formatEditedReply(draftText, editedReply) {
754
+ const lines = [];
755
+
756
+ // ヘッダー
757
+ lines.push("## 添削結果\n");
758
+
759
+ // タイプとトーン
760
+ lines.push(
761
+ `**タイプ**: ${formatTaskType(editedReply.task_type)} | **トーン**: ${formatTone(editedReply.tone)}`,
762
+ );
763
+ lines.push("");
764
+
765
+ // Before
766
+ lines.push("### Before");
767
+ lines.push(`「${draftText}」`);
768
+ lines.push("");
769
+
770
+ // After
771
+ lines.push("### After");
772
+ lines.push(`「${editedReply.after}」`);
773
+ lines.push("");
774
+
775
+ // 構造
776
+ lines.push("### 構造");
777
+ lines.push(`- **結論**: ${editedReply.structure.conclusion}`);
778
+ if (editedReply.structure.reasoning) {
779
+ lines.push(`- **根拠**: ${editedReply.structure.reasoning}`);
780
+ }
781
+ if (editedReply.structure.action) {
782
+ lines.push(`- **アクション**: ${editedReply.structure.action}`);
783
+ }
784
+ lines.push("");
785
+
786
+ // 変更ポイント
787
+ if (editedReply.changes && editedReply.changes.length > 0) {
788
+ lines.push("### 変更ポイント");
789
+ editedReply.changes.forEach((change, index) => {
790
+ lines.push(`${index + 1}. **${formatChangeType(change.type)}**: ${change.description}`);
791
+ lines.push(` - 理由: ${change.reason}`);
792
+ });
793
+ lines.push("");
794
+ }
795
+
796
+ // コピー用
797
+ lines.push("---");
798
+ lines.push("📋 **コピー用**");
799
+ lines.push(editedReply.after);
800
+
801
+ return lines.join("\n");
802
+ }
803
+
804
+ server.tool(
805
+ "draft_reply",
806
+ "返信の下書きを添削し、結論→根拠→アクションの構造に整理して返す",
807
+ {
808
+ draft_text: z.string().max(2000).describe("添削対象の下書きテキスト"),
809
+ task_type: TaskTypeSchema.optional().describe("タスクタイプ(省略時は自動判定)"),
810
+ tone: ToneSchema.optional().describe("トーン(デフォルト: formal)"),
811
+ thread_content: z.string().optional().describe("文脈用のスレッド内容"),
812
+ edited_reply: EditedReplySchema.describe("Claudeが生成した添削結果"),
813
+ },
814
+ async ({ draft_text, task_type, tone, thread_content, edited_reply }) => {
815
+ try {
816
+ // 添削結果をフォーマット
817
+ const formatted = formatEditedReply(draft_text, edited_reply);
818
+
819
+ return {
820
+ content: [
821
+ {
822
+ type: "text",
823
+ text: formatted,
824
+ },
825
+ ],
826
+ };
827
+ } catch (err) {
828
+ return {
829
+ content: [
830
+ {
831
+ type: "text",
832
+ text: `❌ 添削結果の処理中にエラーが発生しました: ${err.message}`,
833
+ },
834
+ ],
835
+ };
836
+ }
837
+ },
838
+ );
839
+
840
+ // ============================================
841
+ // ツール: Slack検索
842
+ // ============================================
843
+
844
+ server.tool(
845
+ "search_slack",
846
+ "Slackメッセージをキーワードで検索します(search:readスコープが必要)",
847
+ {
848
+ query: z.string().min(1).describe("検索クエリ(Slack検索構文対応: from:@user, in:#channel等)"),
849
+ count: z.number().min(1).max(100).optional().describe("最大件数(デフォルト10)"),
850
+ channel: z.string().optional().describe("チャンネル名で絞り込み(#なし)"),
851
+ },
852
+ async ({ query, count = 10, channel }) => {
853
+ // 未認証チェック
854
+ if (!slackClient) {
855
+ return {
856
+ content: [
857
+ {
858
+ type: "text",
859
+ text: "❌ Slack認証されていません。\n\n`npx slack-task-mcp auth` を実行して認証してください。",
860
+ },
861
+ ],
862
+ };
863
+ }
864
+
865
+ try {
866
+ // チャンネル指定時はクエリに追加
867
+ const fullQuery = channel ? `${query} in:#${channel}` : query;
868
+
869
+ // 検索実行
870
+ const { messages, total } = await searchSlackMessages(fullQuery, count);
871
+
872
+ // 結果をフォーマット
873
+ const formatted = await formatSearchResults(messages, total, count);
874
+
875
+ return {
876
+ content: [
877
+ {
878
+ type: "text",
879
+ text: formatted,
880
+ },
881
+ ],
882
+ };
883
+ } catch (err) {
884
+ // search:read スコープ不足の場合
885
+ if (err.message?.includes("missing_scope") || err.message?.includes("not_allowed")) {
886
+ return {
887
+ content: [
888
+ {
889
+ type: "text",
890
+ text: "❌ 検索権限がありません。\n\n`search:read` スコープが必要です。\n`npx slack-task-mcp auth` で再認証してください。",
891
+ },
892
+ ],
893
+ };
894
+ }
895
+
896
+ // レート制限
897
+ if (err.message?.includes("ratelimited")) {
898
+ return {
899
+ content: [
900
+ {
901
+ type: "text",
902
+ text: "❌ APIレート制限に達しました。\n\nしばらく待ってから再試行してください。",
903
+ },
904
+ ],
905
+ };
906
+ }
907
+
908
+ // その他のエラー
909
+ return {
910
+ content: [
911
+ {
912
+ type: "text",
913
+ text: `❌ 検索中にエラーが発生しました: ${err.message}`,
914
+ },
915
+ ],
916
+ };
917
+ }
918
+ },
919
+ );
920
+
921
+ // サーバー起動
922
+ async function main() {
923
+ // OAuth認証からトークンを取得
924
+ const credentials = await loadCredentials();
925
+ if (credentials?.access_token) {
926
+ slackClient = new WebClient(credentials.access_token);
927
+ } else {
928
+ console.error("❌ Slack認証されていません。");
929
+ console.error(" `npx slack-task-mcp auth` を実行して認証してください。");
930
+ }
931
+
932
+ await initDataDir();
933
+
934
+ const transport = new StdioServerTransport();
935
+ await server.connect(transport);
936
+ }
937
+
938
+ main().catch(console.error);