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