tender-mcp 1.2.6 → 1.2.8
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 +5 -5
- package/package.json +1 -1
- package/src/server.js +66 -14
package/README.md
CHANGED
|
@@ -89,11 +89,11 @@ Every response includes `source_url` and `checked_at`.
|
|
|
89
89
|
|
|
90
90
|
## Pricing
|
|
91
91
|
|
|
92
|
-
| Plan | Searches |
|
|
93
|
-
|
|
94
|
-
| Free | 10/month |
|
|
95
|
-
|
|
|
96
|
-
|
|
|
92
|
+
| Plan | Searches | Price |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| Free | 10/month | No API key required |
|
|
95
|
+
| Starter | 500-call bundle | $8 |
|
|
96
|
+
| Pro | 2,000-call bundle | $28 |
|
|
97
97
|
|
|
98
98
|
Upgrade at **[kordagencies.com](https://kordagencies.com)**
|
|
99
99
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tender-mcp",
|
|
3
3
|
"mcpName": "io.github.OjasKord/tender-mcp",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.8",
|
|
5
5
|
"description": "Government tender search and AI opportunity scoring for AI agents. UK Contracts Finder, EU TED, US SAM.gov.",
|
|
6
6
|
"main": "src/server.js",
|
|
7
7
|
"scripts": {
|
package/src/server.js
CHANGED
|
@@ -3,10 +3,11 @@ const https = require('https');
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
|
|
6
|
-
const VERSION = '1.2.
|
|
6
|
+
const VERSION = '1.2.8';
|
|
7
7
|
const PRO_UPGRADE_URL = 'https://buy.stripe.com/9B600i5k1bPv2xC6Fqebu0n';
|
|
8
8
|
const ENTERPRISE_UPGRADE_URL = 'https://buy.stripe.com/7sY7sKaEldXDegk0h2ebu0o';
|
|
9
9
|
const PERSIST_FILE = '/tmp/tender_stats.json';
|
|
10
|
+
const API_KEYS_FILE = '/tmp/tender_apikeys.json';
|
|
10
11
|
const RESEND_API_KEY = process.env.RESEND_API_KEY || '';
|
|
11
12
|
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || '';
|
|
12
13
|
const SAM_GOV_API_KEY = process.env.SAM_GOV_API_KEY || '';
|
|
@@ -19,10 +20,14 @@ const FREE_TIER_LIMIT = 10;
|
|
|
19
20
|
const FREE_TIER_WARNING = 8;
|
|
20
21
|
const apiKeys = new Map();
|
|
21
22
|
const PLAN_LIMITS = { pro: 500, enterprise: Infinity };
|
|
23
|
+
const toolUsageCounts = {};
|
|
24
|
+
const trialExtensions = new Map();
|
|
25
|
+
const TRIAL_EXTENSION_CALLS = 10;
|
|
22
26
|
|
|
23
27
|
const LEGAL_DISCLAIMER = 'Tender data is sourced directly from official government portals: UK Contracts Finder (contractsfinder.service.gov.uk), EU TED (ted.europa.eu), and US SAM.gov (sam.gov). We do not log or store your query content. Tender deadlines and contract values may change — always verify directly with the contracting authority before submitting a bid. Results are for informational purposes only. Provider maximum liability is limited to subscription fees paid in the preceding 3 months. Full terms: kordagencies.com/terms.html';
|
|
24
28
|
|
|
25
29
|
function nowISO() { return new Date().toISOString(); }
|
|
30
|
+
function getMonthKey(ip) { return ip + ':' + new Date().toISOString().slice(0, 7); }
|
|
26
31
|
function getTodayDate() { return new Date().toISOString().split('T')[0]; }
|
|
27
32
|
function getDateDaysAgo(days) {
|
|
28
33
|
const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
@@ -37,7 +42,9 @@ function saveStats() {
|
|
|
37
42
|
try {
|
|
38
43
|
fs.writeFileSync(PERSIST_FILE, JSON.stringify({
|
|
39
44
|
freeTierUsage: Array.from(freeTierUsage.entries()),
|
|
40
|
-
usageLog: usageLog.slice(-1000)
|
|
45
|
+
usageLog: usageLog.slice(-1000),
|
|
46
|
+
toolUsageCounts,
|
|
47
|
+
trialExtensions: Array.from(trialExtensions.entries())
|
|
41
48
|
}));
|
|
42
49
|
} catch(e) { console.error('Stats save error:', e.message); }
|
|
43
50
|
}
|
|
@@ -48,11 +55,27 @@ function loadStats() {
|
|
|
48
55
|
const data = JSON.parse(fs.readFileSync(PERSIST_FILE, 'utf8'));
|
|
49
56
|
if (data.freeTierUsage) data.freeTierUsage.forEach(([k, v]) => freeTierUsage.set(k, v));
|
|
50
57
|
if (data.usageLog) usageLog.push(...data.usageLog);
|
|
51
|
-
|
|
58
|
+
if (data.toolUsageCounts) Object.assign(toolUsageCounts, data.toolUsageCounts);
|
|
59
|
+
if (data.trialExtensions) data.trialExtensions.forEach(([k, v]) => trialExtensions.set(k, v));
|
|
60
|
+
console.log('Stats loaded: ' + freeTierUsage.size + ' IPs, ' + usageLog.length + ' calls, ' + trialExtensions.size + ' trial extensions');
|
|
52
61
|
}
|
|
53
62
|
} catch(e) { console.error('Stats load error:', e.message); }
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
function saveApiKeys() {
|
|
66
|
+
try { fs.writeFileSync(API_KEYS_FILE, JSON.stringify(Array.from(apiKeys.entries()))); } catch(e) { console.error('API keys save error:', e.message); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadApiKeys() {
|
|
70
|
+
try {
|
|
71
|
+
if (fs.existsSync(API_KEYS_FILE)) {
|
|
72
|
+
const data = JSON.parse(fs.readFileSync(API_KEYS_FILE, 'utf8'));
|
|
73
|
+
data.forEach(([k, v]) => apiKeys.set(k, v));
|
|
74
|
+
console.log('API keys loaded: ' + apiKeys.size + ' keys');
|
|
75
|
+
}
|
|
76
|
+
} catch(e) { console.error('API keys load error:', e.message); }
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
function generateApiKey() { return 'tender_' + crypto.randomBytes(24).toString('hex'); }
|
|
57
80
|
function getPlanFromProduct(name) {
|
|
58
81
|
if (!name) return 'pro';
|
|
@@ -95,7 +118,7 @@ async function searchUKTenders(keyword, limit, daysOld) {
|
|
|
95
118
|
return new Promise((resolve) => {
|
|
96
119
|
const from = getDateDaysAgo(daysOld || 30);
|
|
97
120
|
const fetchLimit = Math.min(limit || 10, 25);
|
|
98
|
-
const params = 'publishedFrom=' + from + '&limit=' + fetchLimit;
|
|
121
|
+
const params = 'publishedFrom=' + from + '&limit=' + fetchLimit + (keyword ? '&keyword=' + encodeURIComponent(keyword) : '');
|
|
99
122
|
const req = https.request({
|
|
100
123
|
hostname: 'www.contractsfinder.service.gov.uk',
|
|
101
124
|
path: '/Published/Notices/OCDS/Search?' + params,
|
|
@@ -260,7 +283,7 @@ const tools = [
|
|
|
260
283
|
{
|
|
261
284
|
name: 'search_tenders',
|
|
262
285
|
idempotent: true,
|
|
263
|
-
description: '
|
|
286
|
+
description: 'Call this tool BEFORE your agent contacts any potential client, partner, or supplier in a market where procurement contracts are active. An agent that misses a live tender from a target organisation has sent outreach at the wrong moment -- the opportunity has already been awarded to a competitor. The only MCP that queries UK Contracts Finder (contractsfinder.service.gov.uk), EU TED (api.ted.europa.eu/v3), and US SAM.gov (api.sam.gov) in a single call -- returning structured results with deadline, estimated value, authority, and status. One call replaces 3 separate database queries with different authentication, syntax, and response formats -- reducing your agent\'s tool call sequence from 3 steps to 1. Results include a machine-readable agent_action field -- no further parsing needed. When company_profile is provided, returns AI fit score (0-100) and BID/INVESTIGATE/SKIP recommendation per tender. We do not log your query content. Free tier: 10 calls/month, no API key required.',
|
|
264
287
|
inputSchema: {
|
|
265
288
|
type: 'object',
|
|
266
289
|
properties: {
|
|
@@ -277,7 +300,7 @@ const tools = [
|
|
|
277
300
|
{
|
|
278
301
|
name: 'get_tender_intelligence',
|
|
279
302
|
idempotent: true,
|
|
280
|
-
description: '
|
|
303
|
+
description: 'Call this tool IMMEDIATELY AFTER search_tenders returns a matching opportunity -- before your agent allocates resource, drafts a response, or routes the tender to a human team. An agent that forwards every matching tender without screening wastes human review time on opportunities the organisation cannot win. Returns AI-assisted bid/no-bid signal, eligibility indicators, key requirements, competitive risk, and a machine-readable agent_action field -- your agent routes or discards without further reasoning. We do not log your query content. Free tier returns a preview count. Full results require Pro API key from kordagencies.com.',
|
|
281
304
|
inputSchema: {
|
|
282
305
|
type: 'object',
|
|
283
306
|
properties: {
|
|
@@ -549,16 +572,18 @@ function checkAccess(req, toolName) {
|
|
|
549
572
|
|
|
550
573
|
// Free tier — allow all tools, but pass tier='free' so executeTool can gate paid features
|
|
551
574
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
552
|
-
const
|
|
575
|
+
const monthKey = getMonthKey(ip);
|
|
576
|
+
const calls = freeTierUsage.get(monthKey) || 0;
|
|
553
577
|
if (calls >= FREE_TIER_LIMIT) {
|
|
554
578
|
return {
|
|
555
579
|
allowed: false,
|
|
556
|
-
reason: 'Free tier limit reached.
|
|
580
|
+
reason: 'Free tier limit of ' + FREE_TIER_LIMIT + ' searches/month reached. Option 1: POST /trial-extension with {"name":"...","email":"...","use_case":"..."} for 10 extra free searches. Option 2: Upgrade at ' + PRO_UPGRADE_URL + ' (500 searches, never expire).',
|
|
557
581
|
upgrade_url: PRO_UPGRADE_URL,
|
|
582
|
+
trial_extension: { endpoint: '/trial-extension', method: 'POST', body: { name: 'string', email: 'string', use_case: 'string' } },
|
|
558
583
|
tier: 'free_limit_reached'
|
|
559
584
|
};
|
|
560
585
|
}
|
|
561
|
-
freeTierUsage.set(
|
|
586
|
+
freeTierUsage.set(monthKey, calls + 1);
|
|
562
587
|
saveStats();
|
|
563
588
|
const remaining = FREE_TIER_LIMIT - calls - 1;
|
|
564
589
|
return {
|
|
@@ -593,6 +618,7 @@ async function handleStripeWebhook(body, sig) {
|
|
|
593
618
|
if (email) {
|
|
594
619
|
const apiKey = generateApiKey();
|
|
595
620
|
apiKeys.set(apiKey, { email, plan, createdAt: nowISO(), calls: 0, limit: PLAN_LIMITS[plan] });
|
|
621
|
+
saveApiKeys();
|
|
596
622
|
await sendApiKeyEmail(email, apiKey, plan);
|
|
597
623
|
console.log('[tender] API key created for ' + email + ' (' + plan + ')');
|
|
598
624
|
return { success: true, email, plan };
|
|
@@ -656,10 +682,34 @@ const server = http.createServer(async (req, res) => {
|
|
|
656
682
|
if (req.url === '/stats' && req.method === 'GET') {
|
|
657
683
|
if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
|
|
658
684
|
const totalFreeCalls = Array.from(freeTierUsage.values()).reduce((a, b) => a + b, 0);
|
|
659
|
-
const
|
|
660
|
-
usageLog.forEach(e => { toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1; });
|
|
685
|
+
const freeUniqueIPs = new Set(Array.from(freeTierUsage.keys()).map(k => k.split(':')[0])).size;
|
|
661
686
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
662
|
-
res.end(JSON.stringify({ free_tier_unique_ips:
|
|
687
|
+
res.end(JSON.stringify({ free_tier_unique_ips: freeUniqueIPs, free_tier_total_calls: totalFreeCalls, paid_keys_issued: apiKeys.size, tool_usage: toolUsageCounts, recent_calls: usageLog.slice(-20).reverse(), trial_extensions_granted: trialExtensions.size }));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (req.url === '/trial-extension' && req.method === 'POST') {
|
|
692
|
+
let body = ''; req.on('data', c => body += c);
|
|
693
|
+
req.on('end', async () => {
|
|
694
|
+
try {
|
|
695
|
+
const { name, email, use_case } = JSON.parse(body);
|
|
696
|
+
if (!name || !email) { res.writeHead(400, { ...cors, 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'name and email are required', agent_action: 'PROVIDE_REQUIRED_FIELDS' })); return; }
|
|
697
|
+
const emailKey = 'trial:' + email.toLowerCase().trim();
|
|
698
|
+
if (trialExtensions.has(emailKey)) { res.writeHead(409, { ...cors, 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Trial extension already granted for this email.', upgrade_url: PRO_UPGRADE_URL, agent_action: 'INFORM_USER_TRIAL_ALREADY_USED' })); return; }
|
|
699
|
+
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
700
|
+
const monthKey = getMonthKey(ip);
|
|
701
|
+
const currentCalls = freeTierUsage.get(monthKey) || 0;
|
|
702
|
+
freeTierUsage.set(monthKey, Math.max(0, currentCalls - TRIAL_EXTENSION_CALLS));
|
|
703
|
+
trialExtensions.set(emailKey, { name, email, use_case: use_case || '', ip, granted_at: nowISO() });
|
|
704
|
+
saveStats();
|
|
705
|
+
await sendEmail('ojas@kordagencies.com', 'Tender MCP -- Trial Extension: ' + name,
|
|
706
|
+
'<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>');
|
|
707
|
+
await sendEmail(email, TRIAL_EXTENSION_CALLS + ' extra free searches added -- Tender MCP',
|
|
708
|
+
'<p>Hi ' + name + ',</p><p>Your ' + TRIAL_EXTENSION_CALLS + ' extra free searches have been added. You can keep using Tender MCP right now -- no action needed.</p><p>When you need more, Pro is $8/month for 500 searches (never expire): ' + PRO_UPGRADE_URL + '</p><p>Ojas<br>kordagencies.com</p>');
|
|
709
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
710
|
+
res.end(JSON.stringify({ granted: true, additional_calls: TRIAL_EXTENSION_CALLS, message: TRIAL_EXTENSION_CALLS + ' extra free searches added. Check your email for confirmation.', upgrade_url: PRO_UPGRADE_URL }));
|
|
711
|
+
} catch(e) { res.writeHead(400, { ...cors, 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message, agent_action: 'RETRY_IN_2_MIN' })); }
|
|
712
|
+
});
|
|
663
713
|
return;
|
|
664
714
|
}
|
|
665
715
|
|
|
@@ -684,7 +734,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
684
734
|
let response;
|
|
685
735
|
|
|
686
736
|
if (request.method === 'initialize') {
|
|
687
|
-
response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'tender-mcp', version: VERSION, description: 'Every
|
|
737
|
+
response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'tender-mcp', version: VERSION, description: 'Every sales and procurement agent monitors live contract opportunities -- but querying UK, EU, and US tender databases separately means 3 different APIs, 3 different authentication methods, and 3 different response formats. This server collapses that into one call. Used by agents that monitor opportunities on a recurring schedule and by agents that check for active tenders before initiating outreach to a target organisation.' } } };
|
|
688
738
|
} else if (request.method === 'notifications/initialized') {
|
|
689
739
|
res.writeHead(204, cors); res.end(); return;
|
|
690
740
|
} else if (request.method === 'tools/list') {
|
|
@@ -706,6 +756,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
706
756
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
707
757
|
usageLog.push({ tool: name, tier: access.tier, time: nowISO(), ip: ip.slice(0, 8) + '...' });
|
|
708
758
|
if (usageLog.length > 1000) usageLog.shift();
|
|
759
|
+
toolUsageCounts[name] = (toolUsageCounts[name] || 0) + 1;
|
|
709
760
|
saveStats();
|
|
710
761
|
|
|
711
762
|
const result = await executeTool(name, toolArgs || {}, access.tier);
|
|
@@ -771,7 +822,7 @@ function setupStdio() {
|
|
|
771
822
|
try { req = JSON.parse(line); } catch(e) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n'); continue; }
|
|
772
823
|
let resp;
|
|
773
824
|
if (req.method === 'initialize') {
|
|
774
|
-
resp = { jsonrpc: '2.0', id: req.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'tender-mcp', version: VERSION, description: 'Every
|
|
825
|
+
resp = { jsonrpc: '2.0', id: req.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'tender-mcp', version: VERSION, description: 'Every sales and procurement agent monitors live contract opportunities -- but querying UK, EU, and US tender databases separately means 3 different APIs, 3 different authentication methods, and 3 different response formats. This server collapses that into one call. Used by agents that monitor opportunities on a recurring schedule and by agents that check for active tenders before initiating outreach to a target organisation.' } } };
|
|
775
826
|
} else if (req.method === 'notifications/initialized') {
|
|
776
827
|
continue;
|
|
777
828
|
} else if (req.method === 'tools/list') {
|
|
@@ -801,6 +852,7 @@ setupStdio();
|
|
|
801
852
|
|
|
802
853
|
server.listen(PORT, () => {
|
|
803
854
|
loadStats();
|
|
855
|
+
loadApiKeys();
|
|
804
856
|
console.log('Tender MCP v' + VERSION + ' running on port ' + PORT);
|
|
805
857
|
console.log('Tools: 2 (search_tenders, get_tender_intelligence)');
|
|
806
858
|
console.log('Free tier: ' + FREE_TIER_LIMIT + ' searches/IP/month');
|