@mikoto_zero/minigame-open-mcp
Version:
TapTap Minigame Open API MCP Server - Documentation and Management APIs for TapTap minigame (Leaderboard, and more features coming)
309 lines • 12.6 kB
JavaScript
/**
* HTTP Client for TapTap API Requests
* Handles MAC authentication, request signing, headers, and error responses
*/
import process from 'node:process';
import cryptoJS from 'crypto-js';
import { logger } from '../utils/logger.js';
/**
* Environment configuration
*/
export class ApiConfig {
constructor() {
// Required environment variables (TDS_MCP_* prefix for consistency)
const macTokenStr = process.env.TDS_MCP_MAC_TOKEN || '';
this.clientId = process.env.TDS_MCP_CLIENT_ID || '';
this.clientSecret = process.env.TDS_MCP_CLIENT_TOKEN || ''; // Using CLIENT_TOKEN to match tapcode-mcp-h5
// Parse MAC Token from JSON string
try {
this.macToken = macTokenStr ? JSON.parse(macTokenStr) : {};
}
catch (error) {
process.stderr.write('❌ Failed to parse TDS_MCP_MAC_TOKEN: Invalid JSON format\n');
process.exit(1);
}
// Optional: default to production
this.environment = (process.env.TDS_MCP_ENV === 'rnd') ? 'rnd' : 'production';
// Set API base URL based on environment
this.apiBaseUrl = this.environment === 'production'
? 'https://agent.tapapis.cn'
: 'https://agent.api.xdrnd.cn';
// Validate required environment variables
this.validateConfig();
}
validateConfig() {
const missing = [];
if (!this.macToken.kid || !this.macToken.mac_key) {
missing.push('TDS_MCP_MAC_TOKEN (must be valid JSON with kid and mac_key)');
}
if (!this.clientId) {
missing.push('TDS_MCP_CLIENT_ID');
}
if (!this.clientSecret) {
missing.push('TDS_MCP_CLIENT_TOKEN');
}
if (missing.length > 0) {
process.stderr.write(`❌ Missing required environment variables: ${missing.join(', ')}\n`);
process.stderr.write('\nExample TDS_MCP_MAC_TOKEN format:\n');
process.stderr.write('{"kid":"abc123","token_type":"mac","mac_key":"secret_key","mac_algorithm":"hmac-sha-1"}\n');
process.exit(1);
}
}
static getInstance() {
if (!ApiConfig.instance) {
ApiConfig.instance = new ApiConfig();
}
return ApiConfig.instance;
}
isConfigured() {
return !!(this.macToken.kid && this.macToken.mac_key && this.clientId && this.clientSecret);
}
getConfigStatus() {
return {
'TDS_MCP_MAC_TOKEN': this.macToken.kid ? `✅ 已配置 (kid: ${this.macToken.kid.substring(0, 8)}...)` : '❌ 未配置',
'TDS_MCP_CLIENT_ID': this.clientId ? '✅ 已配置' : '❌ 未配置',
'TDS_MCP_CLIENT_TOKEN': this.clientSecret ? '✅ 已配置' : '❌ 未配置',
'TDS_MCP_ENV': `${this.environment} (${this.apiBaseUrl})`,
};
}
}
/**
* Generic HTTP Client for TapTap API
*/
export class HttpClient {
constructor() {
this.config = ApiConfig.getInstance();
}
/**
* Make a GET request
*/
async get(path, options = {}) {
return this.request('GET', path, options);
}
/**
* Make a POST request
*/
async post(path, options = {}) {
return this.request('POST', path, options);
}
/**
* Generic request method with MAC authentication and signature
*/
async request(method, path, options = {}) {
// Build full URL with query parameters
let fullUrl = `${this.config.apiBaseUrl}${path}`;
let signUrl = new URL(fullUrl).pathname;
// Add client_id to query params
const queryParams = new URLSearchParams();
queryParams.append('client_id', this.config.clientId);
if (options.params) {
Object.entries(options.params).forEach(([key, value]) => {
queryParams.append(key, value);
});
}
fullUrl += '?' + queryParams.toString();
signUrl += '?' + queryParams.toString();
// Prepare request body
let bodyString = method === 'POST' ? '{}' : '';
const headers = {
'Content-Type': 'application/json',
...(options.headers || {})
};
if (options.body) {
if (headers['Content-Type'] === 'application/x-www-form-urlencoded') {
// Form-encoded body
const formData = new URLSearchParams();
Object.entries(options.body).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
formData.append(key, String(value));
}
});
bodyString = formData.toString();
}
else {
// JSON body
bodyString = JSON.stringify(options.body);
}
}
// Generate MAC Authorization header
const authorization = this.generateMacAuthorization(fullUrl, method);
headers['Authorization'] = authorization;
// Add timestamp and nonce headers
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = this.generateRandomString(8);
headers['X-Tap-Ts'] = timestamp;
headers['X-Tap-Nonce'] = nonce;
// Calculate signature using CLIENT_SECRET
const signature = this.generateSignature(method, signUrl, headers, bodyString);
headers['X-Tap-Sign'] = signature;
// Log request
logger.logRequest(method, fullUrl, headers, bodyString);
// Set up timeout
const controller = new AbortController();
const timeout = options.timeout || 30000;
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
// @ts-ignore - fetch is available in Node.js 18+
const response = await fetch(fullUrl, {
method,
headers,
body: bodyString || undefined,
signal: controller.signal
});
clearTimeout(timeoutId);
// Extract response headers
const responseHeaders = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
// Handle non-OK responses
if (!response.ok) {
const contentType = response.headers.get('content-type');
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
let errorBody = null;
if (contentType?.includes('application/json')) {
const errorData = await response.json();
errorBody = errorData;
errorMessage += ` - ${errorData.message || errorData.error || JSON.stringify(errorData)}`;
}
else {
const errorText = await response.text();
errorBody = errorText;
errorMessage += ` - ${errorText}`;
}
// Log error response with headers
logger.logResponse(method, fullUrl, response.status, response.statusText, errorBody, false, responseHeaders);
throw new Error(errorMessage);
}
// Parse response
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const jsonData = await response.json();
// Log successful response with headers
logger.logResponse(method, fullUrl, response.status, response.statusText, jsonData, true, responseHeaders);
// Handle API response format
if (jsonData.success === false) {
throw new Error(jsonData.message || jsonData.error || 'API request failed');
}
// Return data field if available, otherwise return full response
return (jsonData.data !== undefined ? jsonData.data : jsonData);
}
// If not JSON, return text
const text = await response.text();
// Log text response with headers
logger.logResponse(method, fullUrl, response.status, response.statusText, text, true, responseHeaders);
return text;
}
catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error) {
if (error.name === 'AbortError') {
const timeoutError = new Error(`Request timeout after ${timeout}ms`);
logger.error(`HTTP Request timeout: ${method} ${fullUrl}`, timeoutError);
throw timeoutError;
}
logger.error(`HTTP Request failed: ${method} ${fullUrl}`, error);
throw error;
}
const genericError = new Error(`Request failed: ${String(error)}`);
logger.error(`HTTP Request failed: ${method} ${fullUrl}`, genericError);
throw genericError;
}
}
/**
* Generate MAC Authorization header
* Format: MAC id="kid", ts="timestamp", nonce="random", mac="signature"
*/
generateMacAuthorization(requestUrl, method) {
const url = new URL(requestUrl);
const timestamp = Math.floor(Date.now() / 1000).toString().padStart(10, '0');
const nonce = this.generateRandomString(16);
const host = url.hostname;
const uri = url.pathname + url.search;
const port = url.port || (url.protocol === 'https:' ? '443' : '80');
const other = '';
// Build MAC signature base string
const signatureBase = this.buildMacSignatureBase(timestamp, nonce, method, uri, host, port, other);
// Sign with mac_key using HMAC-SHA1
const hmac = cryptoJS.HmacSHA1(signatureBase, this.config.macToken.mac_key);
const macSignature = cryptoJS.enc.Base64.stringify(hmac);
return `MAC id="${this.config.macToken.kid}", ts="${timestamp}", nonce="${nonce}", mac="${macSignature}"`;
}
/**
* Build MAC signature base string
*/
buildMacSignatureBase(time, nonce, method, uri, host, port, other) {
let base = `${time}\n${nonce}\n${method}\n${uri}\n${host}\n${port}\n`;
if (!other) {
base += '\n';
}
else {
base += `${other}\n`;
}
return base;
}
/**
* Generate request signature for X-Tap-Sign header
* Format: HMAC-SHA256(method + url + headers + body, CLIENT_SECRET)
*/
generateSignature(method, url, headers, body) {
try {
const methodPart = method;
const urlPart = url;
const headersPart = this.getHeadersPart(headers);
const bodyPart = body;
const signParts = `${methodPart}\n${urlPart}\n${headersPart}\n${bodyPart}\n`;
const hmacResult = cryptoJS.HmacSHA256(signParts, this.config.clientSecret);
const signatureBase64 = cryptoJS.enc.Base64.stringify(hmacResult);
return signatureBase64;
}
catch (error) {
throw new Error(`Failed to generate signature: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get headers part for signature
* Only includes X-Tap-* headers (excluding X-Tap-Sign)
*/
getHeadersPart(headers) {
const headerKeys = [];
const headerValues = {};
for (const [key, value] of Object.entries(headers)) {
const k = key.toLowerCase();
if (!k.startsWith('x-tap-') || k === 'x-tap-sign') {
continue;
}
headerKeys.push(k);
headerValues[k] = value;
}
headerKeys.sort();
const formattedHeaders = headerKeys.map((k) => {
return `${k}:${headerValues[k]}`;
});
return formattedHeaders.join('\n');
}
/**
* Generate random string
*/
generateRandomString(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Get current environment
*/
getEnvironment() {
return this.config.environment;
}
/**
* Get API base URL
*/
getBaseUrl() {
return this.config.apiBaseUrl;
}
}
//# sourceMappingURL=httpClient.js.map