@phantom/mcp-server 0.1.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 +498 -0
- package/bin/phantom-mcp +2 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +1448 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
+
mod
|
|
28
|
+
));
|
|
29
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
|
+
|
|
31
|
+
// src/index.ts
|
|
32
|
+
var src_exports = {};
|
|
33
|
+
__export(src_exports, {
|
|
34
|
+
SessionManager: () => SessionManager
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(src_exports);
|
|
37
|
+
|
|
38
|
+
// src/server.ts
|
|
39
|
+
var import_server = require("@modelcontextprotocol/sdk/server/index.js");
|
|
40
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
41
|
+
var import_types = require("@modelcontextprotocol/sdk/types.js");
|
|
42
|
+
|
|
43
|
+
// src/session/manager.ts
|
|
44
|
+
var import_client = require("@phantom/client");
|
|
45
|
+
var import_api_key_stamper = require("@phantom/api-key-stamper");
|
|
46
|
+
|
|
47
|
+
// src/session/storage.ts
|
|
48
|
+
var fs = __toESM(require("fs"));
|
|
49
|
+
var path = __toESM(require("path"));
|
|
50
|
+
var os = __toESM(require("os"));
|
|
51
|
+
var SessionStorage = class {
|
|
52
|
+
constructor(sessionDir) {
|
|
53
|
+
this.sessionDir = sessionDir || path.join(os.homedir(), ".phantom-mcp");
|
|
54
|
+
this.sessionFile = path.join(this.sessionDir, "session.json");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ensures session directory exists with secure permissions (0o700)
|
|
58
|
+
*/
|
|
59
|
+
ensureSessionDir() {
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(this.sessionDir, { mode: 448, recursive: true });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const err = error;
|
|
64
|
+
if (err.code !== "EEXIST") {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
fs.chmodSync(this.sessionDir, 448);
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Loads session data from disk
|
|
75
|
+
* @returns SessionData if exists and valid, null otherwise
|
|
76
|
+
*/
|
|
77
|
+
load() {
|
|
78
|
+
try {
|
|
79
|
+
if (!fs.existsSync(this.sessionFile)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const data = fs.readFileSync(this.sessionFile, "utf-8");
|
|
83
|
+
const session = JSON.parse(data);
|
|
84
|
+
if (typeof session.walletId !== "string" || typeof session.organizationId !== "string" || typeof session.authUserId !== "string" || typeof session.stamperKeys?.publicKey !== "string" || typeof session.stamperKeys?.secretKey !== "string" || typeof session.createdAt !== "number" || typeof session.updatedAt !== "number") {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return session;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Saves session data to disk with secure permissions (0o600)
|
|
94
|
+
* @param session Session data to save
|
|
95
|
+
*/
|
|
96
|
+
save(session) {
|
|
97
|
+
this.ensureSessionDir();
|
|
98
|
+
const data = JSON.stringify(session, null, 2);
|
|
99
|
+
fs.writeFileSync(this.sessionFile, data, { mode: 384 });
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Deletes the session file from disk
|
|
103
|
+
*/
|
|
104
|
+
delete() {
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(this.sessionFile)) {
|
|
107
|
+
fs.unlinkSync(this.sessionFile);
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Checks if a session is expired
|
|
114
|
+
* @param session Session data to check
|
|
115
|
+
* @returns false - SSO sessions don't expire (stamper keys are permanent)
|
|
116
|
+
*/
|
|
117
|
+
isExpired(_session) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// src/auth/oauth.ts
|
|
123
|
+
var crypto = __toESM(require("crypto"));
|
|
124
|
+
var import_axios2 = __toESM(require("axios"));
|
|
125
|
+
var import_open = __toESM(require("open"));
|
|
126
|
+
|
|
127
|
+
// src/utils/logger.ts
|
|
128
|
+
var Logger = class {
|
|
129
|
+
constructor(context = "MCP") {
|
|
130
|
+
this.context = context;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Private method to write to stderr with proper formatting
|
|
134
|
+
*/
|
|
135
|
+
log(level, message) {
|
|
136
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
137
|
+
const logMessage = `[${timestamp}] [${level}] [${this.context}] ${message}
|
|
138
|
+
`;
|
|
139
|
+
process.stderr.write(logMessage);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Log info message
|
|
143
|
+
*/
|
|
144
|
+
info(message) {
|
|
145
|
+
this.log("INFO", message);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Log error message
|
|
149
|
+
*/
|
|
150
|
+
error(message) {
|
|
151
|
+
this.log("ERROR", message);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Log warning message
|
|
155
|
+
*/
|
|
156
|
+
warn(message) {
|
|
157
|
+
this.log("WARN", message);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Log debug message (only if DEBUG or PHANTOM_MCP_DEBUG env var is set)
|
|
161
|
+
*/
|
|
162
|
+
debug(message) {
|
|
163
|
+
if (process.env.DEBUG || process.env.PHANTOM_MCP_DEBUG) {
|
|
164
|
+
this.log("DEBUG", message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Create a child logger with combined context
|
|
169
|
+
* Example: parent context "MCP" + child "Transport" = "MCP:Transport"
|
|
170
|
+
*/
|
|
171
|
+
child(childContext) {
|
|
172
|
+
return new Logger(`${this.context}:${childContext}`);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var logger = new Logger();
|
|
176
|
+
|
|
177
|
+
// src/auth/dcr.ts
|
|
178
|
+
var import_axios = __toESM(require("axios"));
|
|
179
|
+
var DCRClient = class {
|
|
180
|
+
/**
|
|
181
|
+
* Creates a new DCR client
|
|
182
|
+
*
|
|
183
|
+
* @param authBaseUrl - Base URL of the authorization server (default: https://auth.phantom.app or PHANTOM_AUTH_BASE_URL env var)
|
|
184
|
+
* @param appId - Application identifier prefix (default: phantom-mcp)
|
|
185
|
+
*/
|
|
186
|
+
constructor(authBaseUrl = process.env.PHANTOM_AUTH_BASE_URL ?? "https://auth.phantom.app", appId = "phantom-mcp") {
|
|
187
|
+
this.authBaseUrl = authBaseUrl;
|
|
188
|
+
this.appId = appId;
|
|
189
|
+
this.logger = new Logger("DCR");
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Registers a new OAuth client dynamically with the authorization server
|
|
193
|
+
*
|
|
194
|
+
* @param redirectUri - The redirect URI where the authorization server will send callbacks
|
|
195
|
+
* @returns Promise resolving to the client configuration (client_id, client_secret, etc.)
|
|
196
|
+
* @throws Error if registration fails
|
|
197
|
+
*/
|
|
198
|
+
async register(redirectUri) {
|
|
199
|
+
const registrationEndpoint = `${this.authBaseUrl}/oauth/register`;
|
|
200
|
+
const clientName = `${this.appId}-${Date.now()}`;
|
|
201
|
+
const payload = {
|
|
202
|
+
client_name: clientName,
|
|
203
|
+
redirect_uris: [redirectUri],
|
|
204
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
205
|
+
response_types: ["code"],
|
|
206
|
+
application_type: "native",
|
|
207
|
+
token_endpoint_auth_method: "client_secret_basic"
|
|
208
|
+
};
|
|
209
|
+
this.logger.info(`Registering OAuth client: ${clientName}`);
|
|
210
|
+
this.logger.debug(`Registration endpoint: ${registrationEndpoint}`);
|
|
211
|
+
this.logger.debug(`Redirect URI: ${redirectUri}`);
|
|
212
|
+
try {
|
|
213
|
+
const response = await import_axios.default.post(registrationEndpoint, payload, {
|
|
214
|
+
headers: {
|
|
215
|
+
"Content-Type": "application/json"
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
this.logger.info(`Successfully registered client: ${response.data.client_id}`);
|
|
219
|
+
return {
|
|
220
|
+
client_id: response.data.client_id,
|
|
221
|
+
client_secret: response.data.client_secret,
|
|
222
|
+
client_id_issued_at: response.data.client_id_issued_at
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const axiosError = error;
|
|
226
|
+
const errorMessage = axiosError.response?.data ? JSON.stringify(axiosError.response.data) : axiosError.message;
|
|
227
|
+
this.logger.error(`Failed to register OAuth client: ${errorMessage}`);
|
|
228
|
+
throw new Error(`Dynamic Client Registration failed: ${errorMessage}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/auth/callback-server.ts
|
|
234
|
+
var http = __toESM(require("http"));
|
|
235
|
+
var import_url = require("url");
|
|
236
|
+
var CallbackServer = class {
|
|
237
|
+
/**
|
|
238
|
+
* Creates a new callback server
|
|
239
|
+
*
|
|
240
|
+
* @param options - Server configuration options
|
|
241
|
+
* @param options.port - Port to listen on (default: 8080)
|
|
242
|
+
* @param options.host - Host to bind to (default: localhost)
|
|
243
|
+
* @param options.path - Callback path (default: /callback)
|
|
244
|
+
* @param options.timeoutMs - Timeout in milliseconds (default: 300000 = 5 minutes)
|
|
245
|
+
*/
|
|
246
|
+
constructor(options = {}) {
|
|
247
|
+
this.server = null;
|
|
248
|
+
this.listeningPromise = null;
|
|
249
|
+
this.listeningResolve = null;
|
|
250
|
+
this.listeningReject = null;
|
|
251
|
+
this.port = options.port ?? 8080;
|
|
252
|
+
this.host = options.host ?? "localhost";
|
|
253
|
+
this.path = options.path ?? "/callback";
|
|
254
|
+
this.timeoutMs = options.timeoutMs ?? 3e5;
|
|
255
|
+
this.logger = new Logger("CallbackServer");
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Gets the callback URL that should be used in OAuth authorization requests
|
|
259
|
+
*
|
|
260
|
+
* @returns The callback URL (e.g., http://localhost:8080/callback)
|
|
261
|
+
*/
|
|
262
|
+
getCallbackUrl() {
|
|
263
|
+
return `http://${this.host}:${this.port}${this.path}`;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Starts the server and waits for an OAuth callback
|
|
267
|
+
*
|
|
268
|
+
* This method:
|
|
269
|
+
* 1. Starts an HTTP server on the configured host/port
|
|
270
|
+
* 2. Waits for a GET request to /callback
|
|
271
|
+
* 3. Validates the state parameter (CSRF protection)
|
|
272
|
+
* 4. Extracts OAuth parameters from the query string
|
|
273
|
+
* 5. Sends an HTML response to the browser
|
|
274
|
+
* 6. Closes the server
|
|
275
|
+
* 7. Returns the callback parameters
|
|
276
|
+
*
|
|
277
|
+
* @param expectedState - The expected state parameter value (for CSRF protection)
|
|
278
|
+
* @returns Promise resolving to the OAuth callback parameters
|
|
279
|
+
* @throws Error if the callback times out, state validation fails, or parameters are missing
|
|
280
|
+
*/
|
|
281
|
+
async waitForCallback(expectedState) {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
let timeoutId = null;
|
|
284
|
+
let hasResponded = false;
|
|
285
|
+
this.listeningPromise = new Promise((listeningResolve, listeningReject) => {
|
|
286
|
+
this.listeningResolve = listeningResolve;
|
|
287
|
+
this.listeningReject = listeningReject;
|
|
288
|
+
});
|
|
289
|
+
this.server = http.createServer((req, res) => {
|
|
290
|
+
if (req.url?.includes("favicon.ico")) {
|
|
291
|
+
res.writeHead(404);
|
|
292
|
+
res.end();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (req.method !== "GET" || !req.url) {
|
|
296
|
+
res.writeHead(404, { "Content-Type": "text/html" });
|
|
297
|
+
res.end(this.getErrorPage("Invalid endpoint"));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
let url;
|
|
301
|
+
try {
|
|
302
|
+
url = new import_url.URL(req.url, `http://${this.host}:${this.port}`);
|
|
303
|
+
} catch {
|
|
304
|
+
res.writeHead(404, { "Content-Type": "text/html" });
|
|
305
|
+
res.end(this.getErrorPage("Invalid endpoint"));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (url.pathname !== this.path) {
|
|
309
|
+
res.writeHead(404, { "Content-Type": "text/html" });
|
|
310
|
+
res.end(this.getErrorPage("Invalid endpoint"));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (hasResponded) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
hasResponded = true;
|
|
317
|
+
try {
|
|
318
|
+
const response_type = url.searchParams.get("response_type");
|
|
319
|
+
const session_id = url.searchParams.get("session_id");
|
|
320
|
+
const wallet_id = url.searchParams.get("wallet_id");
|
|
321
|
+
const organization_id = url.searchParams.get("organization_id");
|
|
322
|
+
const auth_user_id = url.searchParams.get("auth_user_id");
|
|
323
|
+
this.logger.info("Received SSO callback");
|
|
324
|
+
this.logger.debug(`Session ID: ${session_id}`);
|
|
325
|
+
this.logger.debug(`Response type: ${response_type}`);
|
|
326
|
+
if (!session_id || session_id !== expectedState) {
|
|
327
|
+
const error = "Invalid session_id parameter";
|
|
328
|
+
this.logger.error(error);
|
|
329
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
330
|
+
res.end(this.getErrorPage("Authorization failed: Invalid session_id"));
|
|
331
|
+
this.cleanup(timeoutId);
|
|
332
|
+
reject(new Error(error));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (response_type !== "success") {
|
|
336
|
+
const error = `SSO flow failed with response_type: ${response_type}`;
|
|
337
|
+
this.logger.error(error);
|
|
338
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
339
|
+
res.end(this.getErrorPage(`Authorization failed: ${response_type}`));
|
|
340
|
+
this.cleanup(timeoutId);
|
|
341
|
+
reject(new Error(error));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!wallet_id) {
|
|
345
|
+
const error = "Missing wallet_id parameter";
|
|
346
|
+
this.logger.error(error);
|
|
347
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
348
|
+
res.end(this.getErrorPage("Authorization failed: Missing wallet_id"));
|
|
349
|
+
this.cleanup(timeoutId);
|
|
350
|
+
reject(new Error(error));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!organization_id) {
|
|
354
|
+
const error = "Missing organization_id parameter";
|
|
355
|
+
this.logger.error(error);
|
|
356
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
357
|
+
res.end(this.getErrorPage("Authorization failed: Missing organization_id"));
|
|
358
|
+
this.cleanup(timeoutId);
|
|
359
|
+
reject(new Error(error));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (!auth_user_id) {
|
|
363
|
+
const error = "Missing auth_user_id parameter";
|
|
364
|
+
this.logger.error(error);
|
|
365
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
366
|
+
res.end(this.getErrorPage("Authorization failed: Missing auth_user_id"));
|
|
367
|
+
this.cleanup(timeoutId);
|
|
368
|
+
reject(new Error(error));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
this.logger.info("SSO callback successful");
|
|
372
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
373
|
+
res.end(this.getSuccessPage());
|
|
374
|
+
this.cleanup(timeoutId);
|
|
375
|
+
resolve({
|
|
376
|
+
session_id,
|
|
377
|
+
wallet_id,
|
|
378
|
+
organization_id,
|
|
379
|
+
auth_user_id
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
383
|
+
this.logger.error(`Failed to process callback: ${errorMessage}`);
|
|
384
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
385
|
+
res.end(this.getErrorPage("Internal server error"));
|
|
386
|
+
this.cleanup(timeoutId);
|
|
387
|
+
reject(new Error(`Failed to process callback: ${errorMessage}`));
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
this.server.listen(this.port, this.host, () => {
|
|
391
|
+
this.logger.info(`Callback server listening on ${this.getCallbackUrl()}`);
|
|
392
|
+
this.listeningResolve?.();
|
|
393
|
+
this.listeningResolve = null;
|
|
394
|
+
this.listeningReject = null;
|
|
395
|
+
});
|
|
396
|
+
this.server.on("error", (error) => {
|
|
397
|
+
this.logger.error(`Server error: ${error.message}`);
|
|
398
|
+
this.listeningReject?.(error);
|
|
399
|
+
this.listeningResolve = null;
|
|
400
|
+
this.listeningReject = null;
|
|
401
|
+
this.cleanup(timeoutId);
|
|
402
|
+
reject(new Error(`Server error: ${error.message}`));
|
|
403
|
+
});
|
|
404
|
+
timeoutId = setTimeout(() => {
|
|
405
|
+
if (!hasResponded) {
|
|
406
|
+
hasResponded = true;
|
|
407
|
+
this.logger.error("Callback timeout");
|
|
408
|
+
this.cleanup(null);
|
|
409
|
+
reject(new Error("OAuth callback timeout"));
|
|
410
|
+
}
|
|
411
|
+
}, this.timeoutMs);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Cleans up the server and timeout
|
|
416
|
+
*
|
|
417
|
+
* @param timeoutId - The timeout ID to clear, or null
|
|
418
|
+
*/
|
|
419
|
+
cleanup(timeoutId) {
|
|
420
|
+
if (timeoutId) {
|
|
421
|
+
clearTimeout(timeoutId);
|
|
422
|
+
}
|
|
423
|
+
if (this.server) {
|
|
424
|
+
this.server.close(() => {
|
|
425
|
+
this.logger.info("Callback server closed");
|
|
426
|
+
});
|
|
427
|
+
this.server = null;
|
|
428
|
+
}
|
|
429
|
+
this.listeningPromise = null;
|
|
430
|
+
this.listeningResolve = null;
|
|
431
|
+
this.listeningReject = null;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Waits until the callback server is listening for requests
|
|
435
|
+
*
|
|
436
|
+
* @returns Promise resolving when the server is listening
|
|
437
|
+
*/
|
|
438
|
+
async waitForListening() {
|
|
439
|
+
if (this.server?.listening) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (!this.listeningPromise) {
|
|
443
|
+
throw new Error("Callback server has not been started");
|
|
444
|
+
}
|
|
445
|
+
return this.listeningPromise;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Generates an HTML success page
|
|
449
|
+
*
|
|
450
|
+
* @returns HTML string
|
|
451
|
+
*/
|
|
452
|
+
getSuccessPage() {
|
|
453
|
+
return `<!DOCTYPE html>
|
|
454
|
+
<html>
|
|
455
|
+
<head>
|
|
456
|
+
<meta charset="UTF-8">
|
|
457
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
458
|
+
<title>Authorization Successful</title>
|
|
459
|
+
<style>
|
|
460
|
+
body {
|
|
461
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
462
|
+
display: flex;
|
|
463
|
+
justify-content: center;
|
|
464
|
+
align-items: center;
|
|
465
|
+
min-height: 100vh;
|
|
466
|
+
margin: 0;
|
|
467
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
468
|
+
}
|
|
469
|
+
.container {
|
|
470
|
+
background: white;
|
|
471
|
+
padding: 3rem;
|
|
472
|
+
border-radius: 1rem;
|
|
473
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
474
|
+
text-align: center;
|
|
475
|
+
max-width: 500px;
|
|
476
|
+
}
|
|
477
|
+
.checkmark {
|
|
478
|
+
width: 80px;
|
|
479
|
+
height: 80px;
|
|
480
|
+
border-radius: 50%;
|
|
481
|
+
display: block;
|
|
482
|
+
stroke-width: 2;
|
|
483
|
+
stroke: #4CAF50;
|
|
484
|
+
stroke-miterlimit: 10;
|
|
485
|
+
margin: 0 auto 2rem;
|
|
486
|
+
box-shadow: inset 0px 0px 0px #4CAF50;
|
|
487
|
+
animation: fill .4s ease-in-out .4s forwards, scale .3s ease-in-out .9s both;
|
|
488
|
+
}
|
|
489
|
+
.checkmark__circle {
|
|
490
|
+
stroke-dasharray: 166;
|
|
491
|
+
stroke-dashoffset: 166;
|
|
492
|
+
stroke-width: 2;
|
|
493
|
+
stroke-miterlimit: 10;
|
|
494
|
+
stroke: #4CAF50;
|
|
495
|
+
fill: none;
|
|
496
|
+
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
|
|
497
|
+
}
|
|
498
|
+
.checkmark__check {
|
|
499
|
+
transform-origin: 50% 50%;
|
|
500
|
+
stroke-dasharray: 48;
|
|
501
|
+
stroke-dashoffset: 48;
|
|
502
|
+
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
|
|
503
|
+
}
|
|
504
|
+
@keyframes stroke {
|
|
505
|
+
100% {
|
|
506
|
+
stroke-dashoffset: 0;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
@keyframes scale {
|
|
510
|
+
0%, 100% {
|
|
511
|
+
transform: none;
|
|
512
|
+
}
|
|
513
|
+
50% {
|
|
514
|
+
transform: scale3d(1.1, 1.1, 1);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
@keyframes fill {
|
|
518
|
+
100% {
|
|
519
|
+
box-shadow: inset 0px 0px 0px 30px #4CAF50;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
h1 {
|
|
523
|
+
color: #333;
|
|
524
|
+
margin-bottom: 1rem;
|
|
525
|
+
}
|
|
526
|
+
p {
|
|
527
|
+
color: #666;
|
|
528
|
+
line-height: 1.6;
|
|
529
|
+
}
|
|
530
|
+
</style>
|
|
531
|
+
</head>
|
|
532
|
+
<body>
|
|
533
|
+
<div class="container">
|
|
534
|
+
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
|
535
|
+
<circle class="checkmark__circle" cx="26" cy="26" r="25" fill="none"/>
|
|
536
|
+
<path class="checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
|
537
|
+
</svg>
|
|
538
|
+
<h1>Authorization Successful!</h1>
|
|
539
|
+
<p>You have successfully connected your Phantom wallet.</p>
|
|
540
|
+
<p>You can close this window and return to your application.</p>
|
|
541
|
+
</div>
|
|
542
|
+
</body>
|
|
543
|
+
</html>`;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Generates an HTML error page
|
|
547
|
+
*
|
|
548
|
+
* @param message - Error message to display
|
|
549
|
+
* @returns HTML string
|
|
550
|
+
*/
|
|
551
|
+
getErrorPage(message) {
|
|
552
|
+
return `<!DOCTYPE html>
|
|
553
|
+
<html>
|
|
554
|
+
<head>
|
|
555
|
+
<meta charset="UTF-8">
|
|
556
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
557
|
+
<title>Authorization Failed</title>
|
|
558
|
+
<style>
|
|
559
|
+
body {
|
|
560
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
561
|
+
display: flex;
|
|
562
|
+
justify-content: center;
|
|
563
|
+
align-items: center;
|
|
564
|
+
min-height: 100vh;
|
|
565
|
+
margin: 0;
|
|
566
|
+
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
567
|
+
}
|
|
568
|
+
.container {
|
|
569
|
+
background: white;
|
|
570
|
+
padding: 3rem;
|
|
571
|
+
border-radius: 1rem;
|
|
572
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
573
|
+
text-align: center;
|
|
574
|
+
max-width: 500px;
|
|
575
|
+
}
|
|
576
|
+
.error-icon {
|
|
577
|
+
width: 80px;
|
|
578
|
+
height: 80px;
|
|
579
|
+
border-radius: 50%;
|
|
580
|
+
display: block;
|
|
581
|
+
stroke-width: 2;
|
|
582
|
+
stroke: #f44336;
|
|
583
|
+
stroke-miterlimit: 10;
|
|
584
|
+
margin: 0 auto 2rem;
|
|
585
|
+
box-shadow: inset 0px 0px 0px #f44336;
|
|
586
|
+
animation: fill .4s ease-in-out .4s forwards, scale .3s ease-in-out .9s both;
|
|
587
|
+
}
|
|
588
|
+
.error-icon__circle {
|
|
589
|
+
stroke-dasharray: 166;
|
|
590
|
+
stroke-dashoffset: 166;
|
|
591
|
+
stroke-width: 2;
|
|
592
|
+
stroke-miterlimit: 10;
|
|
593
|
+
stroke: #f44336;
|
|
594
|
+
fill: none;
|
|
595
|
+
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
|
|
596
|
+
}
|
|
597
|
+
.error-icon__cross {
|
|
598
|
+
transform-origin: 50% 50%;
|
|
599
|
+
stroke-dasharray: 48;
|
|
600
|
+
stroke-dashoffset: 48;
|
|
601
|
+
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
|
|
602
|
+
}
|
|
603
|
+
@keyframes stroke {
|
|
604
|
+
100% {
|
|
605
|
+
stroke-dashoffset: 0;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
@keyframes scale {
|
|
609
|
+
0%, 100% {
|
|
610
|
+
transform: none;
|
|
611
|
+
}
|
|
612
|
+
50% {
|
|
613
|
+
transform: scale3d(1.1, 1.1, 1);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
@keyframes fill {
|
|
617
|
+
100% {
|
|
618
|
+
box-shadow: inset 0px 0px 0px 30px #f44336;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
h1 {
|
|
622
|
+
color: #333;
|
|
623
|
+
margin-bottom: 1rem;
|
|
624
|
+
}
|
|
625
|
+
p {
|
|
626
|
+
color: #666;
|
|
627
|
+
line-height: 1.6;
|
|
628
|
+
}
|
|
629
|
+
.error-message {
|
|
630
|
+
color: #f44336;
|
|
631
|
+
font-weight: 500;
|
|
632
|
+
}
|
|
633
|
+
</style>
|
|
634
|
+
</head>
|
|
635
|
+
<body>
|
|
636
|
+
<div class="container">
|
|
637
|
+
<svg class="error-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
|
638
|
+
<circle class="error-icon__circle" cx="26" cy="26" r="25" fill="none"/>
|
|
639
|
+
<path class="error-icon__cross" fill="none" d="M16 16 36 36 M36 16 16 36"/>
|
|
640
|
+
</svg>
|
|
641
|
+
<h1>Authorization Failed</h1>
|
|
642
|
+
<p class="error-message">${this.escapeHtml(message)}</p>
|
|
643
|
+
<p>Please close this window and try again.</p>
|
|
644
|
+
</div>
|
|
645
|
+
</body>
|
|
646
|
+
</html>`;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Escapes HTML special characters
|
|
650
|
+
*
|
|
651
|
+
* @param text - Text to escape
|
|
652
|
+
* @returns Escaped text
|
|
653
|
+
*/
|
|
654
|
+
escapeHtml(text) {
|
|
655
|
+
const map = {
|
|
656
|
+
"&": "&",
|
|
657
|
+
"<": "<",
|
|
658
|
+
">": ">",
|
|
659
|
+
'"': """,
|
|
660
|
+
"'": "'"
|
|
661
|
+
};
|
|
662
|
+
return text.replace(/[&<>"']/g, (char) => map[char]);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// src/auth/oauth.ts
|
|
667
|
+
var OAuthFlow = class {
|
|
668
|
+
/**
|
|
669
|
+
* Creates a new OAuth flow
|
|
670
|
+
*
|
|
671
|
+
* @param options - OAuth flow configuration
|
|
672
|
+
* @param options.authBaseUrl - Base URL of the authorization server (default: https://auth.phantom.app or PHANTOM_AUTH_BASE_URL env var)
|
|
673
|
+
* @param options.connectBaseUrl - Base URL of Phantom Connect (default: https://connect.phantom.app or PHANTOM_CONNECT_BASE_URL env var)
|
|
674
|
+
* @param options.callbackPort - Port for the local callback server (default: 8080 or PHANTOM_CALLBACK_PORT env var)
|
|
675
|
+
* @param options.callbackPath - Path for the OAuth callback (default: /callback or PHANTOM_CALLBACK_PATH env var)
|
|
676
|
+
* @param options.appId - Application identifier prefix (default: phantom-mcp)
|
|
677
|
+
* @param options.provider - SSO provider (default: google or PHANTOM_SSO_PROVIDER env var)
|
|
678
|
+
*/
|
|
679
|
+
constructor(options = {}) {
|
|
680
|
+
this.authBaseUrl = options.authBaseUrl ?? process.env.PHANTOM_AUTH_BASE_URL ?? "https://auth.phantom.app";
|
|
681
|
+
this.connectBaseUrl = options.connectBaseUrl ?? process.env.PHANTOM_CONNECT_BASE_URL ?? "https://connect.phantom.app";
|
|
682
|
+
const envPort = process.env.PHANTOM_CALLBACK_PORT?.trim();
|
|
683
|
+
const defaultPort = 8080;
|
|
684
|
+
if (options.callbackPort !== void 0) {
|
|
685
|
+
const port = options.callbackPort;
|
|
686
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
687
|
+
throw new Error(`Invalid callbackPort: "${port}". Must be a valid port number between 1 and 65535.`);
|
|
688
|
+
}
|
|
689
|
+
this.callbackPort = port;
|
|
690
|
+
} else if (envPort !== void 0) {
|
|
691
|
+
const port = parseInt(envPort, 10);
|
|
692
|
+
if (isNaN(port) || port <= 0 || port > 65535) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`Invalid PHANTOM_CALLBACK_PORT: "${envPort}". Must be a valid port number between 1 and 65535.`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
this.callbackPort = port;
|
|
698
|
+
} else {
|
|
699
|
+
this.callbackPort = defaultPort;
|
|
700
|
+
}
|
|
701
|
+
this.callbackPath = options.callbackPath ?? process.env.PHANTOM_CALLBACK_PATH ?? "/callback";
|
|
702
|
+
this.appId = options.appId ?? "phantom-mcp";
|
|
703
|
+
const provider = options.provider ?? process.env.PHANTOM_SSO_PROVIDER ?? "google";
|
|
704
|
+
if (!["google", "apple", "phantom"].includes(provider)) {
|
|
705
|
+
throw new Error(`Unsupported SSO provider: ${provider}`);
|
|
706
|
+
}
|
|
707
|
+
this.provider = provider;
|
|
708
|
+
this.logger = new Logger("OAuthFlow");
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Executes the complete SSO authentication flow
|
|
712
|
+
*
|
|
713
|
+
* @returns Promise resolving to tokens, wallet/org IDs, client config, and stamper public key
|
|
714
|
+
* @throws Error if any step of the flow fails
|
|
715
|
+
*/
|
|
716
|
+
async authenticate() {
|
|
717
|
+
this.logger.info("Starting SSO authentication flow");
|
|
718
|
+
const callbackServer = new CallbackServer({
|
|
719
|
+
port: this.callbackPort,
|
|
720
|
+
path: this.callbackPath
|
|
721
|
+
});
|
|
722
|
+
const redirectUri = callbackServer.getCallbackUrl();
|
|
723
|
+
let clientConfig;
|
|
724
|
+
const envClientId = (process.env.PHANTOM_APP_ID || process.env.PHANTOM_CLIENT_ID)?.trim();
|
|
725
|
+
const envClientSecret = process.env.PHANTOM_CLIENT_SECRET?.trim();
|
|
726
|
+
const hasClientId = envClientId && envClientId.length > 0;
|
|
727
|
+
const hasClientSecret = envClientSecret && envClientSecret.length > 0;
|
|
728
|
+
if (hasClientId) {
|
|
729
|
+
this.logger.info("Step 1: Using client credentials from environment variables");
|
|
730
|
+
const clientType = hasClientSecret ? "confidential" : "public";
|
|
731
|
+
this.logger.info(`Client type: ${clientType}`);
|
|
732
|
+
clientConfig = {
|
|
733
|
+
client_id: envClientId,
|
|
734
|
+
client_secret: envClientSecret || "",
|
|
735
|
+
// Empty string for public clients
|
|
736
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3)
|
|
737
|
+
};
|
|
738
|
+
this.logger.info(`Using app ID: ${clientConfig.client_id}`);
|
|
739
|
+
} else {
|
|
740
|
+
this.logger.info("Step 1: Registering OAuth client via DCR");
|
|
741
|
+
this.logger.warn(
|
|
742
|
+
"DCR is not currently supported by auth.phantom.app - you should provide PHANTOM_APP_ID or PHANTOM_CLIENT_ID"
|
|
743
|
+
);
|
|
744
|
+
const dcrClient = new DCRClient(this.authBaseUrl, this.appId);
|
|
745
|
+
clientConfig = await dcrClient.register(redirectUri);
|
|
746
|
+
this.logger.info(`Client registered with ID: ${clientConfig.client_id}`);
|
|
747
|
+
}
|
|
748
|
+
this.logger.info("Step 2: Generating stamper keypair");
|
|
749
|
+
const { generateKeyPair } = await import("@phantom/crypto");
|
|
750
|
+
const stamperKeys = generateKeyPair();
|
|
751
|
+
this.logger.info(`Stamper public key: ${stamperKeys.publicKey}`);
|
|
752
|
+
this.logger.info("Step 3: Generating session ID");
|
|
753
|
+
const sessionId = this.generateSessionId();
|
|
754
|
+
this.logger.debug(`Session ID: ${sessionId}`);
|
|
755
|
+
this.logger.info("Step 4: Building SSO authorization URL");
|
|
756
|
+
const authUrl = this.buildAuthorizationUrl(clientConfig.client_id, redirectUri, stamperKeys.publicKey, sessionId);
|
|
757
|
+
this.logger.debug(`Authorization URL: ${authUrl}`);
|
|
758
|
+
this.logger.info("Step 5: Starting callback server");
|
|
759
|
+
const callbackPromise = callbackServer.waitForCallback(sessionId);
|
|
760
|
+
await callbackServer.waitForListening();
|
|
761
|
+
this.logger.info(`Step 6: Opening browser for ${this.provider} authentication`);
|
|
762
|
+
try {
|
|
763
|
+
await (0, import_open.default)(authUrl);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
766
|
+
this.logger.error(`Failed to automatically open browser for ${this.provider} authentication: ${errorMessage}`);
|
|
767
|
+
this.logger.error(`Auth URL: ${authUrl}`);
|
|
768
|
+
this.logger.info("Please open the following URL manually in your browser to complete authentication:");
|
|
769
|
+
this.logger.info(authUrl);
|
|
770
|
+
}
|
|
771
|
+
this.logger.info("Step 7: Waiting for SSO callback");
|
|
772
|
+
const callbackParams = await callbackPromise;
|
|
773
|
+
this.logger.info("Callback received successfully");
|
|
774
|
+
this.logger.debug(`Wallet ID: ${callbackParams.wallet_id}`);
|
|
775
|
+
this.logger.debug(`Organization ID: ${callbackParams.organization_id}`);
|
|
776
|
+
this.logger.debug(`Auth User ID: ${callbackParams.auth_user_id}`);
|
|
777
|
+
return {
|
|
778
|
+
walletId: callbackParams.wallet_id,
|
|
779
|
+
organizationId: callbackParams.organization_id,
|
|
780
|
+
authUserId: callbackParams.auth_user_id,
|
|
781
|
+
clientConfig,
|
|
782
|
+
stamperKeys
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Refreshes an access token using a refresh token
|
|
787
|
+
*
|
|
788
|
+
* Note: Not used in SSO flow, kept for future OAuth compatibility.
|
|
789
|
+
*
|
|
790
|
+
* @param refreshToken - The refresh token
|
|
791
|
+
* @param clientConfig - The OAuth client configuration
|
|
792
|
+
* @returns Promise resolving to new tokens
|
|
793
|
+
* @throws Error if token refresh fails
|
|
794
|
+
*/
|
|
795
|
+
async refreshToken(refreshToken, clientConfig) {
|
|
796
|
+
this.logger.info("Refreshing access token");
|
|
797
|
+
const tokenEndpoint = `${this.authBaseUrl}/oauth2/token`;
|
|
798
|
+
const isPublicClient = !clientConfig.client_secret || clientConfig.client_secret.length === 0;
|
|
799
|
+
const params = {
|
|
800
|
+
grant_type: "refresh_token",
|
|
801
|
+
refresh_token: refreshToken
|
|
802
|
+
};
|
|
803
|
+
if (isPublicClient) {
|
|
804
|
+
params.client_id = clientConfig.client_id;
|
|
805
|
+
}
|
|
806
|
+
const headers = {
|
|
807
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
808
|
+
};
|
|
809
|
+
if (!isPublicClient) {
|
|
810
|
+
const basicAuth = Buffer.from(`${clientConfig.client_id}:${clientConfig.client_secret}`).toString("base64");
|
|
811
|
+
headers.Authorization = `Basic ${basicAuth}`;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const response = await import_axios2.default.post(tokenEndpoint, new URLSearchParams(params).toString(), {
|
|
815
|
+
headers,
|
|
816
|
+
timeout: 3e4
|
|
817
|
+
});
|
|
818
|
+
this.logger.info("Token refresh successful");
|
|
819
|
+
return {
|
|
820
|
+
access_token: response.data.access_token,
|
|
821
|
+
refresh_token: response.data.refresh_token,
|
|
822
|
+
expires_in: response.data.expires_in
|
|
823
|
+
};
|
|
824
|
+
} catch (error) {
|
|
825
|
+
const axiosError = error;
|
|
826
|
+
const errorMessage = axiosError.response?.data ? JSON.stringify(axiosError.response.data) : axiosError.message;
|
|
827
|
+
this.logger.error(`Token refresh failed: ${errorMessage}`);
|
|
828
|
+
throw new Error(`Token refresh failed: ${errorMessage}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Generates a random session ID for SSO flow
|
|
833
|
+
*
|
|
834
|
+
* @returns Random session ID string
|
|
835
|
+
*/
|
|
836
|
+
generateSessionId() {
|
|
837
|
+
return this.base64URLEncode(crypto.randomBytes(32));
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Encodes a buffer to base64url format (RFC 4648)
|
|
841
|
+
* Base64url encoding is base64 with URL-safe characters:
|
|
842
|
+
* - Replace + with -
|
|
843
|
+
* - Replace / with _
|
|
844
|
+
* - Remove padding =
|
|
845
|
+
*
|
|
846
|
+
* @param buffer - Buffer to encode
|
|
847
|
+
* @returns Base64url encoded string
|
|
848
|
+
*/
|
|
849
|
+
base64URLEncode(buffer) {
|
|
850
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Builds the SSO authorization URL
|
|
854
|
+
*
|
|
855
|
+
* @param appId - Application ID
|
|
856
|
+
* @param redirectUri - Callback redirect URI
|
|
857
|
+
* @param publicKey - Stamper public key
|
|
858
|
+
* @param sessionId - Session ID for correlation
|
|
859
|
+
* @returns Authorization URL
|
|
860
|
+
*/
|
|
861
|
+
buildAuthorizationUrl(appId, redirectUri, publicKey, sessionId) {
|
|
862
|
+
const params = new URLSearchParams({
|
|
863
|
+
provider: this.provider,
|
|
864
|
+
app_id: appId,
|
|
865
|
+
redirect_uri: redirectUri,
|
|
866
|
+
public_key: publicKey,
|
|
867
|
+
session_id: sessionId,
|
|
868
|
+
sdk_version: "1.0.0",
|
|
869
|
+
sdk_type: "mcp-server"
|
|
870
|
+
});
|
|
871
|
+
return `${this.connectBaseUrl}/login?${params.toString()}`;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Exchanges an authorization code for access and refresh tokens
|
|
875
|
+
*
|
|
876
|
+
* Note: Not used in SSO flow, kept for future OAuth compatibility.
|
|
877
|
+
*
|
|
878
|
+
* @param code - Authorization code
|
|
879
|
+
* @param redirectUri - Callback redirect URI (must match the one used in authorization)
|
|
880
|
+
* @param clientConfig - OAuth client configuration
|
|
881
|
+
* @returns Promise resolving to tokens
|
|
882
|
+
* @throws Error if token exchange fails
|
|
883
|
+
*/
|
|
884
|
+
// @ts-expect-error - Unused in SSO flow, kept for future OAuth support
|
|
885
|
+
async exchangeCodeForTokens(code, redirectUri, clientConfig) {
|
|
886
|
+
const tokenEndpoint = `${this.authBaseUrl}/oauth2/token`;
|
|
887
|
+
const isPublicClient = !clientConfig.client_secret || clientConfig.client_secret.length === 0;
|
|
888
|
+
const params = {
|
|
889
|
+
grant_type: "authorization_code",
|
|
890
|
+
code,
|
|
891
|
+
redirect_uri: redirectUri
|
|
892
|
+
};
|
|
893
|
+
if (isPublicClient) {
|
|
894
|
+
params.client_id = clientConfig.client_id;
|
|
895
|
+
}
|
|
896
|
+
const headers = {
|
|
897
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
898
|
+
};
|
|
899
|
+
if (!isPublicClient) {
|
|
900
|
+
const basicAuth = Buffer.from(`${clientConfig.client_id}:${clientConfig.client_secret}`).toString("base64");
|
|
901
|
+
headers.Authorization = `Basic ${basicAuth}`;
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
const response = await import_axios2.default.post(tokenEndpoint, new URLSearchParams(params).toString(), {
|
|
905
|
+
headers,
|
|
906
|
+
timeout: 3e4
|
|
907
|
+
});
|
|
908
|
+
return {
|
|
909
|
+
access_token: response.data.access_token,
|
|
910
|
+
refresh_token: response.data.refresh_token,
|
|
911
|
+
expires_in: response.data.expires_in
|
|
912
|
+
};
|
|
913
|
+
} catch (error) {
|
|
914
|
+
const axiosError = error;
|
|
915
|
+
const errorMessage = axiosError.response?.data ? JSON.stringify(axiosError.response.data) : axiosError.message;
|
|
916
|
+
this.logger.error(`Token exchange failed: ${errorMessage}`);
|
|
917
|
+
throw new Error(`Token exchange failed: ${errorMessage}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
// src/session/manager.ts
|
|
923
|
+
var SessionManager = class {
|
|
924
|
+
/**
|
|
925
|
+
* Creates a new SessionManager
|
|
926
|
+
*
|
|
927
|
+
* @param options - Configuration options
|
|
928
|
+
*/
|
|
929
|
+
constructor(options = {}) {
|
|
930
|
+
this.session = null;
|
|
931
|
+
this.client = null;
|
|
932
|
+
this.logger = new Logger("SessionManager");
|
|
933
|
+
this.authBaseUrl = options.authBaseUrl ?? process.env.PHANTOM_AUTH_BASE_URL ?? "https://auth.phantom.app";
|
|
934
|
+
this.connectBaseUrl = options.connectBaseUrl;
|
|
935
|
+
this.apiBaseUrl = options.apiBaseUrl ?? process.env.PHANTOM_API_BASE_URL ?? "https://api.phantom.app/v1/wallets";
|
|
936
|
+
const defaultPort = 8080;
|
|
937
|
+
const parseEnvPort = (value) => {
|
|
938
|
+
const parsed = Number.parseInt(value, 10);
|
|
939
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
return parsed;
|
|
943
|
+
};
|
|
944
|
+
if (options.callbackPort !== void 0) {
|
|
945
|
+
if (!Number.isInteger(options.callbackPort) || options.callbackPort <= 0 || options.callbackPort > 65535) {
|
|
946
|
+
throw new Error(
|
|
947
|
+
`Invalid callbackPort: "${options.callbackPort}". Must be a valid port number between 1 and 65535.`
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
this.callbackPort = options.callbackPort;
|
|
951
|
+
} else {
|
|
952
|
+
const envPort = process.env.PHANTOM_CALLBACK_PORT?.trim();
|
|
953
|
+
const parsedEnvPort = envPort ? parseEnvPort(envPort) : null;
|
|
954
|
+
if (envPort && parsedEnvPort === null) {
|
|
955
|
+
this.logger.warn(`Invalid PHANTOM_CALLBACK_PORT "${envPort}". Falling back to ${defaultPort}.`);
|
|
956
|
+
this.callbackPort = defaultPort;
|
|
957
|
+
} else {
|
|
958
|
+
this.callbackPort = parsedEnvPort ?? defaultPort;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
this.callbackPath = options.callbackPath ?? process.env.PHANTOM_CALLBACK_PATH ?? "/callback";
|
|
962
|
+
this.appId = options.appId ?? "phantom-mcp";
|
|
963
|
+
this.storage = new SessionStorage(options.sessionDir);
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Initializes the session manager
|
|
967
|
+
* Loads existing session or authenticates if needed
|
|
968
|
+
*
|
|
969
|
+
* @throws Error if authentication fails
|
|
970
|
+
*/
|
|
971
|
+
async initialize() {
|
|
972
|
+
this.logger.info("Initializing session manager");
|
|
973
|
+
const existingSession = this.storage.load();
|
|
974
|
+
if (existingSession && !this.storage.isExpired(existingSession)) {
|
|
975
|
+
this.logger.info("Loaded valid session from storage");
|
|
976
|
+
this.session = existingSession;
|
|
977
|
+
this.createClient();
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (existingSession) {
|
|
981
|
+
this.logger.info("Session expired, re-authenticating");
|
|
982
|
+
} else {
|
|
983
|
+
this.logger.info("No session found, authenticating");
|
|
984
|
+
}
|
|
985
|
+
await this.authenticate();
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Returns the initialized PhantomClient
|
|
989
|
+
*
|
|
990
|
+
* @returns PhantomClient instance
|
|
991
|
+
* @throws Error if not initialized
|
|
992
|
+
*/
|
|
993
|
+
getClient() {
|
|
994
|
+
if (!this.client) {
|
|
995
|
+
throw new Error("SessionManager not initialized. Call initialize() first.");
|
|
996
|
+
}
|
|
997
|
+
return this.client;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Returns the current session data
|
|
1001
|
+
*
|
|
1002
|
+
* @returns Current session data
|
|
1003
|
+
* @throws Error if not initialized
|
|
1004
|
+
*/
|
|
1005
|
+
getSession() {
|
|
1006
|
+
if (!this.session) {
|
|
1007
|
+
throw new Error("SessionManager not initialized. Call initialize() first.");
|
|
1008
|
+
}
|
|
1009
|
+
return this.session;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Resets the session by clearing stored data and re-authenticating
|
|
1013
|
+
*
|
|
1014
|
+
* @throws Error if authentication fails
|
|
1015
|
+
*/
|
|
1016
|
+
async resetSession() {
|
|
1017
|
+
this.logger.info("Resetting session");
|
|
1018
|
+
this.storage.delete();
|
|
1019
|
+
this.session = null;
|
|
1020
|
+
this.client = null;
|
|
1021
|
+
await this.authenticate();
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Executes the SSO flow and creates a new session
|
|
1025
|
+
* Steps:
|
|
1026
|
+
* 1. Execute SSO flow to get wallet/org IDs and stamper keypair
|
|
1027
|
+
* 2. Create SessionData with SSO result and stamper keys
|
|
1028
|
+
* 3. Save to storage
|
|
1029
|
+
* 4. Create PhantomClient
|
|
1030
|
+
*
|
|
1031
|
+
* Note: Stamper keypair is generated during SSO flow and public key is sent to auth server
|
|
1032
|
+
*
|
|
1033
|
+
* @throws Error if SSO flow fails
|
|
1034
|
+
*/
|
|
1035
|
+
async authenticate() {
|
|
1036
|
+
this.logger.info("Starting authentication");
|
|
1037
|
+
const oauthFlow = new OAuthFlow({
|
|
1038
|
+
authBaseUrl: this.authBaseUrl,
|
|
1039
|
+
connectBaseUrl: this.connectBaseUrl,
|
|
1040
|
+
callbackPort: this.callbackPort,
|
|
1041
|
+
callbackPath: this.callbackPath,
|
|
1042
|
+
appId: this.appId
|
|
1043
|
+
});
|
|
1044
|
+
const oauthResult = await oauthFlow.authenticate();
|
|
1045
|
+
this.logger.info("SSO flow completed successfully");
|
|
1046
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1047
|
+
this.session = {
|
|
1048
|
+
walletId: oauthResult.walletId,
|
|
1049
|
+
organizationId: oauthResult.organizationId,
|
|
1050
|
+
authUserId: oauthResult.authUserId,
|
|
1051
|
+
stamperKeys: oauthResult.stamperKeys,
|
|
1052
|
+
createdAt: now,
|
|
1053
|
+
updatedAt: now
|
|
1054
|
+
};
|
|
1055
|
+
this.storage.save(this.session);
|
|
1056
|
+
this.logger.info("Session saved to storage");
|
|
1057
|
+
this.createClient();
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Creates a PhantomClient instance from the current session
|
|
1061
|
+
* Steps:
|
|
1062
|
+
* 1. Create ApiKeyStamper with session keypair
|
|
1063
|
+
* 2. Create PhantomClient with stamper, organizationId, and app headers
|
|
1064
|
+
* 3. Set walletType to 'user-wallet'
|
|
1065
|
+
*
|
|
1066
|
+
* @throws Error if session is not available
|
|
1067
|
+
*/
|
|
1068
|
+
createClient() {
|
|
1069
|
+
if (!this.session) {
|
|
1070
|
+
throw new Error("Cannot create client without session");
|
|
1071
|
+
}
|
|
1072
|
+
this.logger.info("Creating PhantomClient");
|
|
1073
|
+
const stamper = new import_api_key_stamper.ApiKeyStamper({
|
|
1074
|
+
apiSecretKey: this.session.stamperKeys.secretKey
|
|
1075
|
+
});
|
|
1076
|
+
const appId = process.env.PHANTOM_APP_ID || process.env.PHANTOM_CLIENT_ID || this.appId;
|
|
1077
|
+
this.client = new import_client.PhantomClient(
|
|
1078
|
+
{
|
|
1079
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
1080
|
+
organizationId: this.session.organizationId,
|
|
1081
|
+
walletType: "user-wallet",
|
|
1082
|
+
headers: {
|
|
1083
|
+
"X-App-Id": appId
|
|
1084
|
+
}
|
|
1085
|
+
// Type assertion needed as X-App-Id is not in SdkAnalyticsHeaders
|
|
1086
|
+
},
|
|
1087
|
+
stamper
|
|
1088
|
+
);
|
|
1089
|
+
this.logger.info("PhantomClient created successfully");
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
// src/tools/get-wallet-addresses.ts
|
|
1094
|
+
var getWalletAddressesTool = {
|
|
1095
|
+
name: "get_wallet_addresses",
|
|
1096
|
+
description: "Gets all blockchain addresses for the authenticated embedded wallet (Solana, Ethereum, Bitcoin, Sui)",
|
|
1097
|
+
inputSchema: {
|
|
1098
|
+
type: "object",
|
|
1099
|
+
properties: {
|
|
1100
|
+
derivationIndex: {
|
|
1101
|
+
type: "number",
|
|
1102
|
+
description: "Optional derivation index for the addresses",
|
|
1103
|
+
minimum: 0
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
},
|
|
1107
|
+
handler: async (params, context) => {
|
|
1108
|
+
const { client, session, logger: logger2 } = context;
|
|
1109
|
+
const derivationIndex = typeof params.derivationIndex === "number" ? params.derivationIndex : void 0;
|
|
1110
|
+
logger2.info("Getting addresses for wallet");
|
|
1111
|
+
try {
|
|
1112
|
+
const addresses = await client.getWalletAddresses(
|
|
1113
|
+
session.walletId,
|
|
1114
|
+
void 0,
|
|
1115
|
+
// Use default derivation paths (Solana, Ethereum, Bitcoin, Sui)
|
|
1116
|
+
derivationIndex
|
|
1117
|
+
);
|
|
1118
|
+
logger2.info(`Successfully retrieved ${addresses.length} addresses`);
|
|
1119
|
+
return {
|
|
1120
|
+
walletId: session.walletId,
|
|
1121
|
+
organizationId: session.organizationId,
|
|
1122
|
+
addresses: addresses.map((addr) => ({
|
|
1123
|
+
addressType: addr.addressType,
|
|
1124
|
+
address: addr.address
|
|
1125
|
+
}))
|
|
1126
|
+
};
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1129
|
+
logger2.error(`Failed to get wallet addresses: ${errorMessage}`);
|
|
1130
|
+
throw new Error(`Failed to get wallet addresses: ${errorMessage}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
// src/tools/sign-transaction.ts
|
|
1136
|
+
var signTransactionTool = {
|
|
1137
|
+
name: "sign_transaction",
|
|
1138
|
+
description: "Signs a transaction using the authenticated embedded wallet. Supports Solana, Ethereum, Bitcoin, and other chains.",
|
|
1139
|
+
inputSchema: {
|
|
1140
|
+
type: "object",
|
|
1141
|
+
properties: {
|
|
1142
|
+
walletId: {
|
|
1143
|
+
type: "string",
|
|
1144
|
+
description: "Optional wallet ID to use for signing (defaults to authenticated wallet)"
|
|
1145
|
+
},
|
|
1146
|
+
transaction: {
|
|
1147
|
+
type: "string",
|
|
1148
|
+
description: "The transaction to sign (format depends on chain: base64url for Solana, RLP-encoded hex for Ethereum)"
|
|
1149
|
+
},
|
|
1150
|
+
networkId: {
|
|
1151
|
+
type: "string",
|
|
1152
|
+
description: 'Network identifier (e.g., "eip155:1" for Ethereum mainnet, "solana:mainnet" for Solana)'
|
|
1153
|
+
},
|
|
1154
|
+
derivationIndex: {
|
|
1155
|
+
type: "number",
|
|
1156
|
+
description: "Optional derivation index for the account (default: 0)",
|
|
1157
|
+
minimum: 0
|
|
1158
|
+
},
|
|
1159
|
+
account: {
|
|
1160
|
+
type: "string",
|
|
1161
|
+
description: "Optional specific account address to use for simulation/signing"
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
required: ["transaction", "networkId"]
|
|
1165
|
+
},
|
|
1166
|
+
handler: async (params, context) => {
|
|
1167
|
+
const { client, session, logger: logger2 } = context;
|
|
1168
|
+
if (typeof params.transaction !== "string") {
|
|
1169
|
+
throw new Error("transaction must be a string");
|
|
1170
|
+
}
|
|
1171
|
+
if (typeof params.networkId !== "string") {
|
|
1172
|
+
throw new Error("networkId must be a string");
|
|
1173
|
+
}
|
|
1174
|
+
const walletId = typeof params.walletId === "string" ? params.walletId : session.walletId;
|
|
1175
|
+
if (!walletId) {
|
|
1176
|
+
throw new Error("walletId is required (missing from session and not provided)");
|
|
1177
|
+
}
|
|
1178
|
+
if (params.derivationIndex !== void 0 && params.derivationIndex !== null) {
|
|
1179
|
+
const derivIdx = params.derivationIndex;
|
|
1180
|
+
if (!Number.isInteger(derivIdx) || derivIdx < 0) {
|
|
1181
|
+
throw new Error("derivationIndex must be a non-negative integer");
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (params.account !== void 0 && typeof params.account !== "string") {
|
|
1185
|
+
throw new Error("account must be a string");
|
|
1186
|
+
}
|
|
1187
|
+
const transaction = params.transaction;
|
|
1188
|
+
const networkId = params.networkId;
|
|
1189
|
+
const derivationIndex = typeof params.derivationIndex === "number" ? params.derivationIndex : void 0;
|
|
1190
|
+
const account = typeof params.account === "string" ? params.account : void 0;
|
|
1191
|
+
logger2.info(`Signing transaction for wallet ${walletId} on network ${networkId}`);
|
|
1192
|
+
try {
|
|
1193
|
+
const result = await client.signTransaction({
|
|
1194
|
+
walletId,
|
|
1195
|
+
transaction,
|
|
1196
|
+
networkId,
|
|
1197
|
+
derivationIndex,
|
|
1198
|
+
account
|
|
1199
|
+
});
|
|
1200
|
+
logger2.info(`Successfully signed transaction for wallet ${walletId}`);
|
|
1201
|
+
return {
|
|
1202
|
+
signedTransaction: result.rawTransaction
|
|
1203
|
+
};
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1206
|
+
logger2.error(`Failed to sign transaction: ${errorMessage}`);
|
|
1207
|
+
throw new Error(`Failed to sign transaction: ${errorMessage}`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
// src/tools/sign-message.ts
|
|
1213
|
+
var import_utils = require("@phantom/utils");
|
|
1214
|
+
var import_base64url = require("@phantom/base64url");
|
|
1215
|
+
var signMessageTool = {
|
|
1216
|
+
name: "sign_message",
|
|
1217
|
+
description: "Signs a UTF-8 message using the authenticated embedded wallet. Automatically routes to the correct signing method based on the network (Ethereum vs other chains).",
|
|
1218
|
+
inputSchema: {
|
|
1219
|
+
type: "object",
|
|
1220
|
+
properties: {
|
|
1221
|
+
walletId: {
|
|
1222
|
+
type: "string",
|
|
1223
|
+
description: "Optional wallet ID to use for signing (defaults to authenticated wallet)"
|
|
1224
|
+
},
|
|
1225
|
+
message: {
|
|
1226
|
+
type: "string",
|
|
1227
|
+
description: "The UTF-8 message to sign"
|
|
1228
|
+
},
|
|
1229
|
+
networkId: {
|
|
1230
|
+
type: "string",
|
|
1231
|
+
description: 'Network identifier (e.g., "eip155:1" for Ethereum mainnet, "solana:mainnet" for Solana)'
|
|
1232
|
+
},
|
|
1233
|
+
derivationIndex: {
|
|
1234
|
+
type: "integer",
|
|
1235
|
+
description: "Optional derivation index for the account (default: 0)",
|
|
1236
|
+
minimum: 0
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
required: ["message", "networkId"]
|
|
1240
|
+
},
|
|
1241
|
+
handler: async (params, context) => {
|
|
1242
|
+
const { client, session, logger: logger2 } = context;
|
|
1243
|
+
if (typeof params.message !== "string") {
|
|
1244
|
+
throw new Error("message must be a string");
|
|
1245
|
+
}
|
|
1246
|
+
if (typeof params.networkId !== "string") {
|
|
1247
|
+
throw new Error("networkId must be a string");
|
|
1248
|
+
}
|
|
1249
|
+
const walletId = typeof params.walletId === "string" ? params.walletId : session.walletId;
|
|
1250
|
+
if (!walletId) {
|
|
1251
|
+
throw new Error("walletId is required (missing from session and not provided)");
|
|
1252
|
+
}
|
|
1253
|
+
if (params.derivationIndex !== void 0 && params.derivationIndex !== null) {
|
|
1254
|
+
const derivIdx = params.derivationIndex;
|
|
1255
|
+
if (!Number.isInteger(derivIdx) || derivIdx < 0) {
|
|
1256
|
+
throw new Error("derivationIndex must be a non-negative integer");
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
const message = params.message;
|
|
1260
|
+
const networkId = params.networkId;
|
|
1261
|
+
const derivationIndex = typeof params.derivationIndex === "number" ? params.derivationIndex : void 0;
|
|
1262
|
+
logger2.info(`Signing message for wallet ${walletId} on network ${networkId}`);
|
|
1263
|
+
try {
|
|
1264
|
+
let signature;
|
|
1265
|
+
if ((0, import_utils.isEthereumChain)(networkId)) {
|
|
1266
|
+
const base64Message = (0, import_base64url.stringToBase64url)(message);
|
|
1267
|
+
logger2.debug("Using Ethereum message signing");
|
|
1268
|
+
signature = await client.ethereumSignMessage({
|
|
1269
|
+
walletId,
|
|
1270
|
+
message: base64Message,
|
|
1271
|
+
networkId,
|
|
1272
|
+
derivationIndex
|
|
1273
|
+
});
|
|
1274
|
+
} else {
|
|
1275
|
+
logger2.debug("Using UTF-8 message signing");
|
|
1276
|
+
signature = await client.signUtf8Message({
|
|
1277
|
+
walletId,
|
|
1278
|
+
message,
|
|
1279
|
+
networkId,
|
|
1280
|
+
derivationIndex
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
logger2.info(`Successfully signed message for wallet ${walletId}`);
|
|
1284
|
+
return {
|
|
1285
|
+
signature
|
|
1286
|
+
};
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1289
|
+
logger2.error(`Failed to sign message: ${errorMessage}`);
|
|
1290
|
+
throw new Error(`Failed to sign message: ${errorMessage}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
// src/tools/index.ts
|
|
1296
|
+
var tools = [getWalletAddressesTool, signTransactionTool, signMessageTool];
|
|
1297
|
+
function getTool(name) {
|
|
1298
|
+
return tools.find((tool) => tool.name === name);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/server.ts
|
|
1302
|
+
var PhantomMCPServer = class {
|
|
1303
|
+
/**
|
|
1304
|
+
* Creates a new PhantomMCPServer instance
|
|
1305
|
+
*
|
|
1306
|
+
* @param options - Configuration options
|
|
1307
|
+
*/
|
|
1308
|
+
constructor(options = {}) {
|
|
1309
|
+
this.logger = new Logger("PhantomMCPServer");
|
|
1310
|
+
this.server = new import_server.Server(
|
|
1311
|
+
{
|
|
1312
|
+
name: "phantom-mcp-server",
|
|
1313
|
+
version: "1.0.0"
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
capabilities: {
|
|
1317
|
+
tools: {}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
);
|
|
1321
|
+
this.sessionManager = new SessionManager(options.session);
|
|
1322
|
+
this.setupHandlers();
|
|
1323
|
+
this.logger.info("PhantomMCPServer initialized");
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Sets up MCP request handlers
|
|
1327
|
+
*/
|
|
1328
|
+
setupHandlers() {
|
|
1329
|
+
this.server.setRequestHandler(import_types.ListToolsRequestSchema, () => {
|
|
1330
|
+
this.logger.info("Handling tools/list request");
|
|
1331
|
+
try {
|
|
1332
|
+
const toolDefinitions = tools.map((tool) => ({
|
|
1333
|
+
name: tool.name,
|
|
1334
|
+
description: tool.description,
|
|
1335
|
+
inputSchema: tool.inputSchema
|
|
1336
|
+
}));
|
|
1337
|
+
this.logger.info(`Returning ${toolDefinitions.length} tool definitions`);
|
|
1338
|
+
return {
|
|
1339
|
+
tools: toolDefinitions
|
|
1340
|
+
};
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1343
|
+
this.logger.error(`Failed to list tools: ${errorMessage}`);
|
|
1344
|
+
throw error;
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
this.server.setRequestHandler(import_types.CallToolRequestSchema, async (request) => {
|
|
1348
|
+
const toolName = request.params.name;
|
|
1349
|
+
this.logger.info(`Handling tools/call request for: ${toolName}`);
|
|
1350
|
+
try {
|
|
1351
|
+
const tool = getTool(toolName);
|
|
1352
|
+
if (!tool) {
|
|
1353
|
+
const error = `Unknown tool: ${toolName}`;
|
|
1354
|
+
this.logger.error(error);
|
|
1355
|
+
return {
|
|
1356
|
+
content: [
|
|
1357
|
+
{
|
|
1358
|
+
type: "text",
|
|
1359
|
+
text: JSON.stringify({ error }, null, 2)
|
|
1360
|
+
}
|
|
1361
|
+
],
|
|
1362
|
+
isError: true
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
const client = this.sessionManager.getClient();
|
|
1366
|
+
const session = this.sessionManager.getSession();
|
|
1367
|
+
const context = {
|
|
1368
|
+
client,
|
|
1369
|
+
session,
|
|
1370
|
+
logger: this.logger.child(toolName)
|
|
1371
|
+
};
|
|
1372
|
+
this.logger.info(`Executing tool: ${toolName}`);
|
|
1373
|
+
const result = await tool.handler(request.params.arguments ?? {}, context);
|
|
1374
|
+
this.logger.info(`Tool execution successful: ${toolName}`);
|
|
1375
|
+
return {
|
|
1376
|
+
content: [
|
|
1377
|
+
{
|
|
1378
|
+
type: "text",
|
|
1379
|
+
text: JSON.stringify(result, null, 2)
|
|
1380
|
+
}
|
|
1381
|
+
]
|
|
1382
|
+
};
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1385
|
+
this.logger.error(`Tool execution failed for ${toolName}: ${errorMessage}`);
|
|
1386
|
+
if (error instanceof Error && error.stack) {
|
|
1387
|
+
this.logger.debug(`Stack trace: ${error.stack}`);
|
|
1388
|
+
}
|
|
1389
|
+
return {
|
|
1390
|
+
content: [
|
|
1391
|
+
{
|
|
1392
|
+
type: "text",
|
|
1393
|
+
text: JSON.stringify(
|
|
1394
|
+
{
|
|
1395
|
+
error: errorMessage
|
|
1396
|
+
},
|
|
1397
|
+
null,
|
|
1398
|
+
2
|
|
1399
|
+
)
|
|
1400
|
+
}
|
|
1401
|
+
],
|
|
1402
|
+
isError: true
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
this.logger.info("Request handlers registered");
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Starts the MCP server
|
|
1410
|
+
* - Initializes session (loads or authenticates)
|
|
1411
|
+
* - Connects stdio transport
|
|
1412
|
+
* - Begins listening for requests
|
|
1413
|
+
*
|
|
1414
|
+
* @throws Error if initialization or startup fails
|
|
1415
|
+
*/
|
|
1416
|
+
async start() {
|
|
1417
|
+
this.logger.info("Starting PhantomMCPServer");
|
|
1418
|
+
try {
|
|
1419
|
+
this.logger.info("Initializing session");
|
|
1420
|
+
await this.sessionManager.initialize();
|
|
1421
|
+
this.logger.info("Session initialized successfully");
|
|
1422
|
+
this.logger.info("Connecting stdio transport");
|
|
1423
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
1424
|
+
await this.server.connect(transport);
|
|
1425
|
+
this.logger.info("Server connected and ready to accept requests");
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1428
|
+
this.logger.error(`Failed to start server: ${errorMessage}`);
|
|
1429
|
+
throw error;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
// src/index.ts
|
|
1435
|
+
async function main() {
|
|
1436
|
+
const server = new PhantomMCPServer();
|
|
1437
|
+
await server.start();
|
|
1438
|
+
}
|
|
1439
|
+
if (require.main === module) {
|
|
1440
|
+
main().catch((error) => {
|
|
1441
|
+
console.error("Fatal error:", error);
|
|
1442
|
+
process.exit(1);
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1446
|
+
0 && (module.exports = {
|
|
1447
|
+
SessionManager
|
|
1448
|
+
});
|