diff --git a/mcp-server/index.ts b/mcp-server/index.ts index 3b7f457..c1b2fcb 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -40,6 +40,16 @@ import { USDC_CONTRACT, AMOUNT_STROOPS, } from '../src/lib/constants' +import { formatReceipt } from './receipt' +import type { + SearchResponse, + ImageSearchResponse, + NewsSearchResponse, + ApiErrorResponse, + SearchResult, + ImageResult, + NewsResult, +} from '../src/types/index.js' import { formatConfigurationError, readMcpConfig } from '../src/lib/config' dotenv.config() @@ -230,6 +240,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ name: 'web_search', description: `Search the web via StellarSearch. Automatically pays ${AMOUNT_USDC} USDC on Stellar (x402 protocol). The server handles the full payment flow: HTTP 402 โ sign Soroban auth โ settle โ return results. +Paid responses include a verifiable x402 receipt with on-chain transaction hash and Stellar Expert explorer link. Use for current events, documentation, research, or anything needing up-to-date web information.`, inputSchema: { type: 'object', @@ -245,6 +256,7 @@ Use for current events, documentation, research, or anything needing up-to-date name: 'image_search', description: `Search the web for images via StellarSearch. Automatically pays ${AMOUNT_USDC} USDC on Stellar (x402 protocol). Returns image URLs, titles, and source domains via the Serper.dev images API. +Paid responses include a verifiable x402 receipt with on-chain transaction hash and Stellar Expert explorer link. Use for visual references, photos, diagrams, or anything where you need image results.`, inputSchema: { type: 'object', @@ -259,6 +271,7 @@ Use for visual references, photos, diagrams, or anything where you need image re name: 'news_search', description: `Search recent news articles via StellarSearch. Automatically pays ${AMOUNT_USDC} USDC on Stellar (x402 protocol). Returns articles with title, URL, snippet, publication date, and source via the Serper.dev news API. +Paid responses include a verifiable x402 receipt with on-chain transaction hash and Stellar Expert explorer link. Use for breaking stories, current events, and time-sensitive reporting.`, inputSchema: { type: 'object', @@ -484,6 +497,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(e.error || `HTTP ${res.status}`) } + const data = await res.json() as any + const receipt = formatReceipt({ + txHash: data.txHash, + paidAmount: data.paidAmount, + currency: data.currency, + network: data.network, + x402Version: data.x402Version, + }) + const data = (await res.json()) as SearchResponse + const formatted = data.results + .map((r: SearchResult, i: number) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.description}`) const data = (await res.json()) as any // Record receipt (opted-in only, bounded) try { @@ -520,6 +544,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { cleanup() return { + content: [{ + type: 'text', + text: [ + `๐ Results for: "${query}"`, + `๐ฐ Paid: ${data.paidAmount} ${data.currency} on ${data.network}`, + ...receipt.lines, + `โก Latency: ${data.latencyMs}ms`, + `๐ ${data.count} results\n`, + formatted, + ].join('\n'), + }], content: [ { type: 'text', @@ -563,6 +598,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(e.error || `HTTP ${res.status}`) } + const data: any = await res.json() + const receipt = formatReceipt({ + txHash: data.txHash, + paidAmount: data.paidAmount, + currency: data.currency, + network: data.network, + x402Version: data.x402Version, + }) + const data = (await res.json()) as ImageSearchResponse + const formatted = data.results + .map((r: ImageResult, i: number) => `${i + 1}. **${r.title}**\n Image: ${r.imageUrl}\n Source: ${r.sourceUrl} (${r.source})`) const data = (await res.json()) as any addMcpReceipt({ id: data.txHash || `${Date.now()}-img-${query.slice(0, 6)}`, @@ -581,6 +627,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { cleanup() return { + content: [{ + type: 'text', + text: [ + `๐ผ๏ธ Image results for: "${query}"`, + `๐ฐ Paid: ${data.paidAmount} ${data.currency} on ${data.network}`, + ...receipt.lines, + `โก Latency: ${data.latencyMs}ms`, + `๐ ${data.count} results\n`, + formatted, + ].join('\n'), + }], content: [ { type: 'text', @@ -631,6 +688,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(e.error || `HTTP ${res.status}`) } + const data: any = await res.json() + const receipt = formatReceipt({ + txHash: data.txHash, + paidAmount: data.paidAmount, + currency: data.currency, + network: data.network, + x402Version: data.x402Version, + }) + const data = (await res.json()) as NewsSearchResponse + const formatted = data.results + .map((r: NewsResult, i: number) => { const data = (await res.json()) as any addMcpReceipt({ id: data.txHash || `${Date.now()}-news-${query.slice(0, 6)}`, @@ -652,6 +720,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { cleanup() return { + content: [{ + type: 'text', + text: [ + `๐ฐ News results for: "${query}"`, + `๐ฐ Paid: ${data.paidAmount} ${data.currency} on ${data.network}`, + ...receipt.lines, + `โก Latency: ${data.latencyMs}ms`, + `๐ ${data.count} results\n`, + formatted, + ].join('\n'), + }], content: [ { type: 'text', diff --git a/mcp-server/receipt.test.ts b/mcp-server/receipt.test.ts new file mode 100644 index 0000000..55ba766 --- /dev/null +++ b/mcp-server/receipt.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest' +import { formatReceipt, explorerTxUrl, PaymentReceiptData } from './receipt' +import { STELLAR_EXPERT_URL, STELLAR_NETWORK } from '../src/lib/constants' + +describe('mcp-server/receipt โ x402 receipt metadata', () => { + describe('explorerTxUrl', () => { + it('builds a Stellar Expert tx URL using the configured network', () => { + const hash = 'a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcdef' + const url = explorerTxUrl(hash) + expect(url).toBe(`${STELLAR_EXPERT_URL}/tx/${hash}`) + }) + + it('contains /tx/ path and starts with https', () => { + const url = explorerTxUrl('deadbeef') + expect(url).toContain('/tx/deadbeef') + expect(url.startsWith('https://')).toBe(true) + }) + }) + + describe('formatReceipt โ verified receipt (txHash present)', () => { + const verifiedData: PaymentReceiptData = { + txHash: 'a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcdef', + paidAmount: '0.001', + currency: 'USDC', + network: 'stellar:testnet', + x402Version: 2, + } + + it('returns verified: true when txHash is present', () => { + const result = formatReceipt(verifiedData) + expect(result.verified).toBe(true) + }) + + it('includes the full transaction hash in output', () => { + const result = formatReceipt(verifiedData) + const txLine = result.lines.find(l => l.startsWith(' Tx:')) + expect(txLine).toBe(` Tx: ${verifiedData.txHash}`) + }) + + it('includes the Stellar Expert explorer URL', () => { + const result = formatReceipt(verifiedData) + const explorerLine = result.lines.find(l => l.startsWith(' Explorer:')) + expect(explorerLine).toContain('stellar.expert') + expect(explorerLine).toContain(verifiedData.txHash!) + }) + + it('includes the x402 protocol version', () => { + const result = formatReceipt(verifiedData) + const receiptLine = result.lines.find(l => l.startsWith('๐ Receipt:')) + expect(receiptLine).toContain('x402 v2') + }) + + it('includes the network', () => { + const result = formatReceipt(verifiedData) + const networkLine = result.lines.find(l => l.startsWith(' Network:')) + expect(networkLine).toBe(' Network: stellar:testnet') + }) + + it('defaults x402 version to 2 when not provided', () => { + const data: PaymentReceiptData = { + txHash: 'abc123', + network: 'stellar:testnet', + } + const result = formatReceipt(data) + const receiptLine = result.lines.find(l => l.startsWith('๐ Receipt:')) + expect(receiptLine).toContain('x402 v2') + }) + + it('uses STELLAR_NETWORK when network field is omitted', () => { + const data: PaymentReceiptData = { txHash: 'abc123' } + const result = formatReceipt(data) + const networkLine = result.lines.find(l => l.startsWith(' Network:')) + expect(networkLine).toBe(` Network: ${STELLAR_NETWORK}`) + }) + }) + + describe('formatReceipt โ unverified receipt (missing txHash)', () => { + it('returns verified: false when txHash is null', () => { + const result = formatReceipt({ txHash: null }) + expect(result.verified).toBe(false) + }) + + it('returns verified: false when txHash is undefined', () => { + const result = formatReceipt({}) + expect(result.verified).toBe(false) + }) + + it('returns verified: false when txHash is empty string', () => { + const result = formatReceipt({ txHash: ' ' }) + expect(result.verified).toBe(false) + }) + + it('includes an explicit unverified warning', () => { + const result = formatReceipt({ txHash: null }) + const receiptLine = result.lines.find(l => l.startsWith('๐ Receipt:')) + expect(receiptLine).toContain('โ ๏ธ') + expect(receiptLine).toContain('Unverified') + expect(receiptLine).toContain('no on-chain settlement proof returned') + }) + + it('shows amount and currency when available for unverified receipts', () => { + const data: PaymentReceiptData = { + txHash: null, + paidAmount: '0.001', + currency: 'USDC', + network: 'stellar:testnet', + } + const result = formatReceipt(data) + const amountLine = result.lines.find(l => l.startsWith(' Amount:')) + expect(amountLine).toBe(' Amount: 0.001 USDC') + }) + + it('omits amount line when paidAmount or currency is missing', () => { + const data: PaymentReceiptData = { txHash: null } + const result = formatReceipt(data) + const amountLine = result.lines.find(l => l.startsWith(' Amount:')) + expect(amountLine).toBeUndefined() + }) + + it('always includes at least one line', () => { + const result = formatReceipt({}) + expect(result.lines.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('formatReceipt โ edge cases', () => { + it('trims whitespace from txHash before deciding verification', () => { + const result = formatReceipt({ txHash: ' abc123 ' }) + expect(result.verified).toBe(true) + const txLine = result.lines.find(l => l.startsWith(' Tx:')) + expect(txLine).toBe(' Tx: abc123') + }) + + it('handles all fields provided with different x402 version', () => { + const data: PaymentReceiptData = { + txHash: 'ff00ff00', + paidAmount: '0.01', + currency: 'USDC', + network: 'stellar:mainnet', + x402Version: 1, + } + const result = formatReceipt(data) + expect(result.verified).toBe(true) + const receiptLine = result.lines.find(l => l.startsWith('๐ Receipt:')) + expect(receiptLine).toContain('x402 v1') + const networkLine = result.lines.find(l => l.startsWith(' Network:')) + expect(networkLine).toBe(' Network: stellar:mainnet') + }) + }) +}) diff --git a/mcp-server/receipt.ts b/mcp-server/receipt.ts new file mode 100644 index 0000000..38fa66f --- /dev/null +++ b/mcp-server/receipt.ts @@ -0,0 +1,82 @@ +/** + * receipt.ts โ x402 receipt formatting for StellarSearch MCP tools. + * + * Surfaces verifiable payment receipt metadata (transaction hash, + * explorer URL, x402 protocol version) from server responses, and + * explicitly represents missing/unverified receipts. + */ + +import { STELLAR_EXPERT_URL, STELLAR_NETWORK } from '../src/lib/constants' + +/** Minimal shape of the receipt fields returned by the StellarSearch API. */ +export interface PaymentReceiptData { + /** On-chain transaction hash (hex string), if settlement completed. */ + txHash?: string | null + /** Amount paid per query. */ + paidAmount?: string | null + /** Currency code (e.g. "USDC"). */ + currency?: string | null + /** Network identifier (e.g. "stellar:testnet"). */ + network?: string | null + /** x402 protocol version used for settlement, if available. */ + x402Version?: number | null +} + +export interface ReceiptResult { + /** Whether the receipt has a verifiable on-chain transaction. */ + verified: boolean + /** Formatted receipt lines for MCP text output (always โฅ1 line). */ + lines: string[] +} + +/** + * Builds the Stellar Expert explorer URL for a given transaction hash. + * Uses the currently-configured network (testnet/mainnet) from constants. + */ +export function explorerTxUrl(txHash: string): string { + return `${STELLAR_EXPERT_URL}/tx/${txHash}` +} + +/** + * Formats x402 receipt metadata from a server response into human-readable + * MCP output lines. + * + * When `txHash` is present and non-empty, the receipt is marked as + * **Verified** with a clickable explorer URL. When the hash is missing, + * the receipt is explicitly marked as **โ ๏ธ Unverified** so consumers + * never confuse a missing settlement proof with a confirmed one. + * + * @param data - The payment-related fields from the server JSON response. + * @returns ReceiptResult containing a verified flag and formatted text lines. + */ +export function formatReceipt(data: PaymentReceiptData): ReceiptResult { + const network = data.network || STELLAR_NETWORK + const txHash = data.txHash?.trim() || null + const version = data.x402Version ?? 2 + + if (txHash) { + const url = explorerTxUrl(txHash) + return { + verified: true, + lines: [ + `๐ Receipt: โ Verified (x402 v${version})`, + ` Tx: ${txHash}`, + ` Explorer: ${url}`, + ` Network: ${network}`, + ], + } + } + + // Explicitly represent the missing-receipt case rather than + // silently treating it as a successful paid response. + return { + verified: false, + lines: [ + `๐ Receipt: โ ๏ธ Unverified โ no on-chain settlement proof returned`, + ` Network: ${network}`, + ...(data.paidAmount && data.currency + ? [` Amount: ${data.paidAmount} ${data.currency}`] + : []), + ], + } +} diff --git a/src/components/ai/GroqAssistant.tsx b/src/components/ai/GroqAssistant.tsx index 4779c96..7ca2ee8 100644 --- a/src/components/ai/GroqAssistant.tsx +++ b/src/components/ai/GroqAssistant.tsx @@ -259,11 +259,12 @@ export function GroqAssistant({ lastSearch }: Props = {}) {
No transactions yet
) : transactions.length === 0 ? ({t('noTransactions')}
) : ( @@ -197,7 +204,7 @@ export function WalletPanel({ className="flex items-center justify-between py-1.5 px-2 rounded bg-white/3 hover:bg-white/5 transition-colors" >+
{tx.type.replace('_', ' ')}
PUBLIC KEY
+PUBLIC KEY
{publicKey}
USDC BALANCE
+USDC BALANCE
{usdcBalance}
-+
{Math.floor(parseFloat(usdcBalance) / parseFloat(AMOUNT_USDC)).toLocaleString()} queries remaining
XLM BALANCE
+XLM BALANCE
{xlmBalance}
-for network fees
+for network fees
Connect your Freighter wallet to see live account data
+Connect your Freighter wallet to see live account data
)} @@ -138,7 +139,7 @@ export function DashboardPage({ transactions, txLoading, publicKey, usdcBalance, >NO TRANSACTIONS YET
- {!publicKey &&Connect your wallet to see your history
} +NO TRANSACTIONS YET
+ {!publicKey &&Connect your wallet to see your history
}NO SEARCH RECEIPTS YET
-Perform a search to see your payment history
+NO SEARCH RECEIPTS YET
+Perform a search to see your payment history
{receipt.amount} USDC
-+
{receipt.network.split(':')[1]}
{label}
+{label}
{value}
-{sub}
+{sub}
{t('kicker', 'DOCUMENTATION')}
@@ -80,7 +83,7 @@ export function DocsPage() {
href={href}
target="_blank"
rel="noopener noreferrer"
- className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg font-display text-xs tracking-wider text-white/40 hover:text-neon-cyan transition-all"
+ className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg font-display text-xs tracking-wider text-white/55 hover:text-neon-cyan transition-all"
style={{ border: '1px solid rgba(255,255,255,0.08)' }}
>
{label} {step.desc} {step.desc}
+
Built for the Agents on Stellar hackathon (March 30 โ April 13, 2026). Addresses the explicit
demand signal: pay-per-query web search instead of monthly subscriptions. Uses real x402 protocol,
real Stellar testnet transactions, real search results, and real Groq AI โ zero mock data.
diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx
index 8906f76..1a4db16 100644
--- a/src/pages/SearchPage.tsx
+++ b/src/pages/SearchPage.tsx
@@ -97,7 +97,7 @@ export function SearchPage({ wallet, onConnectWallet, session, search, reset }:
GET
-
+
Real web search for AI agents.{' '}
{AMOUNT_USDC} USDC per query settled on Stellar via x402.
Powered by Serper.dev +{' '}
Payment flow
{step.title}
{step.code}
@@ -137,7 +140,7 @@ export function DocsPage() {
{/* Real stack */}
Technology used
STELLAR HACKATHON 2026 ยท AGENTS ON STELLAR
-