tariffmonitor-mcp 1.0.0 → 1.2.1
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/README.md +23 -18
- package/package.json +5 -4
- package/src/http.js +156 -0
- package/src/index.js +9 -158
- package/src/tools.js +205 -0
package/README.md
CHANGED
|
@@ -11,26 +11,36 @@ Two tools:
|
|
|
11
11
|
| Tool | What it does |
|
|
12
12
|
|---|---|
|
|
13
13
|
| `lookup_hts_code` | Search 19,856 HTS codes by description or numeric prefix |
|
|
14
|
-
| `calculate_tariff` | Returns stacked rate (base + Section 301 +
|
|
14
|
+
| `calculate_tariff` | Returns an estimated stacked rate (MFN base + Section 301 + Section 232) and flags the Nov 10, 2026 Section 301 exclusion expiry. Optional: annual import volume → dollar impact |
|
|
15
|
+
|
|
16
|
+
Example exchange — a real capture from the live API on 2026-09-11, not a mock-up:
|
|
15
17
|
|
|
16
|
-
Example exchange:
|
|
17
18
|
```
|
|
18
|
-
You: What's the tariff on athletic footwear from China, and
|
|
19
|
+
You: What's the tariff on athletic footwear from China, and what does it cost me at $800k/year?
|
|
20
|
+
|
|
21
|
+
Claude: [calls lookup_hts_code query="athletic footwear", then calculate_tariff
|
|
22
|
+
hts_code="6404110000", origin_country="CN", annual_import_value_usd=800000]
|
|
23
|
+
|
|
24
|
+
Note: 6404110000 is not in the schedule. This rate is for heading 640411, resolved to 6404112030.
|
|
25
|
+
|
|
26
|
+
HTS 6404112030 - For men
|
|
27
|
+
Origin: CN
|
|
19
28
|
|
|
20
|
-
Claude: [uses calculate_tariff with hts_code="6404.11", origin_country="CN", annual_import_value_usd=800000]
|
|
21
|
-
HTS 6404110000 — Footwear with outer soles of rubber/plastics...
|
|
22
29
|
Rate stack:
|
|
23
|
-
|
|
24
|
-
Section 301 (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
MFN base rate: +0.00%
|
|
31
|
+
Section 301 (China trade enforcement): +7.50%
|
|
32
|
+
Total current rate: 7.50%
|
|
33
|
+
|
|
34
|
+
No cliff change projected for this code/country combination.
|
|
28
35
|
|
|
29
36
|
Dollar impact at $800,000/yr import value:
|
|
30
|
-
Current annual duties: $
|
|
31
|
-
Additional after Nov 10 cliff: $180,000/yr
|
|
37
|
+
Current annual duties: $60,000
|
|
32
38
|
```
|
|
33
39
|
|
|
40
|
+
Rates change, and a rate is per HTS code and origin country - treat the numbers above as the shape
|
|
41
|
+
of the answer, not as a quote for your shipment. The tool never invents a rate: if the upstream is
|
|
42
|
+
unreachable or the code is not in the schedule, it says so instead of estimating.
|
|
43
|
+
|
|
34
44
|
## Install (Claude Desktop)
|
|
35
45
|
|
|
36
46
|
Add to your `claude_desktop_config.json`:
|
|
@@ -70,11 +80,6 @@ The tool calls the free public API at adcreator-ai.com. No signup, no rate limit
|
|
|
70
80
|
## Related
|
|
71
81
|
|
|
72
82
|
- [Free tariff calculator](https://adcreator-ai.com/tariffs/calculator) — browser version
|
|
73
|
-
- [
|
|
74
|
-
- [Tariff Survival Kit ($39)](https://weaverdale5.gumroad.com/l/wmpath) — 67 HTS codes pre-computed + sourcing alternatives + Nov 10 action checklist
|
|
75
|
-
|
|
76
|
-
## Affiliate program
|
|
77
|
-
|
|
78
|
-
Earn 35% on every sale. [Sign up here](https://app.gumroad.com/affiliates/products/wmpath).
|
|
83
|
+
- [Official US Harmonized Tariff Schedule](https://hts.usitc.gov/) — the authoritative rates
|
|
79
84
|
|
|
80
85
|
Built by [Dale Weaver / 0xpi](https://0xpi.com).
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tariffmonitor-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Tariff Exposure Monitor
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Tariff Exposure Monitor \u2014 live US import tariff calculator MCP server. Looks up any HTS code, returns an estimated stacked rate (MFN + Section 301 + Section 232) and flags the November 10, 2026 Section 301 exclusion expiry. 19,856 HTS codes. Free, no signup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tariffmonitor-mcp": "src/index.js"
|
|
8
8
|
},
|
|
9
9
|
"main": "src/index.js",
|
|
10
10
|
"scripts": {
|
|
11
|
-
"start": "node src/index.js"
|
|
11
|
+
"start": "node src/index.js",
|
|
12
|
+
"start:http": "node src/http.js"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
14
15
|
"mcp",
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
"import-duty",
|
|
19
20
|
"trade",
|
|
20
21
|
"section-301",
|
|
21
|
-
"
|
|
22
|
+
"section-232",
|
|
22
23
|
"customs",
|
|
23
24
|
"ecommerce",
|
|
24
25
|
"shopify",
|
package/src/http.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tariff Exposure Monitor — stateless HTTPS endpoint for Voidnet.
|
|
3
|
+
//
|
|
4
|
+
// Written to Voidnet's publisher contract (docs.openvoidnet.com/docs/publisher-guide/mcp,
|
|
5
|
+
// read 2026-08-21), which is NOT the same protocol the stdio server speaks:
|
|
6
|
+
// - MCP 2026-07-28, STATELESS. No initialize handshake, no sessions, no GET/DELETE.
|
|
7
|
+
// - One endpoint, /mcp, handling every method.
|
|
8
|
+
// - server/discover replaces initialize.
|
|
9
|
+
// - Authorization: Bearer <publisher key> validated on EVERY request; 401 otherwise.
|
|
10
|
+
// - MCP-Protocol-Version header must match _meta.protocolVersion in the body (-32020 if not).
|
|
11
|
+
// - Unsupported protocol version -> 400 / -32022.
|
|
12
|
+
// TLS is terminated by nginx in front of this process, so it listens on plain HTTP on localhost.
|
|
13
|
+
//
|
|
14
|
+
// The tools themselves come from src/tools.js — the same code the stdio server runs, so the two
|
|
15
|
+
// transports cannot drift apart.
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { TOOLS, callTool, SERVER_INFO } from './tools.js';
|
|
21
|
+
|
|
22
|
+
// Read .env ourselves. systemd would do this via EnvironmentFile, but this runs under pm2, which
|
|
23
|
+
// does not — and the first deploy silently kept reporting key_configured:false because of it.
|
|
24
|
+
// Deliberately does NOT override anything already in the environment.
|
|
25
|
+
(() => {
|
|
26
|
+
try {
|
|
27
|
+
const envPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.env');
|
|
28
|
+
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
|
29
|
+
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
30
|
+
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
|
31
|
+
}
|
|
32
|
+
} catch { /* no .env is a normal state: the server then fails closed, which is intended */ }
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
const PORT = Number(process.env.PORT || 8791);
|
|
36
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
37
|
+
const PROTOCOL_VERSION = '2026-07-28';
|
|
38
|
+
// Generated by Voidnet Console when the app is published, and shown once. Until it is set this
|
|
39
|
+
// server FAILS CLOSED: an endpoint that accepts unauthenticated calls is worse than one that is
|
|
40
|
+
// not published yet. VOIDNET_ALLOW_UNAUTH=1 exists only for local smoke tests.
|
|
41
|
+
const PUBLISHER_KEY = process.env.VOIDNET_PUBLISHER_KEY || '';
|
|
42
|
+
const ALLOW_UNAUTH = process.env.VOIDNET_ALLOW_UNAUTH === '1';
|
|
43
|
+
|
|
44
|
+
const json = (res, status, body, extra = {}) => {
|
|
45
|
+
const s = JSON.stringify(body);
|
|
46
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(s), ...extra });
|
|
47
|
+
res.end(s);
|
|
48
|
+
};
|
|
49
|
+
const rpcError = (res, status, id, code, message, data) =>
|
|
50
|
+
json(res, status, { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data ? { data } : {}) } });
|
|
51
|
+
|
|
52
|
+
function readBody(req, limit = 1_000_000) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let n = 0; const chunks = [];
|
|
55
|
+
req.on('data', (c) => { n += c.length; if (n > limit) { reject(new Error('body too large')); req.destroy(); } else chunks.push(c); });
|
|
56
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
57
|
+
req.on('error', reject);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const server = http.createServer(async (req, res) => {
|
|
62
|
+
// Liveness, for our own monitoring only — not part of the Voidnet contract.
|
|
63
|
+
if (req.method === 'GET' && (req.url === '/health' || req.url === '/mcp/health')) {
|
|
64
|
+
return json(res, 200, { ok: true, server: SERVER_INFO, protocol: PROTOCOL_VERSION,
|
|
65
|
+
tools: TOOLS.map((t) => t.name), key_configured: !!PUBLISHER_KEY });
|
|
66
|
+
}
|
|
67
|
+
// "No GET/DELETE endpoints" — say so explicitly rather than 404, which reads like a wrong URL.
|
|
68
|
+
if (req.method !== 'POST') {
|
|
69
|
+
return rpcError(res, 405, null, -32600, 'Method Not Allowed',
|
|
70
|
+
'This is a stateless MCP 2026-07-28 endpoint; use POST /mcp.');
|
|
71
|
+
}
|
|
72
|
+
if ((req.url || '').split('?')[0] !== '/mcp') {
|
|
73
|
+
return rpcError(res, 404, null, -32601, 'Not Found', 'The MCP endpoint is POST /mcp.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 1. AUTH FIRST — before parsing, before anything. "If the key is invalid, return 401
|
|
77
|
+
// immediately — do not process the MCP request."
|
|
78
|
+
const auth = req.headers['authorization'] || '';
|
|
79
|
+
const presented = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
|
80
|
+
if (!ALLOW_UNAUTH) {
|
|
81
|
+
if (!PUBLISHER_KEY) {
|
|
82
|
+
console.error('[mcp-http] no VOIDNET_PUBLISHER_KEY configured — refusing every request');
|
|
83
|
+
return rpcError(res, 401, null, -32001, 'Unauthorized', 'Server is not configured with a publisher key');
|
|
84
|
+
}
|
|
85
|
+
if (!presented || presented !== PUBLISHER_KEY) {
|
|
86
|
+
return rpcError(res, 401, null, -32001, 'Unauthorized', 'Invalid or missing publisher API key');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let body;
|
|
91
|
+
try { body = JSON.parse(await readBody(req)); }
|
|
92
|
+
catch (e) { return rpcError(res, 400, null, -32700, 'Parse error', e.message); }
|
|
93
|
+
|
|
94
|
+
const id = body?.id ?? null;
|
|
95
|
+
const method = body?.method;
|
|
96
|
+
|
|
97
|
+
// 2. Protocol version: the HEADER must be supported, and must MATCH the body's _meta.
|
|
98
|
+
const headerVersion = req.headers['mcp-protocol-version'];
|
|
99
|
+
if (headerVersion && headerVersion !== PROTOCOL_VERSION) {
|
|
100
|
+
return rpcError(res, 400, id, -32022, 'Unsupported protocol version',
|
|
101
|
+
`This server speaks ${PROTOCOL_VERSION}; received ${headerVersion}`);
|
|
102
|
+
}
|
|
103
|
+
const bodyVersion = body?._meta?.protocolVersion;
|
|
104
|
+
if (headerVersion && bodyVersion && headerVersion !== bodyVersion) {
|
|
105
|
+
return rpcError(res, 400, id, -32020, 'HeaderMismatch',
|
|
106
|
+
`MCP-Protocol-Version (${headerVersion}) does not match _meta.protocolVersion (${bodyVersion})`);
|
|
107
|
+
}
|
|
108
|
+
if (bodyVersion && bodyVersion !== PROTOCOL_VERSION) {
|
|
109
|
+
return rpcError(res, 400, id, -32022, 'Unsupported protocol version',
|
|
110
|
+
`This server speaks ${PROTOCOL_VERSION}; body requested ${bodyVersion}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
if (method === 'server/discover') {
|
|
115
|
+
return json(res, 200, { jsonrpc: '2.0', id, result: {
|
|
116
|
+
supportedVersions: [PROTOCOL_VERSION],
|
|
117
|
+
capabilities: { tools: { listChanged: false } },
|
|
118
|
+
serverInfo: SERVER_INFO,
|
|
119
|
+
instructions: 'US import tariff data. Call lookup_hts_code first when you only have a '
|
|
120
|
+
+ 'product description, then calculate_tariff with the HTS code and an ISO-2 origin '
|
|
121
|
+
+ 'country to get the full stacked rate and the Nov 10 2026 cliff projection.',
|
|
122
|
+
} });
|
|
123
|
+
}
|
|
124
|
+
if (method === 'tools/list') {
|
|
125
|
+
return json(res, 200, { jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
126
|
+
}
|
|
127
|
+
if (method === 'tools/call') {
|
|
128
|
+
const name = body?.params?.name;
|
|
129
|
+
if (!name) return rpcError(res, 400, id, -32602, 'Invalid params', 'params.name is required');
|
|
130
|
+
if (!TOOLS.some((t) => t.name === name)) {
|
|
131
|
+
return rpcError(res, 404, id, -32601, 'Unknown tool', `No tool named "${name}"`);
|
|
132
|
+
}
|
|
133
|
+
const result = await callTool(name, body?.params?.arguments || {});
|
|
134
|
+
return json(res, 200, { jsonrpc: '2.0', id, result });
|
|
135
|
+
}
|
|
136
|
+
if (method === 'ping') return json(res, 200, { jsonrpc: '2.0', id, result: {} });
|
|
137
|
+
// Declared unsupported rather than silently empty: a buyer must be able to tell
|
|
138
|
+
// "this server has no resources" from "this server ignored me".
|
|
139
|
+
if (method === 'resources/list') return json(res, 200, { jsonrpc: '2.0', id, result: { resources: [] } });
|
|
140
|
+
if (method === 'prompts/list') return json(res, 200, { jsonrpc: '2.0', id, result: { prompts: [] } });
|
|
141
|
+
if (method === 'initialize') {
|
|
142
|
+
return rpcError(res, 400, id, -32022, 'Unsupported protocol version',
|
|
143
|
+
'This endpoint is stateless MCP 2026-07-28; use server/discover instead of initialize.');
|
|
144
|
+
}
|
|
145
|
+
return rpcError(res, 404, id, -32601, 'Method not found', String(method));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error('[mcp-http] handler error:', e.message);
|
|
148
|
+
return rpcError(res, 500, id, -32603, 'Internal error', e.message);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
server.listen(PORT, HOST, () => {
|
|
153
|
+
console.log(`[mcp-http] tariffmonitor on http://${HOST}:${PORT}/mcp · protocol ${PROTOCOL_VERSION}`
|
|
154
|
+
+ ` · tools: ${TOOLS.map((t) => t.name).join(', ')}`
|
|
155
|
+
+ ` · publisher key ${PUBLISHER_KEY ? 'configured' : 'NOT SET (all requests will 401)'}`);
|
|
156
|
+
});
|
package/src/index.js
CHANGED
|
@@ -1,168 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Tariff Exposure Monitor MCP server
|
|
2
|
+
// Tariff Exposure Monitor MCP server — stdio transport (Claude Desktop, Cursor).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// calculate_tariff — stacked rate (base + Section 301 + IEEPA + reciprocal)
|
|
7
|
-
// plus the November 10, 2026 cliff projection
|
|
8
|
-
//
|
|
9
|
-
// Data is served from adcreator-ai.com — free, no API key required.
|
|
4
|
+
// The tools themselves live in src/tools.js so that src/http.js can serve exactly the same
|
|
5
|
+
// behaviour over HTTPS. This file is now only the stdio wiring.
|
|
10
6
|
|
|
11
7
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
8
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
CallToolRequestSchema,
|
|
16
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
17
|
-
|
|
18
|
-
const API_BASE = 'https://adcreator-ai.com/api/tem';
|
|
19
|
-
const PACK_URL = 'https://scholar.0xpi.com/get/tariffmonitor';
|
|
20
|
-
const BUY_URL = 'https://weaverdale5.gumroad.com/l/wmpath';
|
|
21
|
-
|
|
22
|
-
const server = new Server(
|
|
23
|
-
{ name: 'tariffmonitor', version: '1.0.0' },
|
|
24
|
-
{ capabilities: { tools: {} } }
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
async function apiFetch(path) {
|
|
28
|
-
const res = await fetch(`${API_BASE}${path}`, {
|
|
29
|
-
headers: { 'User-Agent': 'tariffmonitor-mcp/1.0.0' },
|
|
30
|
-
});
|
|
31
|
-
if (!res.ok) throw new Error(`API error ${res.status}`);
|
|
32
|
-
return res.json();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
36
|
-
tools: [
|
|
37
|
-
{
|
|
38
|
-
name: 'lookup_hts_code',
|
|
39
|
-
description: 'Search for HTS (Harmonized Tariff Schedule) codes by description or numeric prefix. Returns up to 12 matching codes with descriptions. Use this before calculate_tariff when you only have a product description.',
|
|
40
|
-
inputSchema: {
|
|
41
|
-
type: 'object',
|
|
42
|
-
properties: {
|
|
43
|
-
query: {
|
|
44
|
-
type: 'string',
|
|
45
|
-
description: 'Product description (e.g. "athletic footwear") or HTS code prefix (e.g. "6404"). Min 2 characters.',
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
required: ['query'],
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: 'calculate_tariff',
|
|
53
|
-
description: 'Calculate the full stacked US import tariff rate for an HTS code from a specific origin country. Returns: base rate, each additional layer (Section 301, IEEPA, reciprocal), total current rate, and the projected rate after the November 10, 2026 cliff. Optionally computes dollar impact from annual import volume.',
|
|
54
|
-
inputSchema: {
|
|
55
|
-
type: 'object',
|
|
56
|
-
properties: {
|
|
57
|
-
hts_code: {
|
|
58
|
-
type: 'string',
|
|
59
|
-
description: 'HTS code — at least 6 digits, up to 10 (e.g. "6404.11" or "6404110000"). Dots and spaces are ignored.',
|
|
60
|
-
},
|
|
61
|
-
origin_country: {
|
|
62
|
-
type: 'string',
|
|
63
|
-
description: 'ISO-2 origin country code (e.g. "CN" for China, "VN" for Vietnam, "MX" for Mexico). Default: CN.',
|
|
64
|
-
default: 'CN',
|
|
65
|
-
},
|
|
66
|
-
annual_import_value_usd: {
|
|
67
|
-
type: 'number',
|
|
68
|
-
description: 'Optional. Your annual import value in USD. If provided, the response includes the dollar cost of current duties and the additional cost from the Nov 10 cliff.',
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
required: ['hts_code'],
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
],
|
|
75
|
-
}));
|
|
76
|
-
|
|
77
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
78
|
-
const { name, arguments: args } = req.params;
|
|
79
|
-
|
|
80
|
-
if (name === 'lookup_hts_code') {
|
|
81
|
-
const query = String(args.query || '').trim();
|
|
82
|
-
if (query.length < 2) {
|
|
83
|
-
return { content: [{ type: 'text', text: 'Query must be at least 2 characters.' }] };
|
|
84
|
-
}
|
|
85
|
-
let data;
|
|
86
|
-
try {
|
|
87
|
-
data = await apiFetch('/hts/search?q=' + encodeURIComponent(query));
|
|
88
|
-
} catch (e) {
|
|
89
|
-
return { content: [{ type: 'text', text: 'Could not reach Tariff Monitor API: ' + e.message }] };
|
|
90
|
-
}
|
|
91
|
-
if (!data.ok || !data.results?.length) {
|
|
92
|
-
return { content: [{ type: 'text', text: 'No HTS codes found matching "' + query + '". Try a broader description or check the code prefix.' }] };
|
|
93
|
-
}
|
|
94
|
-
const rows = data.results.map(r => `${r.hts10} ${r.description}`).join('\n');
|
|
95
|
-
return {
|
|
96
|
-
content: [{
|
|
97
|
-
type: 'text',
|
|
98
|
-
text: `HTS codes matching "${query}":\n\n${rows}\n\nUse calculate_tariff with the most relevant code.`,
|
|
99
|
-
}],
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (name === 'calculate_tariff') {
|
|
104
|
-
const hts = String(args.hts_code || '').replace(/[^0-9]/g, '');
|
|
105
|
-
const country = String(args.origin_country || 'CN').toUpperCase().slice(0, 2);
|
|
106
|
-
const vol = Number(args.annual_import_value_usd) || 0;
|
|
107
|
-
|
|
108
|
-
if (hts.length < 6) {
|
|
109
|
-
return { content: [{ type: 'text', text: 'hts_code must be at least 6 digits. Use lookup_hts_code to find the right code.' }] };
|
|
110
|
-
}
|
|
111
|
-
if (!/^[A-Z]{2}$/.test(country)) {
|
|
112
|
-
return { content: [{ type: 'text', text: 'origin_country must be an ISO-2 code (e.g. CN, VN, MX, IN, KR, TW, DE, JP).' }] };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let d;
|
|
116
|
-
try {
|
|
117
|
-
d = await apiFetch(`/rate?hts=${encodeURIComponent(hts)}&country=${country}&explain=1${vol > 0 ? '&unit_cost=0' : ''}&utm_source=tariffmonitor-mcp`);
|
|
118
|
-
} catch (e) {
|
|
119
|
-
return { content: [{ type: 'text', text: 'Could not reach Tariff Monitor API: ' + e.message }] };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (!d.ok) {
|
|
123
|
-
return { content: [{ type: 'text', text: `HTS code ${hts} not found in the current US schedule. Use lookup_hts_code to find the right code.` }] };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const layers = (d.layers || []).map(l =>
|
|
127
|
-
` ${l.label}: +${Number(l.rate).toFixed(2)}%`
|
|
128
|
-
).join('\n');
|
|
129
|
-
|
|
130
|
-
const cliffDelta = (d.cliff_total_rate - d.total_rate).toFixed(2);
|
|
131
|
-
const cliffLine = Number(cliffDelta) > 0
|
|
132
|
-
? `\nNov 10, 2026 cliff: ${d.cliff_total_rate.toFixed(2)}% (+${cliffDelta}pp jump)`
|
|
133
|
-
: '\nNo cliff change projected for this code/country combination.';
|
|
134
|
-
|
|
135
|
-
let dollarLines = '';
|
|
136
|
-
if (vol > 0) {
|
|
137
|
-
const currentDuty = Math.round(d.total_rate / 100 * vol);
|
|
138
|
-
const cliffExtra = Number(cliffDelta) > 0 ? Math.round(Number(cliffDelta) / 100 * vol) : 0;
|
|
139
|
-
dollarLines = `\n\nDollar impact at $${vol.toLocaleString()}/yr import value:`
|
|
140
|
-
+ `\n Current annual duties: $${currentDuty.toLocaleString()}`
|
|
141
|
-
+ (cliffExtra > 0 ? `\n Additional after Nov 10 cliff: $${cliffExtra.toLocaleString()}/yr` : '');
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const explainBlock = d.explanation ? `\n\nExplanation: ${d.explanation}` : '';
|
|
145
|
-
|
|
146
|
-
const text = [
|
|
147
|
-
`HTS ${d.hts10} — ${d.description}`,
|
|
148
|
-
`Origin: ${country}`,
|
|
149
|
-
'',
|
|
150
|
-
'Rate stack:',
|
|
151
|
-
layers,
|
|
152
|
-
`Total current rate: ${d.total_rate.toFixed(2)}%`,
|
|
153
|
-
cliffLine,
|
|
154
|
-
dollarLines,
|
|
155
|
-
explainBlock,
|
|
156
|
-
'',
|
|
157
|
-
`Data source: ${PACK_URL}`,
|
|
158
|
-
`Full 67-code Survival Kit (pre-computed cliff math): ${BUY_URL}`,
|
|
159
|
-
].join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
9
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { TOOLS, callTool, SERVER_INFO } from './tools.js';
|
|
160
11
|
|
|
161
|
-
|
|
162
|
-
}
|
|
12
|
+
const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
|
|
163
13
|
|
|
164
|
-
|
|
165
|
-
|
|
14
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
15
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) =>
|
|
16
|
+
callTool(req.params.name, req.params.arguments || {}));
|
|
166
17
|
|
|
167
18
|
const transport = new StdioServerTransport();
|
|
168
19
|
await server.connect(transport);
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Tool definitions and the logic behind them, with NO transport attached.
|
|
2
|
+
//
|
|
3
|
+
// Split out 2026-08-21 so the same code can serve two very different callers:
|
|
4
|
+
// src/index.js — the original stdio server, for Claude Desktop / Cursor. Behaviour unchanged.
|
|
5
|
+
// src/http.js — a stateless HTTPS endpoint, which is the only thing Voidnet accepts.
|
|
6
|
+
// Duplicating the tool bodies for the second transport would have guaranteed they drifted apart.
|
|
7
|
+
//
|
|
8
|
+
// 2026-09-11: measured against the live API, BOTH of the examples this file documented returned
|
|
9
|
+
// nothing, because the upstream matches a search query as ONE substring of a code description and
|
|
10
|
+
// resolves a rate by exact-or-prefix code:
|
|
11
|
+
// /hts/search?q=athletic%20footwear -> ok:true, results: [] while "footwear" alone returns 30+
|
|
12
|
+
// /rate?hts=6404110000 -> ok:false, not found while "640411" resolves to 6404112030
|
|
13
|
+
// An agent that followed our own documentation got "no codes found" and stopped. The upstream runs
|
|
14
|
+
// on another host and is not ours to change for this, so both fallbacks below are client-side —
|
|
15
|
+
// and each one STATES what it did. A silent substitution would be answering a different question.
|
|
16
|
+
|
|
17
|
+
const API_BASE = 'https://adcreator-ai.com/api/tem';
|
|
18
|
+
const PACK_URL = 'https://scholar.0xpi.com/get/tariffmonitor';
|
|
19
|
+
const CALC_URL = 'https://adcreator-ai.com/tariffs/calculator';
|
|
20
|
+
|
|
21
|
+
export const SERVER_INFO = { name: 'tariffmonitor', version: '1.2.1' };
|
|
22
|
+
|
|
23
|
+
async function apiFetch(path) {
|
|
24
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
25
|
+
headers: { 'User-Agent': 'tariffmonitor-mcp/1.2.0' },
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) throw new Error(`API error ${res.status}`);
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A multi-word description finds nothing upstream, so retry the individual words, longest first —
|
|
32
|
+
// the longest token is usually the most specific noun. Reports what was actually searched.
|
|
33
|
+
async function searchHts(query) {
|
|
34
|
+
const first = await apiFetch('/hts/search?q=' + encodeURIComponent(query));
|
|
35
|
+
if (first.ok && first.results?.length) return { data: first, searched: query, fellBackFrom: null };
|
|
36
|
+
// In an HTS description the head noun is usually the LAST word - athletic footwear is a kind
|
|
37
|
+
// of footwear - so try that first, then the remaining words longest-first.
|
|
38
|
+
const tokens = query.split(/\s+/).filter((w) => w.length >= 3);
|
|
39
|
+
const words = tokens.length > 1
|
|
40
|
+
? [tokens[tokens.length - 1]].concat(tokens.slice(0, -1).sort((a, b) => b.length - a.length))
|
|
41
|
+
: tokens;
|
|
42
|
+
if (words.length > 1) {
|
|
43
|
+
for (const w of words) {
|
|
44
|
+
const next = await apiFetch('/hts/search?q=' + encodeURIComponent(w));
|
|
45
|
+
if (next.ok && next.results?.length) return { data: next, searched: w, fellBackFrom: query };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { data: first, searched: query, fellBackFrom: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The schedule holds 10-digit statistical suffixes, not zero-padded headings: 6404110000 does not
|
|
52
|
+
// exist while 640411 does. Walk a not-found code back to its 8- then 6-digit heading rather than
|
|
53
|
+
// telling an importer their product is absent from the US tariff schedule when it is not.
|
|
54
|
+
async function fetchRate(hts, country, vol) {
|
|
55
|
+
const candidates = [hts];
|
|
56
|
+
if (hts.length > 8) candidates.push(hts.slice(0, 8));
|
|
57
|
+
if (hts.length > 6) candidates.push(hts.slice(0, 6));
|
|
58
|
+
let last = null;
|
|
59
|
+
for (const code of candidates) {
|
|
60
|
+
const qs = `/rate?hts=${encodeURIComponent(code)}&country=${country}&explain=1`
|
|
61
|
+
+ `${vol > 0 ? '&unit_cost=0' : ''}&utm_source=tariffmonitor-mcp`;
|
|
62
|
+
last = await apiFetch(qs);
|
|
63
|
+
if (last.ok) return { d: last, requested: hts, used: code };
|
|
64
|
+
}
|
|
65
|
+
return { d: last, requested: hts, used: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const TOOLS = [
|
|
69
|
+
{
|
|
70
|
+
name: 'lookup_hts_code',
|
|
71
|
+
description: 'Search for HTS (Harmonized Tariff Schedule) codes by description or numeric prefix. Returns up to 12 matching codes. Use this before calculate_tariff when you only have a product description. Descriptions are matched as a single phrase, so one keyword ("footwear") matches far more than a phrase ("athletic footwear"); if a phrase matches nothing this tool retries the individual words and tells you which word it used.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
query: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: 'Product keyword (e.g. "footwear") or HTS code prefix (e.g. "6404"). Minimum 2 characters. Single keywords match best.',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ['query'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'calculate_tariff',
|
|
85
|
+
description: 'Calculate the full stacked US import tariff rate for an HTS code from a specific origin country. Returns: base rate, each additional layer (Section 301, Section 232), total current rate, and the projected rate after the November 10, 2026 Section 301 exclusion expiry. Figures are estimates; IEEPA tariffs ended 20 Feb 2026 and are not included. Optionally computes dollar impact from annual import volume.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
hts_code: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: 'HTS code — at least 6 digits, up to 10 (e.g. "6404.11" or "6404112030"). Dots and spaces are ignored. Zero-padded headings such as "6404110000" are not real schedule codes; if the exact code is not found, the 8- then 6-digit heading is tried and the answer states which code the rate is for.',
|
|
92
|
+
},
|
|
93
|
+
origin_country: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'ISO-2 origin country code (e.g. "CN" for China, "VN" for Vietnam, "MX" for Mexico). Default: CN.',
|
|
96
|
+
default: 'CN',
|
|
97
|
+
},
|
|
98
|
+
annual_import_value_usd: {
|
|
99
|
+
type: 'number',
|
|
100
|
+
description: 'Optional. Your annual import value in USD. If provided, the response includes the dollar cost of current duties and the additional cost from the Nov 10 cliff.',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['hts_code'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
// Returns the MCP content object for a tool call. Never throws for ordinary failures —
|
|
109
|
+
// an unreachable upstream is reported to the caller as text, same as the stdio server always did.
|
|
110
|
+
export async function callTool(name, args = {}) {
|
|
111
|
+
|
|
112
|
+
if (name === 'lookup_hts_code') {
|
|
113
|
+
const query = String(args.query || '').trim();
|
|
114
|
+
if (query.length < 2) {
|
|
115
|
+
return { content: [{ type: 'text', text: 'Query must be at least 2 characters.' }] };
|
|
116
|
+
}
|
|
117
|
+
let found;
|
|
118
|
+
try {
|
|
119
|
+
found = await searchHts(query);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { content: [{ type: 'text', text: 'Could not reach Tariff Monitor API: ' + e.message }] };
|
|
122
|
+
}
|
|
123
|
+
const data = found.data;
|
|
124
|
+
if (!data.ok || !data.results?.length) {
|
|
125
|
+
return { content: [{ type: 'text', text: 'No HTS codes found matching "' + query + '"'
|
|
126
|
+
+ (query.includes(' ') ? ', and no single word in it matched either' : '')
|
|
127
|
+
+ '. Try a broader single keyword, or a numeric HTS prefix such as 6404.' }] };
|
|
128
|
+
}
|
|
129
|
+
const rows = data.results.map((r) => `${r.hts10} ${r.description}`).join('\n');
|
|
130
|
+
const note = found.fellBackFrom
|
|
131
|
+
? `No code description contains the whole phrase "${found.fellBackFrom}", so this is a search for "${found.searched}".\n\n`
|
|
132
|
+
: '';
|
|
133
|
+
return {
|
|
134
|
+
content: [{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: `${note}HTS codes matching "${found.searched}":\n\n${rows}\n\nUse calculate_tariff with the most relevant code.`,
|
|
137
|
+
}],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (name === 'calculate_tariff') {
|
|
142
|
+
const hts = String(args.hts_code || '').replace(/[^0-9]/g, '');
|
|
143
|
+
const country = String(args.origin_country || 'CN').toUpperCase().slice(0, 2);
|
|
144
|
+
const vol = Number(args.annual_import_value_usd) || 0;
|
|
145
|
+
|
|
146
|
+
if (hts.length < 6) {
|
|
147
|
+
return { content: [{ type: 'text', text: 'hts_code must be at least 6 digits. Use lookup_hts_code to find the right code.' }] };
|
|
148
|
+
}
|
|
149
|
+
if (!/^[A-Z]{2}$/.test(country)) {
|
|
150
|
+
return { content: [{ type: 'text', text: 'origin_country must be an ISO-2 code (e.g. CN, VN, MX, IN, KR, TW, DE, JP).' }] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let r;
|
|
154
|
+
try {
|
|
155
|
+
r = await fetchRate(hts, country, vol);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
return { content: [{ type: 'text', text: 'Could not reach Tariff Monitor API: ' + e.message }] };
|
|
158
|
+
}
|
|
159
|
+
const d = r.d;
|
|
160
|
+
if (!d || !d.ok) {
|
|
161
|
+
return { content: [{ type: 'text', text: `HTS code ${hts} is not in the current US schedule, and neither is its 8- or 6-digit heading. Use lookup_hts_code to find the right code.` }] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const layers = (d.layers || []).map((l) => ` ${l.label}: +${Number(l.rate).toFixed(2)}%`).join('\n');
|
|
165
|
+
const cliffDelta = (d.cliff_total_rate - d.total_rate).toFixed(2);
|
|
166
|
+
const cliffLine = Number(cliffDelta) > 0
|
|
167
|
+
? `\nNov 10, 2026 cliff: ${d.cliff_total_rate.toFixed(2)}% (+${cliffDelta}pp jump)`
|
|
168
|
+
: '\nNo cliff change projected for this code/country combination.';
|
|
169
|
+
|
|
170
|
+
let dollarLines = '';
|
|
171
|
+
if (vol > 0) {
|
|
172
|
+
const currentDuty = Math.round(d.total_rate / 100 * vol);
|
|
173
|
+
const cliffExtra = Number(cliffDelta) > 0 ? Math.round(Number(cliffDelta) / 100 * vol) : 0;
|
|
174
|
+
dollarLines = `\n\nDollar impact at $${vol.toLocaleString()}/yr import value:`
|
|
175
|
+
+ `\n Current annual duties: $${currentDuty.toLocaleString()}`
|
|
176
|
+
+ (cliffExtra > 0 ? `\n Additional after Nov 10 cliff: $${cliffExtra.toLocaleString()}/yr` : '');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const explainBlock = d.explanation ? `\n\nExplanation: ${d.explanation}` : '';
|
|
180
|
+
// Say it plainly when the answer is for a different code than the one asked for. The rate is
|
|
181
|
+
// right for the code named on the first line, and the caller must be able to see that.
|
|
182
|
+
const resolvedNote = (r.used && r.used !== r.requested)
|
|
183
|
+
? `Note: ${r.requested} is not in the schedule. This rate is for heading ${r.used}, resolved to ${d.hts10}.\n\n`
|
|
184
|
+
: '';
|
|
185
|
+
|
|
186
|
+
const text = [
|
|
187
|
+
`${resolvedNote}HTS ${d.hts10} — ${d.description}`,
|
|
188
|
+
`Origin: ${country}`,
|
|
189
|
+
'',
|
|
190
|
+
'Rate stack:',
|
|
191
|
+
layers,
|
|
192
|
+
`Total current rate: ${d.total_rate.toFixed(2)}%`,
|
|
193
|
+
cliffLine,
|
|
194
|
+
dollarLines,
|
|
195
|
+
explainBlock,
|
|
196
|
+
'',
|
|
197
|
+
`Estimate only. Check the authoritative rate at https://hts.usitc.gov/ before relying on it.`,
|
|
198
|
+
`Free browser calculator: ${CALC_URL}`,
|
|
199
|
+
].join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
200
|
+
|
|
201
|
+
return { content: [{ type: 'text', text }] };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
205
|
+
}
|