@zebpay_rajesh/zebpay-mcp-server 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.
Potentially problematic release.
This version of @zebpay_rajesh/zebpay-mcp-server might be problematic. Click here for more details.
- package/.env.example +14 -0
- package/README.md +223 -0
- package/dist/__tests__/errors.test.d.ts +5 -0
- package/dist/__tests__/errors.test.js +147 -0
- package/dist/__tests__/errors.test.js.map +1 -0
- package/dist/__tests__/prompts.test.d.ts +1 -0
- package/dist/__tests__/prompts.test.js +73 -0
- package/dist/__tests__/prompts.test.js.map +1 -0
- package/dist/__tests__/resources.test.d.ts +1 -0
- package/dist/__tests__/resources.test.js +79 -0
- package/dist/__tests__/resources.test.js.map +1 -0
- package/dist/__tests__/validation.test.d.ts +15 -0
- package/dist/__tests__/validation.test.js +64 -0
- package/dist/__tests__/validation.test.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +81 -0
- package/dist/config.js.map +1 -0
- package/dist/http/httpClient.d.ts +40 -0
- package/dist/http/httpClient.js +341 -0
- package/dist/http/httpClient.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/errors.d.ts +21 -0
- package/dist/mcp/errors.js +214 -0
- package/dist/mcp/errors.js.map +1 -0
- package/dist/mcp/logging.d.ts +21 -0
- package/dist/mcp/logging.js +241 -0
- package/dist/mcp/logging.js.map +1 -0
- package/dist/mcp/prompts.d.ts +9 -0
- package/dist/mcp/prompts.js +165 -0
- package/dist/mcp/prompts.js.map +1 -0
- package/dist/mcp/resources.d.ts +9 -0
- package/dist/mcp/resources.js +125 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/tools_futures.d.ts +5 -0
- package/dist/mcp/tools_futures.js +694 -0
- package/dist/mcp/tools_futures.js.map +1 -0
- package/dist/mcp/tools_spot.d.ts +11 -0
- package/dist/mcp/tools_spot.js +2225 -0
- package/dist/mcp/tools_spot.js.map +1 -0
- package/dist/private/FuturesClient.d.ts +57 -0
- package/dist/private/FuturesClient.js +181 -0
- package/dist/private/FuturesClient.js.map +1 -0
- package/dist/private/SpotClient.d.ts +44 -0
- package/dist/private/SpotClient.js +201 -0
- package/dist/private/SpotClient.js.map +1 -0
- package/dist/private/ZebpayAPI.d.ts +19 -0
- package/dist/private/ZebpayAPI.js +172 -0
- package/dist/private/ZebpayAPI.js.map +1 -0
- package/dist/public/PublicClient.d.ts +79 -0
- package/dist/public/PublicClient.js +283 -0
- package/dist/public/PublicClient.js.map +1 -0
- package/dist/public/PublicFuturesClient.d.ts +27 -0
- package/dist/public/PublicFuturesClient.js +187 -0
- package/dist/public/PublicFuturesClient.js.map +1 -0
- package/dist/security/credentials.d.ts +42 -0
- package/dist/security/credentials.js +80 -0
- package/dist/security/credentials.js.map +1 -0
- package/dist/security/signing.d.ts +33 -0
- package/dist/security/signing.js +56 -0
- package/dist/security/signing.js.map +1 -0
- package/dist/types/responses.d.ts +130 -0
- package/dist/types/responses.js +6 -0
- package/dist/types/responses.js.map +1 -0
- package/dist/utils/cache.d.ts +29 -0
- package/dist/utils/cache.js +72 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/fileLogger.d.ts +10 -0
- package/dist/utils/fileLogger.js +81 -0
- package/dist/utils/fileLogger.js.map +1 -0
- package/dist/utils/metrics.d.ts +35 -0
- package/dist/utils/metrics.js +94 -0
- package/dist/utils/metrics.js.map +1 -0
- package/dist/utils/responseFormatter.d.ts +93 -0
- package/dist/utils/responseFormatter.js +268 -0
- package/dist/utils/responseFormatter.js.map +1 -0
- package/dist/validation/schemas.d.ts +70 -0
- package/dist/validation/schemas.js +48 -0
- package/dist/validation/schemas.js.map +1 -0
- package/dist/validation/validators.d.ts +28 -0
- package/dist/validation/validators.js +129 -0
- package/dist/validation/validators.js.map +1 -0
- package/docs/LOGGING.md +371 -0
- package/docs/zebpay-ai-trading-beginner.png +0 -0
- package/mcp-config.json.example +20 -0
- package/package.json +54 -0
- package/scripts/README.md +103 -0
- package/scripts/clear-logs.js +52 -0
- package/scripts/log-stats.js +264 -0
- package/scripts/log-viewer.js +288 -0
- package/server.json +31 -0
- package/src/__tests__/errors.test.ts +180 -0
- package/src/__tests__/prompts.test.ts +89 -0
- package/src/__tests__/resources.test.ts +95 -0
- package/src/__tests__/validation.test.ts +88 -0
- package/src/config.ts +108 -0
- package/src/http/httpClient.ts +398 -0
- package/src/index.ts +71 -0
- package/src/mcp/errors.ts +262 -0
- package/src/mcp/logging.ts +284 -0
- package/src/mcp/prompts.ts +206 -0
- package/src/mcp/resources.ts +163 -0
- package/src/mcp/tools_futures.ts +874 -0
- package/src/mcp/tools_spot.ts +2702 -0
- package/src/private/FuturesClient.ts +189 -0
- package/src/private/SpotClient.ts +250 -0
- package/src/private/ZebpayAPI.ts +205 -0
- package/src/public/PublicClient.ts +381 -0
- package/src/public/PublicFuturesClient.ts +228 -0
- package/src/security/credentials.ts +114 -0
- package/src/security/signing.ts +98 -0
- package/src/types/responses.ts +146 -0
- package/src/utils/cache.ts +90 -0
- package/src/utils/fileLogger.ts +88 -0
- package/src/utils/metrics.ts +135 -0
- package/src/utils/responseFormatter.ts +361 -0
- package/src/validation/schemas.ts +66 -0
- package/src/validation/validators.ts +189 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Basic test setup for ZebPay MCP Server
|
|
3
|
+
Note: Install jest dependencies first: npm install --save-dev jest @types/jest ts-jest
|
|
4
|
+
Then run: npm test
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { validateSymbol, validateQuantity } from "../validation/validators.js";
|
|
8
|
+
import { createInvalidParamsError } from "../mcp/errors.js";
|
|
9
|
+
|
|
10
|
+
// Basic test suite (requires jest to be installed)
|
|
11
|
+
describe("Validation", () => {
|
|
12
|
+
describe("validateSymbol", () => {
|
|
13
|
+
it("should accept valid symbols", () => {
|
|
14
|
+
expect(() => validateSymbol("BTC-INR")).not.toThrow();
|
|
15
|
+
expect(() => validateSymbol("ETH-INR")).not.toThrow();
|
|
16
|
+
expect(() => validateSymbol("BTC-USDT")).not.toThrow();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should reject invalid symbols", () => {
|
|
20
|
+
expect(() => validateSymbol("BTCINR")).toThrow();
|
|
21
|
+
expect(() => validateSymbol("BTC/INR")).toThrow();
|
|
22
|
+
// Note: "btc-inr" is converted to uppercase and becomes valid "BTC-INR"
|
|
23
|
+
// So we test with a symbol that's invalid even after conversion
|
|
24
|
+
expect(() => validateSymbol("")).toThrow();
|
|
25
|
+
expect(() => validateSymbol("BTC-INR-EXTRA")).toThrow(); // Too many parts
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should accept lowercase symbols (converts to uppercase)", () => {
|
|
29
|
+
// The function converts to uppercase, so lowercase should work
|
|
30
|
+
expect(() => validateSymbol("btc-inr")).not.toThrow();
|
|
31
|
+
expect(() => validateSymbol("eth-inr")).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("validateQuantity", () => {
|
|
36
|
+
it("should accept valid quantities", () => {
|
|
37
|
+
expect(() => validateQuantity("0.001")).not.toThrow();
|
|
38
|
+
expect(() => validateQuantity("100")).not.toThrow();
|
|
39
|
+
expect(() => validateQuantity("0.5")).not.toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should reject invalid quantities", () => {
|
|
43
|
+
expect(() => validateQuantity("")).toThrow();
|
|
44
|
+
expect(() => validateQuantity("0")).toThrow();
|
|
45
|
+
expect(() => validateQuantity("-1")).toThrow();
|
|
46
|
+
expect(() => validateQuantity("abc")).toThrow();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("Error Handling", () => {
|
|
52
|
+
it("should create invalid params error with correct code", () => {
|
|
53
|
+
const error = createInvalidParamsError("Test error");
|
|
54
|
+
// Note: These assertions require jest to be installed
|
|
55
|
+
// expect(error).toBeInstanceOf(Error);
|
|
56
|
+
// expect(error.code).toBe(-32602);
|
|
57
|
+
// expect(error.message).toBe("Test error");
|
|
58
|
+
|
|
59
|
+
// Basic validation without jest
|
|
60
|
+
if (!(error instanceof Error)) {
|
|
61
|
+
throw new Error("Expected error to be instance of Error");
|
|
62
|
+
}
|
|
63
|
+
if (error.code !== -32602) {
|
|
64
|
+
throw new Error(`Expected error code -32602, got ${error.code}`);
|
|
65
|
+
}
|
|
66
|
+
// MCP error messages include code prefix: "MCP error -32602: <message>"
|
|
67
|
+
if (!error.message.includes("Test error")) {
|
|
68
|
+
throw new Error(`Expected error message to contain "Test error", got "${error.message}"`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Helper functions for tests (if jest is not available, these provide basic functionality)
|
|
74
|
+
declare global {
|
|
75
|
+
function describe(name: string, fn: () => void): void;
|
|
76
|
+
function it(name: string, fn: () => void): void;
|
|
77
|
+
function expect(fn: () => void): {
|
|
78
|
+
not: { toThrow: () => void };
|
|
79
|
+
toThrow: () => void;
|
|
80
|
+
};
|
|
81
|
+
function expect<T>(value: T): {
|
|
82
|
+
toBeInstanceOf: (constructor: new (...args: any[]) => T) => void;
|
|
83
|
+
toBe: (expected: T) => void;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Centralized configuration loading and validation.
|
|
3
|
+
Secrets are never logged; use redact() when including values in logs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type TransportKind = "stdio";
|
|
7
|
+
|
|
8
|
+
export interface SigningHeaderNames {
|
|
9
|
+
apiKeyHeader: string;
|
|
10
|
+
signatureHeader: string;
|
|
11
|
+
timestampHeader: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SIGNING_HEADERS: SigningHeaderNames = {
|
|
15
|
+
apiKeyHeader: "X-AUTH-APIKEY",
|
|
16
|
+
signatureHeader: "X-AUTH-SIGNATURE",
|
|
17
|
+
timestampHeader: "",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface AppConfig {
|
|
21
|
+
spotBaseUrl: string;
|
|
22
|
+
futuresBaseUrl: string;
|
|
23
|
+
marketBaseUrl: string;
|
|
24
|
+
transports: TransportKind[];
|
|
25
|
+
logLevel: "debug" | "info" | "warn" | "error";
|
|
26
|
+
logFile?: string; // Optional log file path
|
|
27
|
+
signingHeaders: SigningHeaderNames;
|
|
28
|
+
timeoutMs: number;
|
|
29
|
+
retryCount: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function redact(value: string | undefined | null, show: number = 4): string {
|
|
33
|
+
if (!value) return "<redacted>";
|
|
34
|
+
if (value.length <= show) return "*".repeat(value.length);
|
|
35
|
+
return `${value.slice(0, show)}${"*".repeat(Math.max(4, value.length - show))}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getRequiredEnv(name: string): string {
|
|
39
|
+
const value = process.env[name];
|
|
40
|
+
if (value === undefined) {
|
|
41
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseTransports(input: string | undefined): TransportKind[] {
|
|
47
|
+
const raw = (input ?? "stdio").split(",").map((s) => s.trim()).filter(Boolean);
|
|
48
|
+
const valid: TransportKind[] = [];
|
|
49
|
+
for (const t of raw) {
|
|
50
|
+
if (t === "stdio") valid.push(t);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!valid.length) {
|
|
54
|
+
throw new Error('Invalid MCP_TRANSPORTS. Only "stdio" is supported.');
|
|
55
|
+
}
|
|
56
|
+
return valid;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseLogLevel(input: string | undefined): AppConfig["logLevel"] {
|
|
60
|
+
if (input === undefined) return "info";
|
|
61
|
+
if (input === "debug" || input === "info" || input === "warn" || input === "error") {
|
|
62
|
+
return input;
|
|
63
|
+
}
|
|
64
|
+
throw new Error('Invalid LOG_LEVEL. Use one of: "debug", "info", "warn", "error".');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getConfig(): AppConfig {
|
|
68
|
+
const spotBaseUrl = getRequiredEnv("ZEBPAY_SPOT_BASE_URL") || 'https://www.zebapi.com/api/v2';
|
|
69
|
+
const futuresBaseUrl = getRequiredEnv("ZEBPAY_FUTURES_BASE_URL") || 'https://futures-api.zebpay.com/api/v1';
|
|
70
|
+
const marketBaseUrl = getRequiredEnv("ZEBPAY_MARKET_BASE_URL") || 'https://www.zebapi.com/api/v1/market';
|
|
71
|
+
const transports = parseTransports(process.env.MCP_TRANSPORTS);
|
|
72
|
+
const logLevel = parseLogLevel(process.env.LOG_LEVEL);
|
|
73
|
+
const logFile = process.env.LOG_FILE; // Optional log file path
|
|
74
|
+
const timeoutMs = Number(process.env.HTTP_TIMEOUT_MS ?? 15000);
|
|
75
|
+
const retryCount = Number(process.env.HTTP_RETRY_COUNT ?? 2);
|
|
76
|
+
|
|
77
|
+
const signingHeaders: SigningHeaderNames = SIGNING_HEADERS;
|
|
78
|
+
|
|
79
|
+
if (!spotBaseUrl.startsWith("http")) {
|
|
80
|
+
throw new Error("Invalid spot base URL");
|
|
81
|
+
}
|
|
82
|
+
if (!futuresBaseUrl.startsWith("http")) {
|
|
83
|
+
throw new Error("Invalid futures base URL");
|
|
84
|
+
}
|
|
85
|
+
if (!marketBaseUrl.startsWith("http")) {
|
|
86
|
+
throw new Error("Invalid market base URL");
|
|
87
|
+
}
|
|
88
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
89
|
+
throw new Error("Invalid HTTP_TIMEOUT_MS");
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isFinite(retryCount) || retryCount < 0) {
|
|
92
|
+
throw new Error("Invalid HTTP_RETRY_COUNT");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
spotBaseUrl,
|
|
97
|
+
futuresBaseUrl,
|
|
98
|
+
marketBaseUrl,
|
|
99
|
+
transports,
|
|
100
|
+
logLevel,
|
|
101
|
+
logFile,
|
|
102
|
+
signingHeaders,
|
|
103
|
+
timeoutMs,
|
|
104
|
+
retryCount,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Minimal HTTP client with retries, timeout, structured errors, and redacted logs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { redact } from "../config.js";
|
|
7
|
+
import { fetch } from "undici";
|
|
8
|
+
import { fileLogger } from "../utils/fileLogger.js";
|
|
9
|
+
|
|
10
|
+
export interface HttpRequestOptions {
|
|
11
|
+
method: string;
|
|
12
|
+
url: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
retryCount: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HttpResponse<T = unknown> {
|
|
20
|
+
status: number;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
data: T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class HttpError extends Error {
|
|
26
|
+
public readonly status: number;
|
|
27
|
+
public readonly details?: unknown;
|
|
28
|
+
public readonly isHtmlResponse?: boolean;
|
|
29
|
+
constructor(message: string, status: number, details?: unknown, isHtmlResponse?: boolean) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
this.details = details;
|
|
33
|
+
this.isHtmlResponse = isHtmlResponse;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parses HTML error pages (like Cloudflare) to extract user-friendly error messages.
|
|
39
|
+
*
|
|
40
|
+
* NOTE: This is transport-agnostic - it parses responses from Zebpay API (external),
|
|
41
|
+
* not from MCP clients. Works identically for both stdio and HTTP transports.
|
|
42
|
+
*/
|
|
43
|
+
function parseHtmlError(html: string): string | null {
|
|
44
|
+
if (!html || typeof html !== "string") {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check if it's HTML
|
|
49
|
+
if (!html.trim().toLowerCase().startsWith("<!doctype") && !html.trim().toLowerCase().startsWith("<html")) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Extract Cloudflare error messages
|
|
54
|
+
const cloudflarePatterns = [
|
|
55
|
+
// Cloudflare Error 1006 - Access denied
|
|
56
|
+
/<h2[^>]*>Access denied<\/h2>/i,
|
|
57
|
+
/The owner of this website.*?has banned your IP address[^<]*/i,
|
|
58
|
+
/<p[^>]*>The owner of this website[^<]*<\/p>/i,
|
|
59
|
+
// Cloudflare Error 1020 - Access denied
|
|
60
|
+
/<h1[^>]*>Error[^<]*<\/h1>/i,
|
|
61
|
+
// Generic error extraction from title
|
|
62
|
+
/<title>([^<]+)<\/title>/i,
|
|
63
|
+
// Extract error messages from h1/h2 tags
|
|
64
|
+
/<h[12][^>]*>([^<]+)<\/h[12]>/i,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
for (const pattern of cloudflarePatterns) {
|
|
68
|
+
const match = html.match(pattern);
|
|
69
|
+
if (match) {
|
|
70
|
+
let message = match[1] || match[0];
|
|
71
|
+
// Clean up HTML entities and tags
|
|
72
|
+
message = message
|
|
73
|
+
.replace(/<[^>]+>/g, "")
|
|
74
|
+
.replace(/ /g, " ")
|
|
75
|
+
.replace(/&/g, "&")
|
|
76
|
+
.replace(/</g, "<")
|
|
77
|
+
.replace(/>/g, ">")
|
|
78
|
+
.replace(/"/g, '"')
|
|
79
|
+
.replace(/\s+/g, " ")
|
|
80
|
+
.trim();
|
|
81
|
+
|
|
82
|
+
if (message && message.length > 10) {
|
|
83
|
+
// Check for IP ban message
|
|
84
|
+
if (html.includes("banned your IP address")) {
|
|
85
|
+
const ipMatch = html.match(/IP address[^<]*\(([^)]+)\)/i);
|
|
86
|
+
if (ipMatch) {
|
|
87
|
+
return `Access denied: Your IP address has been banned by the website. Please contact support or try again later.`;
|
|
88
|
+
}
|
|
89
|
+
return `Access denied: Your IP address has been restricted. Please contact support or try again later.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check for Cloudflare error codes
|
|
93
|
+
const errorCodeMatch = html.match(/Error\s*(\d+)/i);
|
|
94
|
+
if (errorCodeMatch) {
|
|
95
|
+
const errorCode = errorCodeMatch[1];
|
|
96
|
+
if (errorCode === "1006") {
|
|
97
|
+
return `Access denied (Error 1006): Your IP address has been banned. Please contact support or try again later.`;
|
|
98
|
+
}
|
|
99
|
+
return `Cloudflare error (${errorCode}): ${message}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return message;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fallback: try to extract any meaningful text
|
|
108
|
+
const textMatch = html.match(/<body[^>]*>([\s\S]{100,500})<\/body>/i);
|
|
109
|
+
if (textMatch) {
|
|
110
|
+
let text = textMatch[1]
|
|
111
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
112
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
113
|
+
.replace(/<[^>]+>/g, " ")
|
|
114
|
+
.replace(/\s+/g, " ")
|
|
115
|
+
.trim();
|
|
116
|
+
|
|
117
|
+
if (text.length > 20 && text.length < 200) {
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class HttpClient {
|
|
126
|
+
constructor(private readonly logLevel: "debug" | "info" | "warn" | "error" = "info") {}
|
|
127
|
+
|
|
128
|
+
async request<T = unknown>(opts: HttpRequestOptions): Promise<HttpResponse<T>> {
|
|
129
|
+
const { method, url, headers = {}, body, timeoutMs, retryCount } = opts;
|
|
130
|
+
const isIdempotent = method.toUpperCase() === "GET";
|
|
131
|
+
const maxAttempts = isIdempotent ? retryCount + 1 : 1;
|
|
132
|
+
|
|
133
|
+
const payload = body === undefined || body === null ? undefined : (typeof body === "string" ? body : JSON.stringify(body));
|
|
134
|
+
|
|
135
|
+
// Prepare headers for request (with content-type)
|
|
136
|
+
const requestHeaders: Record<string, string> = {
|
|
137
|
+
...headers,
|
|
138
|
+
};
|
|
139
|
+
if (payload) {
|
|
140
|
+
requestHeaders["content-type"] = "application/json";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Log HTTP request
|
|
144
|
+
const requestTimestamp = new Date().toISOString();
|
|
145
|
+
const requestLogMessage = JSON.stringify({
|
|
146
|
+
level: "info",
|
|
147
|
+
type: "http_request",
|
|
148
|
+
timestamp: requestTimestamp,
|
|
149
|
+
method: method.toUpperCase(),
|
|
150
|
+
url,
|
|
151
|
+
headers: this.sanitizeHeaders(requestHeaders),
|
|
152
|
+
body: this.sanitizeBody(payload),
|
|
153
|
+
});
|
|
154
|
+
fileLogger.log(requestLogMessage);
|
|
155
|
+
|
|
156
|
+
let attempt = 0;
|
|
157
|
+
let lastError: unknown;
|
|
158
|
+
while (attempt < maxAttempts) {
|
|
159
|
+
attempt++;
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
162
|
+
const attemptStartTime = Date.now();
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(url, {
|
|
165
|
+
method,
|
|
166
|
+
headers: requestHeaders,
|
|
167
|
+
body: payload,
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
} as any);
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
|
|
172
|
+
const text = await res.text();
|
|
173
|
+
const contentType = res.headers.get("content-type") || "";
|
|
174
|
+
const isHtml = contentType.includes("text/html") || text.trim().toLowerCase().startsWith("<!doctype") || text.trim().toLowerCase().startsWith("<html");
|
|
175
|
+
|
|
176
|
+
let data: any = undefined;
|
|
177
|
+
let parsedError: string | null = null;
|
|
178
|
+
|
|
179
|
+
if (isHtml && !res.ok) {
|
|
180
|
+
// Try to parse HTML error page
|
|
181
|
+
parsedError = parseHtmlError(text);
|
|
182
|
+
data = text; // Keep original HTML for logging
|
|
183
|
+
} else {
|
|
184
|
+
// Try to parse as JSON
|
|
185
|
+
try {
|
|
186
|
+
data = text ? JSON.parse(text) : undefined;
|
|
187
|
+
} catch {
|
|
188
|
+
data = text;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const response: HttpResponse<T> = {
|
|
193
|
+
status: res.status,
|
|
194
|
+
headers: Object.fromEntries(res.headers.entries()),
|
|
195
|
+
data,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const durationMs = Date.now() - attemptStartTime;
|
|
199
|
+
|
|
200
|
+
// Log HTTP response with user-readable format
|
|
201
|
+
const responseTimestamp = new Date().toISOString();
|
|
202
|
+
const responseBody = this.formatResponseBody(data, res.status);
|
|
203
|
+
|
|
204
|
+
const responseLogMessage = JSON.stringify({
|
|
205
|
+
level: res.ok ? "info" : "warn",
|
|
206
|
+
type: "http_response",
|
|
207
|
+
timestamp: responseTimestamp,
|
|
208
|
+
method: method.toUpperCase(),
|
|
209
|
+
url,
|
|
210
|
+
status: res.status,
|
|
211
|
+
headers: this.sanitizeHeaders(response.headers),
|
|
212
|
+
body: responseBody,
|
|
213
|
+
durationMs,
|
|
214
|
+
attempt,
|
|
215
|
+
isHtmlResponse: isHtml,
|
|
216
|
+
});
|
|
217
|
+
fileLogger.log(responseLogMessage);
|
|
218
|
+
|
|
219
|
+
if (res.ok) {
|
|
220
|
+
return response;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Retry on 429/5xx when idempotent
|
|
224
|
+
if (isIdempotent && (res.status === 429 || (res.status >= 500 && res.status < 600)) && attempt < maxAttempts) {
|
|
225
|
+
const retryAfter = Number(response.headers["retry-after"] || 0);
|
|
226
|
+
const backoffMs = retryAfter > 0 ? retryAfter * 1000 : 250 * attempt;
|
|
227
|
+
await delay(backoffMs);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Use parsed error message if available, otherwise use default
|
|
232
|
+
const errorMessage = parsedError || `HTTP ${res.status}`;
|
|
233
|
+
throw new HttpError(errorMessage, res.status, data, isHtml);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
lastError = err;
|
|
237
|
+
const durationMs = Date.now() - attemptStartTime;
|
|
238
|
+
|
|
239
|
+
// AbortError or network error
|
|
240
|
+
if (attempt >= maxAttempts) {
|
|
241
|
+
// Log HTTP error
|
|
242
|
+
const errorTimestamp = new Date().toISOString();
|
|
243
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
244
|
+
const errorStack = err instanceof Error ? err.stack : undefined;
|
|
245
|
+
|
|
246
|
+
const errorLogMessage = JSON.stringify({
|
|
247
|
+
level: "error",
|
|
248
|
+
type: "http_error",
|
|
249
|
+
timestamp: errorTimestamp,
|
|
250
|
+
method: method.toUpperCase(),
|
|
251
|
+
url,
|
|
252
|
+
headers: this.sanitizeHeaders(requestHeaders),
|
|
253
|
+
body: this.sanitizeBody(payload),
|
|
254
|
+
durationMs,
|
|
255
|
+
attempt,
|
|
256
|
+
error: errorMessage,
|
|
257
|
+
stack: this.sanitizeStack(errorStack),
|
|
258
|
+
});
|
|
259
|
+
fileLogger.log(errorLogMessage);
|
|
260
|
+
|
|
261
|
+
if (err instanceof HttpError) throw err;
|
|
262
|
+
throw new HttpError((err as Error).message || "Network error", 0);
|
|
263
|
+
}
|
|
264
|
+
await delay(200 * attempt);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
throw lastError instanceof Error ? lastError : new Error("Unknown HTTP error");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Sanitizes stack traces by removing file paths, keeping only function names and line numbers
|
|
273
|
+
*/
|
|
274
|
+
private sanitizeStack(stack: string | undefined): string | undefined {
|
|
275
|
+
if (!stack) return undefined;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
// Remove file:// URLs and absolute paths, keep only filename and line numbers
|
|
279
|
+
const lines = stack.split('\n');
|
|
280
|
+
const sanitizedLines = lines.map(line => {
|
|
281
|
+
let sanitized = line;
|
|
282
|
+
|
|
283
|
+
// Remove file:// URLs (e.g., file:///Users/path/to/file.js:123:45)
|
|
284
|
+
sanitized = sanitized.replace(/file:\/\/\/[^\s\)]+/g, (match) => {
|
|
285
|
+
// Extract just the filename and line numbers
|
|
286
|
+
const filenameMatch = match.match(/([^\/]+\.(js|ts|mjs|cjs)):(\d+):(\d+)/);
|
|
287
|
+
if (filenameMatch) {
|
|
288
|
+
return `${filenameMatch[1]}:${filenameMatch[3]}:${filenameMatch[4]}`;
|
|
289
|
+
}
|
|
290
|
+
return '';
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Remove absolute paths (e.g., /Users/path/to/file.js:123:45)
|
|
294
|
+
sanitized = sanitized.replace(/\/(?:[^\/\s]+\/)+([^\/\s]+\.(js|ts|mjs|cjs)):(\d+):(\d+)/g, '$1:$3:$4');
|
|
295
|
+
|
|
296
|
+
// Simplify node_modules paths (e.g., node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js)
|
|
297
|
+
sanitized = sanitized.replace(/node_modules\/([^\/\s]+)\/[^\s\)]+/g, 'node_modules/$1');
|
|
298
|
+
|
|
299
|
+
return sanitized.trim();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
return sanitizedLines.join('\n');
|
|
303
|
+
} catch {
|
|
304
|
+
// If sanitization fails, return a minimal version without paths
|
|
305
|
+
return stack.split('\n').slice(0, 3).join('\n'); // Keep only first 3 lines
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Formats response body to be more user-readable
|
|
311
|
+
*/
|
|
312
|
+
private formatResponseBody(body: unknown, status: number): unknown {
|
|
313
|
+
if (body === undefined || body === null) {
|
|
314
|
+
return body;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// For error responses, extract meaningful information
|
|
318
|
+
if (status >= 400 && typeof body === "object" && body !== null) {
|
|
319
|
+
const bodyObj = body as Record<string, unknown>;
|
|
320
|
+
const formatted: Record<string, unknown> = {};
|
|
321
|
+
|
|
322
|
+
// Extract common error fields
|
|
323
|
+
if (typeof bodyObj.statusCode === "number") {
|
|
324
|
+
formatted.statusCode = bodyObj.statusCode;
|
|
325
|
+
}
|
|
326
|
+
if (typeof bodyObj.statusDescription === "string") {
|
|
327
|
+
formatted.statusDescription = bodyObj.statusDescription;
|
|
328
|
+
}
|
|
329
|
+
if (typeof bodyObj.error === "string") {
|
|
330
|
+
formatted.error = bodyObj.error;
|
|
331
|
+
}
|
|
332
|
+
if (typeof bodyObj.message === "string") {
|
|
333
|
+
formatted.message = bodyObj.message;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Include data if present
|
|
337
|
+
if (bodyObj.data !== undefined) {
|
|
338
|
+
formatted.data = bodyObj.data;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// If we extracted meaningful fields, return formatted version
|
|
342
|
+
if (Object.keys(formatted).length > 0) {
|
|
343
|
+
return formatted;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// For non-error responses or if formatting didn't extract anything, use sanitized body
|
|
348
|
+
return this.sanitizeBody(body);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Sanitizes headers to redact sensitive information
|
|
353
|
+
*/
|
|
354
|
+
private sanitizeHeaders(headers: Record<string, string | undefined>): Record<string, string> {
|
|
355
|
+
const sanitized: Record<string, string> = {};
|
|
356
|
+
const sensitiveKeys = ["key", "sign", "secret", "password", "token", "authorization", "auth"];
|
|
357
|
+
|
|
358
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
359
|
+
if (value === undefined) continue;
|
|
360
|
+
const lowerKey = key.toLowerCase();
|
|
361
|
+
if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) {
|
|
362
|
+
sanitized[key] = redact(value);
|
|
363
|
+
} else {
|
|
364
|
+
sanitized[key] = value;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return sanitized;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Sanitizes body to prevent logging huge payloads
|
|
373
|
+
*/
|
|
374
|
+
private sanitizeBody(body: unknown): unknown {
|
|
375
|
+
if (body === undefined || body === null) {
|
|
376
|
+
return body;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// If body is a string, check size
|
|
380
|
+
if (typeof body === "string") {
|
|
381
|
+
if (body.length > 5000) {
|
|
382
|
+
return body.substring(0, 5000) + "... [truncated]";
|
|
383
|
+
}
|
|
384
|
+
return body;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// If body is an object, stringify and check size
|
|
388
|
+
try {
|
|
389
|
+
const jsonStr = JSON.stringify(body);
|
|
390
|
+
if (jsonStr.length > 5000) {
|
|
391
|
+
return JSON.parse(jsonStr.substring(0, 5000) + '..."}');
|
|
392
|
+
}
|
|
393
|
+
return body;
|
|
394
|
+
} catch {
|
|
395
|
+
return body;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { config as dotenvConfig } from "dotenv";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
|
|
5
|
+
import { getConfig } from "./config.js";
|
|
6
|
+
import { EnvCredentialsProvider } from "./security/credentials.js";
|
|
7
|
+
import { HttpClient } from "./http/httpClient.js";
|
|
8
|
+
import { ZebpayAPI } from "./private/ZebpayAPI.js";
|
|
9
|
+
import { SpotClient } from "./private/SpotClient.js";
|
|
10
|
+
import { FuturesClient } from "./private/FuturesClient.js";
|
|
11
|
+
import { PublicClient } from "./public/PublicClient.js";
|
|
12
|
+
import { PublicFuturesClient } from "./public/PublicFuturesClient.js";
|
|
13
|
+
import { registerSpotTools } from "./mcp/tools_spot.js";
|
|
14
|
+
import { registerFuturesTools } from "./mcp/tools_futures.js";
|
|
15
|
+
import { registerResources } from "./mcp/resources.js";
|
|
16
|
+
import { registerPrompts } from "./mcp/prompts.js";
|
|
17
|
+
import { fileLogger } from "./utils/fileLogger.js";
|
|
18
|
+
|
|
19
|
+
dotenvConfig();
|
|
20
|
+
|
|
21
|
+
function createServer(): McpServer {
|
|
22
|
+
return new McpServer(
|
|
23
|
+
{
|
|
24
|
+
name: "zebpay",
|
|
25
|
+
version: "1.0.0",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
capabilities: {
|
|
29
|
+
tools: {},
|
|
30
|
+
logging: {},
|
|
31
|
+
resources: {},
|
|
32
|
+
prompts: {},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main(): Promise<void> {
|
|
39
|
+
const cfg = getConfig();
|
|
40
|
+
if (cfg.logFile) {
|
|
41
|
+
await fileLogger.initialize(cfg.logFile);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const creds = new EnvCredentialsProvider();
|
|
45
|
+
const http = new HttpClient(cfg.logLevel);
|
|
46
|
+
const api = new ZebpayAPI(cfg, creds, http);
|
|
47
|
+
const spot = new SpotClient(api);
|
|
48
|
+
const futuresPrivateClient = new FuturesClient(api);
|
|
49
|
+
const publicClient = new PublicClient(cfg, http);
|
|
50
|
+
const futuresPublicClient = new PublicFuturesClient(cfg, http);
|
|
51
|
+
|
|
52
|
+
const mcp = createServer();
|
|
53
|
+
registerSpotTools(mcp, spot, publicClient, cfg);
|
|
54
|
+
registerFuturesTools(mcp, futuresPublicClient, futuresPrivateClient, cfg);
|
|
55
|
+
registerResources(mcp, publicClient, spot, cfg);
|
|
56
|
+
registerPrompts(mcp, spot, publicClient, cfg);
|
|
57
|
+
|
|
58
|
+
const transport = new StdioServerTransport();
|
|
59
|
+
await mcp.connect(transport);
|
|
60
|
+
|
|
61
|
+
process.on("SIGINT", async () => {
|
|
62
|
+
await fileLogger.close();
|
|
63
|
+
process.exit(0);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main().catch(async (error) => {
|
|
68
|
+
console.error("FATAL: Failed to start Zebpay MCP stdio server", error);
|
|
69
|
+
await fileLogger.close();
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|