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.
package/auth-flow.js CHANGED
@@ -3,7 +3,7 @@ const http = require("http");
3
3
  const credentialStore = require("./credential-store.js");
4
4
 
5
5
  const API_BASE = "https://api.taskover.gg";
6
- const AUTH_PAGE_BASE = "https://taskover.gg";
6
+ const AUTH_PAGE_BASE = "https://taskover.com";
7
7
 
8
8
  function generatePKCE() {
9
9
  const verifier = crypto.randomBytes(32).toString("base64url");
@@ -95,7 +95,7 @@ function startCallbackServer(expectedState) {
95
95
  if (!callbackUsed) {
96
96
  callbackUsed = true;
97
97
  server.close();
98
- rejectCode(new Error("Auth timed out (5 min). Restart MCP server to try again."));
98
+ rejectCode(new Error("Auth timed out (5 min). Try your request again to re-launch authorization."));
99
99
  }
100
100
  }, 300000);
101
101
  });
@@ -163,41 +163,9 @@ async function refreshAccessToken(refreshToken) {
163
163
  return res.json();
164
164
  }
165
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
-
166
+ // Full browser PKCE auth flow. Does NOT check cached credentials.
167
+ // Called by auth-gate when no valid tokens exist.
168
+ async function authenticateFull(hostType) {
201
169
  console.error("[AUTH] Opening browser for login...");
202
170
 
203
171
  const pkce = generatePKCE();
@@ -252,24 +220,9 @@ async function authenticate(hostType) {
252
220
  return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token };
253
221
  }
254
222
 
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
223
  async function logout() {
271
224
  await credentialStore.clear();
272
225
  console.error("[AUTH] Logged out. Credentials cleared.");
273
226
  }
274
227
 
