taskover-mcp 1.1.0 → 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.
@@ -0,0 +1,246 @@
1
+ // mcp-server/cloud-adapter.js
2
+ // SECURITY: This module is the cloud-mode replacement for direct data-store access.
3
+ // It forwards allowed methods to api.taskover.gg/api/rpc with the user's API key.
4
+ // The server is the final authority on auth, scope, ownership, and rate limits.
5
+ // Client-side allowlist is defense-in-depth only.
6
+
7
+ const API_BASE = "https://api.taskover.gg";
8
+ const REQUEST_TIMEOUT_MS = 15000;
9
+ const MAX_REQUEST_BYTES = 1 * 1024 * 1024; // 1 MB
10
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; // 5 MB
11
+
12
+ // SECURITY: Default is DENY. New methods must be explicitly added here after security review.
13
+ // Delete operations are deferred past beta -- users must delete via the web UI.
14
+ // Settings, admin, billing, team, import/export are permanently blocked.
15
+ // REVISED per M0 matrix (33 read methods). Removed: getProject, getTask, getSystem,
16
+ // getMilestone, getLastSession, getBlueprintGraphs (DEFERRED), searchProject,
17
+ // getActivityFeed (no MCP tool). Singular get methods don't exist in RPC.
18
+ const MCP_ALLOWED_READS = new Set([
19
+ "listProjects",
20
+ "getTasks",
21
+ "getBugs",
22
+ "getSystems",
23
+ "getMilestones",
24
+ "getSessions",
25
+ "getChangelog",
26
+ "getDecisions",
27
+ "getBlueprints",
28
+ "getNotes",
29
+ "getBuildErrors",
30
+ "getAssets",
31
+ "getOptimizeItems",
32
+ "getPerfBudget",
33
+ "getLevels",
34
+ "getMarketingItems",
35
+ "getDialogues",
36
+ "getSounds", "getControls",
37
+ "getRefs",
38
+ "getPlugins",
39
+ "getShipChecked",
40
+ "getIterations",
41
+ "getPlaytests",
42
+ "getOpenQuestions",
43
+ "getWikiPages",
44
+ "getStories", "getScenes", "getSceneContent", "getStoryBible",
45
+ "search",
46
+ "dashboard", "contextExport",
47
+ ]);
48
+
49
+ // REVISED per M0 matrix (52 write methods). Removed: updateBug (no MCP tool),
50
+ // setBlueprintGraph, addGraphNodes (DEFERRED ANOMALY-1), addBoardColumn (DEFERRED ANOMALY-2),
51
+ // updateStoryCharacter, updateSceneContent (DEFERRED ANOMALY-3).
52
+ const MCP_ALLOWED_WRITES = new Set([
53
+ "addTask", "updateTask", "moveTask", "addTaskComment",
54
+ "addBug", "fixBug",
55
+ "logSession",
56
+ "addChangelog",
57
+ "logDecision",
58
+ "addSystem", "updateSystem",
59
+ "addBlueprint", "updateBlueprint",
60
+ "addNote",
61
+ "addBuildError", "fixBuildError",
62
+ "addOptimizeItem", "updateOptimizeItem",
63
+ "addPerfBudget",
64
+ "addLevel", "updateLevel", "addActor", "removeActor",
65
+ "addPlugin", "updatePlugin",
66
+ "addPlaytest",
67
+ "addMilestone", "updateMilestone",
68
+ "addIteration",
69
+ "addDialogue", "updateDialogue",
70
+ "addSound", "updateSound",
71
+ "addControl", "updateControl",
72
+ "addAsset", "updateAsset",
73
+ "addRef",
74
+ "addMarketingItem", "updateMarketingItem",
75
+ "addWikiPage", "updateWikiPage",
76
+ "addOpenQuestion", "toggleQuestion",
77
+ "toggleShipCheck",
78
+ "logBackup",
79
+ "addStory", "updateStory",
80
+ "addScene", "updateScene",
81
+ "updateStoryBible",
82
+ "addStoryCharacter",
83
+ ]);
84
+
85
+ // SECURITY: Methods that are NEVER allowed through MCP, regardless of future changes.
86
+ // Commented for documentation -- they are blocked by not being in the allow sets above.
87
+ // addProject, deleteProject, updateProject
88
+ // generateApiKey, revokeApiKey
89
+ // any user/auth/team/billing/subscription methods
90
+ // exportData, importData
91
+ // any settings methods
92
+ // any friend/social methods
93
+ // loadAll (returns entire user dataset -- too broad)
94
+ // deleteSystem, deletePlugin, deleteScene (delete ops deferred past beta)
95
+ // DEFERRED per M0: getBlueprintGraphs, setBlueprintGraph, addGraphNodes (ANOMALY-1: ownership)
96
+ // DEFERRED per M0: addBoardColumn (ANOMALY-2: no RPC method)
97
+ // DEFERRED per M0: updateStoryCharacter, updateSceneContent (ANOMALY-3: arg/ownership)
98
+ // REMOVED (no MCP tool): updateBug, getProject, getTask, getSystem, getMilestone,
99
+ // getLastSession, searchProject, getActivityFeed
100
+
101
+ const MCP_ALLOWED_METHODS = new Set([...MCP_ALLOWED_READS, ...MCP_ALLOWED_WRITES]);
102
+
103
+ let _apiKey = null;
104
+
105
+ // Token-based auth state (OAuth flow — set via initWithToken)
106
+ let _accessToken = null;
107
+ let _refreshToken = null;
108
+
109
+ function init(apiKey) {
110
+ _apiKey = apiKey;
111
+ }
112
+
113
+ function initWithToken(accessToken, refreshToken) {
114
+ _accessToken = accessToken;
115
+ _refreshToken = refreshToken;
116
+ }
117
+
118
+ function isAllowed(method) {
119
+ return MCP_ALLOWED_METHODS.has(method);
120
+ }
121
+
122
+ function isWrite(method) {
123
+ return MCP_ALLOWED_WRITES.has(method);
124
+ }
125
+
126
+ async function _doRpcCall(method, args, token) {
127
+ if (!token) throw new Error("Cloud adapter not initialized — no token");
128
+ if (!isAllowed(method)) throw new Error(`Method "${method}" is not allowed through MCP`);
129
+
130
+ const body = JSON.stringify({ method, args: args || [] });
131
+ if (Buffer.byteLength(body, "utf8") > MAX_REQUEST_BYTES) {
132
+ throw new Error("Request too large (max 1 MB)");
133
+ }
134
+
135
+ const controller = new AbortController();
136
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
137
+
138
+ try {
139
+ const res = await fetch(`${API_BASE}/api/rpc`, {
140
+ method: "POST",
141
+ headers: {
142
+ "Content-Type": "application/json",
143
+ "Authorization": `Bearer ${token}`,
144
+ },
145
+ body,
146
+ signal: controller.signal,
147
+ });
148
+
149
+ clearTimeout(timer);
150
+
151
+ if (res.status === 401) throw new Error("AUTH_FAILED");
152
+ if (res.status === 429) {
153
+ const retryAfter = res.headers.get("retry-after") || "60";
154
+ throw new Error(`RATE_LIMITED:${retryAfter}`);
155
+ }
156
+ if (!res.ok) {
157
+ // Privacy: extract machine-readable code only — raw error body may echo customer content.
158
+ let errDetail = "";
159
+ try {
160
+ const errJson = await res.json();
161
+ // Data Control denials have a server-generated message safe to surface (no customer content)
162
+ if (errJson.code && errJson.code.startsWith("DATA_CONTROL_") && errJson.message) throw new Error(errJson.message);
163
+ if (errJson.code) errDetail = ` code=${errJson.code}`;
164
+ else if (typeof errJson.error === "string" && errJson.error.length <= 60) errDetail = ` msg=${errJson.error}`;
165
+ } catch (e) { if (e.message && !e.message.startsWith("Cloud API")) throw e; await res.text().catch(() => ""); /* drain body */ }
166
+ throw new Error(`Cloud API error ${res.status}${errDetail}`);
167
+ }
168
+
169
+ // SECURITY: Enforce response size limit using byte-accurate check.
170
+ // text.length counts UTF-16 code units, not bytes. Multi-byte content
171
+ // (emoji, CJK, etc.) would undercount. Buffer.byteLength is correct.
172
+ const text = await res.text();
173
+ if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) {
174
+ throw new Error("Response too large (max 5 MB)");
175
+ }
176
+
177
+ const parsed = JSON.parse(text);
178
+ return parsed.result !== undefined ? parsed.result : parsed;
179
+ } catch (err) {
180
+ clearTimeout(timer);
181
+ if (err.name === "AbortError") {
182
+ throw new Error("Cloud API request timed out (15s)");
183
+ }
184
+ throw err;
185
+ }
186
+ }
187
+
188
+ async function callRpc(method, args) {
189
+ const token = _accessToken || _apiKey;
190
+ try {
191
+ return await _doRpcCall(method, args, token);
192
+ } catch (err) {
193
+ if (err.message === "AUTH_FAILED" && _accessToken && _refreshToken) {
194
+ try {
195
+ const authFlow = require("./auth-flow.js");
196
+ const tokens = await authFlow.reauthenticate(_refreshToken);
197
+ _accessToken = tokens.access_token;
198
+ _refreshToken = tokens.refresh_token;
199
+ return await _doRpcCall(method, args, _accessToken);
200
+ } catch (refreshErr) {
201
+ throw new Error("Session expired. Restart the MCP server to re-authenticate.");
202
+ }
203
+ }
204
+ throw err;
205
+ }
206
+ }
207
+
208
+ async function validateKey() {
209
+ const token = _accessToken || _apiKey;
210
+ if (!token) return { valid: false, error: "No token or API key configured" };
211
+
212
+ const controller = new AbortController();
213
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
214
+
215
+ try {
216
+ const res = await fetch(`${API_BASE}/api/auth/validate`, {
217
+ method: "POST",
218
+ headers: { "Authorization": `Bearer ${token}` },
219
+ signal: controller.signal,
220
+ });
221
+
222
+ clearTimeout(timer);
223
+
224
+ if (res.status === 401) return { valid: false, error: "Token/key invalid or expired." };
225
+ if (!res.ok) return { valid: false, error: `Unexpected response (HTTP ${res.status})` };
226
+
227
+ const data = await res.json();
228
+ return { valid: true, displayName: data.displayName || "user" };
229
+ } catch (err) {
230
+ clearTimeout(timer);
231
+ if (err.name === "AbortError") return { valid: false, error: "Cannot reach api.taskover.gg (timeout)" };
232
+ return { valid: false, error: `Cannot reach api.taskover.gg: ${err.message}` };
233
+ }
234
+ }
235
+
236
+ module.exports = {
237
+ init,
238
+ initWithToken,
239
+ isAllowed,
240
+ isWrite,
241
+ callRpc,
242
+ validateKey,
243
+ MCP_ALLOWED_METHODS,
244
+ MCP_ALLOWED_READS,
245
+ MCP_ALLOWED_WRITES,
246
+ };
@@ -0,0 +1,93 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+
5
+ const SERVICE_NAME = "taskover-mcp";
6
+ const ACCOUNT_NAME = "default";
7
+ const AUTH_DIR = path.join(os.homedir(), ".taskover");
8
+ const TOKEN_FILE = path.join(AUTH_DIR, "auth.json");
9
+
10
+ let _keytar = null;
11
+ let _keytarAvailable = null;
12
+
13
+ async function _getKeytar() {
14
+ if (_keytarAvailable === false) return null;
15
+ if (_keytar) return _keytar;
16
+ try {
17
+ _keytar = require("keytar");
18
+ await _keytar.getPassword(SERVICE_NAME, "__probe__");
19
+ _keytarAvailable = true;
20
+ return _keytar;
21
+ } catch (_) {
22
+ _keytarAvailable = false;
23
+ _keytar = null;
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function _ensureAuthDir() {
29
+ if (!fs.existsSync(AUTH_DIR)) {
30
+ fs.mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
31
+ }
32
+ }
33
+
34
+ function _readFile() {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"));
37
+ } catch (_) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function _writeFile(data) {
43
+ _ensureAuthDir();
44
+ fs.writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
45
+ }
46
+
47
+ function _deleteFile() {
48
+ try { fs.unlinkSync(TOKEN_FILE); } catch (_) {}
49
+ }
50
+
51
+ async function read() {
52
+ const kt = await _getKeytar();
53
+ if (kt) {
54
+ try {
55
+ const raw = await kt.getPassword(SERVICE_NAME, ACCOUNT_NAME);
56
+ if (raw) return JSON.parse(raw);
57
+ } catch (_) {}
58
+ }
59
+ return _readFile();
60
+ }
61
+
62
+ async function write(tokens) {
63
+ const kt = await _getKeytar();
64
+ if (kt) {
65
+ try {
66
+ await kt.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(tokens));
67
+ // Metadata-only stub — NO tokens or secrets in the file when keychain is available
68
+ _writeFile({ host_type: tokens.host_type, storage: "keychain", expires_at: tokens.expires_at, issued_at: Date.now() });
69
+ return "keychain";
70
+ } catch (_) {}
71
+ }
72
+ _writeFile(tokens);
73
+ if (process.platform === "win32") {
74
+ console.error("[AUTH] Tokens stored in " + TOKEN_FILE);
75
+ console.error("[AUTH] Ensure this directory is not shared or synced to cloud storage.");
76
+ }
77
+ return "file";
78
+ }
79
+
80
+ async function clear() {
81
+ const kt = await _getKeytar();
82
+ if (kt) {
83
+ try { await kt.deletePassword(SERVICE_NAME, ACCOUNT_NAME); } catch (_) {}
84
+ }
85
+ _deleteFile();
86
+ }
87
+
88
+ async function getStorageType() {
89
+ const kt = await _getKeytar();
90
+ return kt ? "keychain" : "file";
91
+ }
92
+
93
+ module.exports = { read, write, clear, getStorageType };