taskover-mcp 1.0.1 → 1.2.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/auth-flow.js +228 -0
- package/auth-gate.js +253 -0
- package/cloud-adapter.js +73 -26
- package/credential-store.js +93 -0
- package/crypto.js +386 -0
- package/data-store.js +9352 -0
- package/data-store.json-backup.js +1264 -0
- package/db.js +2292 -0
- package/image-moderator.js +491 -0
- package/image-processor.js +160 -0
- package/image-upload-service.js +398 -0
- package/index.js +2294 -2068
- package/migrate-json-to-sqlite.js +256 -0
- package/package.json +29 -16
- package/publish/auth-flow.js +275 -0
- package/publish/cloud-adapter.js +246 -0
- package/publish/credential-store.js +93 -0
- package/publish/index.js +1433 -0
- package/publish/package.json +21 -0
- package/publish/tool-map.js +1146 -0
- package/scripts/build-publish.sh +95 -0
- package/scripts/test-auth-failure.js +68 -0
- package/scripts/test-success.js +232 -0
- package/scripts/test-validation.js +105 -0
- package/tool-map.js +58 -0
- /package/{README.md → publish/README.md} +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One-time migration: imports data/tracker.json into data/taskover.db
|
|
4
|
+
* Run: node mcp-server/migrate-json-to-sqlite.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { initDb, getDb, closeDb, saveToDisk } = require("./db");
|
|
10
|
+
|
|
11
|
+
const JSON_FILE = path.join(__dirname, "..", "data", "tracker.json");
|
|
12
|
+
|
|
13
|
+
async function migrate() {
|
|
14
|
+
if (!fs.existsSync(JSON_FILE)) {
|
|
15
|
+
console.log("No tracker.json found — nothing to migrate.");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const raw = fs.readFileSync(JSON_FILE, "utf-8");
|
|
20
|
+
const data = JSON.parse(raw);
|
|
21
|
+
await initDb();
|
|
22
|
+
const db = getDb();
|
|
23
|
+
|
|
24
|
+
const now = new Date().toISOString();
|
|
25
|
+
const today = now.split("T")[0];
|
|
26
|
+
|
|
27
|
+
let counts = {};
|
|
28
|
+
|
|
29
|
+
// Projects
|
|
30
|
+
for (const p of data.projects || []) {
|
|
31
|
+
const d = { ...p }; delete d.id;
|
|
32
|
+
db.prepare("INSERT OR REPLACE INTO projects (id, data, created_at, last_updated) VALUES (?, ?, ?, ?)")
|
|
33
|
+
.run(p.id, JSON.stringify(d), p.createdAt || now, p.lastUpdated || now);
|
|
34
|
+
}
|
|
35
|
+
counts.projects = (data.projects || []).length;
|
|
36
|
+
|
|
37
|
+
// Tasks
|
|
38
|
+
for (const t of data.tasks || []) {
|
|
39
|
+
const d = { ...t }; delete d.id; delete d.projectId; delete d.status;
|
|
40
|
+
db.prepare("INSERT OR REPLACE INTO tasks (id, project_id, status, data, created_at, last_updated) VALUES (?, ?, ?, ?, ?, ?)")
|
|
41
|
+
.run(t.id, t.projectId, t.status || "todo", JSON.stringify(d), t.createdAt || now, t.lastUpdated || now);
|
|
42
|
+
}
|
|
43
|
+
counts.tasks = (data.tasks || []).length;
|
|
44
|
+
|
|
45
|
+
// Systems
|
|
46
|
+
for (const s of data.systems || []) {
|
|
47
|
+
const d = { ...s }; delete d.id; delete d.projectId;
|
|
48
|
+
db.prepare("INSERT OR REPLACE INTO systems (id, project_id, data, created_at, last_updated) VALUES (?, ?, ?, ?, ?)")
|
|
49
|
+
.run(s.id, s.projectId, JSON.stringify(d), s.createdAt || now, s.lastUpdated || today);
|
|
50
|
+
}
|
|
51
|
+
counts.systems = (data.systems || []).length;
|
|
52
|
+
|
|
53
|
+
// Sessions
|
|
54
|
+
for (const s of data.sessions || []) {
|
|
55
|
+
const d = { ...s }; delete d.id; delete d.projectId; delete d.date;
|
|
56
|
+
db.prepare("INSERT OR REPLACE INTO sessions (id, project_id, date, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
57
|
+
.run(s.id, s.projectId, s.date || today, JSON.stringify(d), s.timestamp || now);
|
|
58
|
+
}
|
|
59
|
+
counts.sessions = (data.sessions || []).length;
|
|
60
|
+
|
|
61
|
+
// Changelog
|
|
62
|
+
for (const c of data.changelog || []) {
|
|
63
|
+
const d = { ...c }; delete d.id; delete d.projectId; delete d.date;
|
|
64
|
+
db.prepare("INSERT OR REPLACE INTO changelog (id, project_id, date, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
65
|
+
.run(c.id, c.projectId, c.date || today, JSON.stringify(d), c.timestamp || now);
|
|
66
|
+
}
|
|
67
|
+
counts.changelog = (data.changelog || []).length;
|
|
68
|
+
|
|
69
|
+
// Bugs
|
|
70
|
+
for (const b of data.bugs || []) {
|
|
71
|
+
const d = { ...b }; delete d.id; delete d.projectId; delete d.status;
|
|
72
|
+
db.prepare("INSERT OR REPLACE INTO bugs (id, project_id, status, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
73
|
+
.run(b.id, b.projectId, b.status || "open", JSON.stringify(d), b.createdAt || now);
|
|
74
|
+
}
|
|
75
|
+
counts.bugs = (data.bugs || []).length;
|
|
76
|
+
|
|
77
|
+
// Decisions
|
|
78
|
+
for (const dd of data.decisions || []) {
|
|
79
|
+
const d = { ...dd }; delete d.id; delete d.projectId; delete d.date;
|
|
80
|
+
db.prepare("INSERT OR REPLACE INTO decisions (id, project_id, date, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
81
|
+
.run(dd.id, dd.projectId, dd.date || today, JSON.stringify(d), dd.timestamp || now);
|
|
82
|
+
}
|
|
83
|
+
counts.decisions = (data.decisions || []).length;
|
|
84
|
+
|
|
85
|
+
// Blueprints
|
|
86
|
+
for (const bp of data.blueprints || []) {
|
|
87
|
+
const d = { ...bp }; delete d.id; delete d.projectId;
|
|
88
|
+
db.prepare("INSERT OR REPLACE INTO blueprints (id, project_id, data, last_updated) VALUES (?, ?, ?, ?)")
|
|
89
|
+
.run(bp.id, bp.projectId, JSON.stringify(d), bp.lastUpdated || today);
|
|
90
|
+
}
|
|
91
|
+
counts.blueprints = (data.blueprints || []).length;
|
|
92
|
+
|
|
93
|
+
// Notes
|
|
94
|
+
for (const n of data.notes || []) {
|
|
95
|
+
const d = { ...n }; delete d.id; delete d.projectId; delete d.parentType; delete d.parentId;
|
|
96
|
+
db.prepare("INSERT OR REPLACE INTO notes (id, project_id, parent_type, parent_id, data, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
97
|
+
.run(n.id, n.projectId, n.parentType || "project", n.parentId || n.projectId, JSON.stringify(d), n.createdAt || now);
|
|
98
|
+
}
|
|
99
|
+
counts.notes = (data.notes || []).length;
|
|
100
|
+
|
|
101
|
+
// Backups
|
|
102
|
+
for (const b of data.backups || []) {
|
|
103
|
+
const d = { ...b }; delete d.id; delete d.projectId; delete d.date;
|
|
104
|
+
db.prepare("INSERT OR REPLACE INTO backups_log (id, project_id, date, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
105
|
+
.run(b.id, b.projectId, b.date || today, JSON.stringify(d), b.timestamp || now);
|
|
106
|
+
}
|
|
107
|
+
counts.backups = (data.backups || []).length;
|
|
108
|
+
|
|
109
|
+
// Levels
|
|
110
|
+
for (const l of data.levels || []) {
|
|
111
|
+
const d = { ...l }; delete d.id; delete d.projectId;
|
|
112
|
+
db.prepare("INSERT OR REPLACE INTO levels (id, project_id, data, created_at, last_updated) VALUES (?, ?, ?, ?, ?)")
|
|
113
|
+
.run(l.id, l.projectId, JSON.stringify(d), l.createdAt || now, l.lastUpdated || now);
|
|
114
|
+
}
|
|
115
|
+
counts.levels = (data.levels || []).length;
|
|
116
|
+
|
|
117
|
+
// Plugins
|
|
118
|
+
for (const pl of data.plugins || []) {
|
|
119
|
+
const d = { ...pl }; delete d.id; delete d.projectId;
|
|
120
|
+
db.prepare("INSERT OR REPLACE INTO plugins (id, project_id, data, created_at, last_updated) VALUES (?, ?, ?, ?, ?)")
|
|
121
|
+
.run(pl.id, pl.projectId, JSON.stringify(d), pl.createdAt || now, pl.lastUpdated || now);
|
|
122
|
+
}
|
|
123
|
+
counts.plugins = (data.plugins || []).length;
|
|
124
|
+
|
|
125
|
+
// Build Errors
|
|
126
|
+
for (const e of data.buildErrors || []) {
|
|
127
|
+
const d = { ...e }; delete d.id; delete d.projectId; delete d.status;
|
|
128
|
+
db.prepare("INSERT OR REPLACE INTO build_errors (id, project_id, status, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
129
|
+
.run(e.id, e.projectId, e.status || "open", JSON.stringify(d), e.createdAt || now);
|
|
130
|
+
}
|
|
131
|
+
counts.buildErrors = (data.buildErrors || []).length;
|
|
132
|
+
|
|
133
|
+
// Optimize Items
|
|
134
|
+
for (const o of data.optimizeItems || []) {
|
|
135
|
+
const d = { ...o }; delete d.id; delete d.projectId; delete d.status;
|
|
136
|
+
db.prepare("INSERT OR REPLACE INTO optimize_items (id, project_id, status, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
137
|
+
.run(o.id, o.projectId, o.status || "open", JSON.stringify(d), o.createdAt || now);
|
|
138
|
+
}
|
|
139
|
+
counts.optimizeItems = (data.optimizeItems || []).length;
|
|
140
|
+
|
|
141
|
+
// Perf Budget
|
|
142
|
+
for (const pb of data.perfBudget || []) {
|
|
143
|
+
const d = { ...pb }; delete d.id; delete d.projectId;
|
|
144
|
+
db.prepare("INSERT OR REPLACE INTO perf_budget (id, project_id, data, created_at) VALUES (?, ?, ?, ?)")
|
|
145
|
+
.run(pb.id, pb.projectId, JSON.stringify(d), pb.createdAt || now);
|
|
146
|
+
}
|
|
147
|
+
counts.perfBudget = (data.perfBudget || []).length;
|
|
148
|
+
|
|
149
|
+
// Playtests
|
|
150
|
+
for (const pt of data.playtests || []) {
|
|
151
|
+
const d = { ...pt }; delete d.id; delete d.projectId; delete d.date;
|
|
152
|
+
db.prepare("INSERT OR REPLACE INTO playtests (id, project_id, date, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
153
|
+
.run(pt.id, pt.projectId, pt.date || today, JSON.stringify(d), pt.createdAt || now);
|
|
154
|
+
}
|
|
155
|
+
counts.playtests = (data.playtests || []).length;
|
|
156
|
+
|
|
157
|
+
// Milestones
|
|
158
|
+
for (const m of data.milestones || []) {
|
|
159
|
+
const d = { ...m }; delete d.id; delete d.projectId; delete d.status;
|
|
160
|
+
db.prepare("INSERT OR REPLACE INTO milestones (id, project_id, status, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
161
|
+
.run(m.id, m.projectId, m.status || "upcoming", JSON.stringify(d), m.createdAt || now);
|
|
162
|
+
}
|
|
163
|
+
counts.milestones = (data.milestones || []).length;
|
|
164
|
+
|
|
165
|
+
// Iterations
|
|
166
|
+
for (const it of data.iterations || []) {
|
|
167
|
+
const d = { ...it }; delete d.id; delete d.projectId;
|
|
168
|
+
db.prepare("INSERT OR REPLACE INTO iterations (id, project_id, data, created_at) VALUES (?, ?, ?, ?)")
|
|
169
|
+
.run(it.id, it.projectId, JSON.stringify(d), it.createdAt || now);
|
|
170
|
+
}
|
|
171
|
+
counts.iterations = (data.iterations || []).length;
|
|
172
|
+
|
|
173
|
+
// Dialogues
|
|
174
|
+
for (const dl of data.dialogues || []) {
|
|
175
|
+
const d = { ...dl }; delete d.id; delete d.projectId;
|
|
176
|
+
db.prepare("INSERT OR REPLACE INTO dialogues (id, project_id, data, created_at) VALUES (?, ?, ?, ?)")
|
|
177
|
+
.run(dl.id, dl.projectId, JSON.stringify(d), dl.createdAt || now);
|
|
178
|
+
}
|
|
179
|
+
counts.dialogues = (data.dialogues || []).length;
|
|
180
|
+
|
|
181
|
+
// Sounds
|
|
182
|
+
for (const s of data.sounds || []) {
|
|
183
|
+
const d = { ...s }; delete d.id; delete d.projectId;
|
|
184
|
+
db.prepare("INSERT OR REPLACE INTO sounds (id, project_id, data, created_at) VALUES (?, ?, ?, ?)")
|
|
185
|
+
.run(s.id, s.projectId, JSON.stringify(d), s.createdAt || now);
|
|
186
|
+
}
|
|
187
|
+
counts.sounds = (data.sounds || []).length;
|
|
188
|
+
|
|
189
|
+
// Controls
|
|
190
|
+
for (const c of data.controls || []) {
|
|
191
|
+
const d = { ...c }; delete d.id; delete d.projectId;
|
|
192
|
+
db.prepare("INSERT OR REPLACE INTO controls (id, project_id, data, created_at) VALUES (?, ?, ?, ?)")
|
|
193
|
+
.run(c.id, c.projectId, JSON.stringify(d), c.createdAt || now);
|
|
194
|
+
}
|
|
195
|
+
counts.controls = (data.controls || []).length;
|
|
196
|
+
|
|
197
|
+
// Assets
|
|
198
|
+
for (const a of data.assets || []) {
|
|
199
|
+
const d = { ...a }; delete d.id; delete d.projectId; delete d.status;
|
|
200
|
+
db.prepare("INSERT OR REPLACE INTO assets (id, project_id, status, data, created_at, last_updated) VALUES (?, ?, ?, ?, ?, ?)")
|
|
201
|
+
.run(a.id, a.projectId, a.status || "concept", JSON.stringify(d), a.createdAt || now, a.lastUpdated || now);
|
|
202
|
+
}
|
|
203
|
+
counts.assets = (data.assets || []).length;
|
|
204
|
+
|
|
205
|
+
// Refs
|
|
206
|
+
for (const r of data.refs || []) {
|
|
207
|
+
const d = { ...r }; delete d.id; delete d.projectId;
|
|
208
|
+
db.prepare("INSERT OR REPLACE INTO refs (id, project_id, data, created_at) VALUES (?, ?, ?, ?)")
|
|
209
|
+
.run(r.id, r.projectId, JSON.stringify(d), r.createdAt || now);
|
|
210
|
+
}
|
|
211
|
+
counts.refs = (data.refs || []).length;
|
|
212
|
+
|
|
213
|
+
// Marketing Items
|
|
214
|
+
for (const m of data.marketingItems || []) {
|
|
215
|
+
const d = { ...m }; delete d.id; delete d.projectId; delete d.status;
|
|
216
|
+
db.prepare("INSERT OR REPLACE INTO marketing_items (id, project_id, status, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
217
|
+
.run(m.id, m.projectId, m.status || "todo", JSON.stringify(d), m.createdAt || now);
|
|
218
|
+
}
|
|
219
|
+
counts.marketingItems = (data.marketingItems || []).length;
|
|
220
|
+
|
|
221
|
+
// Wiki Pages
|
|
222
|
+
for (const w of data.wikiPages || []) {
|
|
223
|
+
const d = { ...w }; delete d.id; delete d.projectId;
|
|
224
|
+
db.prepare("INSERT OR REPLACE INTO wiki_pages (id, project_id, data, created_at, last_updated) VALUES (?, ?, ?, ?, ?)")
|
|
225
|
+
.run(w.id, w.projectId, JSON.stringify(d), w.createdAt || now, w.lastUpdated || now);
|
|
226
|
+
}
|
|
227
|
+
counts.wikiPages = (data.wikiPages || []).length;
|
|
228
|
+
|
|
229
|
+
// Ship Checked
|
|
230
|
+
for (const s of data.shipChecked || []) {
|
|
231
|
+
db.prepare("INSERT OR REPLACE INTO ship_checked (project_id, step_id) VALUES (?, ?)")
|
|
232
|
+
.run(s.projectId, s.stepId);
|
|
233
|
+
}
|
|
234
|
+
counts.shipChecked = (data.shipChecked || []).length;
|
|
235
|
+
|
|
236
|
+
// Open Questions
|
|
237
|
+
for (const q of data.openQuestions || []) {
|
|
238
|
+
const d = { ...q }; delete d.id; delete d.projectId; delete d.resolved;
|
|
239
|
+
db.prepare("INSERT OR REPLACE INTO open_questions (id, project_id, resolved, data, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
240
|
+
.run(q.id, q.projectId, q.resolved ? 1 : 0, JSON.stringify(d), q.createdAt || now);
|
|
241
|
+
}
|
|
242
|
+
counts.openQuestions = (data.openQuestions || []).length;
|
|
243
|
+
|
|
244
|
+
saveToDisk();
|
|
245
|
+
closeDb();
|
|
246
|
+
|
|
247
|
+
console.log("\nMigration complete!");
|
|
248
|
+
console.log("Records imported:");
|
|
249
|
+
for (const [k, v] of Object.entries(counts)) {
|
|
250
|
+
if (v > 0) console.log(` ${k}: ${v}`);
|
|
251
|
+
}
|
|
252
|
+
console.log(`\nDatabase: data/taskover.db`);
|
|
253
|
+
console.log(`Original JSON preserved at: data/tracker.json`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
migrate().catch(e => { console.error("Migration failed:", e); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "taskover-mcp",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "MCP server for TaskOver
|
|
5
|
-
"main": "index.js",
|
|
6
|
-
"bin":
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "taskover-mcp",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "MCP server for TaskOver game dev project management",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"taskover": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
11
|
+
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
|
12
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
13
|
+
"@sentry/node": "^8.40.0",
|
|
14
|
+
"file-type": "^16.5.4",
|
|
15
|
+
"keytar": "^7.9.0",
|
|
16
|
+
"open": "^11.0.0",
|
|
17
|
+
"otpauth": "^9.5.0",
|
|
18
|
+
"pdfkit": "^0.15.2",
|
|
19
|
+
"qrcode": "^1.5.4",
|
|
20
|
+
"sharp": "^0.34.5",
|
|
21
|
+
"sql.js": "^1.14.0",
|
|
22
|
+
"stripe": "^17.4.0",
|
|
23
|
+
"ws": "^8.19.0"
|
|
24
|
+
},
|
|
25
|
+
"optionalDependencies": {
|
|
26
|
+
"@node-rs/argon2": "^2.0.2",
|
|
27
|
+
"better-sqlite3": "^11.10.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const http = require("http");
|
|
3
|
+
const credentialStore = require("./credential-store.js");
|
|
4
|
+
|
|
5
|
+
const API_BASE = "https://api.taskover.gg";
|
|
6
|
+
const AUTH_PAGE_BASE = "https://taskover.gg";
|
|
7
|
+
|
|
8
|
+
function generatePKCE() {
|
|
9
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
10
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
11
|
+
return { verifier, challenge };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function generateState() {
|
|
15
|
+
return crypto.randomBytes(32).toString("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function openBrowser(url) {
|
|
19
|
+
try {
|
|
20
|
+
const { default: open } = await import("open");
|
|
21
|
+
await open(url);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (_) {
|
|
24
|
+
const { exec } = require("child_process");
|
|
25
|
+
const cmd = process.platform === "win32" ? `start "" "${url}"`
|
|
26
|
+
: process.platform === "darwin" ? `open "${url}"`
|
|
27
|
+
: `xdg-open "${url}"`;
|
|
28
|
+
try { exec(cmd); return true; } catch (_e) { return false; }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function startCallbackServer(expectedState) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const server = http.createServer();
|
|
35
|
+
let callbackUsed = false;
|
|
36
|
+
|
|
37
|
+
server.listen(0, "127.0.0.1", () => {
|
|
38
|
+
const port = server.address().port;
|
|
39
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
40
|
+
|
|
41
|
+
const codePromise = new Promise((resolveCode, rejectCode) => {
|
|
42
|
+
server.on("request", (req, res) => {
|
|
43
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
44
|
+
|
|
45
|
+
if (url.pathname !== "/callback") {
|
|
46
|
+
res.writeHead(404).end();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (callbackUsed) {
|
|
51
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
52
|
+
res.end(htmlPage("Already processed", "This authorization has already been handled.", "#fbbf24"));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const code = url.searchParams.get("code");
|
|
57
|
+
const returnedState = url.searchParams.get("state");
|
|
58
|
+
const error = url.searchParams.get("error");
|
|
59
|
+
|
|
60
|
+
if (!returnedState || returnedState !== expectedState) {
|
|
61
|
+
callbackUsed = true;
|
|
62
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
63
|
+
res.end(htmlPage("Security Error", "State parameter mismatch. This may be a CSRF attack. The authorization has been rejected.", "#ef4444"));
|
|
64
|
+
setTimeout(() => server.close(), 500);
|
|
65
|
+
rejectCode(new Error("State mismatch on callback — possible CSRF. Auth rejected."));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
callbackUsed = true;
|
|
70
|
+
|
|
71
|
+
if (error) {
|
|
72
|
+
const msg = error === "user_cancelled" ? "You cancelled the authorization." : `Auth error: ${error}`;
|
|
73
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
74
|
+
res.end(htmlPage("Cancelled", msg, "#fbbf24"));
|
|
75
|
+
setTimeout(() => server.close(), 500);
|
|
76
|
+
rejectCode(new Error(msg));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!code) {
|
|
81
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
82
|
+
res.end(htmlPage("Error", "No authorization code received.", "#ef4444"));
|
|
83
|
+
setTimeout(() => server.close(), 500);
|
|
84
|
+
rejectCode(new Error("No auth code received in callback"));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
89
|
+
res.end(htmlPage("Connected!", "You can close this tab. Your AI assistant is authenticated.", "#4ade80"));
|
|
90
|
+
setTimeout(() => server.close(), 500);
|
|
91
|
+
resolveCode(code);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
if (!callbackUsed) {
|
|
96
|
+
callbackUsed = true;
|
|
97
|
+
server.close();
|
|
98
|
+
rejectCode(new Error("Auth timed out (5 min). Restart MCP server to try again."));
|
|
99
|
+
}
|
|
100
|
+
}, 300000);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
resolve({ port, redirectUri, codePromise, server });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
server.on("error", (err) => {
|
|
107
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") {
|
|
108
|
+
reject(new Error(`Cannot bind localhost port for auth callback: ${err.message}. Is another instance running?`));
|
|
109
|
+
} else {
|
|
110
|
+
reject(err);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function escapeHtml(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
117
|
+
|
|
118
|
+
function htmlPage(title, message, color) {
|
|
119
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head>`
|
|
120
|
+
+ `<body style="font-family:system-ui,sans-serif;background:#161920;color:${color};`
|
|
121
|
+
+ `display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center">`
|
|
122
|
+
+ `<div><h2>${escapeHtml(title)}</h2><p style="color:#afafba">${escapeHtml(message)}</p></div></body></html>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function exchangeCodeForTokens(code, codeVerifier, state, deviceLabel) {
|
|
126
|
+
const res = await fetch(`${API_BASE}/api/mcp/token`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
grant_type: "authorization_code",
|
|
131
|
+
code,
|
|
132
|
+
code_verifier: codeVerifier,
|
|
133
|
+
state,
|
|
134
|
+
device_label: deviceLabel,
|
|
135
|
+
}),
|
|
136
|
+
signal: AbortSignal.timeout(15000),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
const data = await res.json().catch(() => ({}));
|
|
141
|
+
throw new Error(data.error || `Token exchange failed (HTTP ${res.status})`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return res.json();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function refreshAccessToken(refreshToken) {
|
|
148
|
+
const res = await fetch(`${API_BASE}/api/mcp/token`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
grant_type: "refresh_token",
|
|
153
|
+
refresh_token: refreshToken,
|
|
154
|
+
}),
|
|
155
|
+
signal: AbortSignal.timeout(15000),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const data = await res.json().catch(() => ({}));
|
|
160
|
+
throw new Error(data.error || `Token refresh failed (HTTP ${res.status})`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return res.json();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function authenticate(hostType) {
|
|
167
|
+
const cached = await credentialStore.read();
|
|
168
|
+
if (cached && cached.access_token && cached.refresh_token) {
|
|
169
|
+
if (cached.expires_at && Date.now() < cached.expires_at - 30000) {
|
|
170
|
+
return { accessToken: cached.access_token, refreshToken: cached.refresh_token };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch(`${API_BASE}/api/auth/validate`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { "Authorization": `Bearer ${cached.access_token}` },
|
|
177
|
+
signal: AbortSignal.timeout(5000),
|
|
178
|
+
});
|
|
179
|
+
if (res.ok) {
|
|
180
|
+
const data = await res.json();
|
|
181
|
+
return { accessToken: cached.access_token, refreshToken: cached.refresh_token, displayName: data.displayName || "user" };
|
|
182
|
+
}
|
|
183
|
+
} catch (_) {}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
console.error("[AUTH] Access token expired, refreshing...");
|
|
187
|
+
const tokens = await refreshAccessToken(cached.refresh_token);
|
|
188
|
+
await credentialStore.write({
|
|
189
|
+
access_token: tokens.access_token,
|
|
190
|
+
refresh_token: tokens.refresh_token,
|
|
191
|
+
expires_at: Date.now() + (tokens.expires_in * 1000),
|
|
192
|
+
host_type: hostType,
|
|
193
|
+
});
|
|
194
|
+
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token };
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error(`[AUTH] Refresh failed: ${err.message}. Starting browser login...`);
|
|
197
|
+
await credentialStore.clear();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.error("[AUTH] Opening browser for login...");
|
|
202
|
+
|
|
203
|
+
const pkce = generatePKCE();
|
|
204
|
+
const state = generateState();
|
|
205
|
+
|
|
206
|
+
let callbackResult;
|
|
207
|
+
try {
|
|
208
|
+
callbackResult = await startCallbackServer(state);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error(`[AUTH] ${err.message}`);
|
|
211
|
+
console.error("[AUTH] Fallback: set TASKOVER_API_KEY environment variable for headless auth.");
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const { port, redirectUri, codePromise } = callbackResult;
|
|
216
|
+
|
|
217
|
+
const authUrl = new URL(`${AUTH_PAGE_BASE}/mcp-auth`);
|
|
218
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
219
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
220
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
221
|
+
authUrl.searchParams.set("state", state);
|
|
222
|
+
authUrl.searchParams.set("host", hostType || "MCP");
|
|
223
|
+
|
|
224
|
+
console.error("[AUTH] If your browser doesn't open, visit:");
|
|
225
|
+
console.error(`[AUTH] ${authUrl.toString()}`);
|
|
226
|
+
|
|
227
|
+
const opened = await openBrowser(authUrl.toString());
|
|
228
|
+
if (!opened) {
|
|
229
|
+
console.error("[AUTH] Could not open browser automatically. Please open the URL above.");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.error("[AUTH] Waiting for browser authorization...");
|
|
233
|
+
let code;
|
|
234
|
+
try {
|
|
235
|
+
code = await codePromise;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error(`[AUTH] ${err.message}`);
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.error("[AUTH] Exchanging authorization code...");
|
|
242
|
+
const tokens = await exchangeCodeForTokens(code, pkce.verifier, state, hostType);
|
|
243
|
+
|
|
244
|
+
const storageType = await credentialStore.write({
|
|
245
|
+
access_token: tokens.access_token,
|
|
246
|
+
refresh_token: tokens.refresh_token,
|
|
247
|
+
expires_at: Date.now() + (tokens.expires_in * 1000),
|
|
248
|
+
host_type: hostType,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
console.error(`[AUTH] Authenticated successfully! (credentials stored in ${storageType})`);
|
|
252
|
+
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function reauthenticate(currentRefreshToken) {
|
|
256
|
+
try {
|
|
257
|
+
const tokens = await refreshAccessToken(currentRefreshToken);
|
|
258
|
+
await credentialStore.write({
|
|
259
|
+
access_token: tokens.access_token,
|
|
260
|
+
refresh_token: tokens.refresh_token,
|
|
261
|
+
expires_at: Date.now() + (tokens.expires_in * 1000),
|
|
262
|
+
});
|
|
263
|
+
return tokens;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
await credentialStore.clear();
|
|
266
|
+
throw new Error("Session expired. Restart the MCP server to re-authenticate via browser.");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function logout() {
|
|
271
|
+
await credentialStore.clear();
|
|
272
|
+
console.error("[AUTH] Logged out. Credentials cleared.");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = { authenticate, reauthenticate, logout };
|