@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 CHANGED
@@ -4,33 +4,41 @@ Model Context Protocol (MCP) server for [Eraser](https://eraser.io) - generate d
4
4
 
5
5
  ## Quick Start
6
6
 
7
- ```bash
8
- npx @eraserlabs/eraser-mcp
7
+ **Remote MCP endpoint:** `https://app.eraser.io/api/mcp`
8
+
9
+ Add this to your MCP config (e.g. **Cursor:** `.cursor/mcp.json` · **Claude Desktop:** your app's MCP settings file):
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "eraser": {
15
+ "type": "http",
16
+ "url": "https://app.eraser.io/api/mcp"
17
+ }
18
+ }
19
+ }
9
20
  ```
10
21
 
11
- ## Configuration
22
+ Your client will prompt you to sign in to Eraser when authentication is required.
12
23
 
13
- ### Cursor
24
+ ### npx (stdio bridge + OAuth)
14
25
 
15
- Add to `.cursor/mcp.json`:
26
+ Use the published npm package when you want a **local stdio** MCP server. It proxies to the same remote endpoint and signs you in with **OAuth** on first run (browser opens; credentials are stored on your machine).
27
+
28
+ Add this to your MCP config:
16
29
 
17
30
  ```json
18
31
  {
19
32
  "mcpServers": {
20
33
  "eraser": {
21
34
  "command": "npx",
22
- "args": ["@eraserlabs/eraser-mcp"],
23
- "env": {
24
- "ERASER_API_TOKEN": "your-api-token"
25
- }
35
+ "args": ["@eraserlabs/eraser-mcp"]
26
36
  }
27
37
  }
28
38
  }
29
39
  ```
30
40
 
31
- ### Claude Desktop
32
-
33
- Add to your Claude Desktop config:
41
+ **API token (optional):** To skip OAuth (e.g. CI, scripts, or headless environments), add `ERASER_API_TOKEN` to `env` with an [API token from Eraser Settings](https://app.eraser.io/settings/api-tokens):
34
42
 
35
43
  ```json
36
44
  {
@@ -46,65 +54,89 @@ Add to your Claude Desktop config:
46
54
  }
