todogether-mcp 1.0.0 → 1.0.2
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.md +13 -3
- package/dist/index.js +195 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,10 +3,20 @@
|
|
|
3
3
|
투두게더(todogether) MCP 서버. Claude에서 **로그인 → 오늘 할 일 조회**를 도구로 사용합니다.
|
|
4
4
|
|
|
5
5
|
## 도구
|
|
6
|
-
- `todogether_login(email, password)` —
|
|
7
|
-
- `
|
|
6
|
+
- `todogether_login(email, password)` — 이메일/비밀번호 로그인 (NextAuth credentials).
|
|
7
|
+
- `todogether_login_social(provider)` — **카카오/네이버 자동 로그인**. 기본 브라우저가 자동으로 열리고, 로그인을 마치면 토큰이 자동 수신됩니다. (복사·붙여넣기 불필요)
|
|
8
|
+
- `todogether_today_todos()` — 오늘 할 일 조회 (매일 투두 + 진행 중 + 마감 지난 미완료, 앱의 "오늘 할 일"과 동일 기준).
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
세션은 MCP 서버 프로세스가 살아있는 동안 유지됩니다.
|
|
11
|
+
|
|
12
|
+
### 보조 도구 (자동 로그인이 안 될 때만)
|
|
13
|
+
- `todogether_social_login_url(provider)` — 소셜 로그인 시작 URL만 반환.
|
|
14
|
+
- `todogether_login_handoff(handoff)` — 직접 받은 handoff 토큰/콜백 URL로 로그인.
|
|
15
|
+
|
|
16
|
+
## 로그인 방법
|
|
17
|
+
- **이메일 계정**: "투두게더 로그인해줘 (이메일: me@example.com)" → 비밀번호 입력 → 끝.
|
|
18
|
+
- **카카오/네이버**: "카카오로 로그인해줘" → 브라우저가 자동으로 열림 → 로그인 → **자동 완료**.
|
|
19
|
+
- 동작 원리: MCP가 로컬 콜백 서버(`127.0.0.1:랜덤포트`)를 띄우고, 서버는 로그인 후 handoff를 그 콜백으로 전달합니다. 브라우저와 같은 PC에서 실행해야 합니다.
|
|
10
20
|
|
|
11
21
|
## 빌드
|
|
12
22
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { platform } from "node:os";
|
|
5
8
|
// ── 투두게더 서버 ────────────────────────────────────────────────
|
|
6
9
|
const BASE_URL = process.env.TODOGETHER_BASE_URL ?? "https://todo.thinkingcatworks.com";
|
|
7
10
|
// ── 쿠키 잔(메모리) ──────────────────────────────────────────────
|
|
@@ -116,6 +119,84 @@ async function loginWithHandoff(input) {
|
|
|
116
119
|
const who = session.user.handle ?? session.user.name ?? session.user.email ?? "사용자";
|
|
117
120
|
return `소셜 로그인 성공: ${who}`;
|
|
118
121
|
}
|
|
122
|
+
// ── 소셜 자동 로그인 (브라우저 + 로컬 콜백 서버) ─────────────────
|
|
123
|
+
// 로컬 HTTP 콜백 서버를 띄우고 브라우저를 자동으로 연다.
|
|
124
|
+
// 서버는 oauth-clear?return_uri=http://127.0.0.1:PORT/cb 로 시작하고,
|
|
125
|
+
// 로그인 완료 후 서버가 handoff 를 그 콜백으로 302 리다이렉트 → 자동 수신.
|
|
126
|
+
function openBrowser(url) {
|
|
127
|
+
const p = platform();
|
|
128
|
+
const cmd = p === "darwin" ? "open" : p === "win32" ? "cmd" : "xdg-open";
|
|
129
|
+
const args = p === "win32" ? ["/c", "start", "", url] : [url];
|
|
130
|
+
try {
|
|
131
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
132
|
+
child.unref();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* 브라우저 자동 오픈 실패 시 안내 메시지로 폴백 */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const DONE_HTML = (title, msg) => `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"/>` +
|
|
139
|
+
`<meta name="viewport" content="width=device-width,initial-scale=1"/>` +
|
|
140
|
+
`<title>${title}</title></head>` +
|
|
141
|
+
`<body style="font-family:system-ui,-apple-system,sans-serif;display:flex;` +
|
|
142
|
+
`align-items:center;justify-content:center;min-height:90vh;margin:0;background:#1A1C1B;color:#fff">` +
|
|
143
|
+
`<div style="text-align:center;max-width:24rem;padding:2rem"><h2 style="margin:0 0 .5rem">${title}</h2>` +
|
|
144
|
+
`<p style="color:#a1a1aa;line-height:1.6">${msg}</p></div></body></html>`;
|
|
145
|
+
function loginSocialAuto(provider) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
let settled = false;
|
|
148
|
+
const finish = (fn) => {
|
|
149
|
+
if (settled)
|
|
150
|
+
return;
|
|
151
|
+
settled = true;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
try {
|
|
154
|
+
server.close();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* noop */
|
|
158
|
+
}
|
|
159
|
+
fn();
|
|
160
|
+
};
|
|
161
|
+
const server = createServer(async (req, res) => {
|
|
162
|
+
const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
163
|
+
if (!reqUrl.pathname.startsWith("/cb")) {
|
|
164
|
+
res.writeHead(404);
|
|
165
|
+
res.end();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const handoff = reqUrl.searchParams.get("handoff");
|
|
169
|
+
if (!handoff) {
|
|
170
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
171
|
+
res.end(DONE_HTML("로그인 실패", "handoff 토큰을 받지 못했습니다. 다시 시도해 주세요."));
|
|
172
|
+
finish(() => reject(new Error("콜백에 handoff 토큰이 없습니다.")));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const msg = await loginWithHandoff(handoff);
|
|
177
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
178
|
+
res.end(DONE_HTML("로그인 완료 ✅", "이 창을 닫고 Claude로 돌아가세요."));
|
|
179
|
+
finish(() => resolve(msg));
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
183
|
+
res.end(DONE_HTML("로그인 실패", e.message));
|
|
184
|
+
finish(() => reject(e));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
server.on("error", (e) => finish(() => reject(e)));
|
|
188
|
+
const timer = setTimeout(() => finish(() => reject(new Error("로그인 시간 초과(3분). 다시 시도해 주세요."))), 180_000);
|
|
189
|
+
// host 미지정 listen → 듀얼스택(IPv4 127.0.0.1 + IPv6 ::1) 모두 수신.
|
|
190
|
+
// 서버가 return_uri 를 localhost(::1)로 콜백하든 127.0.0.1 로 하든 안전하게 받기 위함.
|
|
191
|
+
server.listen(0, () => {
|
|
192
|
+
const addr = server.address();
|
|
193
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
194
|
+
const cb = `http://127.0.0.1:${port}/cb`;
|
|
195
|
+
const startUrl = `${BASE_URL}/auth/mobile/oauth-clear/${provider}?return_uri=${encodeURIComponent(cb)}`;
|
|
196
|
+
openBrowser(startUrl);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
119
200
|
function kstToday() {
|
|
120
201
|
// KST 기준 오늘 0시 ~ 24시
|
|
121
202
|
const now = new Date();
|
|
@@ -179,6 +260,79 @@ async function getTodayTodos() {
|
|
|
179
260
|
});
|
|
180
261
|
return `오늘 할 일 ${todos.length}개:\n${lines.join("\n")}`;
|
|
181
262
|
}
|
|
263
|
+
// ── 오늘 할 일 세부 조회 ─────────────────────────────────────────
|
|
264
|
+
async function getPledgeDetail(id) {
|
|
265
|
+
const res = await api(`/api/pledges/${id}`);
|
|
266
|
+
if (!res.ok)
|
|
267
|
+
return null;
|
|
268
|
+
const data = (await res.json().catch(() => null));
|
|
269
|
+
if (!data)
|
|
270
|
+
return null;
|
|
271
|
+
// API가 { pledge: ... } 또는 직접 Pledge 객체를 반환할 수 있음
|
|
272
|
+
return data.pledge ?? data;
|
|
273
|
+
}
|
|
274
|
+
async function getTodayTodosDetail() {
|
|
275
|
+
if (!isLoggedIn()) {
|
|
276
|
+
return "로그인이 필요합니다. 먼저 `todogether_login` 도구로 로그인해 주세요.";
|
|
277
|
+
}
|
|
278
|
+
const { start, end } = kstToday();
|
|
279
|
+
const [doingRes, expiredRes] = await Promise.all([
|
|
280
|
+
api("/api/pledges?type=doing&limit=50"),
|
|
281
|
+
api("/api/pledges?type=expired&limit=50"),
|
|
282
|
+
]);
|
|
283
|
+
if (doingRes.status === 401 || doingRes.status === 403) {
|
|
284
|
+
return "세션이 만료되었습니다. 다시 로그인해 주세요.";
|
|
285
|
+
}
|
|
286
|
+
const doing = (await doingRes.json().catch(() => ({ pledges: [] }))).pledges ?? [];
|
|
287
|
+
const expired = expiredRes.ok
|
|
288
|
+
? ((await expiredRes.json().catch(() => ({ pledges: [] }))).pledges ?? [])
|
|
289
|
+
: [];
|
|
290
|
+
const todayDoing = doing.filter((p) => {
|
|
291
|
+
if (p.isEveryday)
|
|
292
|
+
return true;
|
|
293
|
+
if (!p.startAt)
|
|
294
|
+
return true;
|
|
295
|
+
return new Date(p.startAt) <= end;
|
|
296
|
+
});
|
|
297
|
+
const overdue = expired.filter((p) => !p.isEveryday && p.isSuccess !== true);
|
|
298
|
+
const seen = new Set();
|
|
299
|
+
const todos = [...todayDoing, ...overdue].filter((p) => {
|
|
300
|
+
if (seen.has(p.id))
|
|
301
|
+
return false;
|
|
302
|
+
seen.add(p.id);
|
|
303
|
+
return true;
|
|
304
|
+
});
|
|
305
|
+
if (todos.length === 0)
|
|
306
|
+
return "오늘 할 일이 없습니다. 🎉";
|
|
307
|
+
// 각 할 일의 세부 정보를 병렬로 가져옴
|
|
308
|
+
const details = await Promise.all(todos.map((p) => getPledgeDetail(p.id)));
|
|
309
|
+
const lines = [];
|
|
310
|
+
for (let i = 0; i < todos.length; i++) {
|
|
311
|
+
const p = details[i] ?? todos[i];
|
|
312
|
+
const project = p.channelPledges?.[0]?.channel?.title;
|
|
313
|
+
const badges = [
|
|
314
|
+
p.isEveryday ? "매일" : null,
|
|
315
|
+
project ? `📁 ${project}` : p.category && p.category !== "미지정" ? p.category : null,
|
|
316
|
+
]
|
|
317
|
+
.filter(Boolean)
|
|
318
|
+
.join(" · ");
|
|
319
|
+
const doneCount = p.subTodos?.filter((s) => s.isDone).length ?? 0;
|
|
320
|
+
const totalCount = p.subTodos?.length ?? 0;
|
|
321
|
+
const progress = totalCount > 0 ? ` (${doneCount}/${totalCount})` : "";
|
|
322
|
+
const deadline = p.endAt ? ` · 마감: ${p.endAt.slice(0, 10)}` : "";
|
|
323
|
+
lines.push(`\n${i + 1}. ${p.title}${badges ? ` [${badges}]` : ""}${progress}${deadline}`);
|
|
324
|
+
if (p.memo) {
|
|
325
|
+
lines.push(` 메모: ${p.memo}`);
|
|
326
|
+
}
|
|
327
|
+
if (p.subTodos && p.subTodos.length > 0) {
|
|
328
|
+
for (const sub of p.subTodos) {
|
|
329
|
+
const check = sub.isDone ? "✅" : "⬜";
|
|
330
|
+
lines.push(` ${check} ${sub.title}${sub.memo ? ` (${sub.memo})` : ""}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return `오늘 할 일 ${todos.length}개:${lines.join("\n")}`;
|
|
335
|
+
}
|
|
182
336
|
// ── MCP 서버 ─────────────────────────────────────────────────────
|
|
183
337
|
const server = new McpServer({ name: "todogether", version: "1.0.0" });
|
|
184
338
|
server.registerTool("todogether_login", {
|
|
@@ -200,6 +354,31 @@ server.registerTool("todogether_login", {
|
|
|
200
354
|
};
|
|
201
355
|
}
|
|
202
356
|
});
|
|
357
|
+
server.registerTool("todogether_login_social", {
|
|
358
|
+
title: "소셜 로그인 (자동)",
|
|
359
|
+
description: "카카오/네이버 소셜 로그인을 자동으로 처리합니다. 기본 브라우저가 자동으로 열리며, 사용자가 로그인을 완료하면 토큰이 자동 수신되어 로그인됩니다. (복사·붙여넣기 불필요)",
|
|
360
|
+
inputSchema: {
|
|
361
|
+
provider: z.enum(["kakao", "naver"]).describe("소셜 로그인 제공자"),
|
|
362
|
+
},
|
|
363
|
+
}, async ({ provider }) => {
|
|
364
|
+
try {
|
|
365
|
+
const msg = await loginSocialAuto(provider);
|
|
366
|
+
return { content: [{ type: "text", text: msg }] };
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
return {
|
|
370
|
+
content: [
|
|
371
|
+
{
|
|
372
|
+
type: "text",
|
|
373
|
+
text: `❌ ${e.message}\n\n` +
|
|
374
|
+
`브라우저가 자동으로 열리지 않았다면, 직접 로그인 후 ` +
|
|
375
|
+
`todogether_login_handoff 도구로 handoff 토큰을 전달하는 방법(todogether_social_login_url)도 있습니다.`,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
isError: true,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
});
|
|
203
382
|
server.registerTool("todogether_social_login_url", {
|
|
204
383
|
title: "소셜 로그인 시작 URL",
|
|
205
384
|
description: "카카오/네이버 소셜 로그인 시작 URL을 반환합니다. 사용자는 이 URL을 브라우저에서 열어 로그인한 뒤, 리다이렉트되는 'todogether://...?handoff=...' URL(또는 handoff 토큰)을 todogether_login_handoff 에 전달해야 합니다.",
|
|
@@ -256,5 +435,21 @@ server.registerTool("todogether_today_todos", {
|
|
|
256
435
|
};
|
|
257
436
|
}
|
|
258
437
|
});
|
|
438
|
+
server.registerTool("todogether_today_todos_detail", {
|
|
439
|
+
title: "오늘 할 일 세부 내용 가져오기",
|
|
440
|
+
description: "로그인된 투두게더 계정의 오늘 할 일 목록을 세부 내용(서브태스크 제목·완료 여부·메모, 마감일 등)까지 포함해 가져옵니다.",
|
|
441
|
+
inputSchema: {},
|
|
442
|
+
}, async () => {
|
|
443
|
+
try {
|
|
444
|
+
const text = await getTodayTodosDetail();
|
|
445
|
+
return { content: [{ type: "text", text }] };
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
return {
|
|
449
|
+
content: [{ type: "text", text: `❌ 조회 실패: ${e.message}` }],
|
|
450
|
+
isError: true,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
});
|
|
259
454
|
const transport = new StdioServerTransport();
|
|
260
455
|
await server.connect(transport);
|