From 63f092eb5c718b514ed7f45b849438315c7bc14a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:25:36 +0000 Subject: [PATCH] feat: RedX courier adapter (create + track, area auto-resolve); v2.2.0 --- package.json | 5 +- src/adapters/redx.ts | 118 ++++++++++++++++++++++++------------------- src/config.ts | 12 +++++ src/http.ts | 5 +- src/registry.ts | 4 ++ src/server.ts | 10 ++-- src/types.ts | 5 ++ 7 files changed, 102 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index 26d808c..56b7f91 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aura-courier-mcp", - "version": "2.1.0", - "description": "Universal Bangladesh courier MCP server — Steadfast & Pathao with a built-in fraud-risk engine — for Claude, Antigravity, Cursor & n8n AI agents. Runs as a local npx (STDIO) server or a remote HTTP endpoint.", + "version": "2.2.0", + "description": "Universal Bangladesh courier MCP server — Steadfast, Pathao & RedX with a built-in fraud-risk engine — for Claude, Antigravity, Cursor & n8n AI agents. Runs as a local npx (STDIO) server or a remote HTTP endpoint.", "main": "dist/index.js", "type": "module", "bin": { @@ -27,6 +27,7 @@ "courier", "steadfast", "pathao", + "redx", "bangladesh", "antigravity", "claude", diff --git a/src/adapters/redx.ts b/src/adapters/redx.ts index ede2d64..f0e590f 100644 --- a/src/adapters/redx.ts +++ b/src/adapters/redx.ts @@ -2,29 +2,28 @@ import axios, { AxiosInstance } from "axios"; import { CourierAdapter } from "./base.js"; import { BalanceResponse, - LocationResolutionResponse, ParcelCreateRequest, ParcelResponse, SupportedCourier, TrackingResponse, } from "../types.js"; -export class RedXAdapter implements CourierAdapter { +export class RedxAdapter implements CourierAdapter { courierName: SupportedCourier = "redx"; private client: AxiosInstance; - private apiToken: string; private enabled: boolean; + private pickupStoreId: string; - constructor(apiToken: string, baseUrl: string = "https://openapi.redx.com.bd/v1.0.0-beta") { - this.apiToken = apiToken; + constructor(apiToken: string, baseUrl: string, pickupStoreId = "") { this.enabled = Boolean(apiToken); + this.pickupStoreId = pickupStoreId; this.client = axios.create({ baseURL: baseUrl, headers: { - Authorization: `Bearer ${apiToken}`, + "API-ACCESS-TOKEN": `Bearer ${apiToken}`, "Content-Type": "application/json", }, - timeout: 10000, + timeout: 15000, }); } @@ -32,26 +31,66 @@ export class RedXAdapter implements CourierAdapter { return this.enabled; } + // RedX's create endpoint needs a numeric delivery_area_id. If the caller did + // not pass one, best-effort resolve it from the address via GET /areas. + private async resolveDeliveryArea(req: ParcelCreateRequest): Promise<{ id: number; name: string }> { + if (req.delivery_area_id) { + return { id: Number(req.delivery_area_id), name: req.delivery_area || "" }; + } + const res = await this.client.get("/areas"); + const areas: Array<{ id: number; name: string }> = res.data?.areas || []; + const addr = (req.recipient_address || "").toLowerCase(); + let best: { id: number; name: string } | null = null; + for (const a of areas) { + const base = String(a.name).split("(")[0].trim().toLowerCase(); + if (base && addr.includes(base)) { + const bestLen = best ? String(best.name).split("(")[0].trim().length : 0; + if (base.length > bestLen) best = a; + } + } + if (!best) { + throw new Error( + "RedX: could not match a delivery area from the address. Pass 'delivery_area_id' explicitly (look it up via RedX /areas)." + ); + } + return { id: best.id, name: best.name }; + } + async createParcel(req: ParcelCreateRequest): Promise { if (!this.enabled) { - throw new Error("RedX Courier credentials (API Token) are not configured."); + throw new Error("RedX credentials (API access token) are not configured."); } - const payload = { + const area = await this.resolveDeliveryArea(req); + const weightGrams = Math.max(1, Math.round((req.item_weight ?? 0.5) * 1000)); + const declaredValue = String(req.value ?? req.cod_amount ?? 0); + + const payload: Record = { customer_name: req.recipient_name, customer_phone: req.recipient_phone, - delivery_area: req.recipient_address, - delivery_area_id: 1, // Default auto-resolved area + delivery_area: area.name || req.delivery_area || "", + delivery_area_id: area.id, customer_address: req.recipient_address, merchant_invoice_id: req.invoice, - cash_collection_amount: req.cod_amount, - parcel_weight: (req.item_weight || 0.5) * 1000, // RedX uses grams + cash_collection_amount: String(req.cod_amount ?? 0), + parcel_weight: weightGrams, instruction: req.note || "Aura AI automated dispatch", - value: req.cod_amount || 500, + value: declaredValue, }; + const storeId = req.pickup_store_id || this.pickupStoreId; + if (storeId) payload.pickup_store_id = Number(storeId); + if (req.item_type) { + payload.parcel_details_json = [ + { name: req.item_type, category: req.item_category || "general", value: Number(req.value ?? req.cod_amount ?? 0) }, + ]; + } - const response = await this.client.post("/parcels", payload); - const trackingId = response.data.tracking_id || response.data.parcel_id; + const response = await this.client.post("/parcel", payload); + const data = response.data; + const trackingId = data?.tracking_id; + if (!trackingId) { + throw new Error(`RedX API error: ${JSON.stringify(data?.message || data)}`); + } return { success: true, @@ -59,58 +98,35 @@ export class RedXAdapter implements CourierAdapter { tracking_code: trackingId, consignment_id: trackingId, invoice: req.invoice, - status: "ready_for_pickup", + status: "pickup-pending", cod_amount: req.cod_amount, - delivery_fee: response.data.delivery_fee || 60, created_at: new Date().toISOString(), - raw_response: response.data, + raw_response: data, }; } async trackParcel(trackingCode: string): Promise { if (!this.enabled) { - throw new Error("RedX Courier credentials are not configured."); + throw new Error("RedX credentials are not configured."); } - - const response = await this.client.get(`/parcels/track/${encodeURIComponent(trackingCode)}`); + const response = await this.client.get(`/parcel/track/${encodeURIComponent(trackingCode)}`); const data = response.data; - + const events: Array<{ message_en?: string; message_bn?: string; time?: string }> = data?.tracking || []; + const latest = events[events.length - 1]; return { success: true, courier: "redx", tracking_code: trackingCode, - status: data.tracking?.status || "in_transit", - timeline: (data.tracking?.history || []).map((h: any) => ({ - status: h.status, - time: h.created_at, - note: h.message, - })), + status: latest?.message_en || "unknown", + updated_at: latest?.time || new Date().toISOString(), + timeline: events.map((e) => ({ status: e.message_en || "", time: e.time || "", note: e.message_bn })), raw_response: data, }; } async getBalance(): Promise { - return { - success: true, - courier: "redx", - current_balance: 0, - raw_response: { message: "RedX payouts are disbursed directly to connected merchant bank accounts." }, - }; - } - - async getLocations(cityName?: string): Promise { - const response = await this.client.get("/areas"); - const areas = response.data.areas || []; - - return { - success: true, - courier: "redx", - locations: areas.map((a: any) => ({ - city_id: a.id, - city_name: a.name, - zone_name: a.zone_name, - area_name: a.name, - })), - }; + throw new Error( + "RedX does not expose a merchant balance endpoint via its public API — check your balance in the RedX merchant panel." + ); } } diff --git a/src/config.ts b/src/config.ts index 98a3a84..a4792ee 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,12 @@ export interface CourierConfig { baseUrl: string; enabled: boolean; }; + redx: { + apiToken: string; + baseUrl: string; + pickupStoreId: string; + enabled: boolean; + }; } export type EnvSource = Record; @@ -50,5 +56,11 @@ export function loadConfig(src: EnvSource = process.env): CourierConfig { baseUrl: src.PATHAO_BASE_URL || "https://api-hermes.pathao.com", enabled: Boolean(pathaoClientId && pathaoClientSecret), }, + redx: { + apiToken: src.REDX_API_TOKEN || "", + baseUrl: src.REDX_BASE_URL || "https://openapi.redx.com.bd/v1.0.0-beta", + pickupStoreId: src.REDX_PICKUP_STORE_ID || "", + enabled: Boolean(src.REDX_API_TOKEN), + }, }; } diff --git a/src/http.ts b/src/http.ts index e1d57c0..6e2006b 100644 --- a/src/http.ts +++ b/src/http.ts @@ -32,6 +32,9 @@ function keysFromRequest(req: Request): EnvSource { PATHAO_PASSWORD: pick("x-pathao-password", "pathao_password"), PATHAO_STORE_ID: pick("x-pathao-store-id", "pathao_store_id"), PATHAO_BASE_URL: pick("x-pathao-base-url", "pathao_base_url"), + REDX_API_TOKEN: pick("x-redx-api-token", "redx_token"), + REDX_BASE_URL: pick("x-redx-base-url", "redx_base_url"), + REDX_PICKUP_STORE_ID: pick("x-redx-pickup-store-id", "redx_pickup_store_id"), }; } @@ -43,7 +46,7 @@ app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); res.header( "Access-Control-Allow-Headers", - "Content-Type, Accept, Authorization, mcp-session-id, mcp-protocol-version, x-steadfast-api-key, x-steadfast-secret-key, x-steadfast-base-url, x-pathao-client-id, x-pathao-client-secret, x-pathao-username, x-pathao-password, x-pathao-store-id, x-pathao-base-url" + "Content-Type, Accept, Authorization, mcp-session-id, mcp-protocol-version, x-steadfast-api-key, x-steadfast-secret-key, x-steadfast-base-url, x-pathao-client-id, x-pathao-client-secret, x-pathao-username, x-pathao-password, x-pathao-store-id, x-pathao-base-url, x-redx-api-token, x-redx-base-url, x-redx-pickup-store-id" ); res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.header("Access-Control-Expose-Headers", "mcp-session-id"); diff --git a/src/registry.ts b/src/registry.ts index 26324c1..97c7bef 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -2,6 +2,7 @@ import { CourierAdapter } from "./adapters/base.js"; import { SteadfastAdapter } from "./adapters/steadfast.js"; import { PathaoAdapter } from "./adapters/pathao.js"; import { FraudRiskEngine } from "./adapters/fraud_engine.js"; +import { RedxAdapter } from "./adapters/redx.js"; import { loadConfig, CourierConfig } from "./config.js"; import { BalanceResponse, @@ -32,6 +33,9 @@ export class CourierRegistry { config.pathao.baseUrl ); this.adapters.set("pathao", pathao); + + const redx = new RedxAdapter(config.redx.apiToken, config.redx.baseUrl, config.redx.pickupStoreId); + this.adapters.set("redx", redx); } listCouriers() { diff --git a/src/server.ts b/src/server.ts index 9e3ead4..96c6963 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,11 +16,11 @@ export const TOOLS: Tool[] = [ }, { name: "create_parcel", - description: "Book a new parcel delivery across Bangladesh (Steadfast or Pathao) with normalized response.", + description: "Book a new parcel delivery across Bangladesh (Steadfast, Pathao or RedX) with normalized response.", inputSchema: { type: "object", properties: { - courier: { type: "string", enum: ["steadfast", "pathao", "auto"], description: "Target courier or 'auto' for AI smart routing (default: auto)" }, + courier: { type: "string", enum: ["steadfast", "pathao", "redx", "auto"], description: "Target courier or 'auto' for AI smart routing (default: auto)" }, invoice: { type: "string", description: "Unique order invoice number (e.g. INV-1002)" }, recipient_name: { type: "string", description: "Customer full name" }, recipient_phone: { type: "string", description: "11-digit Bangladeshi mobile number (e.g. 017XXXXXXXX)" }, @@ -28,6 +28,10 @@ export const TOOLS: Tool[] = [ cod_amount: { type: "number", description: "Cash on delivery amount in BDT (0 if prepaid)" }, note: { type: "string", description: "Special instructions for delivery rider" }, item_weight: { type: "number", description: "Parcel weight in KG (default: 0.5)" }, + item_type: { type: "string", description: "What's inside the parcel (used by RedX)" }, + value: { type: "number", description: "Declared parcel value in BDT (used by RedX; defaults to the COD amount)" }, + delivery_area_id: { type: "number", description: "RedX only: numeric delivery-area id (auto-resolved from the address if omitted)" }, + pickup_store_id: { type: "number", description: "RedX only: your pickup store id (optional)" }, }, required: ["invoice", "recipient_name", "recipient_phone", "recipient_address", "cod_amount"], }, @@ -39,7 +43,7 @@ export const TOOLS: Tool[] = [ type: "object", properties: { tracking_code: { type: "string", description: "Consignment ID or tracking code" }, - courier: { type: "string", enum: ["steadfast", "pathao"], description: "Optional courier name if known" }, + courier: { type: "string", enum: ["steadfast", "pathao", "redx"], description: "Optional courier name if known" }, }, required: ["tracking_code"], }, diff --git a/src/types.ts b/src/types.ts index b06e1f4..3d3f035 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,11 @@ export interface ParcelCreateRequest { note?: string; item_type?: string; item_weight?: number; // in KG + item_category?: string; + value?: number | string; + delivery_area?: string; + delivery_area_id?: number | string; + pickup_store_id?: number | string; } export interface ParcelResponse {