275
- module.exports = { authenticate, reauthenticate, logout };
228
+ module.exports = { authenticateFull, refreshAccessToken, logout };
package/auth-gate.js ADDED
@@ -0,0 +1,253 @@
1
+ // mcp-server/auth-gate.js
2
+ // Singleton just-in-time auth coordinator.
3
+ // All tool calls that need auth go through ensureAuth().
4
+ // Guarantees: single browser popup, request hold+resume, bounded timeout.
5
+ //
6
+ // WRITE SAFETY GUARANTEE:
7
+ // The JIT auth flow guarantees at-most-once execution for mutating requests:
8
+ // 1. ensureAuth() blocks until auth succeeds — the write has not been attempted yet.
9
+ // 2. After auth, callRpc() executes the write exactly once.
10
+ // 3. On 401 during an RPC call, the server rejected the request (nothing was written).
11
+ // handleAuthFailure() re-authenticates, then callRpc() retries — this is the first real execution.
12
+ // 4. No write is ever sent to the server more than once for a single tool invocation.
13
+ // 5. Network-level failures (timeout, connection reset) are NOT retried — they surface
14
+ // as errors to the user. This ensures at-most-once even for partial server processing.
15
+
16
+ const credentialStore = require("./credential-store.js");
17
+
18
+ const COOLDOWN_MS = 10000; // 10s min gap between auth attempts after failure
19
+ const MAX_REAUTH_ATTEMPTS = 2; // max consecutive re-auth attempts before giving up
20
+ const BOOT_GRACE_MS = 8000; // 8s after boot — suppress browser auth from auto-discovery calls
21
+
22
+ // Structured lifecycle logging — concise, non-sensitive, never prints tokens or user data.
23
+ function _log(event, detail) {
24
+ console.error(`[AUTH] ${event}${detail ? " — " + detail : ""}`);
25
+ }
26
+
27
+ // States
28
+ const IDLE = "IDLE";
29
+ const AUTHENTICATED = "AUTHENTICATED";
30
+ const AUTH_IN_PROGRESS = "AUTH_IN_PROGRESS";
31
+ const FAILED = "FAILED";
32
+
33
+ let _state = IDLE;
34
+ let _accessToken = null;
35
+ let _refreshToken = null;
36
+ let _pendingAuthPromise = null; // dedup guard for concurrent ensureAuth() calls
37
+ let _pendingRefreshPromise = null; // dedup guard for concurrent 401 handling
38
+ let _lastFailureTime = 0;
39
+ let _reAuthAttempts = 0;
40
+ let _bootTime = Date.now(); // tracks server start for boot grace period
41
+ let _authFlow = null; // lazy-loaded to avoid circular deps
42
+ let _cloudAdapter = null;
43
+
44
+ function _getAuthFlow() {
45
+ if (!_authFlow) _authFlow = require("./auth-flow.js");
46
+ return _authFlow;
47
+ }
48
+
49
+ function _getCloudAdapter() {
50
+ if (!_cloudAdapter) _cloudAdapter = require("./cloud-adapter.js");
51
+ return _cloudAdapter;
52
+ }
53
+
54
+ // Called at boot to try loading cached credentials without browser.
55
+ // Does NOT launch browser auth if missing — just loads what's there.
56
+ // NOTE: Does NOT validate token against the server (unlike old authenticate()).
57
+ // If the token was revoked server-side, the first tool call will get 401
58
+ // and handleAuthFailure() will handle it gracefully.
59
+ async function tryLoadCachedTokens() {
60
+ try {
61
+ const cached = await credentialStore.read();
62
+ if (cached && cached.access_token && cached.refresh_token) {
63
+ // Check if access token is still valid (with 30s margin)
64
+ if (cached.expires_at && Date.now() < cached.expires_at - 30000) {
65
+ _accessToken = cached.access_token;
66
+ _refreshToken = cached.refresh_token;
67
+ _state = AUTHENTICATED;
68
+ _getCloudAdapter().setTokens(_accessToken, _refreshToken);
69
+ _log("cached_auth_loaded");
70
+ return true;
71
+ }
72
+ // Try refresh silently
73
+ try {
74
+ const authFlow = _getAuthFlow();
75
+ const tokens = await authFlow.refreshAccessToken(cached.refresh_token);
76
+ await credentialStore.write({
77
+ access_token: tokens.access_token,
78
+ refresh_token: tokens.refresh_token,
79
+ expires_at: Date.now() + (tokens.expires_in * 1000),
80
+ host_type: cached.host_type,
81
+ });
82
+ _accessToken = tokens.access_token;
83
+ _refreshToken = tokens.refresh_token;
84
+ _state = AUTHENTICATED;
85
+ _getCloudAdapter().setTokens(_accessToken, _refreshToken);
86
+ _log("token_refresh_success", "silent startup refresh");
87
+ return true;
88
+ } catch (_) {
89
+ // Refresh failed — credentials are stale, clear them.
90
+ // Do NOT launch browser. Just go to IDLE.
91
+ await credentialStore.clear();
92
+ _log("token_refresh_failed", "cached credentials expired, will authenticate on first tool use");
93
+ return false;
94
+ }
95
+ }
96
+ return false;
97
+ } catch (err) {
98
+ // If credential store or cloud adapter fails to load, boot unauthenticated
99
+ _log("cached_auth_error", err.message);
100
+ return false;
101
+ }
102
+ }
103
+
104
+ // The main gate. Every protected tool call awaits this.
105
+ // Returns { accessToken, refreshToken } or throws.
106
+ // When auth is in progress, all concurrent callers share the same pending promise
107
+ // so only ONE browser popup ever opens and all held requests resume together.
108
+ //
109
+ // callerMethod: optional RPC method name for logging which tool triggered auth.
110
+ async function ensureAuth(callerMethod) {
111
+ // Fast path: already authenticated
112
+ if (_state === AUTHENTICATED && _accessToken) {
113
+ return { accessToken: _accessToken, refreshToken: _refreshToken };
114
+ }
115
+
116
+ // Dedup: if auth is already in progress, all callers share the same promise
117
+ if (_state === AUTH_IN_PROGRESS && _pendingAuthPromise) {
118
+ _log("ensureAuth_dedup", `method=${callerMethod || "unknown"}, sharing pending auth`);
119
+ return _pendingAuthPromise;
120
+ }
121
+
122
+ // Boot grace period: suppress browser popups from MCP host auto-discovery calls
123
+ // (e.g. Claude calling taskovergg_dashboard immediately on connect).
124
+ // During the grace window, return a passive error instead of opening the browser.
125
+ // After the grace period, real user-initiated calls trigger auth normally.
126
+ if (_state === IDLE) {
127
+ const sinceBoot = Date.now() - _bootTime;
128
+ if (sinceBoot < BOOT_GRACE_MS) {
129
+ _log("boot_grace_suppressed", `method=${callerMethod || "unknown"}, ${Math.ceil((BOOT_GRACE_MS - sinceBoot) / 1000)}s remaining`);
130
+ throw new Error("AUTH_NOT_READY:TaskOver is not connected yet. Use any TaskOver tool to start authorization.");
131
+ }
132
+ }
133
+
134
+ // Cooldown after failure to prevent popup loops
135
+ if (_state === FAILED) {
136
+ const elapsed = Date.now() - _lastFailureTime;
137
+ if (elapsed < COOLDOWN_MS) {
138
+ _log("reauth_cooldown_active", `${Math.ceil((COOLDOWN_MS - elapsed) / 1000)}s remaining`);
139
+ throw new Error("AUTH_FAILED_RETRY:Authorization was recently declined or timed out. Please try again in a moment.");
140
+ }
141
+ }
142
+
143
+ // Launch browser auth — transitions to AUTH_IN_PROGRESS
144
+ _log("ensureAuth_trigger", `method=${callerMethod || "unknown"}`);
145
+ _state = AUTH_IN_PROGRESS;
146
+ _pendingAuthPromise = _doJitAuth();
147
+
148
+ try {
149
+ const result = await _pendingAuthPromise;
150
+ return result;
151
+ } finally {
152
+ _pendingAuthPromise = null;
153
+ }
154
+ }
155
+
156
+ async function _doJitAuth() {
157
+ const hostType = process.env.TASKOVER_HOST_TYPE || "MCP";
158
+
159
+ try {
160
+ _log("jit_auth_start", `host=${hostType}`);
161
+ const authFlow = _getAuthFlow();
162
+ const auth = await authFlow.authenticateFull(hostType);
163
+
164
+ _accessToken = auth.accessToken;
165
+ _refreshToken = auth.refreshToken;
166
+ _state = AUTHENTICATED;
167
+ _lastFailureTime = 0;
168
+ _reAuthAttempts = 0;
169
+ _getCloudAdapter().setTokens(_accessToken, _refreshToken);
170
+
171
+ _log("jit_auth_success");
172
+ return { accessToken: _accessToken, refreshToken: _refreshToken };
173
+ } catch (err) {
174
+ _state = FAILED;
175
+ _lastFailureTime = Date.now();
176
+ _log("jit_auth_failed", err.message);
177
+ throw new Error(`AUTH_FAILED_RETRY:${err.message}`);
178
+ }
179
+ }
180
+
181
+ // Called by cloud-adapter when a 401 is received during an RPC call.
182
+ // Tries refresh first, falls back to full browser re-auth.
183
+ // DEDUP: If another caller already refreshed successfully (state is AUTHENTICATED),
184
+ // return current tokens. If a refresh is already in progress, share the pending promise.
185
+ async function handleAuthFailure() {
186
+ // Dedup: if another concurrent 401 handler already refreshed, use those tokens
187
+ if (_state === AUTHENTICATED && _accessToken) {
188
+ return { accessToken: _accessToken, refreshToken: _refreshToken };
189
+ }
190
+
191
+ // Dedup: if refresh is already in progress, share the pending promise
192
+ if (_pendingRefreshPromise) {
193
+ return _pendingRefreshPromise;
194
+ }
195
+
196
+ // Max-retry guard to prevent infinite re-auth loops
197
+ _reAuthAttempts++;
198
+ if (_reAuthAttempts > MAX_REAUTH_ATTEMPTS) {
199
+ _log("reauth_max_attempts", `limit=${MAX_REAUTH_ATTEMPTS}`);
200
+ _reAuthAttempts = 0;
201
+ throw new Error("AUTH_FAILED_RETRY:Too many re-authentication attempts. Please try again later.");
202
+ }
203
+
204
+ _pendingRefreshPromise = _doHandleAuthFailure();
205
+ try {
206
+ return await _pendingRefreshPromise;
207
+ } finally {
208
+ _pendingRefreshPromise = null;
209
+ }
210
+ }
211
+
212
+ async function _doHandleAuthFailure() {
213
+ // Try refresh first
214
+ if (_refreshToken) {
215
+ try {
216
+ _log("token_refresh_start");
217
+ const authFlow = _getAuthFlow();
218
+ const tokens = await authFlow.refreshAccessToken(_refreshToken);
219
+ await credentialStore.write({
220
+ access_token: tokens.access_token,
221
+ refresh_token: tokens.refresh_token,
222
+ expires_at: Date.now() + (tokens.expires_in * 1000),
223
+ });
224
+ _accessToken = tokens.access_token;
225
+ _refreshToken = tokens.refresh_token;
226
+ _state = AUTHENTICATED;
227
+ _reAuthAttempts = 0;
228
+ _getCloudAdapter().setTokens(_accessToken, _refreshToken);
229
+ _log("token_refresh_success");
230
+ return { accessToken: _accessToken, refreshToken: _refreshToken };
231
+ } catch (_) {
232
+ _log("token_refresh_failed", "clearing credentials, will re-authenticate");
233
+ await credentialStore.clear();
234
+ }
235
+ }
236
+
237
+ // Refresh failed — need full re-auth
238
+ _state = IDLE;
239
+ _accessToken = null;
240
+ _refreshToken = null;
241
+ return ensureAuth();
242
+ }
243
+
244
+ function isAuthenticated() {
245
+ return _state === AUTHENTICATED && !!_accessToken;
246
+ }
247
+
248
+ module.exports = {
249
+ tryLoadCachedTokens,
250
+ ensureAuth,
251
+ handleAuthFailure,
252
+ isAuthenticated,
253
+ };
package/cloud-adapter.js CHANGED
@@ -44,6 +44,14 @@ const MCP_ALLOWED_READS = new Set([
44
44
  "getStories", "getScenes", "getSceneContent", "getStoryBible",
45
45
  "search",
46
46
  "dashboard", "contextExport",
47
+ // Blueprint Intelligence reads
48
+ "getDiscoveredBpDetail", "getSyncDebugInfo",
49
+ "computeBlueprintComplexity", "computeBlueprintHealth",
50
+ "getBlueprintTimeline", "generateBlueprintSummary",
51
+ "computeBlueprintReview", "computeProjectBlueprintReview",
52
+ "getBlueprintStandards", "getBlueprintViolations",
53
+ "buildBlueprintDependencyGraph", "computeImpactAnalysis",
54
+ "getBlueprintUsageMap", "getBlueprintIntelligenceDashboard",
47
55
  ]);
