@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.
- package/README.md +72 -40
- package/dist/oauth/callback-server.d.ts +6 -0
- package/dist/oauth/callback-server.d.ts.map +1 -0
- package/dist/oauth/callback-server.js +187 -0
- package/dist/oauth/config.d.ts +4 -0
- package/dist/oauth/config.d.ts.map +1 -0
- package/dist/oauth/config.js +13 -0
- package/dist/oauth/flow.d.ts +17 -0
- package/dist/oauth/flow.d.ts.map +1 -0
- package/dist/oauth/flow.js +222 -0
- package/dist/oauth/index.d.ts +7 -0
- package/dist/oauth/index.d.ts.map +1 -0
- package/dist/oauth/index.js +22 -0
- package/dist/oauth/pkce.d.ts +3 -0
- package/dist/oauth/pkce.d.ts.map +1 -0
- package/dist/oauth/pkce.js +45 -0
- package/dist/oauth/token-storage.d.ts +6 -0
- package/dist/oauth/token-storage.d.ts.map +1 -0
- package/dist/oauth/token-storage.js +83 -0
- package/dist/oauth/types.d.ts +26 -0
- package/dist/oauth/types.d.ts.map +1 -0
- package/dist/oauth/types.js +2 -0
- package/dist/stdio.d.ts +7 -2
- package/dist/stdio.d.ts.map +1 -1
- package/dist/stdio.js +280 -67
- package/package.json +1 -1
|
@@ -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"}
|
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
|
* }
|
package/dist/stdio.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stdio.d.ts","sourceRoot":"","sources":["../src/stdio.ts"],"names":[],"mappings":";AACA
|
|
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
|
-
|
|
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();
|
|
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, '');
|
|
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: '
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|