Skip to content

Commit 2994b78

Browse files
chore: sync from internal
1 parent 9ffc476 commit 2994b78

50 files changed

Lines changed: 3640 additions & 955 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/mcp-server/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# @phantom/mcp-server
22

3+
## 0.2.3
4+
5+
### Patch Changes
6+
7+
- Phantom API Client
8+
- 00da963: Update default api endpoint
9+
- Updated dependencies
10+
- Updated dependencies [00da963]
11+
- @phantom/phantom-api-client@0.1.1
12+
- @phantom/perps-client@0.1.2
13+
314
## 0.2.2
415

516
### Patch Changes

packages/mcp-server/README.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,21 +1039,21 @@ All environment variables recognized by the MCP server, grouped by purpose:
10391039

10401040
#### OAuth / Auth URLs
10411041

1042-
| Variable | Default | Description |
1043-
| -------------------------- | ------------------------------------ | ------------------------------------------------------------- |
1044-
| `PHANTOM_AUTH_BASE_URL` | `https://auth.phantom.app` | Base URL for the Phantom auth service (token exchange, DCR). |
1045-
| `PHANTOM_CONNECT_BASE_URL` | `https://connect.phantom.app` | Base URL for the Phantom Connect SSO page (browser redirect). |
1046-
| `PHANTOM_API_BASE_URL` | `https://api.phantom.app/v1/wallets` | Base URL for the Phantom wallet API. |
1047-
| `PHANTOM_CALLBACK_PORT` | `8080` | Local port for the OAuth redirect callback server. |
1048-
| `PHANTOM_CALLBACK_PATH` | `/callback` | Path for the OAuth redirect callback. |
1049-
| `PHANTOM_SSO_PROVIDER` | `google` | Default SSO provider (`google` or `apple`). |
1050-
1051-
#### API / swap
1052-
1053-
| Variable | Default | Description |
1054-
| ------------------------ | ---------------------------------------- | ------------------------------------------------------------------------- |
1055-
| `PHANTOM_QUOTES_API_URL` | `https://api.phantom.app/swap/v2/quotes` | Override the swap quotes API endpoint used by `buy_token`. Must be HTTPS. |
1056-
| `PHANTOM_VERSION` | `mcp-server` | Value sent as the `X-Phantom-Version` request header. |
1042+
| Variable | Default | Description |
1043+
| ------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------- |
1044+
| `PHANTOM_AUTH_BASE_URL` | `https://auth.phantom.app` | Base URL for the Phantom auth service (token exchange, DCR). |
1045+
| `PHANTOM_CONNECT_BASE_URL` | `https://connect.phantom.app` | Base URL for the Phantom Connect SSO page (browser redirect). |
1046+
| `PHANTOM_WALLETS_API_BASE_URL` | `https://api.phantom.app/v1/wallets` | Base URL for the Phantom wallets/KMS API used by `PhantomClient` for signing. |
1047+
| `PHANTOM_CALLBACK_PORT` | `8080` | Local port for the OAuth redirect callback server. |
1048+
| `PHANTOM_CALLBACK_PATH` | `/callback` | Path for the OAuth redirect callback. |
1049+
| `PHANTOM_SSO_PROVIDER` | `google` | Default SSO provider (`google` or `apple`). |
1050+
1051+
#### API
1052+
1053+
| Variable | Default | Description |
1054+
| ---------------------- | ----------------------- | ----------------------------------------------------- |
1055+
| `PHANTOM_API_BASE_URL` | `http://localhost:3001` | Base URL for the Phantom API. |
1056+
| `PHANTOM_VERSION` | `mcp-server` | Value sent as the `X-Phantom-Version` request header. |
10571057

10581058
#### Logging / debugging
10591059