48
56
 
49
57
  // REVISED per M0 matrix (52 write methods). Removed: updateBug (no MCP tool),
@@ -80,6 +88,9 @@ const MCP_ALLOWED_WRITES = new Set([
80
88
  "addScene", "updateScene",
81
89
  "updateStoryBible",
82
90
  "addStoryCharacter",
91
+ // Blueprint Intelligence writes
92
+ "syncDiscoveredBps", "createBlueprintSnapshot",
93
+ "toggleBlueprintStandard", "evaluateBlueprintStandards", "resolveBlueprintViolation",
83
94
  ]);
84
95
 
85
96
  // SECURITY: Methods that are NEVER allowed through MCP, regardless of future changes.
@@ -102,7 +113,7 @@ const MCP_ALLOWED_METHODS = new Set([...MCP_ALLOWED_READS, ...MCP_ALLOWED_WRITES
102
113
 
103
114
  let _apiKey = null;
104
115
 
105
- // Token-based auth state (OAuth flow — set via initWithToken)
116
+ // Token-based auth state (browser auth — set via setTokens, managed by auth-gate)
106
117
  let _accessToken = null;
107
118
  let _refreshToken = null;
108
119
 
@@ -110,11 +121,16 @@ function init(apiKey) {
110
121
  _apiKey = apiKey;
111
122
  }
112
123
 
113
- function initWithToken(accessToken, refreshToken) {
124
+ // Called by auth-gate after successful auth or refresh. Can be called multiple times.
125
+ function setTokens(accessToken, refreshToken) {
114
126
  _accessToken = accessToken;
115
127
  _refreshToken = refreshToken;
116
128
  }
117
129
 
130
+ function isInitialized() {
131
+ return !!(_apiKey || _accessToken);
132
+ }
133
+
118
134
  function isAllowed(method) {
119
135
  return MCP_ALLOWED_METHODS.has(method);
120
136
  }
@@ -186,20 +202,26 @@ async function _doRpcCall(method, args, token) {
186
202
  }
187
203
 
188
204
  async function callRpc(method, args) {
189
- const token = _accessToken || _apiKey;
205
+ // In API-key mode, just use the key directly — no auth gate involvement
206
+ if (_apiKey) {
207
+ return _doRpcCall(method, args, _apiKey);
208
+ }
209
+
210
+ // Browser-auth mode: ensure we have tokens via auth gate.
211
+ // Use the returned token directly (not the module-level _accessToken)
212
+ // to avoid stale-read issues if setTokens hasn't been called yet.
213
+ const authGate = require("./auth-gate.js");
214
+ const { accessToken } = await authGate.ensureAuth(method);
215
+ if (!accessToken) throw new Error("AUTH_FAILED_RETRY:No access token available");
216
+
190
217
  try {
191
- return await _doRpcCall(method, args, token);
218
+ return await _doRpcCall(method, args, accessToken);
192
219
  } 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
- }
220
+ if (err.message === "AUTH_FAILED") {
221
+ // Token expired/revoked mid-session — let auth gate handle refresh or re-auth
222
+ const refreshed = await authGate.handleAuthFailure();
223
+ if (!refreshed.accessToken) throw new Error("AUTH_FAILED_RETRY:Re-authentication failed");
224
+ return await _doRpcCall(method, args, refreshed.accessToken);
203
225
  }
204
226
  throw err;
205
227
  }
@@ -235,11 +257,12 @@ async function validateKey() {
235
257
 
236
258
  module.exports = {
237
259
  init,
238
- initWithToken,
260
+ setTokens,
239
261
  isAllowed,
240
262
  isWrite,
241
263
  callRpc,
242
264
  validateKey,
265
+ isInitialized,
243
266
  MCP_ALLOWED_METHODS,
244
267
  MCP_ALLOWED_READS,
245
268
  MCP_ALLOWED_WRITES,