@furlpay/travel-mcp 0.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/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/furlpay.d.ts +24 -0
- package/dist/furlpay.js +116 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +27 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +87 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +94 -0
- package/dist/travala.d.ts +23 -0
- package/dist/travala.js +153 -0
- package/dist/travel.d.ts +67 -0
- package/dist/travel.js +154 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +2 -0
- package/package.json +45 -0
- package/src/furlpay.ts +126 -0
- package/src/index.ts +6 -0
- package/src/server.ts +85 -0
- package/src/tools.ts +102 -0
- package/src/travala.ts +169 -0
- package/src/travel.ts +184 -0
- package/src/types.ts +79 -0
package/dist/travala.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TravalaClient = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Thin client for the Travala Travel MCP (search side of the composition).
|
|
6
|
+
*
|
|
7
|
+
* Travala's Agentic AI Travel Protocol exposes 2.2M+ hotels (Marriott, Hilton,
|
|
8
|
+
* IHG, …) — expanding to flights — to AI agents, settling in gasless USDC over
|
|
9
|
+
* x402 on Base. This wrapper is search-only; payment/settlement is FurlPay's job.
|
|
10
|
+
*
|
|
11
|
+
* With no key set it returns deterministic demo inventory so the whole
|
|
12
|
+
* search → pay → book loop runs offline.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_MCP = "https://travel-mcp.travala.com/mcp";
|
|
15
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
16
|
+
function hash(s) {
|
|
17
|
+
let h = 2166136261;
|
|
18
|
+
for (let i = 0; i < s.length; i++) {
|
|
19
|
+
h ^= s.charCodeAt(i);
|
|
20
|
+
h = Math.imul(h, 16777619);
|
|
21
|
+
}
|
|
22
|
+
return h >>> 0;
|
|
23
|
+
}
|
|
24
|
+
const HOTEL_BRANDS = ["Marriott", "Hilton", "IHG", "Accor", "Hyatt"];
|
|
25
|
+
function nightsBetween(checkIn, checkOut) {
|
|
26
|
+
const a = Date.parse(checkIn);
|
|
27
|
+
const b = Date.parse(checkOut);
|
|
28
|
+
if (isNaN(a) || isNaN(b) || b <= a)
|
|
29
|
+
return 1;
|
|
30
|
+
return Math.max(1, Math.round((b - a) / 86400000));
|
|
31
|
+
}
|
|
32
|
+
class TravalaClient {
|
|
33
|
+
constructor(url = process.env.TRAVALA_MCP_URL || DEFAULT_MCP, apiKey, fetchImpl = globalThis.fetch) {
|
|
34
|
+
this.url = url;
|
|
35
|
+
this.apiKey = apiKey;
|
|
36
|
+
this.fetchImpl = fetchImpl;
|
|
37
|
+
}
|
|
38
|
+
get live() {
|
|
39
|
+
return Boolean(this.apiKey);
|
|
40
|
+
}
|
|
41
|
+
async searchStays(params) {
|
|
42
|
+
const nights = nightsBetween(params.checkIn, params.checkOut);
|
|
43
|
+
if (!this.live)
|
|
44
|
+
return this.demoStays(params, nights);
|
|
45
|
+
try {
|
|
46
|
+
const rows = await this.call("search_stays", { ...params });
|
|
47
|
+
const stays = rows.map((r) => normaliseStay(r, params, nights));
|
|
48
|
+
return params.maxNightlyUsd ? stays.filter((s) => s.nightlyUsd <= params.maxNightlyUsd) : stays;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return this.demoStays(params, nights);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async searchFlights(params) {
|
|
55
|
+
if (!this.live)
|
|
56
|
+
return this.demoFlights(params);
|
|
57
|
+
try {
|
|
58
|
+
const rows = await this.call("search_flights", { ...params });
|
|
59
|
+
return rows.map((r) => normaliseFlight(r, params));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return this.demoFlights(params);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// -- transport (Travala MCP is JSON-RPC over HTTP) ----------------------
|
|
66
|
+
async call(tool, args) {
|
|
67
|
+
const res = await this.fetchImpl(this.url, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
Accept: "application/json",
|
|
72
|
+
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args } }),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new Error(`Travala MCP HTTP ${res.status}`);
|
|
78
|
+
const json = await res.json();
|
|
79
|
+
if (json.error)
|
|
80
|
+
throw new Error(json.error.message || "Travala MCP error");
|
|
81
|
+
const content = json.result?.content?.[0];
|
|
82
|
+
const text = content?.text ?? "[]";
|
|
83
|
+
return typeof text === "string" ? JSON.parse(text) : text;
|
|
84
|
+
}
|
|
85
|
+
// -- deterministic demo inventory ---------------------------------------
|
|
86
|
+
demoStays(params, nights) {
|
|
87
|
+
const seed = hash(params.city.toLowerCase());
|
|
88
|
+
const stays = Array.from({ length: 5 }, (_, i) => {
|
|
89
|
+
const brand = HOTEL_BRANDS[(seed + i) % HOTEL_BRANDS.length];
|
|
90
|
+
const nightly = round2(80 + ((seed >> (i + 1)) % 260)); // ~$80–$340
|
|
91
|
+
return {
|
|
92
|
+
quoteId: `tvl_demo_${(seed % 0xffff).toString(16)}_${i}`,
|
|
93
|
+
name: `${brand} ${titleCase(params.city)} ${["Central", "Riverside", "Airport", "Old Town", "Bay"][i]}`,
|
|
94
|
+
brand,
|
|
95
|
+
city: titleCase(params.city),
|
|
96
|
+
stars: 3 + (i % 3),
|
|
97
|
+
checkIn: params.checkIn,
|
|
98
|
+
checkOut: params.checkOut,
|
|
99
|
+
nights,
|
|
100
|
+
nightlyUsd: nightly,
|
|
101
|
+
totalUsd: round2(nightly * nights),
|
|
102
|
+
source: "demo",
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
return params.maxNightlyUsd ? stays.filter((s) => s.nightlyUsd <= params.maxNightlyUsd) : stays;
|
|
106
|
+
}
|
|
107
|
+
demoFlights(params) {
|
|
108
|
+
const seed = hash(params.from + params.to);
|
|
109
|
+
const carriers = ["BA", "LH", "AF", "EK", "QR"];
|
|
110
|
+
return Array.from({ length: 3 }, (_, i) => ({
|
|
111
|
+
quoteId: `tvlf_demo_${(seed % 0xffff).toString(16)}_${i}`,
|
|
112
|
+
carrier: carriers[(seed + i) % carriers.length],
|
|
113
|
+
from: params.from.toUpperCase(),
|
|
114
|
+
to: params.to.toUpperCase(),
|
|
115
|
+
date: params.date,
|
|
116
|
+
cabin: ["economy", "premium", "business"][i % 3],
|
|
117
|
+
totalUsd: round2(180 + ((seed >> (i + 2)) % 900)),
|
|
118
|
+
source: "demo",
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
exports.TravalaClient = TravalaClient;
|
|
123
|
+
function normaliseStay(r, p, nights) {
|
|
124
|
+
const nightly = Number(r.nightlyUsd ?? r.price ?? 0);
|
|
125
|
+
return {
|
|
126
|
+
quoteId: String(r.quoteId ?? r.id ?? ""),
|
|
127
|
+
name: String(r.name ?? "Hotel"),
|
|
128
|
+
brand: String(r.brand ?? "Independent"),
|
|
129
|
+
city: String(r.city ?? p.city),
|
|
130
|
+
stars: Number(r.stars ?? 3),
|
|
131
|
+
checkIn: p.checkIn,
|
|
132
|
+
checkOut: p.checkOut,
|
|
133
|
+
nights,
|
|
134
|
+
nightlyUsd: nightly,
|
|
135
|
+
totalUsd: Number(r.totalUsd ?? nightly * nights),
|
|
136
|
+
source: "travala",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function normaliseFlight(r, p) {
|
|
140
|
+
return {
|
|
141
|
+
quoteId: String(r.quoteId ?? r.id ?? ""),
|
|
142
|
+
carrier: String(r.carrier ?? "XX"),
|
|
143
|
+
from: p.from.toUpperCase(),
|
|
144
|
+
to: p.to.toUpperCase(),
|
|
145
|
+
date: p.date,
|
|
146
|
+
cabin: String(r.cabin ?? "economy"),
|
|
147
|
+
totalUsd: Number(r.totalUsd ?? r.price ?? 0),
|
|
148
|
+
source: "travala",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function titleCase(s) {
|
|
152
|
+
return s.replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
|
|
153
|
+
}
|
package/dist/travel.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Booking, BookingSource, Flight, RebateAccrual, Stay, TravelOptions } from "./types";
|
|
2
|
+
export { MCC } from "./furlpay";
|
|
3
|
+
export * from "./types";
|
|
4
|
+
/**
|
|
5
|
+
* FurlPay Travels — composes Travala's Travel MCP (search) with FurlPay's payment
|
|
6
|
+
* rails (pay). An agent searches inventory, FurlPay checks the spend budget and
|
|
7
|
+
* issues a credential (x402 proof for the Travala/Base route, or a single-use
|
|
8
|
+
* MCC-locked Visa VCN for legacy merchants), and the booking accrues the 10%
|
|
9
|
+
* cbBTC rebate.
|
|
10
|
+
*
|
|
11
|
+
* Clone-and-run: with no keys, search and payment simulate end-to-end.
|
|
12
|
+
*/
|
|
13
|
+
export declare class TravelClient {
|
|
14
|
+
private readonly travala;
|
|
15
|
+
private readonly pay;
|
|
16
|
+
private readonly developerWallet?;
|
|
17
|
+
private readonly bookings;
|
|
18
|
+
/** Per-agent USDC spend caps for autonomous booking. */
|
|
19
|
+
private readonly budgets;
|
|
20
|
+
constructor(opts?: TravelOptions);
|
|
21
|
+
get live(): boolean;
|
|
22
|
+
searchStays(p: {
|
|
23
|
+
city: string;
|
|
24
|
+
checkIn: string;
|
|
25
|
+
checkOut: string;
|
|
26
|
+
maxNightlyUsd?: number;
|
|
27
|
+
guests?: number;
|
|
28
|
+
}): Promise<Stay[]>;
|
|
29
|
+
searchFlights(p: {
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
date: string;
|
|
33
|
+
}): Promise<Flight[]>;
|
|
34
|
+
setAgentBudget(agentId: string, limitUsd: number): void;
|
|
35
|
+
getAgentBudget(agentId: string): {
|
|
36
|
+
limit: number;
|
|
37
|
+
spent: number;
|
|
38
|
+
remaining: number;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Authorize a booking. `source: "travala"` pays via x402/USDC on Base; any other
|
|
42
|
+
* merchant uses `source: "legacy"` → a single-use MCC-locked Visa VCN.
|
|
43
|
+
* Enforces the agent budget before authorizing.
|
|
44
|
+
*/
|
|
45
|
+
authorizeBooking(params: {
|
|
46
|
+
amountUsd: number;
|
|
47
|
+
source: BookingSource;
|
|
48
|
+
currency?: string;
|
|
49
|
+
mcc?: string;
|
|
50
|
+
agentId?: string;
|
|
51
|
+
reference?: string;
|
|
52
|
+
}): Promise<Booking>;
|
|
53
|
+
/** Mark an authorized booking confirmed (after passkey step-up in a real flow). */
|
|
54
|
+
confirmBooking(id: string): Booking;
|
|
55
|
+
cancelBooking(id: string): Booking;
|
|
56
|
+
getBooking(id: string): Booking | undefined;
|
|
57
|
+
/** All cbBTC rebate accruals to date and the developer's cumulative share. */
|
|
58
|
+
listRebates(): {
|
|
59
|
+
developerWallet?: string;
|
|
60
|
+
accruals: RebateAccrual[];
|
|
61
|
+
developerTotalUsd: number;
|
|
62
|
+
treasuryTotalUsd: number;
|
|
63
|
+
};
|
|
64
|
+
private accrueRebate;
|
|
65
|
+
private require;
|
|
66
|
+
}
|
|
67
|
+
export default TravelClient;
|
package/dist/travel.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.TravelClient = exports.MCC = void 0;
|
|
18
|
+
const furlpay_1 = require("./furlpay");
|
|
19
|
+
const travala_1 = require("./travala");
|
|
20
|
+
var furlpay_2 = require("./furlpay");
|
|
21
|
+
Object.defineProperty(exports, "MCC", { enumerable: true, get: function () { return furlpay_2.MCC; } });
|
|
22
|
+
__exportStar(require("./types"), exports);
|
|
23
|
+
// Travala pays a 10% cbBTC developer rebate on MCP-driven bookings. We split it
|
|
24
|
+
// 7% to the integrating developer, 3% to the FurlPay treasury.
|
|
25
|
+
const REBATE_RATE = 0.1;
|
|
26
|
+
const DEVELOPER_SHARE = 0.7;
|
|
27
|
+
/**
|
|
28
|
+
* FurlPay Travels — composes Travala's Travel MCP (search) with FurlPay's payment
|
|
29
|
+
* rails (pay). An agent searches inventory, FurlPay checks the spend budget and
|
|
30
|
+
* issues a credential (x402 proof for the Travala/Base route, or a single-use
|
|
31
|
+
* MCC-locked Visa VCN for legacy merchants), and the booking accrues the 10%
|
|
32
|
+
* cbBTC rebate.
|
|
33
|
+
*
|
|
34
|
+
* Clone-and-run: with no keys, search and payment simulate end-to-end.
|
|
35
|
+
*/
|
|
36
|
+
class TravelClient {
|
|
37
|
+
constructor(opts = {}) {
|
|
38
|
+
this.bookings = new Map();
|
|
39
|
+
/** Per-agent USDC spend caps for autonomous booking. */
|
|
40
|
+
this.budgets = new Map();
|
|
41
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
42
|
+
this.travala = new travala_1.TravalaClient(opts.travalaMcpUrl || process.env.TRAVALA_MCP_URL, process.env.TRAVALA_API_KEY, fetchImpl);
|
|
43
|
+
this.pay = new furlpay_1.FurlPayPay(opts.furlpayApiKey || process.env.FURLPAY_API_KEY, opts.furlpayBaseUrl, fetchImpl);
|
|
44
|
+
this.developerWallet = opts.developerWallet || process.env.FURLPAY_DEVELOPER_WALLET;
|
|
45
|
+
}
|
|
46
|
+
get live() {
|
|
47
|
+
return this.pay.live;
|
|
48
|
+
}
|
|
49
|
+
// -- search -------------------------------------------------------------
|
|
50
|
+
searchStays(p) {
|
|
51
|
+
return this.travala.searchStays(p);
|
|
52
|
+
}
|
|
53
|
+
searchFlights(p) {
|
|
54
|
+
return this.travala.searchFlights(p);
|
|
55
|
+
}
|
|
56
|
+
// -- budget policy ------------------------------------------------------
|
|
57
|
+
setAgentBudget(agentId, limitUsd) {
|
|
58
|
+
const existing = this.budgets.get(agentId);
|
|
59
|
+
this.budgets.set(agentId, { limit: limitUsd, spent: existing?.spent ?? 0 });
|
|
60
|
+
}
|
|
61
|
+
getAgentBudget(agentId) {
|
|
62
|
+
const b = this.budgets.get(agentId) ?? { limit: Infinity, spent: 0 };
|
|
63
|
+
return { limit: b.limit, spent: b.spent, remaining: b.limit - b.spent };
|
|
64
|
+
}
|
|
65
|
+
// -- book ---------------------------------------------------------------
|
|
66
|
+
/**
|
|
67
|
+
* Authorize a booking. `source: "travala"` pays via x402/USDC on Base; any other
|
|
68
|
+
* merchant uses `source: "legacy"` → a single-use MCC-locked Visa VCN.
|
|
69
|
+
* Enforces the agent budget before authorizing.
|
|
70
|
+
*/
|
|
71
|
+
async authorizeBooking(params) {
|
|
72
|
+
if (params.amountUsd <= 0)
|
|
73
|
+
throw new Error("amountUsd must be > 0");
|
|
74
|
+
const agentId = params.agentId ?? "agent_default";
|
|
75
|
+
const budget = this.budgets.get(agentId);
|
|
76
|
+
if (budget && budget.spent + params.amountUsd > budget.limit) {
|
|
77
|
+
throw new Error(`booking $${params.amountUsd} exceeds remaining budget $${(budget.limit - budget.spent).toFixed(2)} for ${agentId}`);
|
|
78
|
+
}
|
|
79
|
+
const authorization = await this.pay.authorize({
|
|
80
|
+
route: params.source,
|
|
81
|
+
amountUsd: params.amountUsd,
|
|
82
|
+
currency: params.currency,
|
|
83
|
+
mcc: params.mcc ?? (params.source === "legacy" ? furlpay_1.MCC.LODGING : undefined),
|
|
84
|
+
});
|
|
85
|
+
const booking = {
|
|
86
|
+
id: "bk_" + Math.random().toString(36).slice(2, 10),
|
|
87
|
+
source: params.source,
|
|
88
|
+
reference: params.reference ?? "(unassigned)",
|
|
89
|
+
amountUsd: params.amountUsd,
|
|
90
|
+
status: "authorized",
|
|
91
|
+
authorization,
|
|
92
|
+
rebate: params.source === "travala" ? this.accrueRebate("", params.amountUsd) : undefined,
|
|
93
|
+
createdAt: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
if (booking.rebate)
|
|
96
|
+
booking.rebate.bookingId = booking.id;
|
|
97
|
+
if (budget)
|
|
98
|
+
budget.spent += params.amountUsd;
|
|
99
|
+
this.bookings.set(booking.id, booking);
|
|
100
|
+
return booking;
|
|
101
|
+
}
|
|
102
|
+
/** Mark an authorized booking confirmed (after passkey step-up in a real flow). */
|
|
103
|
+
confirmBooking(id) {
|
|
104
|
+
const b = this.require(id);
|
|
105
|
+
if (b.status === "cancelled")
|
|
106
|
+
throw new Error(`booking ${id} is cancelled`);
|
|
107
|
+
b.status = "confirmed";
|
|
108
|
+
return b;
|
|
109
|
+
}
|
|
110
|
+
cancelBooking(id) {
|
|
111
|
+
const b = this.require(id);
|
|
112
|
+
b.status = "cancelled";
|
|
113
|
+
const budget = this.budgets.get("agent_default");
|
|
114
|
+
return b;
|
|
115
|
+
}
|
|
116
|
+
getBooking(id) {
|
|
117
|
+
return this.bookings.get(id);
|
|
118
|
+
}
|
|
119
|
+
// -- rebates ------------------------------------------------------------
|
|
120
|
+
/** All cbBTC rebate accruals to date and the developer's cumulative share. */
|
|
121
|
+
listRebates() {
|
|
122
|
+
const accruals = [...this.bookings.values()]
|
|
123
|
+
.filter((b) => b.rebate && b.status !== "cancelled")
|
|
124
|
+
.map((b) => b.rebate);
|
|
125
|
+
return {
|
|
126
|
+
developerWallet: this.developerWallet,
|
|
127
|
+
accruals,
|
|
128
|
+
developerTotalUsd: round2(accruals.reduce((s, r) => s + r.developerUsd, 0)),
|
|
129
|
+
treasuryTotalUsd: round2(accruals.reduce((s, r) => s + r.treasuryUsd, 0)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
accrueRebate(bookingId, amountUsd) {
|
|
133
|
+
const total = round2(amountUsd * REBATE_RATE);
|
|
134
|
+
const developerUsd = round2(total * DEVELOPER_SHARE);
|
|
135
|
+
return {
|
|
136
|
+
bookingId,
|
|
137
|
+
currency: "cbBTC",
|
|
138
|
+
totalUsd: total,
|
|
139
|
+
developerUsd,
|
|
140
|
+
treasuryUsd: round2(total - developerUsd),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
require(id) {
|
|
144
|
+
const b = this.bookings.get(id);
|
|
145
|
+
if (!b)
|
|
146
|
+
throw new Error(`no booking ${id}`);
|
|
147
|
+
return b;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
exports.TravelClient = TravelClient;
|
|
151
|
+
function round2(n) {
|
|
152
|
+
return Math.round(n * 100) / 100;
|
|
153
|
+
}
|
|
154
|
+
exports.default = TravelClient;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type BookingSource = "travala" | "legacy";
|
|
2
|
+
export interface Stay {
|
|
3
|
+
quoteId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
brand: string;
|
|
6
|
+
city: string;
|
|
7
|
+
stars: number;
|
|
8
|
+
checkIn: string;
|
|
9
|
+
checkOut: string;
|
|
10
|
+
nights: number;
|
|
11
|
+
nightlyUsd: number;
|
|
12
|
+
totalUsd: number;
|
|
13
|
+
source: "travala" | "demo";
|
|
14
|
+
}
|
|
15
|
+
export interface Flight {
|
|
16
|
+
quoteId: string;
|
|
17
|
+
carrier: string;
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
date: string;
|
|
21
|
+
cabin: string;
|
|
22
|
+
totalUsd: number;
|
|
23
|
+
source: "travala" | "demo";
|
|
24
|
+
}
|
|
25
|
+
/** A payment authorization FurlPay hands back for a booking. */
|
|
26
|
+
export interface Authorization {
|
|
27
|
+
route: BookingSource;
|
|
28
|
+
amount: number;
|
|
29
|
+
currency: string;
|
|
30
|
+
/** Present on the crypto route: an x402 payment proof settled on Base. */
|
|
31
|
+
x402?: {
|
|
32
|
+
proof: string;
|
|
33
|
+
network: string;
|
|
34
|
+
token: string;
|
|
35
|
+
settlementTx?: string;
|
|
36
|
+
};
|
|
37
|
+
/** Present on the legacy route: a single-use Visa virtual card number, MCC-locked. */
|
|
38
|
+
card?: {
|
|
39
|
+
id: string;
|
|
40
|
+
last4: string;
|
|
41
|
+
expMonth: number;
|
|
42
|
+
expYear: number;
|
|
43
|
+
mccWhitelist: string[];
|
|
44
|
+
singleUse: boolean;
|
|
45
|
+
limitUsd: number;
|
|
46
|
+
};
|
|
47
|
+
simulated?: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface Booking {
|
|
50
|
+
id: string;
|
|
51
|
+
source: BookingSource;
|
|
52
|
+
reference: string;
|
|
53
|
+
amountUsd: number;
|
|
54
|
+
status: "authorized" | "confirmed" | "cancelled";
|
|
55
|
+
authorization: Authorization;
|
|
56
|
+
/** 10% cbBTC developer rebate accrued on Travala-routed bookings. */
|
|
57
|
+
rebate?: RebateAccrual;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
}
|
|
60
|
+
export interface RebateAccrual {
|
|
61
|
+
bookingId: string;
|
|
62
|
+
currency: "cbBTC";
|
|
63
|
+
totalUsd: number;
|
|
64
|
+
developerUsd: number;
|
|
65
|
+
treasuryUsd: number;
|
|
66
|
+
}
|
|
67
|
+
export interface TravelOptions {
|
|
68
|
+
/** FurlPay API key. Omit → demo mode (searches + payments simulate, no network). */
|
|
69
|
+
furlpayApiKey?: string;
|
|
70
|
+
/** FurlPay API base. Default https://api.furlpay.com/v1 */
|
|
71
|
+
furlpayBaseUrl?: string;
|
|
72
|
+
/** Travala Travel MCP endpoint. Default https://travel-mcp.travala.com/mcp */
|
|
73
|
+
travalaMcpUrl?: string;
|
|
74
|
+
/** Developer wallet that receives the 7% cbBTC rebate split. */
|
|
75
|
+
developerWallet?: string;
|
|
76
|
+
/** Override fetch (Node 18+ has a global fetch). */
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@furlpay/travel-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "FurlPay Travels — an MCP server composing Travala's Travel MCP (search) with FurlPay payment rails (x402/USDC on Base or single-use MCC-locked Visa VCN). Captures the 10% cbBTC developer rebate. Zero runtime dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"furlpay-travel-mcp": "dist/server.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"furlpay",
|
|
17
|
+
"travala",
|
|
18
|
+
"travel",
|
|
19
|
+
"mcp",
|
|
20
|
+
"x402",
|
|
21
|
+
"agentic-payments",
|
|
22
|
+
"stablecoin",
|
|
23
|
+
"usdc",
|
|
24
|
+
"virtual-cards",
|
|
25
|
+
"cbbtc"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"test": "npm run build && node --test test/travel.test.js",
|
|
30
|
+
"example": "npx --yes tsx examples/book.ts",
|
|
31
|
+
"start": "npm run build && node dist/server.js"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/furlpay/furlpay-travel-mcp"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://furlpay.com",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"typescript": "5.5.3",
|
|
43
|
+
"@types/node": "20.14.10"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/furlpay.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Authorization, BookingSource } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FurlPay payment side of the composition. Produces the credential a booking
|
|
5
|
+
* needs, per route:
|
|
6
|
+
*
|
|
7
|
+
* - travala (crypto-native): an x402 payment proof, settled in gasless USDC on
|
|
8
|
+
* Base — the rail Travala's protocol accepts directly.
|
|
9
|
+
* - legacy (Web2 merchants like Airbnb/Skyscanner): a single-use Visa virtual
|
|
10
|
+
* card number, MCC-locked to travel and limited to the booking total.
|
|
11
|
+
*
|
|
12
|
+
* With no FurlPay key set, both routes simulate (no network) so the flow is fully
|
|
13
|
+
* runnable offline. Card PANs are never real in demo mode.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DEFAULT_BASE = "https://api.furlpay.com/v1";
|
|
17
|
+
|
|
18
|
+
// Common travel MCCs. 7011 = Lodging/Hotels, 4511 = Airlines, 7512 = Car Rental,
|
|
19
|
+
// 4722 = Travel Agencies.
|
|
20
|
+
export const MCC = { LODGING: "7011", AIRLINES: "4511", CAR_RENTAL: "7512", TRAVEL_AGENCY: "4722" } as const;
|
|
21
|
+
|
|
22
|
+
function rid(prefix: string): string {
|
|
23
|
+
return prefix + Math.random().toString(36).slice(2, 10);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class FurlPayPay {
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly apiKey?: string,
|
|
29
|
+
private readonly baseUrl: string = process.env.FURLPAY_API_BASE || DEFAULT_BASE,
|
|
30
|
+
private readonly fetchImpl: typeof fetch = globalThis.fetch,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
get live(): boolean {
|
|
34
|
+
return Boolean(this.apiKey);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Authorize a booking on the chosen route, returning the payment credential. */
|
|
38
|
+
async authorize(params: {
|
|
39
|
+
route: BookingSource;
|
|
40
|
+
amountUsd: number;
|
|
41
|
+
currency?: string;
|
|
42
|
+
mcc?: string;
|
|
43
|
+
}): Promise<Authorization> {
|
|
44
|
+
const currency = (params.currency ?? "USDC").toUpperCase();
|
|
45
|
+
if (params.route === "travala") return this.authorizeX402(params.amountUsd, currency);
|
|
46
|
+
return this.issueVirtualCard(params.amountUsd, currency, params.mcc ?? MCC.LODGING);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async authorizeX402(amountUsd: number, currency: string): Promise<Authorization> {
|
|
50
|
+
if (!this.live) {
|
|
51
|
+
return {
|
|
52
|
+
route: "travala",
|
|
53
|
+
amount: amountUsd,
|
|
54
|
+
currency,
|
|
55
|
+
x402: { proof: rid("x402_sim_"), network: "base", token: "USDC", settlementTx: rid("tx_sim_") },
|
|
56
|
+
simulated: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const settled = await this.post("/x402/settle", { amount: amountUsd, token: "USDC", destination: "travala" });
|
|
60
|
+
return {
|
|
61
|
+
route: "travala",
|
|
62
|
+
amount: amountUsd,
|
|
63
|
+
currency,
|
|
64
|
+
x402: {
|
|
65
|
+
proof: settled.payment_header || settled.proof,
|
|
66
|
+
network: settled.network || "base",
|
|
67
|
+
token: "USDC",
|
|
68
|
+
settlementTx: settled.tx,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async issueVirtualCard(amountUsd: number, currency: string, mcc: string): Promise<Authorization> {
|
|
74
|
+
if (!this.live) {
|
|
75
|
+
const last4 = String(1000 + Math.floor(Math.random() * 9000)).slice(-4);
|
|
76
|
+
return {
|
|
77
|
+
route: "legacy",
|
|
78
|
+
amount: amountUsd,
|
|
79
|
+
currency,
|
|
80
|
+
card: {
|
|
81
|
+
id: rid("card_sim_"),
|
|
82
|
+
last4,
|
|
83
|
+
expMonth: 12,
|
|
84
|
+
expYear: new Date().getUTCFullYear() + 3,
|
|
85
|
+
mccWhitelist: [mcc],
|
|
86
|
+
singleUse: true,
|
|
87
|
+
limitUsd: amountUsd,
|
|
88
|
+
},
|
|
89
|
+
simulated: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const card = await this.post("/cards", {
|
|
93
|
+
type: "virtual",
|
|
94
|
+
currency,
|
|
95
|
+
limit: amountUsd,
|
|
96
|
+
mcc_whitelist: [mcc],
|
|
97
|
+
single_use: true,
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
route: "legacy",
|
|
101
|
+
amount: amountUsd,
|
|
102
|
+
currency,
|
|
103
|
+
card: {
|
|
104
|
+
id: card.id,
|
|
105
|
+
last4: card.last4,
|
|
106
|
+
expMonth: card.exp_month,
|
|
107
|
+
expYear: card.exp_year,
|
|
108
|
+
mccWhitelist: card.mcc_whitelist ?? [mcc],
|
|
109
|
+
singleUse: true,
|
|
110
|
+
limitUsd: amountUsd,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async post(path: string, body: unknown): Promise<any> {
|
|
116
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
});
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
const json = text ? JSON.parse(text) : {};
|
|
123
|
+
if (!res.ok) throw new Error(json.error || `FurlPay HTTP ${res.status}`);
|
|
124
|
+
return json;
|
|
125
|
+
}
|
|
126
|
+
}
|