47
55
  ```
48
56
 
57
+ ## CLI Commands
58
+
59
+ | Command | Description |
60
+ | ----------------------------------- | --------------------------------------------------- |
61
+ | `npx @eraserlabs/eraser-mcp` | Start the MCP server (auto-authenticates via OAuth) |
62
+ | `npx @eraserlabs/eraser-mcp login` | Manually trigger login |
63
+ | `npx @eraserlabs/eraser-mcp logout` | Clear saved credentials |
64
+ | `npx @eraserlabs/eraser-mcp whoami` | Show current auth status |
65
+
49
66
  ## Environment Variables
50
67
 
51
- | Variable | Required | Description |
52
- |----------|----------|-------------|
53
- | `ERASER_API_TOKEN` | Yes | Your Eraser API token |
54
- | `ERASER_API_URL` | No | Custom API URL (default: `https://app.eraser.io/api/mcp`) |
55
- | `ERASER_OUTPUT_DIR` | No | Directory to save rendered diagrams (default: `.eraser/scratchpad`) |
68
+ | Variable | Required | Description |
69
+ | ------------------- | -------- | --------------------------------------------------------------------------------------------------------- |
70
+ | `ERASER_API_TOKEN` | No | API token for CI/CD and headless environments (skips OAuth flow) |
71
+ | `ERASER_API_URL` | No | MCP HTTP endpoint (default: production `https://app.eraser.io/api/mcp`; set only for staging/self-hosted) |
72
+ | `ERASER_OUTPUT_DIR` | No | Directory to save rendered diagrams (default: `.eraser/scratchpad`) |
73
+
74
+ ## CI/CD and Headless Environments
75
+
76
+ For automated pipelines where a browser login isn't possible, set the `ERASER_API_TOKEN` environment variable. The OAuth flow is skipped entirely and the token is passed directly to the Eraser API.
77
+
78
+ Get your API token from [Eraser Settings](https://app.eraser.io/settings/api-tokens). Use the same config block shown in [API token (optional)](#npx-stdio-bridge--oauth) above.
56
79
 
57
80
  ## Available Tools
58
81
 
82
+ ### Identity
83
+
84
+ Call `whoami` first to get the current user and active team.
85
+
86
+ | Tool | Description |
87
+ | ------------ | ------------------------------------------------------------------------------------------------ |
88
+ | `whoami` | Returns current user profile, active team, and list of all teams. **Call this first.** |
89
+ | `listTeams` | Lists all teams the user is a member of (usually not needed since `whoami` includes this) |
90
+ | `selectTeam` | Selects which team to use for subsequent operations when the user has multiple teams |
91
+
59
92
  ### AI Diagram
60
93
 
61
94
  Prompt to diagram.
62
95
 
63
- | Tool | Description |
64
- |------|-------------|
96
+ | Tool | Description |
97
+ | -------------- | ------------------------------------------------ |
65
98
  | `renderPrompt` | Generate diagrams from natural language using AI |
66
99
 
67
100
  ### Rendering
68
101
 
69
102
  Diagram code (DSL) to diagram.
70
103
 
71
- | Tool | Description |
72
- |------|-------------|
73
- | `renderSequenceDiagram` | Render sequence diagrams from diagram code |
74
- | `renderEntityRelationshipDiagram` | Render ERD diagrams from diagram code |
75
- | `renderCloudArchitectureDiagram` | Render cloud architecture diagrams from diagram code |
76
- | `renderFlowchart` | Render flowcharts from diagram code |
77
- | `renderBpmnDiagram` | Render BPMN diagrams from diagram code |
78
- | `renderElements` | Render multiple diagram elements from diagram code |
104
+ | Tool | Description |
105
+ | --------------------------------- | ---------------------------------------------------- |
106
+ | `renderSequenceDiagram` | Render sequence diagrams from diagram code |
107
+ | `renderEntityRelationshipDiagram` | Render ERD diagrams from diagram code |
108
+ | `renderCloudArchitectureDiagram` | Render cloud architecture diagrams from diagram code |
109
+ | `renderFlowchart` | Render flowcharts from diagram code |
110
+ | `renderBpmnDiagram` | Render BPMN diagrams from diagram code |
111
+ | `renderElements` | Render multiple diagram elements from diagram code |
79
112
 
80
113
  ### Files
81
114
 
82
115
  CRUD for files on app.eraser.io.
83
116
 
84
- | Tool | Description |
85
- |------|-------------|
86
- | `createFile` | Create a new Eraser file with document and/or diagram elements |
87
- | `listFiles` | List files in the workspace with pagination, sorting, and filtering |
88
- | `getFile` | Get a single file including metadata, content, and diagram elements |
89
- | `updateFile` | Update an existing file's metadata and/or document content |
90
- | `archiveFile` | Archive (soft-delete) a file |
117
+ | Tool | Description |
118
+ | ------------- | ------------------------------------------------------------------- |
119
+ | `createFile` | Create a new Eraser file with document and/or diagram elements |
120
+ | `listFiles` | List files in the workspace with pagination, sorting, and filtering |
121
+ | `getFile` | Get a single file including metadata, content, and diagram elements |
122
+ | `updateFile` | Update an existing file's metadata and/or document content |
123
+ | `archiveFile` | Archive (soft-delete) a file |
91
124
 
92
125
  ### Diagrams
93
126
 
94
127
  CRUD for diagrams on app.eraser.io.
95
128
 
96
- | Tool | Description |
97
- |------|-------------|
98
- | `listDiagrams` | List all diagrams in a file |
129
+ | Tool | Description |
130
+ | --------------- | ---------------------------------------- |
131
+ | `listDiagrams` | List all diagrams in a file |
99
132
  | `createDiagram` | Create a new diagram in an existing file |
100
- | `getDiagram` | Get a specific diagram from a file |
101
- | `updateDiagram` | Update the code of an existing diagram |
133
+ | `getDiagram` | Get a specific diagram from a file |
134
+ | `updateDiagram` | Update the code of an existing diagram |
102
135
  | `deleteDiagram` | Permanently delete a diagram from a file |
103
136
 
104
137
  ## Documentation
105
138
 
106
139
  - [Eraser Agent Integration Documentation](https://docs.eraser.io/docs/using-ai-agent-integrations)
107
- - [Get an API Token](https://docs.eraser.io/reference/api-token)
108
140
 
109
141
  ## License
110
142
 
@@ -0,0 +1,6 @@
1
+ export interface CallbackResult {
2
+ code: string;
3
+ state: string;
4
+ }
5
+ export declare function startCallbackServer(expectedState: string): Promise<CallbackResult>;
6
+ //# sourceMappingURL=callback-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"callback-server.d.ts","sourceRoot":"","sources":["../../src/oauth/callback-server.ts"],"names":[],"mappings":"AA2FA,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CA0ElF"}
@@ -0,0 +1,187 @@
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.startCallbackServer = startCallbackServer;
37
+ const http = __importStar(require("http"));
38
+ const url_1 = require("url");
39
+ const config_1 = require("./config");
40
+ /** Escape user-controlled OAuth error strings before embedding in HTML. */
41
+ function escapeHtml(text) {
42
+ return text
43
+ .replace(/&/g, '&amp;')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ .replace(/"/g, '&quot;')
47
+ .replace(/'/g, '&#39;');
48
+ }
49
+ const SUCCESS_HTML = `
50
+ <!DOCTYPE html>
51
+ <html>
52
+ <head>
53
+ <title>Eraser MCP - Authorization Complete</title>
54
+ <style>
55
+ body {
56
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
57
+ display: flex;
58
+ justify-content: center;
59
+ align-items: center;
60
+ height: 100vh;
61
+ margin: 0;
62
+ background: #f6f6f6;
63
+ }
64
+ .container {
65
+ text-align: center;
66
+ padding: 40px;
67
+ background: white;
68
+ border-radius: 8px;
69
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
70
+ }
71
+ h1 { color: #2a2b2b; margin-bottom: 16px; }
72
+ p { color: #666; }
73
+ .checkmark { font-size: 48px; margin-bottom: 16px; }
74
+ </style>
75
+ </head>
76
+ <body>
77
+ <div class="container">
78
+ <div class="checkmark">✓</div>
79
+ <h1>Authorization Complete</h1>
80
+ <p>You can close this window and return to your terminal.</p>
81
+ </div>
82
+ </body>
83
+ </html>
84
+ `;
85
+ const ERROR_HTML = (rawMessage) => {
86
+ const message = escapeHtml(rawMessage);
87
+ return `
88
+ <!DOCTYPE html>
89
+ <html>
90
+ <head>
91
+ <title>Eraser MCP - Authorization Failed</title>
92
+ <style>
93
+ body {
94
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
95
+ display: flex;
96
+ justify-content: center;
97
+ align-items: center;
98
+ height: 100vh;
99
+ margin: 0;
100
+ background: #f6f6f6;
101
+ }
102
+ .container {
103
+ text-align: center;
104
+ padding: 40px;
105
+ background: white;
106
+ border-radius: 8px;
107
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
108
+ }
109
+ h1 { color: #e72f6e; margin-bottom: 16px; }
110
+ p { color: #666; }
111
+ .error-icon { font-size: 48px; margin-bottom: 16px; }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <div class="container">
116
+ <div class="error-icon">✗</div>
117
+ <h1>Authorization Failed</h1>
118
+ <p>${message}</p>
119
+ </div>
120
+ </body>
121
+ </html>
122
+ `;
123
+ };
124
+ function startCallbackServer(expectedState) {
125
+ return new Promise((resolve, reject) => {
126
+ let timeoutHandle = null;
127
+ const cleanup = () => {
128
+ if (timeoutHandle) {
129
+ clearTimeout(timeoutHandle);
130
+ timeoutHandle = null;
131
+ }
132
+ };
133
+ const server = http.createServer((req, res) => {
134
+ const url = new url_1.URL(req.url || '/', `http://127.0.0.1:${config_1.CALLBACK_PORT}`);
135
+ if (url.pathname !== '/callback') {
136
+ res.writeHead(404);
137
+ res.end('Not found');
138
+ return;
139
+ }
140
+ const code = url.searchParams.get('code');
141
+ const state = url.searchParams.get('state');
142
+ const error = url.searchParams.get('error');
143
+ const errorDescription = url.searchParams.get('error_description');
144
+ if (error) {
145
+ res.writeHead(400, { 'Content-Type': 'text/html' });
146
+ res.end(ERROR_HTML(errorDescription || error));
147
+ server.close();
148
+ cleanup();
149
+ reject(new Error(errorDescription || error));
150
+ return;
151
+ }
152
+ if (!code) {
153
+ res.writeHead(400, { 'Content-Type': 'text/html' });
154
+ res.end(ERROR_HTML('Missing authorization code'));
155
+ server.close();
156
+ cleanup();
157
+ reject(new Error('Missing authorization code'));
158
+ return;
159
+ }
160
+ if (state !== expectedState) {
161
+ res.writeHead(400, { 'Content-Type': 'text/html' });
162
+ res.end(ERROR_HTML('Invalid state parameter'));
163
+ server.close();
164
+ cleanup();
165
+ reject(new Error('Invalid state parameter - possible CSRF attack'));
166
+ return;
167
+ }
168
+ res.writeHead(200, { 'Content-Type': 'text/html' });
169
+ res.end(SUCCESS_HTML);
170
+ server.close();
171
+ cleanup();
172
+ resolve({ code, state });
173
+ });
174
+ server.on('error', (err) => {
175
+ cleanup();
176
+ reject(new Error(`Failed to start callback server: ${err.message}`));
177
+ });
178
+ server.listen(config_1.CALLBACK_PORT, '127.0.0.1', () => {
179
+ // Server is listening
180
+ });
181
+ // Timeout after 5 minutes
182
+ timeoutHandle = setTimeout(() => {
183
+ server.close();
184
+ reject(new Error('Authorization timed out'));
185
+ }, 5 * 60 * 1000);
186
+ });
187
+ }
@@ -0,0 +1,4 @@
1
+ import type { OAuthConfig } from './types';
2
+ export declare const DEFAULT_OAUTH_CONFIG: OAuthConfig;
3
+ export declare const CALLBACK_PORT = 9876;
4
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/oauth/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAI3C,eAAO,MAAM,oBAAoB,EAAE,WAOlC,CAAC;AAEF,eAAO,MAAM,aAAa,OAAO,CAAC"}
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CALLBACK_PORT = exports.DEFAULT_OAUTH_CONFIG = void 0;
4
+ const BASE_URL = process.env.ERASER_API_URL?.replace('/api/mcp', '') || 'https://app.eraser.io';
5
+ exports.DEFAULT_OAUTH_CONFIG = {
6
+ authorizationEndpoint: `${BASE_URL}/api/oauth/authorize`,
7
+ tokenEndpoint: `${BASE_URL}/api/oauth/token`,
8
+ clientId: 'eraser-mcp-cli',
9
+ redirectUri: 'http://127.0.0.1:9876/callback',
10
+ scopes: ['mcp:read', 'mcp:write', 'mcp:generate', 'mcp:render'],
11
+ resource: `${BASE_URL}/api/mcp`,
12
+ };
13
+ exports.CALLBACK_PORT = 9876;
@@ -0,0 +1,17 @@
1
+ import type { StoredCredentials } from './types';
2
+ export declare function performLogin(): Promise<StoredCredentials>;
3
+ export declare function ensureValidToken(): Promise<string>;
4
+ /**
5
+ * After the API returns 401, try refresh_token before wiping credentials or
6
+ * opening the browser. Use this instead of invalidateCredentials() + ensureValidToken()
7
+ * so refresh_token in ~/.eraser/ is preserved until refresh actually fails.
8
+ */
9
+ export declare function recoverAuthAfter401(): Promise<string>;
10
+ /**
11
+ * Clears stored OAuth credentials (e.g. explicit logout). Prefer recoverAuthAfter401
12
+ * when the server returns 401 so refresh_token can be used first.
13
+ */
14
+ export declare function invalidateCredentials(): void;
15
+ export declare function logout(): void;
16
+ export declare function whoami(): Promise<void>;
17
+ //# sourceMappingURL=flow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flow.d.ts","sourceRoot":"","sources":["../../src/oauth/flow.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,iBAAiB,EAAiB,MAAM,SAAS,CAAC;AA8EhE,wBAAsB,YAAY,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAwC/D;AAED,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC,CA6BxD;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CAyB3D;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED,wBAAgB,MAAM,IAAI,IAAI,CAG7B;AAED,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAgB5C"}
@@ -0,0 +1,222 @@
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.performLogin = performLogin;
37
+ exports.ensureValidToken = ensureValidToken;
38
+ exports.recoverAuthAfter401 = recoverAuthAfter401;
39
+ exports.invalidateCredentials = invalidateCredentials;
40
+ exports.logout = logout;
41
+ exports.whoami = whoami;
42
+ const crypto = __importStar(require("crypto"));
43
+ const config_1 = require("./config");
44
+ const pkce_1 = require("./pkce");
45
+ const callback_server_1 = require("./callback-server");
46
+ const token_storage_1 = require("./token-storage");
47
+ /** The API URL used for scoping stored credentials. */
48
+ const CREDENTIAL_KEY = config_1.DEFAULT_OAUTH_CONFIG.resource;
49
+ function openBrowser(url) {
50
+ const { exec } = require('child_process');
51
+ const platform = process.platform;
52
+ let command;
53
+ if (platform === 'darwin') {
54
+ command = `open "${url}"`;
55
+ }
56
+ else if (platform === 'win32') {
57
+ command = `start "" "${url}"`;
58
+ }
59
+ else {
60
+ command = `xdg-open "${url}"`;
61
+ }
62
+ exec(command, (err) => {
63
+ if (err) {
64
+ console.error(`Failed to open browser: ${err.message}`);
65
+ console.error(`Please open this URL manually:\n${url}`);
66
+ }
67
+ });
68
+ }
69
+ async function exchangeCodeForTokens(code, codeVerifier) {
70
+ const body = new URLSearchParams({
71
+ grant_type: 'authorization_code',
72
+ code,
73
+ code_verifier: codeVerifier,
74
+ client_id: config_1.DEFAULT_OAUTH_CONFIG.clientId,
75
+ redirect_uri: config_1.DEFAULT_OAUTH_CONFIG.redirectUri,
76
+ resource: config_1.DEFAULT_OAUTH_CONFIG.resource,
77
+ });
78
+ const response = await fetch(config_1.DEFAULT_OAUTH_CONFIG.tokenEndpoint, {
79
+ method: 'POST',
80
+ headers: {
81
+ 'Content-Type': 'application/x-www-form-urlencoded',
82
+ },
83
+ body: body.toString(),
84
+ });
85
+ if (!response.ok) {
86
+ const text = await response.text();
87
+ throw new Error(`Token exchange failed: ${response.status} ${text}`);
88
+ }
89
+ return (await response.json());
90
+ }
91
+ async function refreshAccessToken(refreshToken) {
92
+ const body = new URLSearchParams({
93
+ grant_type: 'refresh_token',
94
+ refresh_token: refreshToken,
95
+ client_id: config_1.DEFAULT_OAUTH_CONFIG.clientId,
96
+ resource: config_1.DEFAULT_OAUTH_CONFIG.resource,
97
+ });
98
+ const response = await fetch(config_1.DEFAULT_OAUTH_CONFIG.tokenEndpoint, {
99
+ method: 'POST',
100
+ headers: {
101
+ 'Content-Type': 'application/x-www-form-urlencoded',
102
+ },
103
+ body: body.toString(),
104
+ });
105
+ if (!response.ok) {
106
+ throw new Error(`Token refresh failed: ${response.status}`);
107
+ }
108
+ return (await response.json());
109
+ }
110
+ async function performLogin() {
111
+ const { codeVerifier, codeChallenge } = (0, pkce_1.generatePKCE)();
112
+ const state = crypto.randomBytes(16).toString('hex');
113
+ const authUrl = new URL(config_1.DEFAULT_OAUTH_CONFIG.authorizationEndpoint);
114
+ authUrl.searchParams.set('response_type', 'code');
115
+ authUrl.searchParams.set('client_id', config_1.DEFAULT_OAUTH_CONFIG.clientId);
116
+ authUrl.searchParams.set('redirect_uri', config_1.DEFAULT_OAUTH_CONFIG.redirectUri);
117
+ authUrl.searchParams.set('code_challenge', codeChallenge);
118
+ authUrl.searchParams.set('code_challenge_method', 'S256');
119
+ authUrl.searchParams.set('scope', config_1.DEFAULT_OAUTH_CONFIG.scopes.join(' '));
120
+ authUrl.searchParams.set('resource', config_1.DEFAULT_OAUTH_CONFIG.resource);
121
+ authUrl.searchParams.set('state', state);
122
+ console.error(`Opening browser for authentication...`);
123
+ console.error(`If the browser doesn't open, visit:\n${authUrl.toString()}\n`);
124
+ // Start the callback server before opening the browser
125
+ const callbackPromise = (0, callback_server_1.startCallbackServer)(state);
126
+ // Open browser
127
+ openBrowser(authUrl.toString());
128
+ // Wait for callback
129
+ const { code } = await callbackPromise;
130
+ console.error('Exchanging authorization code for tokens...');
131
+ const tokens = await exchangeCodeForTokens(code, codeVerifier);
132
+ const credentials = {
133
+ accessToken: tokens.access_token,
134
+ refreshToken: tokens.refresh_token,
135
+ expiresAt: Date.now() + tokens.expires_in * 1000,
136
+ scope: tokens.scope,
137
+ };
138
+ (0, token_storage_1.saveCredentials)(credentials, CREDENTIAL_KEY);
139
+ console.error('Login successful! Credentials saved.\n');
140
+ return credentials;
141
+ }
142
+ async function ensureValidToken() {
143
+ let credentials = (0, token_storage_1.loadCredentials)(CREDENTIAL_KEY);
144
+ if (!credentials) {
145
+ credentials = await performLogin();
146
+ return credentials.accessToken;
147
+ }
148
+ if ((0, token_storage_1.isTokenExpired)(credentials)) {
149
+ try {
150
+ console.error('Refreshing access token...');
151
+ const tokens = await refreshAccessToken(credentials.refreshToken);
152
+ credentials = {
153
+ accessToken: tokens.access_token,
154
+ refreshToken: tokens.refresh_token,
155
+ expiresAt: Date.now() + tokens.expires_in * 1000,
156
+ scope: tokens.scope,
157
+ };
158
+ (0, token_storage_1.saveCredentials)(credentials, CREDENTIAL_KEY);
159
+ }
160
+ catch {
161
+ console.error('Token refresh failed, initiating new login...');
162
+ (0, token_storage_1.clearCredentials)(CREDENTIAL_KEY);
163
+ credentials = await performLogin();
164
+ }
165
+ }
166
+ return credentials.accessToken;
167
+ }
168
+ /**
169
+ * After the API returns 401, try refresh_token before wiping credentials or
170
+ * opening the browser. Use this instead of invalidateCredentials() + ensureValidToken()
171
+ * so refresh_token in ~/.eraser/ is preserved until refresh actually fails.
172
+ */
173
+ async function recoverAuthAfter401() {
174
+ const credentials = (0, token_storage_1.loadCredentials)(CREDENTIAL_KEY);
175
+ if (!credentials) {
176
+ const loggedIn = await performLogin();
177
+ return loggedIn.accessToken;
178
+ }
179
+ try {
180
+ console.error('Refreshing access token (server rejected previous access token)...');
181
+ const tokens = await refreshAccessToken(credentials.refreshToken);
182
+ const updated = {
183
+ accessToken: tokens.access_token,
184
+ refreshToken: tokens.refresh_token,
185
+ expiresAt: Date.now() + tokens.expires_in * 1000,
186
+ scope: tokens.scope,
187
+ };
188
+ (0, token_storage_1.saveCredentials)(updated, CREDENTIAL_KEY);
189
+ return updated.accessToken;
190
+ }
191
+ catch {
192
+ console.error('Token refresh failed after 401, clearing credentials and re-authenticating...');
193
+ (0, token_storage_1.clearCredentials)(CREDENTIAL_KEY);
194
+ const loggedIn = await performLogin();
195
+ return loggedIn.accessToken;
196
+ }
197
+ }
198
+ /**
199
+ * Clears stored OAuth credentials (e.g. explicit logout). Prefer recoverAuthAfter401
200
+ * when the server returns 401 so refresh_token can be used first.
201
+ */
202
+ function invalidateCredentials() {
203
+ (0, token_storage_1.clearCredentials)(CREDENTIAL_KEY);
204
+ }
205
+ function logout() {
206
+ (0, token_storage_1.clearCredentials)(CREDENTIAL_KEY);
207
+ console.error('Logged out successfully.\n');
208
+ }
209
+ async function whoami() {
210
+ const credentials = (0, token_storage_1.loadCredentials)(CREDENTIAL_KEY);
211
+ if (!credentials) {
212
+ console.error('Not logged in. Run `eraser-mcp login` to authenticate.\n');
213
+ return;
214
+ }
215
+ if ((0, token_storage_1.isTokenExpired)(credentials)) {
216
+ console.error('Token expired. Run `eraser-mcp login` to re-authenticate.\n');
217
+ return;
218
+ }
219
+ console.error('Logged in to Eraser MCP.');
220
+ console.error(`Scopes: ${credentials.scope}`);
221
+ console.error(`Token expires: ${new Date(credentials.expiresAt).toLocaleString()}\n`);
222
+ }
@@ -0,0 +1,7 @@
1
+ export * from './types';
2
+ export * from './config';
3
+ export * from './pkce';
4
+ export * from './token-storage';
5
+ export * from './callback-server';
6
+ export * from './flow';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/oauth/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,QAAQ,CAAC"}
@@ -0,0 +1,22 @@
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 __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./types"), exports);
18
+ __exportStar(require("./config"), exports);
19
+ __exportStar(require("./pkce"), exports);
20
+ __exportStar(require("./token-storage"), exports);
21
+ __exportStar(require("./callback-server"), exports);
22
+ __exportStar(require("./flow"), exports);