Skip to content

Commit 96d7764

Browse files
chore: sync from internal
1 parent 6b74b08 commit 96d7764

13 files changed

Lines changed: 341 additions & 63 deletions

File tree

packages/mcp-server/src/auth/DeviceCodeAuthProvider.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,4 +231,50 @@ describe("DeviceCodeAuthProvider", () => {
231231
}),
232232
);
233233
});
234+
235+
it("emits a text prompt instead of launching the browser when openBrowser is false", async () => {
236+
mockAxiosPost
237+
.mockResolvedValueOnce({
238+
data: {
239+
device_code: "device-code",
240+
user_code: "ABCD-1234",
241+
verification_uri: "https://auth.phantom.app/oauth2/device",
242+
expires_in: 600,
243+
interval: 1,
244+
},
245+
})
246+
.mockResolvedValueOnce({
247+
data: {
248+
access_token: "access-token",
249+
refresh_token: "refresh-token",
250+
id_token:
251+
"header." + Buffer.from(JSON.stringify({ organization_id: "org-123" })).toString("base64url") + ".sig",
252+
expires_in: 3600,
253+
token_type: "Bearer",
254+
},
255+
});
256+
mockFromAccessToken.mockReturnValue({
257+
sub: "auth-user-1",
258+
clientId: "env-client-id",
259+
wallet: { id: "wallet-from-token", derivationIndex: 0 },
260+
});
261+
mockGetOrCreateAppWallet.mockResolvedValue({ walletId: "agent-wallet-id", tags: [] });
262+
263+
const onPrompt = jest.fn();
264+
const provider = new DeviceCodeAuthProvider(stamper as never, {
265+
authBaseUrl: "https://auth.phantom.app",
266+
connectBaseUrl: "https://connect.phantom.app",
267+
walletsApiBaseUrl: "https://api.phantom.app/v1/wallets",
268+
appId: "phantom-mcp",
269+
sessionDir: "/tmp/test-phantom-mcp",
270+
});
271+
272+
await provider.authenticate({ openBrowser: false, onPrompt });
273+
274+
expect(mockExecFile).not.toHaveBeenCalled();
275+
expect(onPrompt).toHaveBeenCalledTimes(1);
276+
expect(onPrompt.mock.calls[0][0]).toContain("Phantom Wallet — Device Authorization");
277+
expect(onPrompt.mock.calls[0][0]).toContain("ABCD-1234");
278+
expect(onPrompt.mock.calls[0][0]).toContain("https://connect.phantom.app/device-connect");
279+
});
234280
});

packages/mcp-server/src/auth/DeviceCodeAuthProvider.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export type DeviceCodeAuthResult = {
4848
appId: string;
4949
};
5050

