todogether-mcp 1.0.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.
Files changed (3) hide show
  1. package/README.md +75 -0
  2. package/dist/index.js +260 -0
  3. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # todogether-mcp
2
+
3
+ 투두게더(todogether) MCP 서버. Claude에서 **로그인 → 오늘 할 일 조회**를 도구로 사용합니다.
4
+
5
+ ## 도구
6
+ - `todogether_login(email, password)` — 이메일/비밀번호로 로그인 (NextAuth credentials). 세션은 서버 프로세스가 살아있는 동안 유지됩니다.
7
+ - `todogether_today_todos()` — 로그인된 계정의 오늘 할 일 조회 (매일 투두 + 진행 중 + 마감 지난 미완료, 앱의 "오늘 할 일"과 동일 기준).
8
+
9
+ > 소셜 로그인(카카오/네이버)은 브라우저가 필요해 MCP에선 지원하지 않습니다. 이메일 계정으로 사용하세요.
10
+
11
+ ## 빌드
12
+ ```bash
13
+ cd todogether-mcp
14
+ npm install
15
+ npm run build # dist/index.js 생성
16
+ ```
17
+
18
+ ## 배포 (만드는 사람 — npm publish)
19
+ ```bash
20
+ cd todogether-mcp
21
+ npm login # npm 계정 로그인 (최초 1회)
22
+ npm publish --access public
23
+ ```
24
+ - `prepublishOnly` 스크립트가 자동으로 빌드합니다.
25
+ - 버전 올릴 때: `npm version patch && npm publish`
26
+
27
+ ## 설치 (받는 사람 — 클론·빌드 불필요)
28
+ 배포된 뒤에는 한 줄로 등록할 수 있습니다.
29
+
30
+ **Claude Code:**
31
+ ```bash
32
+ claude mcp add todogether -- npx -y todogether-mcp
33
+ ```
34
+
35
+ **Claude Desktop** (`claude_desktop_config.json`):
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "todogether": {
40
+ "command": "npx",
41
+ "args": ["-y", "todogether-mcp"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Claude Desktop 등록
48
+ `~/Library/Application Support/Claude/claude_desktop_config.json` 의 `mcpServers` 에 추가:
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "todogether": {
53
+ "command": "node",
54
+ "args": ["/Users/minhyuk/dev/workspace/thinkingcat_2.0/PROD_todogether/todogether-mcp/dist/index.js"]
55
+ }
56
+ }
57
+ }
58
+ ```
59
+ 저장 후 Claude Desktop 재시작.
60
+
61
+ ## Claude Code 등록 (CLI)
62
+ ```bash
63
+ claude mcp add todogether -- node /Users/minhyuk/dev/workspace/thinkingcat_2.0/PROD_todogether/todogether-mcp/dist/index.js
64
+ ```
65
+
66
+ ## 사용 예
67
+ 1. "투두게더에 로그인해줘 (이메일: me@example.com)" → `todogether_login` 실행 (비밀번호는 입력 요청)
68
+ 2. "오늘 할 일 가져와줘" → `todogether_today_todos` 실행
69
+
70
+ ## 환경 변수 (선택)
71
+ - `TODOGETHER_BASE_URL` — 기본값 `https://todo.thinkingcatworks.com`. 로컬/스테이징 서버로 바꿀 때 사용.
72
+
73
+ ## 보안 참고
74
+ - 비밀번호는 도구 인자로 전달되어 서버에 로그인 요청에만 쓰이고 디스크에 저장하지 않습니다(세션 쿠키만 메모리 유지).
75
+ - 신뢰하는 환경에서만 사용하세요.
package/dist/index.js ADDED
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ // ── 투두게더 서버 ────────────────────────────────────────────────
6
+ const BASE_URL = process.env.TODOGETHER_BASE_URL ?? "https://todo.thinkingcatworks.com";
7
+ // ── 쿠키 잔(메모리) ──────────────────────────────────────────────
8
+ // MCP 서버 프로세스가 살아있는 동안 NextAuth 세션 쿠키를 유지한다.
9
+ const cookieJar = new Map();
10
+ function cookieHeader() {
11
+ return Array.from(cookieJar.entries())
12
+ .map(([k, v]) => `${k}=${v}`)
13
+ .join("; ");
14
+ }
15
+ function storeSetCookies(res) {
16
+ // Node 18+ fetch: getSetCookie() 로 여러 Set-Cookie 추출
17
+ const raw = res.headers.getSetCookie?.();
18
+ const list = raw ?? (res.headers.get("set-cookie") ? [res.headers.get("set-cookie")] : []);
19
+ for (const line of list) {
20
+ const [pair] = line.split(";");
21
+ const idx = pair.indexOf("=");
22
+ if (idx > 0) {
23
+ const name = pair.slice(0, idx).trim();
24
+ const value = pair.slice(idx + 1).trim();
25
+ if (value === "" || value === "deleted")
26
+ cookieJar.delete(name);
27
+ else
28
+ cookieJar.set(name, value);
29
+ }
30
+ }
31
+ }
32
+ async function api(path, init = {}) {
33
+ const headers = new Headers(init.headers);
34
+ headers.set("Accept", "application/json");
35
+ const cookie = cookieHeader();
36
+ if (cookie)
37
+ headers.set("Cookie", cookie);
38
+ const res = await fetch(`${BASE_URL}${path}`, { ...init, headers, redirect: "manual" });
39
+ storeSetCookies(res);
40
+ return res;
41
+ }
42
+ function isLoggedIn() {
43
+ // NextAuth 세션 쿠키 존재 여부로 1차 판단
44
+ return Array.from(cookieJar.keys()).some((k) => k.includes("session-token"));
45
+ }
46
+ // ── 로그인 (이메일/비밀번호 credentials) ─────────────────────────
47
+ async function login(email, password) {
48
+ // 1) CSRF 토큰
49
+ const csrfRes = await api("/api/auth/csrf");
50
+ if (!csrfRes.ok)
51
+ throw new Error(`CSRF 요청 실패 (${csrfRes.status})`);
52
+ const { csrfToken } = (await csrfRes.json());
53
+ // 2) credentials 콜백 (NextAuth)
54
+ const body = new URLSearchParams({
55
+ email,
56
+ password,
57
+ csrfToken,
58
+ redirect: "false",
59
+ json: "true",
60
+ });
61
+ await api("/api/auth/callback/credentials", {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
64
+ body: body.toString(),
65
+ });
66
+ // 3) 세션 확인
67
+ const sessionRes = await api("/api/auth/session");
68
+ const session = (await sessionRes.json().catch(() => null));
69
+ if (!session?.user) {
70
+ throw new Error("로그인에 실패했습니다. 이메일 또는 비밀번호를 확인해 주세요.");
71
+ }
72
+ const who = session.user.handle ?? session.user.name ?? session.user.email ?? "사용자";
73
+ return `로그인 성공: ${who}`;
74
+ }
75
+ // ── 소셜 로그인 (핸드오프 토큰 방식) ─────────────────────────────
76
+ // 카카오/네이버는 브라우저 OAuth 가 필요하므로 2단계로 동작한다:
77
+ // 1) todogether_social_login_url 로 시작 URL 을 받아 브라우저에서 로그인
78
+ // 2) 로그인 후 리다이렉트되는 todogether://...?handoff=JWT 의 handoff 값을
79
+ // todogether_login_handoff 에 넣으면 세션 쿠키로 교환
80
+ function socialLoginStartUrl(provider) {
81
+ return `${BASE_URL}/auth/mobile/oauth-clear/${provider}`;
82
+ }
83
+ /** 입력이 콜백 URL 이면 handoff 쿼리값을, JWT 문자열이면 그대로 반환 */
84
+ function extractHandoff(input) {
85
+ const s = input.trim();
86
+ // URL 형태(todogether://... 또는 https://...)면 handoff 파라미터 추출
87
+ if (s.includes("handoff=")) {
88
+ try {
89
+ const q = s.split("?")[1] ?? s;
90
+ const params = new URLSearchParams(q);
91
+ const h = params.get("handoff");
92
+ if (h)
93
+ return h;
94
+ }
95
+ catch {
96
+ /* fallthrough */
97
+ }
98
+ }
99
+ return s; // JWT 그대로
100
+ }
101
+ async function loginWithHandoff(input) {
102
+ const handoff = extractHandoff(input);
103
+ if (!handoff || handoff.split(".").length < 2) {
104
+ throw new Error("유효한 handoff 토큰이 아닙니다. (JWT 또는 todogether://...?handoff=... URL)");
105
+ }
106
+ // 세션 쿠키 교환
107
+ const res = await api(`/api/auth/mobile-webview-session?handoff=${encodeURIComponent(handoff)}`);
108
+ if (!res.ok) {
109
+ throw new Error(`핸드오프 세션 교환 실패 (${res.status})`);
110
+ }
111
+ // 세션 확인
112
+ const session = (await (await api("/api/auth/session")).json().catch(() => null));
113
+ if (!session?.user) {
114
+ throw new Error("핸드오프 로그인에 실패했습니다. 토큰이 만료되었을 수 있습니다(보통 2분 이내 유효).");
115
+ }
116
+ const who = session.user.handle ?? session.user.name ?? session.user.email ?? "사용자";
117
+ return `소셜 로그인 성공: ${who}`;
118
+ }
119
+ function kstToday() {
120
+ // KST 기준 오늘 0시 ~ 24시
121
+ const now = new Date();
122
+ const kstMs = now.getTime() + 9 * 60 * 60 * 1000;
123
+ const kst = new Date(kstMs);
124
+ const y = kst.getUTCFullYear();
125
+ const m = kst.getUTCMonth();
126
+ const d = kst.getUTCDate();
127
+ const startUtc = Date.UTC(y, m, d) - 9 * 60 * 60 * 1000;
128
+ return { start: new Date(startUtc), end: new Date(startUtc + 24 * 60 * 60 * 1000) };
129
+ }
130
+ async function getTodayTodos() {
131
+ if (!isLoggedIn()) {
132
+ return "로그인이 필요합니다. 먼저 `todogether_login` 도구로 로그인해 주세요.";
133
+ }
134
+ const { start, end } = kstToday();
135
+ // 진행 중(doing) + 만료(expired·미완료) 를 합쳐 앱의 "오늘 할 일"과 동일하게 구성
136
+ const [doingRes, expiredRes] = await Promise.all([
137
+ api("/api/pledges?type=doing&limit=50"),
138
+ api("/api/pledges?type=expired&limit=50"),
139
+ ]);
140
+ if (doingRes.status === 401 || doingRes.status === 403) {
141
+ return "세션이 만료되었습니다. 다시 로그인해 주세요.";
142
+ }
143
+ const doing = (await doingRes.json().catch(() => ({ pledges: [] }))).pledges ?? [];
144
+ const expired = expiredRes.ok
145
+ ? ((await expiredRes.json().catch(() => ({ pledges: [] }))).pledges ?? [])
146
+ : [];
147
+ // 오늘 할 일: 매일 투두 + 이미 시작된 투두(startAt <= 오늘 끝) + 마감 지난 미완료
148
+ const todayDoing = doing.filter((p) => {
149
+ if (p.isEveryday)
150
+ return true;
151
+ if (!p.startAt)
152
+ return true;
153
+ return new Date(p.startAt) <= end;
154
+ });
155
+ const overdue = expired.filter((p) => !p.isEveryday && p.isSuccess !== true);
156
+ const seen = new Set();
157
+ const todos = [...todayDoing, ...overdue].filter((p) => {
158
+ if (seen.has(p.id))
159
+ return false;
160
+ seen.add(p.id);
161
+ return true;
162
+ });
163
+ if (todos.length === 0) {
164
+ return "오늘 할 일이 없습니다. 🎉";
165
+ }
166
+ const lines = todos.map((p, i) => {
167
+ const project = p.channelPledges?.[0]?.channel?.title;
168
+ const badges = [
169
+ p.isEveryday ? "매일" : null,
170
+ project ? `📁 ${project}` : p.category && p.category !== "미지정" ? p.category : null,
171
+ ]
172
+ .filter(Boolean)
173
+ .join(" · ");
174
+ const sub = p.subTodos?.length
175
+ ? ` (${p.subTodos.filter((s) => s.isDone).length}/${p.subTodos.length})`
176
+ : "";
177
+ const head = badges ? `${p.title} [${badges}]` : p.title;
178
+ return `${i + 1}. ${head}${sub}`;
179
+ });
180
+ return `오늘 할 일 ${todos.length}개:\n${lines.join("\n")}`;
181
+ }
182
+ // ── MCP 서버 ─────────────────────────────────────────────────────
183
+ const server = new McpServer({ name: "todogether", version: "1.0.0" });
184
+ server.registerTool("todogether_login", {
185
+ title: "투두게더 로그인",
186
+ description: "투두게더에 이메일/비밀번호로 로그인합니다. 로그인 후 같은 세션에서 오늘 할 일 등을 조회할 수 있습니다.",
187
+ inputSchema: {
188
+ email: z.string().describe("투두게더 계정 이메일"),
189
+ password: z.string().describe("비밀번호"),
190
+ },
191
+ }, async ({ email, password }) => {
192
+ try {
193
+ const msg = await login(email, password);
194
+ return { content: [{ type: "text", text: msg }] };
195
+ }
196
+ catch (e) {
197
+ return {
198
+ content: [{ type: "text", text: `❌ ${e.message}` }],
199
+ isError: true,
200
+ };
201
+ }
202
+ });
203
+ server.registerTool("todogether_social_login_url", {
204
+ title: "소셜 로그인 시작 URL",
205
+ description: "카카오/네이버 소셜 로그인 시작 URL을 반환합니다. 사용자는 이 URL을 브라우저에서 열어 로그인한 뒤, 리다이렉트되는 'todogether://...?handoff=...' URL(또는 handoff 토큰)을 todogether_login_handoff 에 전달해야 합니다.",
206
+ inputSchema: {
207
+ provider: z.enum(["kakao", "naver"]).describe("소셜 로그인 제공자"),
208
+ },
209
+ }, async ({ provider }) => {
210
+ const url = socialLoginStartUrl(provider);
211
+ return {
212
+ content: [
213
+ {
214
+ type: "text",
215
+ text: `${provider} 로그인 URL:\n${url}\n\n` +
216
+ `① 위 URL을 브라우저에서 열어 로그인하세요.\n` +
217
+ `② 로그인 후 'todogether://...?handoff=...' 로 리다이렉트되면, 그 전체 URL(또는 handoff 값)을 복사해\n` +
218
+ ` todogether_login_handoff 도구에 전달하세요. (handoff 토큰은 보통 2분 내 만료되니 바로 진행하세요)`,
219
+ },
220
+ ],
221
+ };
222
+ });
223
+ server.registerTool("todogether_login_handoff", {
224
+ title: "핸드오프 토큰으로 로그인",
225
+ description: "소셜 로그인 후 받은 handoff 토큰(JWT) 또는 'todogether://...?handoff=...' 콜백 URL로 세션을 교환해 로그인합니다.",
226
+ inputSchema: {
227
+ handoff: z
228
+ .string()
229
+ .describe("handoff JWT 토큰 또는 todogether://...?handoff=... 형태의 콜백 URL"),
230
+ },
231
+ }, async ({ handoff }) => {
232
+ try {
233
+ const msg = await loginWithHandoff(handoff);
234
+ return { content: [{ type: "text", text: msg }] };
235
+ }
236
+ catch (e) {
237
+ return {
238
+ content: [{ type: "text", text: `❌ ${e.message}` }],
239
+ isError: true,
240
+ };
241
+ }
242
+ });
243
+ server.registerTool("todogether_today_todos", {
244
+ title: "오늘 할 일 가져오기",
245
+ description: "로그인된 투두게더 계정의 오늘 할 일 목록을 가져옵니다. (매일 투두 + 진행 중 + 마감 지난 미완료 포함)",
246
+ inputSchema: {},
247
+ }, async () => {
248
+ try {
249
+ const text = await getTodayTodos();
250
+ return { content: [{ type: "text", text }] };
251
+ }
252
+ catch (e) {
253
+ return {
254
+ content: [{ type: "text", text: `❌ 조회 실패: ${e.message}` }],
255
+ isError: true,
256
+ };
257
+ }
258
+ });
259
+ const transport = new StdioServerTransport();
260
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "todogether-mcp",
3
+ "version": "1.0.0",
4
+ "description": "투두게더(todogether) MCP 서버 — 로그인 후 오늘 할 일 조회",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "todogether",
11
+ "claude",
12
+ "todo"
13
+ ],
14
+ "bin": {
15
+ "todogether-mcp": "dist/index.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "start": "node dist/index.js",
27
+ "prepare": "npm run build",
28
+ "prepublishOnly": "npm run build"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.12.0",
32
+ "zod": "^3.23.8"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.6.0",
36
+ "@types/node": "^20.14.0"
37
+ }
38
+ }