Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Run this local MCP only if you need to:
- Study a working reference implementation of a Bitrefill MCP server.
- Fork it to add custom tools, prompts, validation, logging, or routing.
- Self-host inside a private network or air-gapped environment.
- Experiment with a wider set of v2 endpoints (this sample exposes 18 tools, while the official remote MCP intentionally exposes a curated set of 7; see [eCommerce MCP](https://docs.bitrefill.com/docs/ecommerce-mcp#available-tools)).
- Experiment with a wider set of v2 endpoints and custom routing tools (this sample exposes 19 tools, while the official remote MCP intentionally exposes a curated set of 7; see [eCommerce MCP](https://docs.bitrefill.com/docs/ecommerce-mcp#available-tools)).

For everyday "buy gift cards / eSIMs from my AI assistant" use cases, prefer the hosted server above.

Expand All @@ -53,6 +53,7 @@ If `BITREFILL_API_KEY` is missing, **no tools** are registered (v2 requires auth
| Tool | API |
|------|-----|
| `search-products` | `GET /products/search` (with `q`) or `GET /products` (browse) |
| `resolve-spend-intent` | Planner: maps broad user goals to Bitrefill-purchasable needs and product candidates |
| `product-details` | `GET /products/{id}` |
| `buy-products` | `POST /invoices` |
| `get-invoice-by-id` | `GET /invoices/{id}` |
Expand All @@ -73,6 +74,16 @@ If `BITREFILL_API_KEY` is missing, **no tools** are registered (v2 requires auth

**Breaking change vs 0.x:** old snake_case tool names (`search`, `create_invoice`, `unseal_order`, ...) were removed. Use the names above. There is no `unseal_order` in v2; `GET /orders/{id}` returns `redemption_info` when delivered.

### Spend intent planning

`resolve-spend-intent` is a high-level planning tool for agentic commerce flows where the user describes the outcome, not the payment rail. For example, an agent can call it for:

```text
I'm travelling to Germany next month. Plan what I need.
```

The tool resolves practical spendable needs such as eSIM data, flights, lodging, local transport, food delivery, groceries, entertainment, and backup spend, then returns matching Bitrefill product candidates and safe next tool calls. It does **not** buy anything; agents should call `product-details` for a selected candidate and only call `buy-products` after explicit user approval of the product, amount, payment method, and total price.

## Resources

- `bitrefill://payment-methods`: allowed `payment_method` strings for `buy-products` / `create-esim-invoice`
Expand All @@ -99,6 +110,7 @@ src/
pnpm install
pnpm run build
pnpm run typecheck
pnpm run test:spend-intent
pnpm run lint
```

Expand All @@ -122,7 +134,7 @@ pnpm run build
pnpm run smoke:inspector
```

**All 18 tools** (Inspector CLI, summary lines, dummy ids on purpose):
**All 19 tools** (Inspector CLI, summary lines, dummy ids on purpose):

```bash
pnpm run test:inspector:all-tools
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"prepare": "pnpm run build",
"watch": "tsc --watch",
"typecheck": "tsc --noEmit",
"test:spend-intent": "pnpm run build && node scripts/test-spend-intent.mjs",
"lint": "eslint . --cache --cache-location .cache/eslintcache",
"lint:ci": "eslint . --cache --cache-location .cache/eslintcache --cache-strategy content",
"inspector": "pnpm dlx @modelcontextprotocol/inspector node build/index.js"
Expand Down
129 changes: 129 additions & 0 deletions scripts/test-spend-intent.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';

import {
detectSpendContexts,
resolveCountry,
selectNeeds,
SpendIntentService,
} from '../build/services/spendIntent.js';

const germanyTripCountry = resolveCountry('I am travelling to Germany next month');
assert.equal(germanyTripCountry.code, 'DE');
assert.equal(germanyTripCountry.confidence, 'inferred');

const germanyTripContexts = detectSpendContexts(
'I am travelling to Germany next month, plan my itinerary'
);
assert.deepEqual(germanyTripContexts, ['travel']);

const travelNeeds = selectNeeds(germanyTripContexts).map((need) => need.id);
assert.ok(travelNeeds.includes('connectivity'));
assert.ok(travelNeeds.includes('lodging'));
assert.ok(travelNeeds.includes('food_delivery'));
assert.ok(travelNeeds.includes('general_spend'));

const portugalCountry = resolveCountry('buy groceries for tonight in Portugal');
assert.equal(portugalCountry.code, 'PT');

const streamingNeeds = selectNeeds(detectSpendContexts('renew Netflix for my account')).map(
(need) => need.id
);
assert.deepEqual(streamingNeeds, ['streaming']);

const explicitCountry = resolveCountry('buy groceries in Lisbon', 'DE');
assert.equal(explicitCountry.code, 'DE');
assert.equal(explicitCountry.confidence, 'explicit');

const deutschlandCountry = resolveCountry('Ich reise nach Deutschland');
assert.equal(deutschlandCountry.code, 'DE');

process.env.BITREFILL_API_KEY = 'test-key';
const calls = [];
globalThis.fetch = async (url, options) => {
const parsedUrl = new URL(String(url));
calls.push({
endpoint: parsedUrl.pathname,
category: parsedUrl.searchParams.get('category'),
country: parsedUrl.searchParams.get('country'),
authorization: options?.headers?.authorization,
});

let data = [];

if (parsedUrl.pathname === '/v2/products/esims') {
data = [
product({
id: 'bitrefill-esim-germany',
name: 'Germany eSIM',
categories: ['esim'],
packages: [{ id: '1GB', value: '1GB, 7 Days', price: 5 }],
}),
];
} else if (parsedUrl.searchParams.get('category')?.includes('groceries')) {
data = [
product({
id: 'rewe-germany',
name: 'REWE Gift Card',
categories: ['groceries'],
packages: [{ id: '25', value: '25', price: 25 }],
}),
product({
id: 'other-grocery',
name: 'Other Grocery',
categories: ['groceries'],
in_stock: false,
}),
];
} else if (parsedUrl.searchParams.get('category')?.includes('travel')) {
data = [
product({
id: 'airbnb-germany',
name: 'Airbnb Gift Card',
categories: ['travel'],
packages: [{ id: '100', value: '100', price: 100 }],
}),
];
}

return new Response(JSON.stringify({ meta: { _endpoint: parsedUrl.pathname }, data }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
};

const resolved = await SpendIntentService.resolveSpendIntent({
intent: 'I am travelling to Germany next month, plan what I need',
max_results_per_need: 2,
});

assert.equal(resolved.country.code, 'DE');
assert.equal(resolved.needs.find((need) => need.id === 'connectivity').candidates[0].product_id, 'bitrefill-esim-germany');
assert.equal(resolved.needs.find((need) => need.id === 'groceries').candidates[0].product_id, 'rewe-germany');
assert.ok(
resolved.needs
.find((need) => need.id === 'lodging')
.candidates.some((candidate) => candidate.product_id === 'airbnb-germany')
);
assert.ok(resolved.agent_instructions.join(' ').includes('Never call buy-products'));
assert.ok(calls.every((call) => call.authorization === 'Bearer test-key'));
assert.ok(calls.some((call) => call.endpoint === '/v2/products/esims'));
assert.ok(calls.every((call) => call.country === 'DE'));

console.log('spend intent tests passed');

function product(overrides) {
return {
id: 'sample-product',
name: 'Sample Product',
country_code: 'DE',
country_name: 'Germany',
currency: 'EUR',
categories: [],
created_time: '',
recipient_type: 'email',
image: '',
in_stock: true,
packages: [],
...overrides,
};
}
13 changes: 13 additions & 0 deletions src/handlers/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import { ProductIdInput } from '../schemas/detail.js';
import { BuyProductsInput, InvoiceListInput, InvoiceIdInput } from '../schemas/invoice.js';
import { OrderListInput, OrderIdInput } from '../schemas/order.js';
import { CheckPhoneNumberInput } from '../schemas/misc.js';
import { ResolveSpendIntentInput } from '../schemas/spend-intent.js';
import {
CreateEsimInvoiceInput,
EsimListInput,
EsimProductIdInput,
EsimIdInput,
EsimInvoiceIdInput,
} from '../schemas/esim.js';
import { SpendIntentService } from '../services/spendIntent.js';

function jsonText(data: unknown): { type: 'text'; text: string } {
return { type: 'text', text: JSON.stringify(data, null, 2) };
Expand Down Expand Up @@ -64,6 +66,17 @@ export function registerToolHandlers(server: McpServer): void {
(args) => runTool(() => SearchService.searchProducts(args))
);

server.registerTool(
'resolve-spend-intent',
{
title: 'Resolve spend intent',
description:
'Map a broad real-world user goal (travel planning, errands, gifts, subscriptions, gaming, shopping) to practical Bitrefill-purchasable needs and product candidates. Use this when the user did not explicitly ask for Bitrefill but the task may include things that can be bought, funded, or prepared through Bitrefill. This tool only plans; buy-products remains the explicit purchase boundary.',
inputSchema: ResolveSpendIntentInput,
},
(args) => runTool(() => SpendIntentService.resolveSpendIntent(args))
);

server.registerTool(
'product-details',
{
Expand Down
60 changes: 60 additions & 0 deletions src/schemas/spend-intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Zod input for high-level spend intent resolution.
*/
import * as z from 'zod/v4';

export const ResolveSpendIntentInput = {
intent: z
.string()
.min(3)
.max(2000)
.describe(
'Natural-language user goal, e.g. "I am travelling to Germany, plan what I need"'
),
country: z
.string()
.min(2)
.max(80)
.optional()
.describe(
'Destination or purchase country as ISO alpha-2 code or country name. If omitted, the tool tries to infer it from intent.'
),
city: z
.string()
.min(1)
.max(120)
.optional()
.describe('Destination or purchase city, used only as context for routing.'),
trip_start: z
.string()
.optional()
.describe('Optional trip start date as YYYY-MM-DD.'),
trip_end: z
.string()
.optional()
.describe('Optional trip end date as YYYY-MM-DD.'),
budget: z
.number()
.positive()
.optional()
.describe('Optional total budget for the user goal. This tool does not purchase.'),
currency: z
.string()
.length(3)
.optional()
.describe('Optional ISO 4217 currency code for budget context.'),
max_results_per_need: z
.number()
.int()
.positive()
.max(8)
.default(4)
.describe('Maximum product candidates returned for each detected need.'),
include_test_products: z
.boolean()
.optional()
.describe('Include Bitrefill test products in candidate searches.'),
};

export const ResolveSpendIntentInputSchema = z.object(ResolveSpendIntentInput);
export type ResolveSpendIntentInput = z.infer<typeof ResolveSpendIntentInputSchema>;
Loading