packages/mcp-server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@phantom/mcp-server",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "MCP Server for Phantom Wallet",
55
"repository": {
66
"type": "git",
@@ -46,6 +46,7 @@
4646
"@phantom/crypto": "workspace:^",
4747
"@phantom/parsers": "workspace:^",
4848
"@phantom/perps-client": "workspace:^",
49+
"@phantom/phantom-api-client": "workspace:^",
4950
"@phantom/server-sdk": "workspace:^",
5051
"@phantom/utils": "workspace:^",
5152
"@solana/spl-token": "^0.4.14",

packages/mcp-server/src/server.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ import {
3636
ListToolsRequestSchema,
3737
type CallToolRequest,
3838
} from "@modelcontextprotocol/sdk/types.js";
39+
import { AddressType, type NetworkId } from "@phantom/client";
40+
import { PhantomApiClient, PaymentRequiredError, RateLimitError } from "@phantom/phantom-api-client";
41+
import { base64urlEncode } from "@phantom/base64url";
42+
import { ANALYTICS_HEADERS } from "@phantom/constants";
3943
import { SessionManager } from "./session/manager.js";
44+
import * as packageJson from "../package.json";
45+
import { normalizeNetworkId } from "./utils/network.js";
4046
import { tools, getTool } from "./tools/index.js";
4147
import { Logger } from "./utils/logger.js";
4248

@@ -60,7 +66,7 @@ export interface PhantomMCPServerOptions {
6066
session?: {
6167
authBaseUrl?: string;
6268
connectBaseUrl?: string;
63-
apiBaseUrl?: string;
69+
walletsApiBaseUrl?: string;
6470
callbackPort?: number;
6571
appId?: string;
6672
sessionDir?: string;
@@ -92,6 +98,7 @@ export class PhantomMCPServer {
9298
private readonly server: Server;
9399
private readonly sessionManager: SessionManager;
94100
private readonly logger: Logger;
101+
private readonly apiClient: PhantomApiClient;
95102
/**
96103
* Resolves when the startup initialization attempt finishes (success or failure).
97104
* Tool call handlers await this so they don't race with the OAuth browser flow.
@@ -107,6 +114,13 @@ export class PhantomMCPServer {
107114
constructor(options: PhantomMCPServerOptions = {}) {
108115
this.logger = new Logger("PhantomMCPServer");
109116

117+
// Initialize shared API client — points at api.phantom.app by default.
118+
// Override with PHANTOM_API_BASE_URL to point at a different proxy or local server.
119+
this.apiClient = new PhantomApiClient({
120+
baseUrl: process.env.PHANTOM_API_BASE_URL ?? "https://api.phantom.app",
121+
logger: this.logger.child("api-client"),
122+
});
123+
110124
// Initialize MCP Server
111125
// The server name and instructions are surfaced to agents during the MCP handshake,
112126
// giving them immediate context about Phantom and available capabilities.
@@ -206,6 +220,12 @@ export class PhantomMCPServer {
206220
try {
207221
await this.sessionManager.resetSession();
208222
const session = this.sessionManager.getSession();
223+
try {
224+
await this.wirePaymentHandler();
225+
} catch (err) {
226+
const msg = err instanceof Error ? err.message : String(err);
227+
this.logger.error(`Failed to wire payment handler after login: ${msg}`);
228+
}
209229
return {
210230
content: [
211231
{
@@ -242,6 +262,7 @@ export class PhantomMCPServer {
242262
client,
243263
session,
244264
logger: this.logger.child(toolName),
265+
apiClient: this.apiClient,
245266
};
246267

247268
// Step 4: Execute tool handler
@@ -283,6 +304,59 @@ export class PhantomMCPServer {
283304
};
284305
}
285306

307+
// Payment required: auto-pay failed (insufficient CASH, signing error, etc.)
308+
// Return a structured response so the agent knows exactly what happened and what to do.
309+
if (error instanceof PaymentRequiredError) {
310+
this.logger.warn(`Payment required on tool ${toolName}: auto-pay failed — ${error.message}`);
311+
return {
312+
content: [
313+
{
314+
type: "text" as const,
315+
text: JSON.stringify(
316+
{
317+
error: "API_PAYMENT_REQUIRED",
318+
message:
319+
`Daily API limit reached. Call pay_api_access with the preparedTx below to pay ` +
320+
`${error.payment.amount} ${error.payment.token} and unlock access, then retry ${toolName}.`,
321+
preparedTx: error.payment.preparedTx,
322+
payment: {
323+
amount: error.payment.amount,
324+
token: error.payment.token,
325+
network: error.payment.network,
326+
description: error.payment.description,
327+
},
328+
},
329+
null,
330+
2,
331+
),
332+
},
333+
],
334+
isError: true,
335+
};
336+
}
337+
338+
// Rate limited: tell the agent to back off
339+
if (error instanceof RateLimitError) {
340+
this.logger.warn(`Rate limited on tool ${toolName}: retry in ${error.retryAfterMs}ms`);
341+
return {
342+
content: [
343+
{
344+
type: "text" as const,
345+
text: JSON.stringify(
346+
{
347+
error: "RATE_LIMITED",
348+
message: `Too many requests. Wait ${Math.ceil(error.retryAfterMs / 1000)} seconds before retrying.`,
349+
retryAfterMs: error.retryAfterMs,
350+
},
351+
null,
352+
2,
353+
),
354+
},
355+
],
356+
isError: true,
357+
};
358+
}
359+
286360
// Non-auth error: return as-is
287361
const errorMessage = error instanceof Error ? error.message : String(error);
288362

@@ -352,5 +426,71 @@ export class PhantomMCPServer {
352426
});
353427

354428
await this.initPromise;
429+
430+
// Wire payment handler after init completes so any failure here is clearly visible
431+
// and not silently swallowed by the session-init catch block above.
432+
try {
433+
await this.wirePaymentHandler();
434+
} catch (err) {
435+
const msg = err instanceof Error ? err.message : String(err);
436+
this.logger.error(`Failed to wire payment handler: ${msg} — API payment auto-pay will be disabled`);
437+
}
438+
}
439+
440+
/**
441+
* Wires a payment handler into the shared apiClient so any 402 from the proxy
442+
* is automatically paid and the original request retried — no tool changes needed.
443+
* Called after every session init/reset so the handler always uses the current client.
444+
*/
445+
private async wirePaymentHandler(): Promise<void> {
446+
// getClient/getSession throw if no session — don't wire if unauthenticated
447+
let client: ReturnType<typeof this.sessionManager.getClient>;
448+
let session: ReturnType<typeof this.sessionManager.getSession>;
449+
try {
450+
client = this.sessionManager.getClient();
451+
session = this.sessionManager.getSession();
452+
} catch {
453+
this.logger.warn("wirePaymentHandler: no active session — skipping (will retry after login)");
454+
return;
455+
}
456+
this.logger.info("Wiring payment handler into apiClient");
457+
458+
// Set static headers — wallet address and app id are known after session init
459+
const appId = process.env.PHANTOM_APP_ID ?? process.env.PHANTOM_CLIENT_ID;
460+
const addresses = await client.getWalletAddresses(session.walletId);
461+
const solanaAddress = addresses.find(a => a.addressType === AddressType.solana)?.address;
462+
const staticHeaders: Record<string, string> = {
463+
[ANALYTICS_HEADERS.PLATFORM]: "ext-sdk",
464+
[ANALYTICS_HEADERS.CLIENT]: "mcp",
465+
[ANALYTICS_HEADERS.SDK_VERSION]: process.env.PHANTOM_VERSION ?? packageJson.version ?? "unknown",
466+
};
467+
if (appId) {
468+
staticHeaders["x-api-key"] = appId;
469+
staticHeaders["X-App-Id"] = appId;
470+
}
471+
if (solanaAddress) {
472+
staticHeaders["X-Wallet-Address"] = solanaAddress;
473+
}
474+
this.apiClient.setHeaders(staticHeaders);
475+
476+
this.apiClient.setPaymentHandler(async payment => {
477+
this.logger.info(`Paying ${payment.amount} ${payment.token} to unlock API access`);
478+
479+
// Re-resolve Solana address in case session was refreshed
480+
const paymentAddresses = await client.getWalletAddresses(session.walletId);
481+
const solanaAddress = paymentAddresses.find(a => a.addressType === AddressType.solana)?.address;
482+
if (!solanaAddress) throw new Error("No Solana address found for payment");
483+
484+
const txBytes = Buffer.from(payment.preparedTx, "base64");
485+
const result = await client.signAndSendTransaction({
486+
walletId: session.walletId,
487+
transaction: base64urlEncode(txBytes),
488+
networkId: normalizeNetworkId("solana:mainnet") as NetworkId,
489+
account: solanaAddress,
490+
});
491+
if (!result.hash) throw new Error("Payment tx submitted but no signature returned");
492+
this.logger.info(`Payment signature: ${result.hash}`);
493+
return result.hash;
494+
});
355495
}
356496
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,15 @@ describe("SessionManager", () => {
105105
it("should use environment variables for URLs", () => {
106106
process.env.PHANTOM_AUTH_BASE_URL = "https://env-auth.example.com";
107107
process.env.PHANTOM_CONNECT_BASE_URL = "https://env-connect.example.com";
108-
process.env.PHANTOM_API_BASE_URL = "https://env-api.example.com";
108+
process.env.PHANTOM_WALLETS_API_BASE_URL = "https://env-api.example.com";
109109

110110
const manager = new SessionManager();
111111
expect(manager).toBeDefined();
112112

113113
// Clean up
114114
delete process.env.PHANTOM_AUTH_BASE_URL;
115115
delete process.env.PHANTOM_CONNECT_BASE_URL;
116-
delete process.env.PHANTOM_API_BASE_URL;
116+
delete process.env.PHANTOM_WALLETS_API_BASE_URL;
117117
});
118118
});
119119

@@ -326,7 +326,7 @@ describe("SessionManager", () => {
326326
mockStorage.isExpired.mockReturnValue(false);
327327

328328
const manager = new SessionManager({
329-
apiBaseUrl: "https://custom-api.example.com",
329+
walletsApiBaseUrl: "https://custom-api.example.com",
330330
});
331331
await manager.initialize();
332332

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export interface SessionManagerOptions {
2424
authBaseUrl?: string;
2525
/** Base URL for Phantom Connect (overrides PHANTOM_CONNECT_BASE_URL and env-based default) */
2626
connectBaseUrl?: string;
27-
/** Base URL for Phantom API (default: https://api.phantom.app or PHANTOM_API_BASE_URL env var) */
28-
apiBaseUrl?: string;
27+
/** Base URL for Phantom wallets API (default: https://api.phantom.app/v1/wallets or PHANTOM_WALLETS_API_BASE_URL env var) */
28+
walletsApiBaseUrl?: string;
2929
/** Port for local OAuth callback server (default: 8080 or PHANTOM_CALLBACK_PORT env var) */
3030
callbackPort?: number;
3131
/** Path for OAuth callback (default: /callback or PHANTOM_CALLBACK_PATH env var) */
@@ -63,7 +63,7 @@ export interface SessionManagerOptions {
6363
export class SessionManager {
6464
private readonly authBaseUrl: string;
6565
private readonly connectBaseUrl: string;
66-
private readonly apiBaseUrl: string;
66+
private readonly walletsApiBaseUrl: string;
6767
private readonly callbackPort: number;
6868
private readonly callbackPath: string;
6969
private readonly appId: string;
@@ -136,7 +136,11 @@ export class SessionManager {
136136
resolvedAuthFlow = "sso";
137137
}
138138
this.authFlow = resolvedAuthFlow;
139-
this.apiBaseUrl = options.apiBaseUrl ?? process.env.PHANTOM_API_BASE_URL ?? "https://api.phantom.app/v1/wallets";
139+
this.walletsApiBaseUrl =
140+
options.walletsApiBaseUrl?.trim() ||
141+
process.env.PHANTOM_WALLETS_API_BASE_URL?.trim() ||
142+
"" ||
143+
"https://api.phantom.app/v1/wallets";
140144

141145
const defaultPort = 8080;
142146
const parseEnvPort = (value: string): number | null => {
@@ -396,7 +400,7 @@ export class SessionManager {
396400
// Step 3: Create PhantomClient with stamper, organizationId, and headers
397401
this.client = new PhantomClient(
398402
{
399-
apiBaseUrl: this.apiBaseUrl,
403+
apiBaseUrl: this.walletsApiBaseUrl,
400404
organizationId: this.session.organizationId,
401405
walletType: "user-wallet",
402406
headers,

0 commit comments

Comments
 (0)