Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions mcp-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

dotenv.config()

Expand Down Expand Up @@ -208,6 +218,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',
Expand All @@ -223,6 +234,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',
Expand All @@ -237,6 +249,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',
Expand Down Expand Up @@ -462,6 +475,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 {
Expand All @@ -483,6 +507,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',
Expand Down Expand Up @@ -529,6 +564,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)}`,
Expand All @@ -547,6 +593,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',
Expand Down Expand Up @@ -597,6 +654,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)}`,
Expand All @@ -618,6 +686,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',
Expand Down
150 changes: 150 additions & 0 deletions mcp-server/receipt.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
82 changes: 82 additions & 0 deletions mcp-server/receipt.ts
Original file line number Diff line number Diff line change
@@ -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}`]
: []),
],
}
}
Loading