@eraserlabs/eraser-mcp 0.7.0 → 0.8.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,3 @@
1
+ import type { PKCEPair } from './types';
2
+ export declare function generatePKCE(): PKCEPair;
3
+ //# sourceMappingURL=pkce.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pkce.d.ts","sourceRoot":"","sources":["../../src/oauth/pkce.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAExC,wBAAgB,YAAY,IAAI,QAAQ,CAQvC"}
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.generatePKCE = generatePKCE;
37
+ const crypto = __importStar(require("crypto"));
38
+ function generatePKCE() {
39
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
40
+ const codeChallenge = crypto
41
+ .createHash('sha256')
42
+ .update(codeVerifier)
43
+ .digest('base64url');
44
+ return { codeVerifier, codeChallenge };
45
+ }
@@ -0,0 +1,6 @@
1
+ import type { StoredCredentials } from './types';
2
+ export declare function loadCredentials(apiUrl: string): StoredCredentials | null;
3
+ export declare function saveCredentials(credentials: StoredCredentials, apiUrl: string): void;
4
+ export declare function clearCredentials(apiUrl: string): void;
5
+ export declare function isTokenExpired(credentials: StoredCredentials): boolean;
6
+ //# sourceMappingURL=token-storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-storage.d.ts","sourceRoot":"","sources":["../../src/oauth/token-storage.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAajD,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAWxE;AAED,wBAAgB,eAAe,CAAC,WAAW,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAKpF;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAMrD;AAED,wBAAgB,cAAc,CAAC,WAAW,EAAE,iBAAiB,GAAG,OAAO,CAGtE"}
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadCredentials = loadCredentials;
37
+ exports.saveCredentials = saveCredentials;
38
+ exports.clearCredentials = clearCredentials;
39
+ exports.isTokenExpired = isTokenExpired;
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const os = __importStar(require("os"));
43
+ const crypto = __importStar(require("crypto"));
44
+ const CREDENTIALS_DIR = path.join(os.homedir(), '.eraser');
45
+ /**
46
+ * Returns a credentials file path scoped to the given API base URL so that
47
+ * production, staging, and local-dev tokens never overwrite each other.
48
+ */
49
+ function credentialsFilePath(apiUrl) {
50
+ const hash = crypto.createHash('sha256').update(apiUrl).digest('hex').slice(0, 12);
51
+ return path.join(CREDENTIALS_DIR, `credentials-${hash}.json`);
52
+ }
53
+ function loadCredentials(apiUrl) {
54
+ try {
55
+ const file = credentialsFilePath(apiUrl);
56
+ if (!fs.existsSync(file)) {
57
+ return null;
58
+ }
59
+ const data = fs.readFileSync(file, 'utf-8');
60
+ return JSON.parse(data);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function saveCredentials(credentials, apiUrl) {
67
+ fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
68
+ fs.writeFileSync(credentialsFilePath(apiUrl), JSON.stringify(credentials, null, 2), {
69
+ mode: 0o600,
70
+ });
71
+ }
72
+ function clearCredentials(apiUrl) {
73
+ try {
74
+ fs.unlinkSync(credentialsFilePath(apiUrl));
75
+ }
76
+ catch {
77
+ // File doesn't exist, that's fine
78
+ }
79
+ }
80
+ function isTokenExpired(credentials) {
81
+ // Consider expired if less than 5 minutes remaining
82
+ return Date.now() > credentials.expiresAt - 5 * 60 * 1000;
83
+ }
@@ -0,0 +1,26 @@
1
+ export interface OAuthConfig {
2
+ authorizationEndpoint: string;
3
+ tokenEndpoint: string;
4
+ clientId: string;
5
+ redirectUri: string;
6
+ scopes: string[];
7
+ resource: string;
8
+ }
9
+ export interface TokenResponse {
10
+ access_token: string;
11
+ token_type: string;
12
+ expires_in: number;
13
+ refresh_token: string;
14
+ scope: string;
15
+ }
16
+ export interface StoredCredentials {
17
+ accessToken: string;
18
+ refreshToken: string;
19
+ expiresAt: number;
20
+ scope: string;
21
+ }
22
+ export interface PKCEPair {
23
+ codeVerifier: string;
24
+ codeChallenge: string;
25
+ }
26
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/oauth/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,QAAQ;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;CACvB"}
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/stdio.d.ts CHANGED
@@ -6,6 +6,12 @@
6
6
  * with the Eraser API via stdio transport.
7
7
  *
8
8
  * Usage:
9
+ * npx @eraserlabs/eraser-mcp # Normal mode (authenticates via OAuth)
10
+ * npx @eraserlabs/eraser-mcp login # Manually trigger login
11
+ * npx @eraserlabs/eraser-mcp logout # Clear saved credentials
12
+ * npx @eraserlabs/eraser-mcp whoami # Show current auth status
13
+ *
14
+ * For CI/CD and headless environments, set ERASER_API_TOKEN to bypass the OAuth flow:
9
15
  * ERASER_API_TOKEN=your-token npx @eraserlabs/eraser-mcp
10
16
  *
11
17
  * Or configure in .cursor/mcp.json:
@@ -13,8 +19,7 @@
13
19
  * "mcpServers": {
14
20
  * "eraser": {
15
21
  * "command": "npx",
16
- * "args": ["@eraserlabs/eraser-mcp"],
17
- * "env": { "ERASER_API_TOKEN": "your-token" }
22
+ * "args": ["@eraserlabs/eraser-mcp"]
18
23
  * }
19
24
  * }
20
25
  * }
@@ -1 +1 @@
1
- {"version":3,"file":"stdio.d.ts","sourceRoot":"","sources":["../src/stdio.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;GAmBG"}
1
+ {"version":3,"file":"stdio.d.ts","sourceRoot":"","sources":["../src/stdio.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG"}
package/dist/stdio.js CHANGED
@@ -7,6 +7,12 @@
7
7
  * with the Eraser API via stdio transport.
8
8
  *
9
9
  * Usage:
10
+ * npx @eraserlabs/eraser-mcp # Normal mode (authenticates via OAuth)
11
+ * npx @eraserlabs/eraser-mcp login # Manually trigger login
12
+ * npx @eraserlabs/eraser-mcp logout # Clear saved credentials
13
+ * npx @eraserlabs/eraser-mcp whoami # Show current auth status
14
+ *
15
+ * For CI/CD and headless environments, set ERASER_API_TOKEN to bypass the OAuth flow:
10
16
  * ERASER_API_TOKEN=your-token npx @eraserlabs/eraser-mcp
11
17
  *
12
18
  * Or configure in .cursor/mcp.json:
@@ -14,8 +20,7 @@
14
20
  * "mcpServers": {
15
21
  * "eraser": {
16
22
  * "command": "npx",
17
- * "args": ["@eraserlabs/eraser-mcp"],
18
- * "env": { "ERASER_API_TOKEN": "your-token" }
23
+ * "args": ["@eraserlabs/eraser-mcp"]
19
24
  * }
20
25
  * }
21
26
  * }
@@ -58,9 +63,11 @@ const readline = __importStar(require("readline"));
58
63
  const fs = __importStar(require("fs"));
59
64
  const path = __importStar(require("path"));
60
65
  const tools_1 = require("./tools");
66
+ const flow_1 = require("./oauth/flow");
61
67
  const API_URL = process.env.ERASER_API_URL || 'https://app.eraser.io/api/mcp';
62
68
  const ERASER_OUTPUT_DIR = process.env.ERASER_OUTPUT_DIR || '.eraser/scratchpad';
63
- const API_TOKEN = process.env.ERASER_API_TOKEN;
69
+ // When set, use this token directly instead of the OAuth flow (for CI/CD and headless environments)
70
+ const ERASER_API_TOKEN = process.env.ERASER_API_TOKEN;
64
71
  function sendResponse(response) {
65
72
  process.stdout.write(JSON.stringify(response) + '\n');
66
73
  }
@@ -71,7 +78,6 @@ function sendError(id, code, message, data) {
71
78
  error: { code, message, data },
72
79
  });
73
80
  }
