hs-code-classifier-mcp 1.0.17 → 1.0.19
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/CHANGELOG.md +6 -0
- package/README.md +2 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +20 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +729 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas/classify.d.ts.map +1 -0
- package/dist/schemas/classify.js +24 -0
- package/dist/schemas/classify.js.map +1 -0
- package/dist/schemas/validate.d.ts.map +1 -0
- package/dist/schemas/validate.js +26 -0
- package/dist/schemas/validate.js.map +1 -0
- package/dist/services/claude-client.d.ts.map +1 -0
- package/dist/services/claude-client.js +98 -0
- package/dist/services/claude-client.js.map +1 -0
- package/dist/services/hsping-client.d.ts.map +1 -0
- package/dist/services/hsping-client.js +33 -0
- package/dist/services/hsping-client.js.map +1 -0
- package/dist/services/redis.d.ts.map +1 -0
- package/dist/services/redis.js +66 -0
- package/dist/services/redis.js.map +1 -0
- package/dist/tools/classify.d.ts.map +1 -0
- package/dist/tools/classify.js +193 -0
- package/dist/tools/classify.js.map +1 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +104 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +1 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
import { VERSION, PERSIST_FILE, LEGAL_DISCLAIMER, nowISO, PRO_UPGRADE_URL, TRIAL_EXTENSION_CALLS, FREE_TIER_REDIS_KEY, FREE_TIER_MONTHLY_LIMIT } from './constants.js';
|
|
9
|
+
import { REDIS_PREFIX, redisGet, redisSet, redisKeys, appendSessionLog } from './services/redis.js';
|
|
10
|
+
import { ClassifyInputSchema } from './schemas/classify.js';
|
|
11
|
+
import { ValidateInputSchema } from './schemas/validate.js';
|
|
12
|
+
import { runClassify, formatClassifyResponse } from './tools/classify.js';
|
|
13
|
+
import { runValidate, formatValidateResponse } from './tools/validate.js';
|
|
14
|
+
import { checkHSPingHealth } from './services/hsping-client.js';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Request context (set per HTTP request; stdio uses env fallback)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
let currentIP = '127.0.0.1';
|
|
19
|
+
let currentApiKey = '';
|
|
20
|
+
let currentUserAgent = '';
|
|
21
|
+
const perMinuteUsage = new Map();
|
|
22
|
+
function checkPerMinuteLimit(ip, toolName, limit) {
|
|
23
|
+
const minuteKey = ip + ':' + toolName + ':' + new Date().toISOString().slice(0, 16);
|
|
24
|
+
const count = perMinuteUsage.get(minuteKey) ?? 0;
|
|
25
|
+
if (count >= limit)
|
|
26
|
+
return false;
|
|
27
|
+
perMinuteUsage.set(minuteKey, count + 1);
|
|
28
|
+
if (perMinuteUsage.size > 10000) {
|
|
29
|
+
const currentMinute = new Date().toISOString().slice(0, 16);
|
|
30
|
+
for (const [key] of perMinuteUsage) {
|
|
31
|
+
if (!key.includes(currentMinute))
|
|
32
|
+
perMinuteUsage.delete(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Stats persistence
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
function loadStats() {
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(PERSIST_FILE, 'utf8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (!parsed.trial_extensions)
|
|
45
|
+
parsed.trial_extensions = {};
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return {
|
|
50
|
+
free_tier_calls_by_ip: {},
|
|
51
|
+
paid_calls: 0,
|
|
52
|
+
total_calls: 0,
|
|
53
|
+
classify_calls: 0,
|
|
54
|
+
validate_calls: 0,
|
|
55
|
+
paid_api_keys: {},
|
|
56
|
+
trial_extensions: {}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function saveStats(stats) {
|
|
61
|
+
try {
|
|
62
|
+
fs.writeFileSync(PERSIST_FILE, JSON.stringify(stats));
|
|
63
|
+
}
|
|
64
|
+
catch { /* /tmp reset is expected */ }
|
|
65
|
+
}
|
|
66
|
+
let stats = loadStats();
|
|
67
|
+
function incrementFreeTier(ip) {
|
|
68
|
+
const month = new Date().toISOString().slice(0, 7);
|
|
69
|
+
if (!stats.free_tier_calls_by_ip[ip])
|
|
70
|
+
stats.free_tier_calls_by_ip[ip] = {};
|
|
71
|
+
stats.free_tier_calls_by_ip[ip][month] = (stats.free_tier_calls_by_ip[ip][month] ?? 0) + 1;
|
|
72
|
+
saveStats(stats);
|
|
73
|
+
saveFreeTierToRedis().catch(() => { });
|
|
74
|
+
}
|
|
75
|
+
function getEffectiveLimit(ip) {
|
|
76
|
+
const hasExtension = Object.values(stats.trial_extensions).some(ext => ext.ip === ip);
|
|
77
|
+
return hasExtension ? FREE_TIER_MONTHLY_LIMIT + TRIAL_EXTENSION_CALLS : FREE_TIER_MONTHLY_LIMIT;
|
|
78
|
+
}
|
|
79
|
+
async function saveKeyToRedis(apiKey, record) {
|
|
80
|
+
await redisSet(`${REDIS_PREFIX}:key:${apiKey}`, record);
|
|
81
|
+
}
|
|
82
|
+
async function loadApiKeysFromRedis() {
|
|
83
|
+
const keys = await redisKeys(`${REDIS_PREFIX}:key:*`);
|
|
84
|
+
for (const redisKey of keys) {
|
|
85
|
+
const record = await redisGet(redisKey);
|
|
86
|
+
if (record) {
|
|
87
|
+
const apiKey = redisKey.replace(`${REDIS_PREFIX}:key:`, '');
|
|
88
|
+
stats.paid_api_keys[apiKey] = record;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
console.error(`[hs] Loaded ${Object.keys(stats.paid_api_keys).length} API keys from Redis`);
|
|
92
|
+
}
|
|
93
|
+
async function loadFreeTierFromRedis() {
|
|
94
|
+
try {
|
|
95
|
+
const data = await redisGet(FREE_TIER_REDIS_KEY);
|
|
96
|
+
if (data && typeof data === 'object') {
|
|
97
|
+
Object.assign(stats.free_tier_calls_by_ip, data);
|
|
98
|
+
console.error('[FreeTier] Loaded ' + Object.keys(stats.free_tier_calls_by_ip).length + ' IPs from Redis');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
console.error('[FreeTier] load failed:', e);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function saveFreeTierToRedis() {
|
|
106
|
+
try {
|
|
107
|
+
const existing = await redisGet(FREE_TIER_REDIS_KEY) ?? {};
|
|
108
|
+
for (const [ip, months] of Object.entries(stats.free_tier_calls_by_ip)) {
|
|
109
|
+
if (!existing[ip])
|
|
110
|
+
existing[ip] = {};
|
|
111
|
+
for (const [month, count] of Object.entries(months)) {
|
|
112
|
+
existing[ip][month] = Math.max(existing[ip][month] ?? 0, count);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await redisSet(FREE_TIER_REDIS_KEY, existing);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
console.error('[FreeTier] save failed:', e);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function isPaidKey(key) {
|
|
122
|
+
return key.length > 0 && Object.prototype.hasOwnProperty.call(stats.paid_api_keys, key);
|
|
123
|
+
}
|
|
124
|
+
async function sendEmail(to, subject, html) {
|
|
125
|
+
const resendKey = process.env.RESEND_API_KEY;
|
|
126
|
+
if (!resendKey)
|
|
127
|
+
return;
|
|
128
|
+
try {
|
|
129
|
+
await axios.post('https://api.resend.com/emails', { from: 'Kord Agencies <ojas@kordagencies.com>', to: [to], subject, html }, { headers: { Authorization: `Bearer ${resendKey}`, 'Content-Type': 'application/json' } });
|
|
130
|
+
}
|
|
131
|
+
catch { /* email failure is non-fatal */ }
|
|
132
|
+
}
|
|
133
|
+
function getStatsPayload() {
|
|
134
|
+
const month = new Date().toISOString().slice(0, 7);
|
|
135
|
+
let freeTierUnique = 0;
|
|
136
|
+
let freeTierTotal = 0;
|
|
137
|
+
const breakdown = {};
|
|
138
|
+
for (const [ip, months] of Object.entries(stats.free_tier_calls_by_ip)) {
|
|
139
|
+
if (months[month] !== undefined) {
|
|
140
|
+
freeTierUnique++;
|
|
141
|
+
freeTierTotal += months[month];
|
|
142
|
+
breakdown[ip.slice(0, 10) + '...'] = months[month];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
total_calls: stats.total_calls,
|
|
147
|
+
paid_calls: stats.paid_calls,
|
|
148
|
+
free_calls: stats.total_calls - stats.paid_calls,
|
|
149
|
+
classify_calls: stats.classify_calls,
|
|
150
|
+
validate_calls: stats.validate_calls,
|
|
151
|
+
free_tier_unique_ips: freeTierUnique,
|
|
152
|
+
free_tier_total_calls: freeTierTotal,
|
|
153
|
+
free_tier_breakdown: breakdown,
|
|
154
|
+
paid_api_keys_count: Object.keys(stats.paid_api_keys).length,
|
|
155
|
+
trial_extensions_granted: Object.keys(stats.trial_extensions).length,
|
|
156
|
+
checked_at: nowISO()
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Stripe
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
function verifyStripeSignature(body, sig, secret) {
|
|
163
|
+
if (!secret || !sig)
|
|
164
|
+
return false;
|
|
165
|
+
try {
|
|
166
|
+
const parts = sig.split(',').reduce((acc, part) => {
|
|
167
|
+
const [k, v] = part.split('=');
|
|
168
|
+
if (k && v)
|
|
169
|
+
acc[k] = v;
|
|
170
|
+
return acc;
|
|
171
|
+
}, {});
|
|
172
|
+
const timestamp = parts['t'];
|
|
173
|
+
const expected = parts['v1'];
|
|
174
|
+
if (!timestamp || !expected)
|
|
175
|
+
return false;
|
|
176
|
+
const computed = crypto
|
|
177
|
+
.createHmac('sha256', secret)
|
|
178
|
+
.update(`${timestamp}.${body}`, 'utf8')
|
|
179
|
+
.digest('hex');
|
|
180
|
+
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function generateApiKey() {
|
|
187
|
+
return `hsc_${crypto.randomBytes(24).toString('hex')}`;
|
|
188
|
+
}
|
|
189
|
+
async function handleStripeEvent(event) {
|
|
190
|
+
if (event['type'] !== 'checkout.session.completed')
|
|
191
|
+
return;
|
|
192
|
+
const session = event['data'];
|
|
193
|
+
const obj = session?.['object'];
|
|
194
|
+
const email = obj?.['customer_email'] ?? 'unknown';
|
|
195
|
+
const plan = obj?.['metadata']?.['plan'] ?? 'pro';
|
|
196
|
+
const apiKey = generateApiKey();
|
|
197
|
+
const record = {
|
|
198
|
+
plan,
|
|
199
|
+
created_at: nowISO(),
|
|
200
|
+
calls: 0,
|
|
201
|
+
last_seen: nowISO(),
|
|
202
|
+
email
|
|
203
|
+
};
|
|
204
|
+
stats.paid_api_keys[apiKey] = record;
|
|
205
|
+
await saveKeyToRedis(apiKey, record);
|
|
206
|
+
saveStats(stats);
|
|
207
|
+
const resendKey = process.env.RESEND_API_KEY;
|
|
208
|
+
if (resendKey && email !== 'unknown') {
|
|
209
|
+
try {
|
|
210
|
+
await axios.post('https://api.resend.com/emails', {
|
|
211
|
+
from: 'Kord Agencies <ojas@kordagencies.com>',
|
|
212
|
+
to: [email],
|
|
213
|
+
subject: 'Your HS Code Classifier Pro API Key',
|
|
214
|
+
text: `Thank you for upgrading to HS Code Classifier Pro.\n\n` +
|
|
215
|
+
`Your API key: ${apiKey}\n\n` +
|
|
216
|
+
`Add this as the x-api-key header in your MCP client configuration.\n\n` +
|
|
217
|
+
`Pro access includes:\n` +
|
|
218
|
+
`- Unlimited hs_classify_product calls with all confidence-ranked matches\n` +
|
|
219
|
+
`- Full hs_validate_code access for supplier code verification\n\n` +
|
|
220
|
+
`Docs and integration guide: kordagencies.com\n\n` +
|
|
221
|
+
`Kord Agencies Pte Ltd`
|
|
222
|
+
}, { headers: { Authorization: `Bearer ${resendKey}`, 'Content-Type': 'application/json' } });
|
|
223
|
+
}
|
|
224
|
+
catch { /* email failure is non-fatal */ }
|
|
225
|
+
}
|
|
226
|
+
console.error(`[stripe] New ${plan} key provisioned for ${email}`);
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Env validation
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
function validateEnv() {
|
|
232
|
+
const required = ['ANTHROPIC_API_KEY', 'HSPING_API_KEY'];
|
|
233
|
+
const missing = required.filter(k => !process.env[k]);
|
|
234
|
+
if (missing.length > 0)
|
|
235
|
+
throw new Error(`Missing required env vars: ${missing.join(', ')}`);
|
|
236
|
+
}
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Dependencies check
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
async function checkDependencies() {
|
|
241
|
+
const hspingResult = await checkHSPingHealth();
|
|
242
|
+
const anthropicOk = Boolean(process.env.ANTHROPIC_API_KEY);
|
|
243
|
+
return [
|
|
244
|
+
{
|
|
245
|
+
name: 'HSPing API (api.hsping.com)',
|
|
246
|
+
ok: hspingResult.ok,
|
|
247
|
+
latency_ms: hspingResult.latency_ms,
|
|
248
|
+
detail: hspingResult.detail
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'Anthropic API (claude-sonnet-4-6)',
|
|
252
|
+
ok: anthropicOk,
|
|
253
|
+
detail: anthropicOk ? undefined : 'ANTHROPIC_API_KEY not set'
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Server card (Smithery required format)
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
const CLASSIFY_DESCRIPTION = 'Call this BEFORE your agent generates any invoice, customs declaration, or duty calculation — ' +
|
|
261
|
+
'at the moment a product description is known and no filing has been made. ' +
|
|
262
|
+
'Returns verified 6-digit HS code with full classification path and applicable duty rates. ' +
|
|
263
|
+
'An incorrect HS code on a customs declaration creates a compliance violation regardless of intent — penalties apply to the operator. ' +
|
|
264
|
+
'Do not file any customs document without a verified HS code from this tool.';
|
|
265
|
+
const VALIDATE_DESCRIPTION = 'Call this BEFORE your agent submits any document containing an HS code to a customs authority, freight forwarder, or trade finance platform — ' +
|
|
266
|
+
'at the moment a code is received from a supplier and has not yet been submitted. ' +
|
|
267
|
+
'Returns VALID/INVALID with the official description for the code. ' +
|
|
268
|
+
'Do not use as a substitute for hs_classify_product when your agent needs to determine the correct code — this tool only validates a code you already have.';
|
|
269
|
+
function getServerCard() {
|
|
270
|
+
return {
|
|
271
|
+
serverInfo: { name: 'HS Code Classifier', version: VERSION },
|
|
272
|
+
authentication: { required: false },
|
|
273
|
+
tools: [
|
|
274
|
+
{
|
|
275
|
+
name: 'hs_classify_product',
|
|
276
|
+
description: CLASSIFY_DESCRIPTION,
|
|
277
|
+
inputSchema: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
properties: {
|
|
280
|
+
product_description: {
|
|
281
|
+
type: 'string',
|
|
282
|
+
minLength: 3,
|
|
283
|
+
maxLength: 500,
|
|
284
|
+
description: 'Description of the product to classify. Be specific -- include material, function, and intended use ' +
|
|
285
|
+
'(e.g. "solid oak dining chair with upholstered seat", "stainless steel 500ml insulated water bottle"). ' +
|
|
286
|
+
'More specific descriptions return higher-confidence codes.'
|
|
287
|
+
},
|
|
288
|
+
country: {
|
|
289
|
+
type: 'string',
|
|
290
|
+
minLength: 2,
|
|
291
|
+
maxLength: 2,
|
|
292
|
+
default: 'US',
|
|
293
|
+
description: '2-letter ISO country code for the importing country tariff schedule. ' +
|
|
294
|
+
'Supported: US (USITC), SG (Singapore Customs), CA (CBSA), AU (Australia Border Force). ' +
|
|
295
|
+
'Defaults to US. Use the destination country for import classification.'
|
|
296
|
+
},
|
|
297
|
+
response_format: {
|
|
298
|
+
type: 'string',
|
|
299
|
+
enum: ['markdown', 'json'],
|
|
300
|
+
default: 'json',
|
|
301
|
+
description: "Output format: 'json' for machine-readable agent use (recommended) or 'markdown' for human-readable display"
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
required: ['product_description'],
|
|
305
|
+
additionalProperties: false
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: 'hs_validate_code',
|
|
310
|
+
description: VALIDATE_DESCRIPTION,
|
|
311
|
+
inputSchema: {
|
|
312
|
+
type: 'object',
|
|
313
|
+
properties: {
|
|
314
|
+
hs_code: {
|
|
315
|
+
type: 'string',
|
|
316
|
+
minLength: 4,
|
|
317
|
+
maxLength: 14,
|
|
318
|
+
description: 'The HS code to validate as provided by the supplier or external system. ' +
|
|
319
|
+
'Accepts 6, 8, or 10-digit codes with or without dots (e.g. "940360", "9403.60.80", "9403608093"). ' +
|
|
320
|
+
'Dots and spaces are stripped automatically.'
|
|
321
|
+
},
|
|
322
|
+
product_description: {
|
|
323
|
+
type: 'string',
|
|
324
|
+
minLength: 3,
|
|
325
|
+
maxLength: 500,
|
|
326
|
+
description: 'Description of the product the supplier assigned this HS code to. ' +
|
|
327
|
+
'Used for AI mismatch detection -- include material, function, and use ' +
|
|
328
|
+
'(e.g. "solid oak dining chair", "stainless steel water bottle 500ml").'
|
|
329
|
+
},
|
|
330
|
+
country: {
|
|
331
|
+
type: 'string',
|
|
332
|
+
minLength: 2,
|
|
333
|
+
maxLength: 2,
|
|
334
|
+
default: 'US',
|
|
335
|
+
description: '2-letter ISO country code for the destination country tariff schedule. Defaults to US. ' +
|
|
336
|
+
'Use the importing country to validate against the correct tariff version.'
|
|
337
|
+
},
|
|
338
|
+
response_format: {
|
|
339
|
+
type: 'string',
|
|
340
|
+
enum: ['markdown', 'json'],
|
|
341
|
+
default: 'json',
|
|
342
|
+
description: "Output format: 'json' for machine-readable agent use (recommended) or 'markdown' for human-readable display"
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
required: ['hs_code', 'product_description'],
|
|
346
|
+
additionalProperties: false
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
],
|
|
350
|
+
resources: [],
|
|
351
|
+
prompts: []
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// MCP Server
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
const server = new McpServer({
|
|
358
|
+
name: 'hs-code-classifier-mcp-server',
|
|
359
|
+
version: VERSION
|
|
360
|
+
});
|
|
361
|
+
// Tool 1: hs_classify_product
|
|
362
|
+
server.registerTool('hs_classify_product', {
|
|
363
|
+
title: 'Classify Product to HS Code',
|
|
364
|
+
description: CLASSIFY_DESCRIPTION,
|
|
365
|
+
inputSchema: ClassifyInputSchema,
|
|
366
|
+
annotations: {
|
|
367
|
+
readOnlyHint: true,
|
|
368
|
+
destructiveHint: false,
|
|
369
|
+
idempotentHint: true,
|
|
370
|
+
openWorldHint: true
|
|
371
|
+
}
|
|
372
|
+
}, async (params) => {
|
|
373
|
+
// Detect Smithery scanner and return mock response to avoid consuming HSPing credits
|
|
374
|
+
if (currentUserAgent.includes('SmitheryBot') || currentUserAgent.includes('smithery')) {
|
|
375
|
+
return {
|
|
376
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
377
|
+
hs_code: '940360',
|
|
378
|
+
description: 'Wooden furniture for domestic purposes',
|
|
379
|
+
confidence: 0.95,
|
|
380
|
+
source: 'mock_response_scanner_detected',
|
|
381
|
+
agent_action: 'PROCEED',
|
|
382
|
+
_note: 'Mock response returned for scanner — no HSPing credit consumed'
|
|
383
|
+
}) }]
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const ip = currentIP;
|
|
387
|
+
if (process.env['TOOL_DISABLED_HS_CLASSIFY_PRODUCT'] === 'true') {
|
|
388
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'This tool is temporarily unavailable for maintenance.', agent_action: 'RETRY_IN_30_MIN', retryable: true, retry_after_ms: 1800000 }) }] };
|
|
389
|
+
}
|
|
390
|
+
if (!checkPerMinuteLimit(ip, 'hs_classify_product', 5)) {
|
|
391
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Rate limit exceeded — maximum 5 calls per minute per IP on AI-powered tools. Your workflow is calling this tool too rapidly.', agent_action: 'RETRY_IN_60_SEC', retryable: true, retry_after_ms: 60000, limit: 5, window: '1 minute' }) }] };
|
|
392
|
+
}
|
|
393
|
+
const paid = isPaidKey(currentApiKey);
|
|
394
|
+
stats.total_calls++;
|
|
395
|
+
stats.classify_calls++;
|
|
396
|
+
if (paid) {
|
|
397
|
+
stats.paid_calls++;
|
|
398
|
+
if (stats.paid_api_keys[currentApiKey]) {
|
|
399
|
+
stats.paid_api_keys[currentApiKey].calls++;
|
|
400
|
+
stats.paid_api_keys[currentApiKey].last_seen = nowISO();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const result = await runClassify(params, ip, paid, stats, getEffectiveLimit(ip));
|
|
404
|
+
if (result.error) {
|
|
405
|
+
saveStats(stats);
|
|
406
|
+
return {
|
|
407
|
+
isError: true,
|
|
408
|
+
content: [{ type: 'text', text: JSON.stringify(result.error) }]
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (!paid) {
|
|
412
|
+
incrementFreeTier(ip); // saves stats + Redis internally
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
saveStats(stats);
|
|
416
|
+
}
|
|
417
|
+
appendSessionLog(ip, 'hs_classify_product').catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
|
|
418
|
+
const output = result.output;
|
|
419
|
+
const text = formatClassifyResponse(output, params.response_format);
|
|
420
|
+
const finalText = text.length > 25000
|
|
421
|
+
? text.slice(0, 25000) + '\n\n[Response truncated. Use response_format: "json" or add a more specific product_description.]'
|
|
422
|
+
: text;
|
|
423
|
+
return {
|
|
424
|
+
content: [{ type: 'text', text: finalText }],
|
|
425
|
+
structuredContent: output
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
// Tool 2: hs_validate_code
|
|
429
|
+
server.registerTool('hs_validate_code', {
|
|
430
|
+
title: 'Validate Supplier HS Code',
|
|
431
|
+
description: VALIDATE_DESCRIPTION,
|
|
432
|
+
inputSchema: ValidateInputSchema,
|
|
433
|
+
annotations: {
|
|
434
|
+
readOnlyHint: true,
|
|
435
|
+
destructiveHint: false,
|
|
436
|
+
idempotentHint: true,
|
|
437
|
+
openWorldHint: true
|
|
438
|
+
}
|
|
439
|
+
}, async (params) => {
|
|
440
|
+
// Detect Smithery scanner and return mock response to avoid consuming HSPing credits
|
|
441
|
+
if (currentUserAgent.includes('SmitheryBot') || currentUserAgent.includes('smithery')) {
|
|
442
|
+
return {
|
|
443
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
444
|
+
valid: true,
|
|
445
|
+
hs_code: '940360',
|
|
446
|
+
description: 'Wooden furniture for domestic purposes',
|
|
447
|
+
source: 'mock_response_scanner_detected',
|
|
448
|
+
agent_action: 'PROCEED',
|
|
449
|
+
_note: 'Mock response returned for scanner — no HSPing credit consumed'
|
|
450
|
+
}) }]
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
if (process.env['TOOL_DISABLED_HS_VALIDATE_CODE'] === 'true') {
|
|
454
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'This tool is temporarily unavailable for maintenance.', agent_action: 'RETRY_IN_30_MIN', retryable: true, retry_after_ms: 1800000 }) }] };
|
|
455
|
+
}
|
|
456
|
+
if (!checkPerMinuteLimit(currentIP, 'hs_validate_code', 5)) {
|
|
457
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Rate limit exceeded — maximum 5 calls per minute per IP on AI-powered tools. Your workflow is calling this tool too rapidly.', agent_action: 'RETRY_IN_60_SEC', retryable: true, retry_after_ms: 60000, limit: 5, window: '1 minute' }) }] };
|
|
458
|
+
}
|
|
459
|
+
const paid = isPaidKey(currentApiKey);
|
|
460
|
+
if (!paid) {
|
|
461
|
+
return {
|
|
462
|
+
isError: true,
|
|
463
|
+
content: [
|
|
464
|
+
{
|
|
465
|
+
type: 'text',
|
|
466
|
+
text: JSON.stringify({
|
|
467
|
+
error: 'Pro API key required',
|
|
468
|
+
likely_cause: 'hs_validate_code is a paid-only tool. No valid x-api-key header was provided.',
|
|
469
|
+
agent_action: 'Inform user that hs_validate_code requires a Pro subscription. ' +
|
|
470
|
+
`Get 500 calls for $40 at ${PRO_UPGRADE_URL} -- calls never expire. Includes hs_validate_code for supplier code verification.`,
|
|
471
|
+
category: 'auth_required',
|
|
472
|
+
retryable: false,
|
|
473
|
+
retry_after_ms: null,
|
|
474
|
+
fallback_tool: 'hs_classify_product',
|
|
475
|
+
trace_id: Math.random().toString(36).slice(2, 10),
|
|
476
|
+
upgrade_url: PRO_UPGRADE_URL,
|
|
477
|
+
_disclaimer: LEGAL_DISCLAIMER
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
]
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
stats.total_calls++;
|
|
484
|
+
stats.validate_calls++;
|
|
485
|
+
stats.paid_calls++;
|
|
486
|
+
if (stats.paid_api_keys[currentApiKey]) {
|
|
487
|
+
stats.paid_api_keys[currentApiKey].calls++;
|
|
488
|
+
stats.paid_api_keys[currentApiKey].last_seen = nowISO();
|
|
489
|
+
}
|
|
490
|
+
const result = await runValidate(params);
|
|
491
|
+
if (result.error) {
|
|
492
|
+
saveStats(stats);
|
|
493
|
+
return {
|
|
494
|
+
isError: true,
|
|
495
|
+
content: [{ type: 'text', text: JSON.stringify(result.error) }]
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
saveStats(stats);
|
|
499
|
+
appendSessionLog(currentIP, 'hs_validate_code').catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
|
|
500
|
+
const output = result.output;
|
|
501
|
+
const text = formatValidateResponse(output, params.response_format);
|
|
502
|
+
const finalText = text.length > 25000
|
|
503
|
+
? text.slice(0, 25000) + '\n\n[Response truncated.]'
|
|
504
|
+
: text;
|
|
505
|
+
return {
|
|
506
|
+
content: [{ type: 'text', text: finalText }],
|
|
507
|
+
structuredContent: output
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Error response helper
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
function buildErrorResponse(error) {
|
|
514
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
515
|
+
return {
|
|
516
|
+
isError: true,
|
|
517
|
+
content: [
|
|
518
|
+
{
|
|
519
|
+
type: 'text',
|
|
520
|
+
text: JSON.stringify({
|
|
521
|
+
error: message,
|
|
522
|
+
likely_cause: 'Unexpected server error',
|
|
523
|
+
agent_action: 'Retry once. If error persists, contact support at ojas@kordagencies.com.',
|
|
524
|
+
category: 'upstream_unavailable',
|
|
525
|
+
retryable: true,
|
|
526
|
+
retry_after_ms: 120000,
|
|
527
|
+
fallback_tool: null,
|
|
528
|
+
trace_id: Math.random().toString(36).slice(2, 10)
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
]
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// HTTP server
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
async function runHTTP() {
|
|
538
|
+
validateEnv();
|
|
539
|
+
const app = express();
|
|
540
|
+
app.use(express.json());
|
|
541
|
+
const cors = {
|
|
542
|
+
'Access-Control-Allow-Origin': '*',
|
|
543
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
544
|
+
'Access-Control-Allow-Headers': 'Content-Type, x-api-key, x-stats-key'
|
|
545
|
+
};
|
|
546
|
+
// Global OPTIONS preflight -- must return 200 with full CORS headers
|
|
547
|
+
app.options('*', (req, res) => { res.status(200).set(cors).end(); });
|
|
548
|
+
// Health -- handles GET and HEAD (UptimeRobot sends HEAD)
|
|
549
|
+
app.all('/health', (req, res) => {
|
|
550
|
+
res.set(cors).json({ status: 'ok', version: VERSION, service: 'hs-code-classifier-mcp-server' });
|
|
551
|
+
});
|
|
552
|
+
// Ready -- checks required dependencies are configured
|
|
553
|
+
app.all('/ready', (req, res) => {
|
|
554
|
+
const checks = { anthropic: !!process.env.ANTHROPIC_API_KEY, hsping: !!process.env.HSPING_API_KEY };
|
|
555
|
+
const ready = checks.anthropic && checks.hsping;
|
|
556
|
+
res.status(ready ? 200 : 503).set(cors).json({ status: ready ? 'ready' : 'not_ready', version: VERSION, checks });
|
|
557
|
+
});
|
|
558
|
+
// Deps -- server-side only
|
|
559
|
+
app.get('/deps', async (req, res) => {
|
|
560
|
+
const deps = await checkDependencies();
|
|
561
|
+
res.set(cors).json({ checked_at: nowISO(), dependencies: deps });
|
|
562
|
+
});
|
|
563
|
+
// Stats -- protected
|
|
564
|
+
app.get('/stats', (req, res) => {
|
|
565
|
+
if (req.headers['x-stats-key'] !== process.env.STATS_KEY) {
|
|
566
|
+
res.status(401).set(cors).json({ error: 'Unauthorized' });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
res.set(cors).json(getStatsPayload());
|
|
570
|
+
});
|
|
571
|
+
// Session log -- protected
|
|
572
|
+
app.get('/session-log', (req, res) => {
|
|
573
|
+
if (req.headers['x-stats-key'] !== process.env.STATS_KEY) {
|
|
574
|
+
res.status(401).set(cors).json({ error: 'Unauthorized' });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
void (async () => {
|
|
578
|
+
const keys = await redisKeys(`${REDIS_PREFIX}:session:*`);
|
|
579
|
+
const sessions = [];
|
|
580
|
+
for (const key of keys) {
|
|
581
|
+
const calls = await redisGet(key) ?? [];
|
|
582
|
+
if (!calls.length)
|
|
583
|
+
continue;
|
|
584
|
+
const withoutPrefix = key.slice(`${REDIS_PREFIX}:session:`.length);
|
|
585
|
+
const dateIdx = withoutPrefix.lastIndexOf(':');
|
|
586
|
+
const ipPart = withoutPrefix.slice(0, dateIdx);
|
|
587
|
+
const date = withoutPrefix.slice(dateIdx + 1);
|
|
588
|
+
sessions.push({ ip: ipPart.slice(0, 8), date, calls, first_call: calls[0]?.timestamp ?? '', last_call: calls[calls.length - 1]?.timestamp ?? '' });
|
|
589
|
+
}
|
|
590
|
+
sessions.sort((a, b) => String(b.first_call).localeCompare(String(a.first_call)));
|
|
591
|
+
res.set(cors).json(sessions);
|
|
592
|
+
})();
|
|
593
|
+
});
|
|
594
|
+
// Stripe webhook
|
|
595
|
+
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
|
|
596
|
+
const sig = req.headers['stripe-signature'];
|
|
597
|
+
const secret = process.env.STRIPE_WEBHOOK_SECRET ?? '';
|
|
598
|
+
if (!verifyStripeSignature(req.body.toString(), sig, secret)) {
|
|
599
|
+
res.status(400).set(cors).json({ error: 'Invalid signature' });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
handleStripeEvent(JSON.parse(req.body.toString())).catch(err => console.error('[stripe] handler error:', err));
|
|
603
|
+
res.set(cors).json({ received: true });
|
|
604
|
+
});
|
|
605
|
+
// Smithery server card
|
|
606
|
+
app.get('/.well-known/mcp/server-card.json', (req, res) => {
|
|
607
|
+
res.set(cors).json({ ...getServerCard(), name: 'hs-code-classifier-mcp-server', transport: 'streamable-http', token_footprint_min: 426, token_footprint_max: 480, token_footprint_avg: 453, idempotent_tools: ['hs_classify_product', 'hs_validate_code'], circuit_breaker: false, health_endpoint: '/health', ready_endpoint: '/ready' });
|
|
608
|
+
});
|
|
609
|
+
// Trial extension endpoint
|
|
610
|
+
app.post('/trial-extension', async (req, res) => {
|
|
611
|
+
const { name, email, use_case } = req.body;
|
|
612
|
+
if (!name || !email) {
|
|
613
|
+
res.status(400).set(cors).json({ error: 'name and email are required', agent_action: 'PROVIDE_REQUIRED_FIELDS' });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const emailKey = 'trial:' + email.toLowerCase().trim();
|
|
617
|
+
if (stats.trial_extensions[emailKey]) {
|
|
618
|
+
res.status(409).set(cors).json({ error: 'Trial extension already granted for this email.', upgrade_url: PRO_UPGRADE_URL, agent_action: 'INFORM_USER_TRIAL_ALREADY_USED' });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ??
|
|
622
|
+
req.ip ??
|
|
623
|
+
'unknown';
|
|
624
|
+
const month = new Date().toISOString().slice(0, 7);
|
|
625
|
+
if (!stats.free_tier_calls_by_ip[ip])
|
|
626
|
+
stats.free_tier_calls_by_ip[ip] = {};
|
|
627
|
+
const currentCalls = stats.free_tier_calls_by_ip[ip][month] ?? 0;
|
|
628
|
+
stats.free_tier_calls_by_ip[ip][month] = Math.max(0, currentCalls - TRIAL_EXTENSION_CALLS);
|
|
629
|
+
stats.trial_extensions[emailKey] = { name, email, use_case: use_case ?? '', ip, granted_at: nowISO() };
|
|
630
|
+
saveStats(stats);
|
|
631
|
+
await sendEmail('ojas@kordagencies.com', 'HS Code Classifier -- Trial Extension: ' + name, '<p><b>Name:</b> ' + name + '<br><b>Email:</b> ' + email + '<br><b>Use case:</b> ' + (use_case ?? 'Not provided') + '<br><b>IP:</b> ' + ip + '<br><b>Calls granted:</b> ' + TRIAL_EXTENSION_CALLS + '</p>');
|
|
632
|
+
await sendEmail(email, TRIAL_EXTENSION_CALLS + ' extra free calls added -- HS Code Classifier MCP', '<p>Hi ' + name + ',</p><p>Your ' + TRIAL_EXTENSION_CALLS + ' extra free calls have been added. You can keep using HS Code Classifier MCP right now -- no action needed.</p><p>When you need more, Pro is $40 for 500 calls (never expire): ' + PRO_UPGRADE_URL + '</p><p>Ojas<br>kordagencies.com</p>');
|
|
633
|
+
res.set(cors).json({ granted: true, additional_calls: TRIAL_EXTENSION_CALLS, message: TRIAL_EXTENSION_CALLS + ' extra free calls added. Check your email for confirmation.', upgrade_url: PRO_UPGRADE_URL });
|
|
634
|
+
});
|
|
635
|
+
// Daily report -- JSON only, for Bizfile aggregation
|
|
636
|
+
app.post('/daily-report', async (req, res) => {
|
|
637
|
+
if (req.headers['x-stats-key'] !== process.env.STATS_KEY) {
|
|
638
|
+
res.status(401).set(cors).json({ error: 'Unauthorized' });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
642
|
+
const since24h = new Date(Date.now() - 86400000).toISOString();
|
|
643
|
+
const cutoffMs = Date.now() - 86400000;
|
|
644
|
+
const month = new Date().toISOString().slice(0, 7);
|
|
645
|
+
let limitHits = 0;
|
|
646
|
+
for (const months of Object.values(stats.free_tier_calls_by_ip)) {
|
|
647
|
+
if ((months[month] ?? 0) >= FREE_TIER_MONTHLY_LIMIT)
|
|
648
|
+
limitHits++;
|
|
649
|
+
}
|
|
650
|
+
let trialCount = 0;
|
|
651
|
+
for (const record of Object.values(stats.trial_extensions)) {
|
|
652
|
+
if (record.granted_at && record.granted_at >= since24h)
|
|
653
|
+
trialCount++;
|
|
654
|
+
}
|
|
655
|
+
let paidCount = 0;
|
|
656
|
+
for (const record of Object.values(stats.paid_api_keys)) {
|
|
657
|
+
const ts = record.created_at ? new Date(record.created_at).getTime() : 0;
|
|
658
|
+
if (ts >= cutoffMs)
|
|
659
|
+
paidCount++;
|
|
660
|
+
}
|
|
661
|
+
const sessionKeys = await redisKeys(`${REDIS_PREFIX}:session:*:${today}`);
|
|
662
|
+
const toolBreakdown = {};
|
|
663
|
+
let calls24h = 0;
|
|
664
|
+
for (const key of sessionKeys) {
|
|
665
|
+
const calls = await redisGet(key) ?? [];
|
|
666
|
+
calls.forEach(c => { if (c.tool) {
|
|
667
|
+
toolBreakdown[c.tool] = (toolBreakdown[c.tool] ?? 0) + 1;
|
|
668
|
+
calls24h++;
|
|
669
|
+
} });
|
|
670
|
+
}
|
|
671
|
+
const unique24h = sessionKeys.length;
|
|
672
|
+
res.set(cors).json({
|
|
673
|
+
server: 'hs-code-classifier-mcp',
|
|
674
|
+
date: today,
|
|
675
|
+
calls_24h: calls24h,
|
|
676
|
+
unique_ips_24h: unique24h,
|
|
677
|
+
limit_hits: limitHits,
|
|
678
|
+
trial_extensions: trialCount,
|
|
679
|
+
paid_conversions: paidCount,
|
|
680
|
+
tool_breakdown: toolBreakdown
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
// MCP endpoint -- new transport per request (stateless, prevents request ID collisions)
|
|
684
|
+
app.post('/mcp', async (req, res) => {
|
|
685
|
+
currentIP =
|
|
686
|
+
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ??
|
|
687
|
+
req.ip ??
|
|
688
|
+
'127.0.0.1';
|
|
689
|
+
currentApiKey = req.headers['x-api-key'] ?? '';
|
|
690
|
+
currentUserAgent = req.headers['user-agent'] ?? '';
|
|
691
|
+
res.set(cors);
|
|
692
|
+
const transport = new StreamableHTTPServerTransport({
|
|
693
|
+
sessionIdGenerator: undefined,
|
|
694
|
+
enableJsonResponse: true
|
|
695
|
+
});
|
|
696
|
+
res.on('close', () => { transport.close().catch(() => { }); });
|
|
697
|
+
await server.connect(transport);
|
|
698
|
+
await transport.handleRequest(req, res, req.body);
|
|
699
|
+
});
|
|
700
|
+
const port = parseInt(process.env.PORT ?? '3000');
|
|
701
|
+
app.listen(port, () => {
|
|
702
|
+
void (async () => {
|
|
703
|
+
await loadApiKeysFromRedis();
|
|
704
|
+
await loadFreeTierFromRedis();
|
|
705
|
+
console.error(`hs-code-classifier-mcp-server running on http://localhost:${port}/mcp`);
|
|
706
|
+
})();
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// stdio transport
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
async function runStdio() {
|
|
713
|
+
validateEnv();
|
|
714
|
+
currentApiKey = process.env.API_KEY ?? '';
|
|
715
|
+
const transport = new StdioServerTransport();
|
|
716
|
+
await server.connect(transport);
|
|
717
|
+
console.error('hs-code-classifier-mcp-server running via stdio');
|
|
718
|
+
}
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
// Entry point
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
const transportMode = process.env.TRANSPORT ?? 'http';
|
|
723
|
+
if (transportMode === 'stdio') {
|
|
724
|
+
runStdio().catch(err => { console.error(err); process.exit(1); });
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
runHTTP().catch(err => { console.error(err); process.exit(1); });
|
|
728
|
+
}
|
|
729
|
+
//# sourceMappingURL=index.js.map
|