nullpath-mcp 1.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/AWESOME_MCP_ENTRY.md +44 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/__tests__/x402.test.d.ts +2 -0
- package/dist/__tests__/x402.test.d.ts.map +1 -0
- package/dist/__tests__/x402.test.js +187 -0
- package/dist/__tests__/x402.test.js.map +1 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +264 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/__tests__/x402.test.ts +229 -0
- package/src/index.ts +390 -0
- package/tsconfig.json +19 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* nullpath MCP Client
|
|
5
|
+
*
|
|
6
|
+
* Connects to nullpath.com/mcp - AI agent marketplace with x402 micropayments.
|
|
7
|
+
*
|
|
8
|
+
* Available tools:
|
|
9
|
+
* - discover_agents: Search agents by capability
|
|
10
|
+
* - lookup_agent: Get agent details by ID
|
|
11
|
+
* - execute_agent: Run an agent (paid via x402)
|
|
12
|
+
* - register_agent: Register a new agent (paid)
|
|
13
|
+
* - get_capabilities: List capability categories
|
|
14
|
+
* - check_reputation: Get agent trust score
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
18
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
19
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
21
|
+
import {
|
|
22
|
+
ListToolsRequestSchema,
|
|
23
|
+
CallToolRequestSchema,
|
|
24
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
25
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
26
|
+
import type { Address, Hex } from 'viem';
|
|
27
|
+
|
|
28
|
+
const NULLPATH_MCP_URL = process.env.NULLPATH_MCP_URL || 'https://nullpath.com/mcp';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Payment requirements from x402 402 response
|
|
32
|
+
*/
|
|
33
|
+
export interface PaymentRequirements {
|
|
34
|
+
scheme: 'exact';
|
|
35
|
+
network: 'base' | 'base-sepolia';
|
|
36
|
+
maxAmountRequired: string;
|
|
37
|
+
resource: string;
|
|
38
|
+
description: string;
|
|
39
|
+
mimeType: string;
|
|
40
|
+
payTo: Address;
|
|
41
|
+
maxTimeoutSeconds: number;
|
|
42
|
+
asset: Address;
|
|
43
|
+
extra: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* x402 error response structure from MCP server
|
|
48
|
+
*/
|
|
49
|
+
export interface X402ErrorData {
|
|
50
|
+
x402Version: number;
|
|
51
|
+
error: string;
|
|
52
|
+
accepts: PaymentRequirements[];
|
|
53
|
+
priceBreakdown?: {
|
|
54
|
+
platformFee: number;
|
|
55
|
+
agentFee: number;
|
|
56
|
+
platformCut: number;
|
|
57
|
+
agentEarnings: number;
|
|
58
|
+
total: number;
|
|
59
|
+
currency: string;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* EIP-3009 TransferWithAuthorization typed data
|
|
65
|
+
*/
|
|
66
|
+
const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
67
|
+
TransferWithAuthorization: [
|
|
68
|
+
{ name: 'from', type: 'address' },
|
|
69
|
+
{ name: 'to', type: 'address' },
|
|
70
|
+
{ name: 'value', type: 'uint256' },
|
|
71
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
72
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
73
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
74
|
+
],
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* USDC contract metadata by network
|
|
79
|
+
*/
|
|
80
|
+
const USDC_CONTRACTS: Record<string, { name: string; version: string; chainId: number }> = {
|
|
81
|
+
'base': { name: 'USD Coin', version: '2', chainId: 8453 },
|
|
82
|
+
'base-sepolia': { name: 'USD Coin', version: '2', chainId: 84532 },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Payment payload structure
|
|
87
|
+
*/
|
|
88
|
+
export interface PaymentPayload {
|
|
89
|
+
x402Version: number;
|
|
90
|
+
scheme: 'exact';
|
|
91
|
+
network: string;
|
|
92
|
+
payload: {
|
|
93
|
+
signature: `0x${string}`;
|
|
94
|
+
authorization: {
|
|
95
|
+
from: Address;
|
|
96
|
+
to: Address;
|
|
97
|
+
value: string;
|
|
98
|
+
validAfter: string;
|
|
99
|
+
validBefore: string;
|
|
100
|
+
nonce: `0x${string}`;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Generate a random 32-byte nonce for EIP-3009
|
|
107
|
+
*/
|
|
108
|
+
function generateNonce(): `0x${string}` {
|
|
109
|
+
const bytes = new Uint8Array(32);
|
|
110
|
+
crypto.getRandomValues(bytes);
|
|
111
|
+
return `0x${Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')}` as `0x${string}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Wallet with address and signing capability
|
|
116
|
+
*/
|
|
117
|
+
export interface Wallet {
|
|
118
|
+
address: Address;
|
|
119
|
+
privateKey: Hex;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sign an EIP-3009 TransferWithAuthorization
|
|
124
|
+
*/
|
|
125
|
+
export async function signTransferAuthorization(
|
|
126
|
+
wallet: Wallet,
|
|
127
|
+
requirements: PaymentRequirements
|
|
128
|
+
): Promise<PaymentPayload> {
|
|
129
|
+
const network = requirements.network;
|
|
130
|
+
const contractMeta = USDC_CONTRACTS[network];
|
|
131
|
+
|
|
132
|
+
if (!contractMeta) {
|
|
133
|
+
throw new Error(`Unsupported network: ${network}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const now = Math.floor(Date.now() / 1000);
|
|
137
|
+
const validAfter = now - 60; // Valid from 1 minute ago
|
|
138
|
+
const validBefore = now + requirements.maxTimeoutSeconds;
|
|
139
|
+
const nonce = generateNonce();
|
|
140
|
+
|
|
141
|
+
const authorization = {
|
|
142
|
+
from: wallet.address,
|
|
143
|
+
to: requirements.payTo,
|
|
144
|
+
value: BigInt(requirements.maxAmountRequired),
|
|
145
|
+
validAfter: BigInt(validAfter),
|
|
146
|
+
validBefore: BigInt(validBefore),
|
|
147
|
+
nonce,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Create account for signing
|
|
151
|
+
const account = privateKeyToAccount(wallet.privateKey);
|
|
152
|
+
|
|
153
|
+
const signature = await account.signTypedData({
|
|
154
|
+
domain: {
|
|
155
|
+
name: contractMeta.name,
|
|
156
|
+
version: contractMeta.version,
|
|
157
|
+
chainId: contractMeta.chainId,
|
|
158
|
+
verifyingContract: requirements.asset,
|
|
159
|
+
},
|
|
160
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
161
|
+
primaryType: 'TransferWithAuthorization',
|
|
162
|
+
message: authorization,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
x402Version: 1,
|
|
167
|
+
scheme: 'exact',
|
|
168
|
+
network,
|
|
169
|
+
payload: {
|
|
170
|
+
signature,
|
|
171
|
+
authorization: {
|
|
172
|
+
from: wallet.address,
|
|
173
|
+
to: requirements.payTo,
|
|
174
|
+
value: authorization.value.toString(),
|
|
175
|
+
validAfter: authorization.validAfter.toString(),
|
|
176
|
+
validBefore: authorization.validBefore.toString(),
|
|
177
|
+
nonce,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Encode payment payload to base64 for X-PAYMENT header
|
|
185
|
+
*/
|
|
186
|
+
export function encodePaymentHeader(payment: PaymentPayload): string {
|
|
187
|
+
return Buffer.from(JSON.stringify(payment)).toString('base64');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check if an error response is an x402 payment required error
|
|
192
|
+
*/
|
|
193
|
+
export function isX402Error(error: unknown): error is { code: number; message: string; data: X402ErrorData } {
|
|
194
|
+
if (typeof error !== 'object' || error === null) return false;
|
|
195
|
+
const err = error as Record<string, unknown>;
|
|
196
|
+
if (err.code !== -32000) return false;
|
|
197
|
+
if (typeof err.data !== 'object' || err.data === null) return false;
|
|
198
|
+
const data = err.data as Record<string, unknown>;
|
|
199
|
+
return typeof data.x402Version === 'number' && Array.isArray(data.accepts);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get wallet from environment variable
|
|
204
|
+
*/
|
|
205
|
+
export function getWallet(): Wallet {
|
|
206
|
+
const privateKey = process.env.NULLPATH_WALLET_KEY;
|
|
207
|
+
|
|
208
|
+
if (!privateKey) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
'NULLPATH_WALLET_KEY environment variable is required for paid tool calls. ' +
|
|
211
|
+
'Set it to your wallet private key (0x-prefixed hex string).'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Ensure the key is properly formatted
|
|
216
|
+
const formattedKey = privateKey.startsWith('0x')
|
|
217
|
+
? privateKey as Hex
|
|
218
|
+
: `0x${privateKey}` as Hex;
|
|
219
|
+
|
|
220
|
+
const account = privateKeyToAccount(formattedKey);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
address: account.address,
|
|
224
|
+
privateKey: formattedKey,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Handle x402 payment flow
|
|
230
|
+
*
|
|
231
|
+
* 1. Parse payment requirements from 402 error
|
|
232
|
+
* 2. Sign EIP-3009 authorization
|
|
233
|
+
* 3. Return payment header for retry
|
|
234
|
+
*/
|
|
235
|
+
export async function handleX402Payment(
|
|
236
|
+
errorData: X402ErrorData
|
|
237
|
+
): Promise<string> {
|
|
238
|
+
const wallet = getWallet();
|
|
239
|
+
|
|
240
|
+
if (errorData.accepts.length === 0) {
|
|
241
|
+
throw new Error('No payment options available in 402 response');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Use the first payment option
|
|
245
|
+
const requirements = errorData.accepts[0];
|
|
246
|
+
|
|
247
|
+
// Sign the payment authorization
|
|
248
|
+
const payment = await signTransferAuthorization(wallet, requirements);
|
|
249
|
+
|
|
250
|
+
// Encode for header
|
|
251
|
+
return encodePaymentHeader(payment);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function main() {
|
|
255
|
+
// Create a local stdio server that proxies to nullpath's remote MCP
|
|
256
|
+
const server = new Server(
|
|
257
|
+
{
|
|
258
|
+
name: 'nullpath-mcp',
|
|
259
|
+
version: '1.0.0',
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
capabilities: {
|
|
263
|
+
tools: {},
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Connect to remote nullpath MCP server
|
|
269
|
+
const transport = new SSEClientTransport(new URL(NULLPATH_MCP_URL));
|
|
270
|
+
const client = new Client(
|
|
271
|
+
{
|
|
272
|
+
name: 'nullpath-mcp-proxy',
|
|
273
|
+
version: '1.0.0',
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
capabilities: {},
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
await client.connect(transport);
|
|
281
|
+
|
|
282
|
+
// List available tools from remote server
|
|
283
|
+
const tools = await client.listTools();
|
|
284
|
+
|
|
285
|
+
// Register tool handlers that proxy to remote
|
|
286
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
287
|
+
return tools;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
291
|
+
try {
|
|
292
|
+
// First attempt without payment
|
|
293
|
+
const result = await client.callTool({
|
|
294
|
+
name: request.params.name,
|
|
295
|
+
arguments: request.params.arguments,
|
|
296
|
+
});
|
|
297
|
+
return result;
|
|
298
|
+
} catch (error: unknown) {
|
|
299
|
+
// Check if this is a 402 Payment Required error
|
|
300
|
+
if (isX402Error(error)) {
|
|
301
|
+
console.error(`Payment required for tool: ${request.params.name}`);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
// Handle x402 payment
|
|
305
|
+
const paymentHeader = await handleX402Payment(error.data);
|
|
306
|
+
|
|
307
|
+
// Retry with payment header
|
|
308
|
+
// Note: The MCP SDK doesn't directly support custom headers on tool calls,
|
|
309
|
+
// so we need to make the request ourselves with the payment header
|
|
310
|
+
const retryResult = await retryWithPayment(
|
|
311
|
+
request.params.name,
|
|
312
|
+
request.params.arguments,
|
|
313
|
+
paymentHeader
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
return retryResult;
|
|
317
|
+
} catch (paymentError) {
|
|
318
|
+
// Re-throw with more context
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Payment failed for tool ${request.params.name}: ${
|
|
321
|
+
paymentError instanceof Error ? paymentError.message : String(paymentError)
|
|
322
|
+
}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Re-throw non-payment errors
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Start local stdio transport
|
|
333
|
+
const stdioTransport = new StdioServerTransport();
|
|
334
|
+
await server.connect(stdioTransport);
|
|
335
|
+
|
|
336
|
+
console.error('nullpath MCP client connected to', NULLPATH_MCP_URL);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Retry a tool call with x402 payment header
|
|
341
|
+
*
|
|
342
|
+
* Makes a direct HTTP request to the MCP server with the X-PAYMENT header
|
|
343
|
+
*/
|
|
344
|
+
async function retryWithPayment(
|
|
345
|
+
toolName: string,
|
|
346
|
+
args: Record<string, unknown> | undefined,
|
|
347
|
+
paymentHeader: string
|
|
348
|
+
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
349
|
+
const requestBody = {
|
|
350
|
+
jsonrpc: '2.0',
|
|
351
|
+
method: 'tools/call',
|
|
352
|
+
params: {
|
|
353
|
+
name: toolName,
|
|
354
|
+
arguments: args || {},
|
|
355
|
+
},
|
|
356
|
+
id: Date.now(),
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const response = await fetch(NULLPATH_MCP_URL, {
|
|
360
|
+
method: 'POST',
|
|
361
|
+
headers: {
|
|
362
|
+
'Content-Type': 'application/json',
|
|
363
|
+
'X-PAYMENT': paymentHeader,
|
|
364
|
+
},
|
|
365
|
+
body: JSON.stringify(requestBody),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (!response.ok) {
|
|
369
|
+
const errorText = await response.text();
|
|
370
|
+
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = await response.json() as {
|
|
374
|
+
jsonrpc: string;
|
|
375
|
+
result?: { content: Array<{ type: string; text: string }> };
|
|
376
|
+
error?: { code: number; message: string; data?: unknown };
|
|
377
|
+
id: number;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (result.error) {
|
|
381
|
+
throw new Error(`RPC Error: ${result.error.message}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return result.result || { content: [{ type: 'text', text: 'Success' }] };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
main().catch((error) => {
|
|
388
|
+
console.error('Failed to start nullpath MCP client:', error);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|