feat: RedX courier adapter (create + track, area auto-resolve); v2.2.0
This commit is contained in:
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aura-courier-mcp",
|
"name": "aura-courier-mcp",
|
||||||
"version": "2.1.0",
|
"version": "2.2.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.",
|
"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",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"courier",
|
"courier",
|
||||||
"steadfast",
|
"steadfast",
|
||||||
"pathao",
|
"pathao",
|
||||||
|
"redx",
|
||||||
"bangladesh",
|
"bangladesh",
|
||||||
"antigravity",
|
"antigravity",
|
||||||
"claude",
|
"claude",
|
||||||
|
|||||||
+69
-53
@@ -2,29 +2,28 @@ import axios, { AxiosInstance } from "axios";
|
|||||||
import { CourierAdapter } from "./base.js";
|
import { CourierAdapter } from "./base.js";
|
||||||
import {
|
import {
|
||||||
BalanceResponse,
|
BalanceResponse,
|
||||||
LocationResolutionResponse,
|
|
||||||
ParcelCreateRequest,
|
ParcelCreateRequest,
|
||||||
ParcelResponse,
|
ParcelResponse,
|
||||||
SupportedCourier,
|
SupportedCourier,
|
||||||
TrackingResponse,
|
TrackingResponse,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
|
|
||||||
export class RedXAdapter implements CourierAdapter {
|
export class RedxAdapter implements CourierAdapter {
|
||||||
courierName: SupportedCourier = "redx";
|
courierName: SupportedCourier = "redx";
|
||||||
private client: AxiosInstance;
|
private client: AxiosInstance;
|
||||||
private apiToken: string;
|
|
||||||
private enabled: boolean;
|
private enabled: boolean;
|
||||||
|
private pickupStoreId: string;
|
||||||
|
|
||||||
constructor(apiToken: string, baseUrl: string = "https://openapi.redx.com.bd/v1.0.0-beta") {
|
constructor(apiToken: string, baseUrl: string, pickupStoreId = "") {
|
||||||
this.apiToken = apiToken;
|
|
||||||
this.enabled = Boolean(apiToken);
|
this.enabled = Boolean(apiToken);
|
||||||
|
this.pickupStoreId = pickupStoreId;
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiToken}`,
|
"API-ACCESS-TOKEN": `Bearer ${apiToken}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 10000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,26 +31,66 @@ export class RedXAdapter implements CourierAdapter {
|
|||||||
return this.enabled;
|
return this.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createParcel(req: ParcelCreateRequest): Promise<ParcelResponse> {
|
// RedX's create endpoint needs a numeric delivery_area_id. If the caller did
|
||||||
if (!this.enabled) {
|
// not pass one, best-effort resolve it from the address via GET /areas.
|
||||||
throw new Error("RedX Courier credentials (API Token) are not configured.");
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
async createParcel(req: ParcelCreateRequest): Promise<ParcelResponse> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
throw new Error("RedX credentials (API access token) are not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any> = {
|
||||||
customer_name: req.recipient_name,
|
customer_name: req.recipient_name,
|
||||||
customer_phone: req.recipient_phone,
|
customer_phone: req.recipient_phone,
|
||||||
delivery_area: req.recipient_address,
|
delivery_area: area.name || req.delivery_area || "",
|
||||||
delivery_area_id: 1, // Default auto-resolved area
|
delivery_area_id: area.id,
|
||||||
customer_address: req.recipient_address,
|
customer_address: req.recipient_address,
|
||||||
merchant_invoice_id: req.invoice,
|
merchant_invoice_id: req.invoice,
|
||||||
cash_collection_amount: req.cod_amount,
|
cash_collection_amount: String(req.cod_amount ?? 0),
|
||||||
parcel_weight: (req.item_weight || 0.5) * 1000, // RedX uses grams
|
parcel_weight: weightGrams,
|
||||||
instruction: req.note || "Aura AI automated dispatch",
|
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 response = await this.client.post("/parcel", payload);
|
||||||
const trackingId = response.data.tracking_id || response.data.parcel_id;
|
const data = response.data;
|
||||||
|
const trackingId = data?.tracking_id;
|
||||||
|
if (!trackingId) {
|
||||||
|
throw new Error(`RedX API error: ${JSON.stringify(data?.message || data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -59,58 +98,35 @@ export class RedXAdapter implements CourierAdapter {
|
|||||||
tracking_code: trackingId,
|
tracking_code: trackingId,
|
||||||
consignment_id: trackingId,
|
consignment_id: trackingId,
|
||||||
invoice: req.invoice,
|
invoice: req.invoice,
|
||||||
status: "ready_for_pickup",
|
status: "pickup-pending",
|
||||||
cod_amount: req.cod_amount,
|
cod_amount: req.cod_amount,
|
||||||
delivery_fee: response.data.delivery_fee || 60,
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
raw_response: response.data,
|
raw_response: data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async trackParcel(trackingCode: string): Promise<TrackingResponse> {
|
async trackParcel(trackingCode: string): Promise<TrackingResponse> {
|
||||||
if (!this.enabled) {
|
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(`/parcel/track/${encodeURIComponent(trackingCode)}`);
|
||||||
const response = await this.client.get(`/parcels/track/${encodeURIComponent(trackingCode)}`);
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
const events: Array<{ message_en?: string; message_bn?: string; time?: string }> = data?.tracking || [];
|
||||||
|
const latest = events[events.length - 1];
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
courier: "redx",
|
courier: "redx",
|
||||||
tracking_code: trackingCode,
|
tracking_code: trackingCode,
|
||||||
status: data.tracking?.status || "in_transit",
|
status: latest?.message_en || "unknown",
|
||||||
timeline: (data.tracking?.history || []).map((h: any) => ({
|
updated_at: latest?.time || new Date().toISOString(),
|
||||||
status: h.status,
|
timeline: events.map((e) => ({ status: e.message_en || "", time: e.time || "", note: e.message_bn })),
|
||||||
time: h.created_at,
|
|
||||||
note: h.message,
|
|
||||||
})),
|
|
||||||
raw_response: data,
|
raw_response: data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBalance(): Promise<BalanceResponse> {
|
async getBalance(): Promise<BalanceResponse> {
|
||||||
return {
|
throw new Error(
|
||||||
success: true,
|
"RedX does not expose a merchant balance endpoint via its public API — check your balance in the RedX merchant panel."
|
||||||
courier: "redx",
|
);
|
||||||
current_balance: 0,
|
|
||||||
raw_response: { message: "RedX payouts are disbursed directly to connected merchant bank accounts." },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLocations(cityName?: string): Promise<LocationResolutionResponse> {
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export interface CourierConfig {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
redx: {
|
||||||
|
apiToken: string;
|
||||||
|
baseUrl: string;
|
||||||
|
pickupStoreId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EnvSource = Record<string, string | undefined>;
|
export type EnvSource = Record<string, string | undefined>;
|
||||||
@@ -50,5 +56,11 @@ export function loadConfig(src: EnvSource = process.env): CourierConfig {
|
|||||||
baseUrl: src.PATHAO_BASE_URL || "https://api-hermes.pathao.com",
|
baseUrl: src.PATHAO_BASE_URL || "https://api-hermes.pathao.com",
|
||||||
enabled: Boolean(pathaoClientId && pathaoClientSecret),
|
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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -32,6 +32,9 @@ function keysFromRequest(req: Request): EnvSource {
|
|||||||
PATHAO_PASSWORD: pick("x-pathao-password", "pathao_password"),
|
PATHAO_PASSWORD: pick("x-pathao-password", "pathao_password"),
|
||||||
PATHAO_STORE_ID: pick("x-pathao-store-id", "pathao_store_id"),
|
PATHAO_STORE_ID: pick("x-pathao-store-id", "pathao_store_id"),
|
||||||
PATHAO_BASE_URL: pick("x-pathao-base-url", "pathao_base_url"),
|
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-Origin", "*");
|
||||||
res.header(
|
res.header(
|
||||||
"Access-Control-Allow-Headers",
|
"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-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||||
res.header("Access-Control-Expose-Headers", "mcp-session-id");
|
res.header("Access-Control-Expose-Headers", "mcp-session-id");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CourierAdapter } from "./adapters/base.js";
|
|||||||
import { SteadfastAdapter } from "./adapters/steadfast.js";
|
import { SteadfastAdapter } from "./adapters/steadfast.js";
|
||||||
import { PathaoAdapter } from "./adapters/pathao.js";
|
import { PathaoAdapter } from "./adapters/pathao.js";
|
||||||
import { FraudRiskEngine } from "./adapters/fraud_engine.js";
|
import { FraudRiskEngine } from "./adapters/fraud_engine.js";
|
||||||
|
import { RedxAdapter } from "./adapters/redx.js";
|
||||||
import { loadConfig, CourierConfig } from "./config.js";
|
import { loadConfig, CourierConfig } from "./config.js";
|
||||||
import {
|
import {
|
||||||
BalanceResponse,
|
BalanceResponse,
|
||||||
@@ -32,6 +33,9 @@ export class CourierRegistry {
|
|||||||
config.pathao.baseUrl
|
config.pathao.baseUrl
|
||||||
);
|
);
|
||||||
this.adapters.set("pathao", pathao);
|
this.adapters.set("pathao", pathao);
|
||||||
|
|
||||||
|
const redx = new RedxAdapter(config.redx.apiToken, config.redx.baseUrl, config.redx.pickupStoreId);
|
||||||
|
this.adapters.set("redx", redx);
|
||||||
}
|
}
|
||||||
|
|
||||||
listCouriers() {
|
listCouriers() {
|
||||||
|
|||||||
+7
-3
@@ -16,11 +16,11 @@ export const TOOLS: Tool[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_parcel",
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
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)" },
|
invoice: { type: "string", description: "Unique order invoice number (e.g. INV-1002)" },
|
||||||
recipient_name: { type: "string", description: "Customer full name" },
|
recipient_name: { type: "string", description: "Customer full name" },
|
||||||
recipient_phone: { type: "string", description: "11-digit Bangladeshi mobile number (e.g. 017XXXXXXXX)" },
|
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)" },
|
cod_amount: { type: "number", description: "Cash on delivery amount in BDT (0 if prepaid)" },
|
||||||
note: { type: "string", description: "Special instructions for delivery rider" },
|
note: { type: "string", description: "Special instructions for delivery rider" },
|
||||||
item_weight: { type: "number", description: "Parcel weight in KG (default: 0.5)" },
|
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"],
|
required: ["invoice", "recipient_name", "recipient_phone", "recipient_address", "cod_amount"],
|
||||||
},
|
},
|
||||||
@@ -39,7 +43,7 @@ export const TOOLS: Tool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
tracking_code: { type: "string", description: "Consignment ID or tracking code" },
|
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"],
|
required: ["tracking_code"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export interface ParcelCreateRequest {
|
|||||||
note?: string;
|
note?: string;
|
||||||
item_type?: string;
|
item_type?: string;
|
||||||
item_weight?: number; // in KG
|
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 {
|
export interface ParcelResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user