Fetch L402-protected URL
l402_fetchFetch URLs protected by the L402 protocol, automatically handling Bitcoin Lightning payments from your session budget.
Instructions
Fetch a URL that may require a Bitcoin Lightning payment (L402 protocol). Side effect: deducts sats from the session budget when a payment is required — check l402_balance first if budget is limited. Flow: sends request → if 402 received, pays the Lightning invoice (1 attempt) → retries once with payment proof → returns response body as text. Fails with error if: budget is exhausted, URL is unreachable, or the Lightning payment fails. Do NOT use for regular (non-L402) URLs — use a standard fetch tool instead. Do NOT use if l402_balance shows 0 sats remaining.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| url | Yes | The URL to fetch (http or https) | |
| method | No | HTTP method — GET, POST, PUT, DELETE, PATCH. Default: GET | |
| body | No | Request body as string (for POST/PUT requests) | |
| headers | No | Additional HTTP request headers as key-value pairs |
Implementation Reference
- mcp/server.ts:131-161 (handler)The MCP tool handler function for 'l402_fetch'. Calls L402Client.fetch() with the provided url, method, body, and headers, then returns the response text with a '[Paid X sats]' prefix if a payment was made.
async ({ url, method, body, headers }) => { const httpMethod = (method ?? "GET").toUpperCase(); try { const res = await requireClient().fetch(url, { method: httpMethod, body, headers: (headers ?? {}) as Record<string, string>, }); const text = await res.text(); const report = requireClient().spendingReport(); const spent = report?.transactions.at(-1)?.sats ?? 0; return { content: [ { type: "text" as const, text: spent > 0 ? `[Paid ${spent} sats] HTTP ${res.status}\n\n${text}` : `HTTP ${res.status}\n\n${text}`, }, ], }; } catch (err) { return { content: [{ type: "text" as const, text: `Error: ${String(err)}` }], isError: true, }; } } ); - mcp/server.ts:119-129 (schema)Input schema for the l402_fetch tool, defining url (required), method, body, and headers (all optional) using Zod validators.
inputSchema: { url: z.string().describe("The URL to fetch (http or https)"), method: z.string().optional().describe("HTTP method — GET, POST, PUT, DELETE, PATCH. Default: GET"), body: z.string().optional().describe("Request body as string (for POST/PUT requests)"), headers: z.record(z.string(), z.string()).optional().describe("Additional HTTP request headers as key-value pairs"), }, annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: true, }, - mcp/server.ts:107-161 (registration)Registration of the 'l402_fetch' tool via server.registerTool(), including its title, description, inputSchema, annotations, and the handler callback.
// Tool: l402_fetch server.registerTool( "l402_fetch", { title: "Fetch L402-protected URL", description: "Fetch a URL that may require a Bitcoin Lightning payment (L402 protocol). " + "Side effect: deducts sats from the session budget when a payment is required — check l402_balance first if budget is limited. " + "Flow: sends request → if 402 received, pays the Lightning invoice (1 attempt) → retries once with payment proof → returns response body as text. " + "Fails with error if: budget is exhausted, URL is unreachable, or the Lightning payment fails. " + "Do NOT use for regular (non-L402) URLs — use a standard fetch tool instead. " + "Do NOT use if l402_balance shows 0 sats remaining.", inputSchema: { url: z.string().describe("The URL to fetch (http or https)"), method: z.string().optional().describe("HTTP method — GET, POST, PUT, DELETE, PATCH. Default: GET"), body: z.string().optional().describe("Request body as string (for POST/PUT requests)"), headers: z.record(z.string(), z.string()).optional().describe("Additional HTTP request headers as key-value pairs"), }, annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: true, }, }, async ({ url, method, body, headers }) => { const httpMethod = (method ?? "GET").toUpperCase(); try { const res = await requireClient().fetch(url, { method: httpMethod, body, headers: (headers ?? {}) as Record<string, string>, }); const text = await res.text(); const report = requireClient().spendingReport(); const spent = report?.transactions.at(-1)?.sats ?? 0; return { content: [ { type: "text" as const, text: spent > 0 ? `[Paid ${spent} sats] HTTP ${res.status}\n\n${text}` : `HTTP ${res.status}\n\n${text}`, }, ], }; } catch (err) { return { content: [{ type: "text" as const, text: `Error: ${String(err)}` }], isError: true, }; } } ); - src/client.ts:105-198 (helper)The L402Client.fetch() method — the core runtime logic handling the L402 flow: cached tokens, initial request, 402 parsing, payment via wallet, token caching, and retry with credentials.
async fetch(url: string, init: RequestInit = {}): Promise<Response> { // Try cached token first const cached = this.tokenStore.get(url); if (cached) { const res = await this._fetchWithToken(url, init, cached.macaroon, cached.preimage); if (res.status !== 402) return res; // Token rejected — clear cache and fall through to fresh payment this.tokenStore.set(url, { macaroon: "", preimage: "" }); } // Initial unauthenticated request const res402 = await fetch(url, init); if (res402.status !== 402) return res402; // Parse 402 response — supports both L402 and x402 formats const { macaroon, invoice, priceSats } = await this._parse402(res402); // Check budget before paying if (this.budget && priceSats !== undefined) { this.budget.check(url, priceSats); } // Pay invoice let preimage: string; try { const result = await this.wallet.payInvoice(invoice); preimage = result.preimage; } catch (err) { throw new L402PaymentError(`Payment failed: ${String(err)}`, invoice); } // Record spend if (this.budget && priceSats !== undefined && priceSats > 0) { this.budget.record(url, priceSats); } // Cache token for future calls this.tokenStore.set(url, { macaroon, preimage }); // Retry with credentials for (let attempt = 0; attempt < this.maxRetries; attempt++) { const retryRes = await this._fetchWithToken(url, init, macaroon, preimage); if (retryRes.status !== 402) return retryRes; } throw new L402PaymentError("Server rejected payment after retries", invoice); } private async _fetchWithToken( url: string, init: RequestInit, macaroon: string, preimage: string, ): Promise<Response> { return fetch(url, { ...init, headers: { ...(init.headers ?? {}), "Authorization": `L402 ${macaroon}:${preimage}`, }, }); } private async _parse402(res: Response): Promise<{ macaroon: string; invoice: string; priceSats?: number }> { let body: Record<string, unknown> = {}; try { body = await res.clone().json() as Record<string, unknown>; } catch { /* non-JSON 402 */ } // L402 format: { macaroon, invoice, priceSats } const macaroon = body.macaroon as string | undefined; const invoice = (body.invoice ?? body.paymentRequest ?? body.payment_request) as string | undefined; const priceSats = (body.priceSats ?? body.price_sats) as number | undefined; // x402 (Coinbase) format: X-Payment-Required header with JSON if (!macaroon || !invoice) { const xHeader = res.headers.get("X-Payment-Required") ?? res.headers.get("x-payment-required"); if (xHeader) { try { const xData = JSON.parse(xHeader) as Record<string, unknown>; const xInvoice = xData.invoice as string | undefined; const xMacaroon = xData.macaroon ?? xData.token ?? xData.payment_token; if (xInvoice && xMacaroon) { return { macaroon: String(xMacaroon), invoice: xInvoice }; } } catch { /* malformed header */ } } } if (!macaroon) throw new L402ParseError("402 response missing macaroon field", body); if (!invoice) throw new L402ParseError("402 response missing invoice field", body); return { macaroon, invoice, priceSats }; } } - python/l402kit/langchain.py:31-35 (schema)Python Pydantic input schema (_L402FetchInput) for the l402_fetch LangChain tool, with url, method, and body fields.
class _L402FetchInput(BaseModel): url: str = Field(description="The URL to fetch. Must be an L402-protected API endpoint.") method: str = Field(default="GET", description="HTTP method: GET, POST, PUT, DELETE") body: Optional[str] = Field(default=None, description="Request body as JSON string (for POST/PUT)")