51+
export type DeviceCodeAuthDisplayOptions = {
52+
openBrowser?: boolean;
53+
promptOnly?: boolean;
54+
onPrompt?: (message: string) => void | Promise<void>;
55+
};
56+
5157
const UUID_CLIENT_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5258
export class DeviceCodeAuthProvider {
5359
constructor(
@@ -56,7 +62,7 @@ export class DeviceCodeAuthProvider {
5662
private readonly logger: Logger = new Logger("DeviceCodeAuthProvider"),
5763
) {}
5864

59-
async authenticate(): Promise<DeviceCodeAuthResult> {
65+
async authenticate(displayOptions: DeviceCodeAuthDisplayOptions = {}): Promise<DeviceCodeAuthResult> {
6066
if (!this.stamper.getKeyInfo()) {
6167
await this.stamper.init();
6268
}
@@ -72,7 +78,15 @@ export class DeviceCodeAuthProvider {
7278
const nonce = await _deriveNonce(keyPair, "");
7379
const deviceAuth = await this.requestDeviceCode(clientConfig.client_id, nonce);
7480

75-
await this.displayDeviceCode(deviceAuth, clientConfig.client_id, keyInfo.publicKey);
81+
const promptText = await this.displayDeviceCode(
82+
deviceAuth,
83+
clientConfig.client_id,
84+
keyInfo.publicKey,
85+
displayOptions,
86+
);
87+
if (displayOptions.promptOnly && promptText) {
88+
throw new Error(promptText);
89+
}
7690

7791
const tokens = await this.pollForTokens(
7892
clientConfig.client_id,
@@ -198,26 +212,29 @@ export class DeviceCodeAuthProvider {
198212
deviceAuth: DeviceAuthorizationResponse,
199213
clientId: string,
200214
publicKey: string,
201-
): Promise<void> {
215+
displayOptions: DeviceCodeAuthDisplayOptions = {},
216+
): Promise<string | null> {
202217
const { user_code } = deviceAuth;
203218
const urlToOpen = `${this.options.connectBaseUrl}/device-connect?user_code=${encodeURIComponent(user_code)}&client_id=${encodeURIComponent(clientId)}&public_key=${encodeURIComponent(publicKey)}`;
204219
this.logger.info(`Generated device connect URL: ${urlToOpen}`);
205220

206221
let browserOpened = false;
207-
try {
208-
await new Promise<void>((resolve, reject) => {
209-
if (process.platform === "win32") {
210-
execFile("cmd", ["/c", "start", "", urlToOpen], err => (err ? reject(err) : resolve()));
211-
} else {
212-
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
213-
execFile(cmd, [urlToOpen], err => (err ? reject(err) : resolve()));
214-
}
215-
});
216-
browserOpened = true;
217-
this.logger.info(`Browser opened for device authorization: ${urlToOpen}`);
218-
} catch (error) {
219-
const msg = error instanceof Error ? error.message : String(error);
220-
this.logger.warn(`Could not open browser automatically: ${msg}`);
222+
if (displayOptions.openBrowser !== false) {
223+
try {
224+
await new Promise<void>((resolve, reject) => {
225+
if (process.platform === "win32") {
226+
execFile("cmd", ["/c", "start", "", urlToOpen], err => (err ? reject(err) : resolve()));
227+
} else {
228+
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
229+
execFile(cmd, [urlToOpen], err => (err ? reject(err) : resolve()));
230+
}
231+
});
232+
browserOpened = true;
233+
this.logger.info(`Browser opened for device authorization: ${urlToOpen}`);
234+
} catch (error) {
235+
const msg = error instanceof Error ? error.message : String(error);
236+
this.logger.warn(`Could not open browser automatically: ${msg}`);
237+
}
221238
}
222239

223240
const line = (content: string) => process.stderr.write(content + "\n");
@@ -233,14 +250,27 @@ export class DeviceCodeAuthProvider {
233250
line("║ Waiting for approval... ║");
234251
line("╚══════════════════════════════════════════════╝");
235252
line("");
236-
return;
253+
return null;
237254
}
238255

239256
const hyperlink = `\x1b]8;;${urlToOpen}\x07${urlToOpen}\x1b]8;;\x07`;
240257
const qr = await new Promise<string>(resolve => {
241258
qrcode.generate(urlToOpen, { small: true }, resolve);
242259
});
243260

261+
if (displayOptions.onPrompt) {
262+
const promptText = [
263+
"Phantom Wallet — Device Authorization",
264+
"",
265+
"Please visit this link to approve the connection:",
266+
urlToOpen,
267+
"",
268+
`Code: ${user_code}`,
269+
].join("\n");
270+
await displayOptions.onPrompt(promptText);
271+
return promptText;
272+
}
273+
244274
line("");
245275
line("╔══════════════════════════════════════════════╗");
246276
line("║ Phantom Wallet — Device Authorization ║");
@@ -257,6 +287,7 @@ export class DeviceCodeAuthProvider {
257287
process.stderr.write(qr);
258288
line(hyperlink);
259289
line("");
290+
return null;
260291
}
261292

262293
private async pollForTokens(

packages/mcp-server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// Export SessionManager and types for external usage
55
export { SessionManager } from "./session/manager.js";
66
export type { SessionData } from "./session/types.js";
7+
export type { DeviceCodeAuthDisplayOptions } from "./auth/DeviceCodeAuthProvider.js";
78

89
// Export tools for external usage
910
export { tools } from "./tools/index.js";

packages/mcp-server/src/server.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,35 @@ describe("PhantomMCPServer", () => {
191191
expect(mockSetPaymentHandler).toHaveBeenCalledTimes(1);
192192
});
193193

194+
it("passes text display mode to phantom_login and returns the prompt text", async () => {
195+
mockGetTool.mockReturnValue(mockTools[0]);
196+
mockSessionManagerInstance.resetSession.mockImplementation((_opts?: unknown) => Promise.resolve(undefined));
197+
mockSessionManagerInstance.getSession.mockReturnValue({
198+
walletId: "wallet-login",
199+
organizationId: "org-1",
200+
appId: "session-client-id",
201+
authFlow: "device-code",
202+
});
203+
mockSessionManagerInstance.getClient.mockReturnValue({
204+
getWalletAddresses: jest.fn().mockResolvedValue([{ addressType: "solana", address: "So1Address" }]),
205+
});
206+
207+
new PhantomMCPServer();
208+
const { callTool } = getHandlers();
209+
const result = await callTool({ params: { name: "phantom_login", arguments: { displayMode: "text" } } });
210+
211+
expect(mockSessionManagerInstance.resetSession).toHaveBeenCalledWith(
212+
expect.objectContaining({
213+
openBrowser: false,
214+
onPrompt: expect.any(Function),
215+
}),
216+
);
217+
218+
const parsed = JSON.parse(result.content[0].text);
219+
expect(parsed.success).toBe(true);
220+
expect(parsed.authFlow).toBe("device-code");
221+
});
222+
194223
it("returns AUTH_EXPIRED and resets session when token refresh fails on 401", async () => {
195224
const toolHandler = jest.fn().mockRejectedValue({ response: { status: 401 } });
196225
mockGetTool.mockReturnValue({

packages/mcp-server/src/server.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,24 @@ export class PhantomMCPServer {
211211
// even when the session is not yet initialized (first run, expired, etc.)
212212
if (toolName === "phantom_login") {
213213
this.logger.info("Handling phantom_login: resetting session");
214+
const displayMode = request.params.arguments?.displayMode;
215+
const useTextPrompt = displayMode === "text";
216+
let promptText: string | null = null;
214217
this.logger.info(
215218
"Starting authentication now. If using SSO, Phantom Connect will open in your browser. " +
216219
"If using device-code, a device connect URL and code will be shown in the terminal.",
217220
);
218221
try {
219-
await this.sessionManager.resetSession();
222+
await this.sessionManager.resetSession(
223+
useTextPrompt
224+
? {
225+
openBrowser: false,
226+
onPrompt: message => {
227+
promptText = message;
228+
},
229+
}
230+
: undefined,
231+
);
220232
const session = this.sessionManager.getSession();
221233
this.logger.info(
222234
`phantom_login successful for walletId: ${session.walletId}, authFlow: ${session.authFlow}`,
@@ -235,6 +247,7 @@ export class PhantomMCPServer {
235247
{
236248
success: true,
237249
message: "Authentication successful.",
250+
...(promptText ? { prompt: promptText } : {}),
238251
walletId: session.walletId,
239252
authFlow: session.authFlow ?? "device-code",
240253
},

packages/mcp-server/src/session/manager.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { SessionStorage } from "./storage.js";
1414
import { OAuthFlow } from "../auth/oauth.js";
1515
import { DeviceCodeAuthProvider } from "../auth/DeviceCodeAuthProvider.js";
1616
import { NodeFileAuth2StamperStorage } from "../auth/NodeFileAuth2StamperStorage.js";
17+
import type { DeviceCodeAuthDisplayOptions } from "../auth/DeviceCodeAuthProvider.js";
1718
import type { SessionData } from "./types.js";
1819
import { Logger } from "../utils/logger.js";
1920
import * as packageJson from "../../package.json";
@@ -154,7 +155,7 @@ export class SessionManager {
154155
*
155156
* @throws Error if authentication fails
156157
*/
157-
async initialize(): Promise<void> {
158+
async initialize(displayOptions?: DeviceCodeAuthDisplayOptions): Promise<void> {
158159
this.logger.info("Initializing session manager");
159160

160161
// Step 1: Try to load existing session
@@ -179,7 +180,7 @@ export class SessionManager {
179180
this.storage.delete();
180181
this.session = null;
181182
this.client = null;
182-
await this.authenticate();
183+
await this.authenticate(displayOptions);
183184
return;
184185
}
185186
// Non-auth errors (network down, timeout) — keep the session and proceed
@@ -196,7 +197,7 @@ export class SessionManager {
196197
this.logger.info("No session found, authenticating");
197198
}
198199

199-
await this.authenticate();
200+
await this.authenticate(displayOptions);
200201
}
201202

202203
/**
@@ -269,7 +270,7 @@ export class SessionManager {
269270
*
270271
* @throws Error if authentication fails
271272
*/
272-
async resetSession(): Promise<void> {
273+
async resetSession(displayOptions?: DeviceCodeAuthDisplayOptions): Promise<void> {
273274
this.logger.info("Resetting session");
274275

275276
// Clear stored session
@@ -280,7 +281,7 @@ export class SessionManager {
280281
this.stamper = null;
281282

282283
// Re-authenticate
283-
await this.authenticate();
284+
await this.authenticate(displayOptions);
284285
}
285286

286287
/**
@@ -289,11 +290,11 @@ export class SessionManager {
289290
*
290291
* @throws Error if authentication fails
291292
*/
292-
private async authenticate(): Promise<void> {
293+
private async authenticate(displayOptions?: DeviceCodeAuthDisplayOptions): Promise<void> {
293294
this.logger.info(`Starting authentication (flow: ${this.authFlow})`);
294295

295296
if (this.authFlow === "device-code") {
296-
await this.authenticateWithDeviceFlow();
297+
await this.authenticateWithDeviceFlow(displayOptions);
297298
} else {
298299
await this.authenticateWithSso();
299300
}
@@ -338,7 +339,7 @@ export class SessionManager {
338339
*
339340
* @throws Error if device flow fails
340341
*/
341-
private async authenticateWithDeviceFlow(): Promise<void> {
342+
private async authenticateWithDeviceFlow(displayOptions?: DeviceCodeAuthDisplayOptions): Promise<void> {
342343
const stamper = new Auth2Stamper(new NodeFileAuth2StamperStorage(this.storage.sessionDir), {
343344
authApiBaseUrl: this.authBaseUrl,
344345
clientId: this.resolveAppId(),
@@ -357,7 +358,7 @@ export class SessionManager {
357358
this.logger.child("DeviceCodeAuthProvider"),
358359
);
359360

360-
const result = await deviceFlow.authenticate();
361+
const result = await deviceFlow.authenticate(displayOptions);
361362
this.logger.info("Device authorization flow completed successfully");
362363

363364
const now = Math.floor(Date.now() / 1000);

packages/mcp-server/src/tools/login.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,18 @@ import type { ToolHandler } from "./types.js";
1313
export const loginTool: ToolHandler = {
1414
name: "phantom_login",
1515
description:
16-
"Re-authenticate with Phantom. Use this to log in for the first time, switch accounts, or refresh an expired session.",
16+
"Re-authenticate with Phantom. Use this to log in for the first time, switch accounts, or refresh an expired session. " +
17+
"Set displayMode to 'text' if you want the login prompt returned as text instead of trying to open a browser automatically.",
1718
inputSchema: {
1819
type: "object",
19-
properties: {},
20+
properties: {
21+
displayMode: {
22+
type: "string",
23+
enum: ["browser", "text"],
24+
description:
25+
"'browser' (default) tries to open the browser automatically. 'text' returns the login prompt text instead.",
26+
},
27+
},
2028
},
2129
annotations: {
2230
readOnlyHint: false,

packages/phantom-openclaw-plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@phantom/phantom-openclaw-plugin",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"description": "Phantom Embedded Wallet — sign transactions, transfer tokens, and swap on Solana and Ethereum. Powered by Phantom.",
55
"repository": {
66
"type": "git",

packages/phantom-openclaw-plugin/src/index.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,9 @@ function resetSession(): void {
115115
/**
116116
* Plugin registration function
117117
*/
118-
export default async function register(api: OpenClawApi) {
118+
export default function register(api: OpenClawApi) {
119119
try {
120-
// Initialize session (authenticate if needed)
121120
const session = getSession(api.config);
122-
await session.initialize();
123-
124-
// Register all Phantom MCP tools
125121
registerPhantomTools(api, session);
126122
} catch (error) {
127123
console.error("Failed to initialize Phantom OpenClaw plugin:", error); // eslint-disable-line no-console

0 commit comments

Comments
 (0)