74
- // Server capabilities and info for MCP handshake
75
81
  const SERVER_INFO = {
76
82
  name: 'eraser-mcp',
77
83
  version: '1.0.0',
@@ -79,7 +85,6 @@ const SERVER_INFO = {
79
85
  const SERVER_CAPABILITIES = {
80
86
  tools: {},
81
87
  };
82
- // Convert mcpTools to MCP tool list format
83
88
  function getToolsList() {
84
89
  return tools_1.mcpTools.map((tool) => ({
85
90
  name: tool.name,
@@ -87,10 +92,6 @@ function getToolsList() {
87
92
  inputSchema: tool.jsonSchema,
88
93
  }));
89
94
  }
90
- /**
91
- * Extracts the title from diagram code (looks for a line starting with "title ").
92
- * Normalizes it to a valid filename.
93
- */
94
95
  function extractTitleFromCode(code) {
95
96
  if (!code) {
96
97
  return undefined;
@@ -99,118 +100,287 @@ function extractTitleFromCode(code) {
99
100
  for (const line of lines) {
100
101
  const trimmed = line.trim();
101
102
  if (trimmed.toLowerCase().startsWith('title ')) {
102
- const title = trimmed.slice(6).trim(); // Remove "title " prefix
103
- // Normalize to filename: lowercase, replace spaces/special chars with hyphens
103
+ const title = trimmed.slice(6).trim();
104
104
  return title
105
105
  .toLowerCase()
106
106
  .replace(/[^a-z0-9]+/g, '-')
107
- .replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
107
+ .replace(/^-+|-+$/g, '');
108
108
  }
109
109
  }
110
110
  return undefined;
111
111
  }
112
- /**
113
- * Downloads an image from a URL and saves it locally.
114
- * Returns the local file path if successful, undefined otherwise.
115
- */
116
112
  async function saveImageLocally(imageUrl, diagramCode) {
117
113
  try {
118
114
  const outputDir = path.resolve(process.cwd(), ERASER_OUTPUT_DIR);
119
- // Ensure output directory exists
120
115
  await fs.promises.mkdir(outputDir, { recursive: true });
121
- // Generate filename from title or timestamp
122
116
  const title = extractTitleFromCode(diagramCode);
123
117
  const timestamp = Date.now();
124
118
  const filename = title ? `${title}-${timestamp}.png` : `diagram-${timestamp}.png`;
125
119
  const localPath = path.join(outputDir, filename);
126
- // Fetch the image
127
120
  const response = await fetch(imageUrl);
128
121
  if (!response.ok) {
129
122
  return undefined;
130
123
  }
131
- // Save to disk
132
124
  const buffer = Buffer.from(await response.arrayBuffer());
133
125
  await fs.promises.writeFile(localPath, buffer);
134
126
  return localPath;
135
127
  }
136
128
  catch {
137
- // Silently fail - local saving is a nice-to-have
138
129
  return undefined;
139
130
  }
140
131
  }
132
+ let cachedAccessToken = null;
133
+ let mcpSessionId = null;
134
+ let sessionInitError = null;
135
+ // Serialize OAuth / session initialization to avoid concurrent performLogin() calls
136
+ // racing for the same callback server port.
137
+ let serverSessionPromise = null;
138
+ async function getAccessToken() {
139
+ // API token mode: return directly, skip OAuth
140
+ if (ERASER_API_TOKEN) {
141
+ return ERASER_API_TOKEN;
142
+ }
143
+ if (!cachedAccessToken) {
144
+ cachedAccessToken = await (0, flow_1.ensureValidToken)();
145
+ }
146
+ return cachedAccessToken;
147
+ }
148
+ async function ensureServerSessionInternal() {
149
+ // API token mode: server handles team directly from the token — no session needed
150
+ if (ERASER_API_TOKEN) {
151
+ return;
152
+ }
153
+ if (mcpSessionId) {
154
+ return;
155
+ }
156
+ const runInitialize = async (accessToken) => {
157
+ const initRequest = {
158
+ jsonrpc: '2.0',
159
+ id: 'stdio-init',
160
+ method: 'initialize',
161
+ params: {
162
+ protocolVersion: '2025-11-25',
163
+ capabilities: {},
164
+ clientInfo: { name: 'eraser-mcp-stdio', version: '1.0.0' },
165
+ },
166
+ };
167
+ return fetch(API_URL, {
168
+ method: 'POST',
169
+ headers: {
170
+ 'Content-Type': 'application/json',
171
+ Authorization: `Bearer ${accessToken}`,
172
+ },
173
+ body: JSON.stringify(initRequest),
174
+ });
175
+ };
176
+ let accessToken = await getAccessToken();
177
+ let response = await runInitialize(accessToken);
178
+ if (response.status === 401) {
179
+ cachedAccessToken = null;
180
+ accessToken = await (0, flow_1.recoverAuthAfter401)();
181
+ cachedAccessToken = accessToken;
182
+ response = await runInitialize(accessToken);
183
+ }
184
+ if (response.status === 401) {
185
+ (0, flow_1.invalidateCredentials)();
186
+ cachedAccessToken = null;
187
+ throw new Error('Not authenticated with Eraser. Run `npx @eraserlabs/eraser-mcp login` to sign in.');
188
+ }
189
+ if (!response.ok) {
190
+ throw new Error(`Server initialize failed: ${response.status}`);
191
+ }
192
+ sessionInitError = null;
193
+ const sessionHeader = response.headers.get('Mcp-Session-Id');
194
+ if (sessionHeader) {
195
+ mcpSessionId = sessionHeader;
196
+ }
197
+ // MCP 2025-11-25: client MUST send notifications/initialized after initialize result.
198
+ const initializedNotif = {
199
+ jsonrpc: '2.0',
200
+ method: 'notifications/initialized',
201
+ params: {},
202
+ };
203
+ const postInitHeaders = {
204
+ 'Content-Type': 'application/json',
205
+ Authorization: `Bearer ${accessToken}`,
206
+ };
207
+ if (mcpSessionId) {
208
+ postInitHeaders['Mcp-Session-Id'] = mcpSessionId;
209
+ }
210
+ try {
211
+ await fetch(API_URL, {
212
+ method: 'POST',
213
+ headers: postInitHeaders,
214
+ body: JSON.stringify(initializedNotif),
215
+ });
216
+ }
217
+ catch {
218
+ // Non-fatal if the server ignores the notification
219
+ }
220
+ }
221
+ /**
222
+ * Serialized wrapper for session initialization.
223
+ * Ensures only one OAuth/session init runs at a time to avoid concurrent
224
+ * performLogin() calls racing for the same callback server port.
225
+ */
226
+ async function ensureServerSession() {
227
+ // API token mode: no session needed
228
+ if (ERASER_API_TOKEN) {
229
+ return;
230
+ }
231
+ // Already have a session
232
+ if (mcpSessionId) {
233
+ return;
234
+ }
235
+ // If there's already an in-flight init, wait on that promise
236
+ if (serverSessionPromise) {
237
+ return serverSessionPromise;
238
+ }
239
+ // Start a new init and store the promise so concurrent callers can await it
240
+ serverSessionPromise = ensureServerSessionInternal();
241
+ try {
242
+ await serverSessionPromise;
243
+ }
244
+ finally {
245
+ serverSessionPromise = null;
246
+ }
247
+ }
141
248
  async function handleRequest(request) {
142
249
  const id = request.id ?? null;
143
- // Handle MCP protocol methods locally
144
250
  if (request.method === 'initialize') {
251
+ // Respond to the local client with our capabilities
145
252
  sendResponse({
146
253
  jsonrpc: '2.0',
147
254
  id,
148
255
  result: {
149
- protocolVersion: '2024-11-05',
256
+ protocolVersion: '2025-11-25',
150
257
  capabilities: SERVER_CAPABILITIES,
151
258
  serverInfo: SERVER_INFO,
152
259
  },
153
260
  });
261
+ // Proactively establish server session (so tools/call has a session ready).
262
+ // Capture any auth error so tool calls can surface it immediately.
263
+ try {
264
+ await ensureServerSession();
265
+ sessionInitError = null;
266
+ }
267
+ catch (err) {
268
+ sessionInitError = err instanceof Error ? err.message : 'Authentication failed';
269
+ }
154
270
  return;
155
271
  }
156
272
  if (request.method === 'notifications/initialized') {
157
- // This is a notification, no response needed
158
273
  return;
159
274
  }
160
275
  if (request.method === 'tools/list') {
161
- sendResponse({
162
- jsonrpc: '2.0',
163
- id,
164
- result: {
165
- tools: getToolsList(),
166
- },
167
- });
276
+ // Proxy to the remote server so identity tools (whoami, listTeams, selectTeam)
277
+ // defined server-side are included in the response.
278
+ try {
279
+ // If there was a previous init error, retry once before giving up.
280
+ // This allows recovery from transient network failures.
281
+ if (sessionInitError) {
282
+ sessionInitError = null;
283
+ mcpSessionId = null;
284
+ }
285
+ await ensureServerSession();
286
+ const accessToken = await getAccessToken();
287
+ const headers = {
288
+ 'Content-Type': 'application/json',
289
+ Authorization: `Bearer ${accessToken}`,
290
+ };
291
+ if (mcpSessionId) {
292
+ headers['Mcp-Session-Id'] = mcpSessionId;
293
+ }
294
+ const response = await fetch(API_URL, {
295
+ method: 'POST',
296
+ headers,
297
+ body: JSON.stringify(request),
298
+ });
299
+ if (!response.ok) {
300
+ // Fall back to local tool list if server is unreachable
301
+ sendResponse({ jsonrpc: '2.0', id, result: { tools: getToolsList() } });
302
+ return;
303
+ }
304
+ const rpcResponse = (await response.json());
305
+ sendResponse(rpcResponse);
306
+ }
307
+ catch {
308
+ sendResponse({ jsonrpc: '2.0', id, result: { tools: getToolsList() } });
309
+ }
168
310
  return;
169
311
  }
170
- // For tools/call, forward to the API
171
312
  if (request.method === 'tools/call') {
172
- if (!API_TOKEN) {
173
- sendError(id, -32000, 'ERASER_API_TOKEN environment variable is required');
174
- return;
175
- }
176
313
  try {
314
+ // If there was a previous init error, retry once before giving up.
315
+ // This allows recovery from transient network failures.
316
+ if (sessionInitError) {
317
+ sessionInitError = null;
318
+ mcpSessionId = null;
319
+ }
320
+ await ensureServerSession();
321
+ const accessToken = await getAccessToken();
322
+ const headers = {
323
+ 'Content-Type': 'application/json',
324
+ Authorization: `Bearer ${accessToken}`,
325
+ };
326
+ if (mcpSessionId) {
327
+ headers['Mcp-Session-Id'] = mcpSessionId;
328
+ }
177
329
  const response = await fetch(API_URL, {
178
330
  method: 'POST',
179
- headers: {
180
- 'Content-Type': 'application/json',
181
- Authorization: `Bearer ${API_TOKEN}`,
182
- },
331
+ headers,
183
332
  body: JSON.stringify(request),
184
333
  });
334
+ // Check for updated session token (e.g., after selectTeam)
335
+ const newSessionId = response.headers.get('Mcp-Session-Id');
336
+ if (newSessionId) {
337
+ mcpSessionId = newSessionId;
338
+ }
339
+ if (response.status === 401) {
340
+ // Token might be invalid/expired; try refresh_token before wiping credentials.
341
+ // In API token mode, the token is immutable — nothing to refresh.
342
+ if (ERASER_API_TOKEN) {
343
+ const text = await response.text();
344
+ sendError(id, -32000, `API token rejected (HTTP 401): ${text}`);
345
+ return;
346
+ }
347
+ cachedAccessToken = null;
348
+ mcpSessionId = null;
349
+ cachedAccessToken = await (0, flow_1.recoverAuthAfter401)();
350
+ await ensureServerSession();
351
+ const newToken = await getAccessToken();
352
+ const retryHeaders = {
353
+ 'Content-Type': 'application/json',
354
+ Authorization: `Bearer ${newToken}`,
355
+ };
356
+ if (mcpSessionId) {
357
+ retryHeaders['Mcp-Session-Id'] = mcpSessionId;
358
+ }
359
+ const retryResponse = await fetch(API_URL, {
360
+ method: 'POST',
361
+ headers: retryHeaders,
362
+ body: JSON.stringify(request),
363
+ });
364
+ const retrySessionId = retryResponse.headers.get('Mcp-Session-Id');
365
+ if (retrySessionId) {
366
+ mcpSessionId = retrySessionId;
367
+ }
368
+ if (!retryResponse.ok) {
369
+ const text = await retryResponse.text();
370
+ sendError(id, -32000, `HTTP ${retryResponse.status}: ${text}`);
371
+ return;
372
+ }
373
+ const rpcResponse = (await retryResponse.json());
374
+ await processAndSendResponse(rpcResponse, request);
375
+ return;
376
+ }
185
377
  if (!response.ok) {
186
378
  const text = await response.text();
187
379
  sendError(id, -32000, `HTTP ${response.status}: ${text}`);
188
380
  return;
189
381
  }
190
382
  const rpcResponse = (await response.json());
191
- // Try to save the image locally if there's an imageUrl in the result
192
- if (rpcResponse.result) {
193
- const result = rpcResponse.result;
194
- if (result.content?.[0]?.type === 'text' && result.content[0].text) {
195
- try {
196
- const renderResult = JSON.parse(result.content[0].text);
197
- if (renderResult.imageUrl) {
198
- // Extract diagram code from request params for title extraction
199
- const params = request.params;
200
- const diagramCode = params?.arguments?.code;
201
- const localPath = await saveImageLocally(renderResult.imageUrl, diagramCode);
202
- if (localPath) {
203
- renderResult.localPath = localPath;
204
- result.content[0].text = JSON.stringify(renderResult);
205
- }
206
- }
207
- }
208
- catch {
209
- // If parsing fails, just return the original response
210
- }
211
- }
212
- }
213
- sendResponse(rpcResponse);
383
+ await processAndSendResponse(rpcResponse, request);
214
384
  }
215
385
  catch (error) {
216
386
  const message = error instanceof Error ? error.message : 'Unknown error';
@@ -218,10 +388,32 @@ async function handleRequest(request) {
218
388
  }
219
389
  return;
220
390
  }
221
- // Unknown method
222
391
  sendError(id, -32601, `Method not found: ${request.method}`);
223
392
  }
224
- function main() {
393
+ async function processAndSendResponse(rpcResponse, request) {
394
+ if (rpcResponse.result) {
395
+ const result = rpcResponse.result;
396
+ if (result.content?.[0]?.type === 'text' && result.content[0].text) {
397
+ try {
398
+ const renderResult = JSON.parse(result.content[0].text);
399
+ if (renderResult.imageUrl) {
400
+ const params = request.params;
401
+ const diagramCode = params?.arguments?.code;
402
+ const localPath = await saveImageLocally(renderResult.imageUrl, diagramCode);
403
+ if (localPath) {
404
+ renderResult.localPath = localPath;
405
+ result.content[0].text = JSON.stringify(renderResult);
406
+ }
407
+ }
408
+ }
409
+ catch {
410
+ // If parsing fails, just return the original response
411
+ }
412
+ }
413
+ }
414
+ sendResponse(rpcResponse);
415
+ }
416
+ function runStdioServer() {
225
417
  const rl = readline.createInterface({
226
418
  input: process.stdin,
227
419
  output: process.stdout,
@@ -246,10 +438,31 @@ function main() {
246
438
  rl.on('close', () => {
247
439
  process.exit(0);
248
440
  });
249
- // Prevent unhandled promise rejections from crashing
250
441
  process.on('unhandledRejection', (error) => {
251
442
  const message = error instanceof Error ? error.message : 'Unknown error';
252
443
  sendError(null, -32000, `Unhandled error: ${message}`);
253
444
  });
254
445
  }
255
- main();
446
+ async function main() {
447
+ const args = process.argv.slice(2);
448
+ const command = args[0];
449
+ switch (command) {
450
+ case 'login':
451
+ await (0, flow_1.performLogin)();
452
+ break;
453
+ case 'logout':
454
+ (0, flow_1.logout)();
455
+ break;
456
+ case 'whoami':
457
+ await (0, flow_1.whoami)();
458
+ break;
459
+ default:
460
+ // Default: run as MCP stdio server
461
+ runStdioServer();
462
+ break;
463
+ }
464
+ }
465
+ main().catch((err) => {
466
+ console.error(`Fatal error: ${err.message}`);
467
+ process.exit(1);
468
+ });