tender-mcp 1.2.6 → 1.2.9
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 +5 -0
- package/README.md +48 -5
- package/package.json +1 -1
- package/src/server.js +71 -16
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -22,6 +22,49 @@ Or via Smithery:
|
|
|
22
22
|
npx -y @smithery/cli@latest mcp add OjasKord/tender-mcp
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
## Harness Integration
|
|
26
|
+
|
|
27
|
+
### Claude Code / Claude Desktop (.mcp.json)
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"tender": {
|
|
32
|
+
"type": "http",
|
|
33
|
+
"url": "https://tender-mcp-production.up.railway.app"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### LangChain (Python)
|
|
40
|
+
```python
|
|
41
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
42
|
+
client = MultiServerMCPClient({
|
|
43
|
+
"tender": {
|
|
44
|
+
"url": "https://tender-mcp-production.up.railway.app",
|
|
45
|
+
"transport": "http"
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
tools = await client.get_tools()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### OpenAI Agents SDK (Python)
|
|
52
|
+
```python
|
|
53
|
+
from agents import Agent, HostedMCPTool
|
|
54
|
+
agent = Agent(
|
|
55
|
+
name="Assistant",
|
|
56
|
+
tools=[HostedMCPTool(tool_config={
|
|
57
|
+
"type": "mcp",
|
|
58
|
+
"server_label": "tender",
|
|
59
|
+
"server_url": "https://tender-mcp-production.up.railway.app",
|
|
60
|
+
"require_approval": "never"
|
|
61
|
+
})]
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### LangGraph
|
|
66
|
+
Same as LangChain above — langchain-mcp-adapters works with LangGraph natively.
|
|
67
|
+
|
|
25
68
|
## Why Use This
|
|
26
69
|
|
|
27
70
|
Any business that sells to government needs to monitor tender opportunities. But searching three separate government portals daily, reading hundreds of notices, and manually judging relevance takes hours. Tender MCP does it in seconds — search UK, EU, and US simultaneously, then let AI score which opportunities actually match your capabilities.
|
|
@@ -89,11 +132,11 @@ Every response includes `source_url` and `checked_at`.
|
|
|
89
132
|
|
|
90
133
|
## Pricing
|
|
91
134
|
|
|
92
|
-
| Plan | Searches |
|
|
93
|
-
|
|
94
|
-
| Free | 10/month |
|
|
95
|
-
|
|
|
96
|
-
|
|
|
135
|
+
| Plan | Searches | Price |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| Free | 10/month | No API key required |
|
|
138
|
+
| Starter | 500-call bundle | $8 |
|
|
139
|
+
| Pro | 2,000-call bundle | $28 |
|
|
97
140
|
|
|
98
141
|
Upgrade at **[kordagencies.com](https://kordagencies.com)**
|
|
99
142
|
|
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.9",
|
|
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.9';
|
|
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: {
|
|
@@ -548,17 +571,20 @@ function checkAccess(req, toolName) {
|
|
|
548
571
|
}
|
|
549
572
|
|
|
550
573
|
// Free tier — allow all tools, but pass tier='free' so executeTool can gate paid features
|
|
551
|
-
const
|
|
552
|
-
const
|
|
574
|
+
const rawIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
575
|
+
const ip = rawIp.split(',')[0].trim();
|
|
576
|
+
const monthKey = getMonthKey(ip);
|
|
577
|
+
const calls = freeTierUsage.get(monthKey) || 0;
|
|
553
578
|
if (calls >= FREE_TIER_LIMIT) {
|
|
554
579
|
return {
|
|
555
580
|
allowed: false,
|
|
556
|
-
reason: 'Free tier limit reached.
|
|
581
|
+
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
582
|
upgrade_url: PRO_UPGRADE_URL,
|
|
583
|
+
trial_extension: { endpoint: '/trial-extension', method: 'POST', body: { name: 'string', email: 'string', use_case: 'string' } },
|
|
558
584
|
tier: 'free_limit_reached'
|
|
559
585
|
};
|
|
560
586
|
}
|
|
561
|
-
freeTierUsage.set(
|
|
587
|
+
freeTierUsage.set(monthKey, calls + 1);
|
|
562
588
|
saveStats();
|
|
563
589
|
const remaining = FREE_TIER_LIMIT - calls - 1;
|
|
564
590
|
return {
|
|
@@ -593,6 +619,7 @@ async function handleStripeWebhook(body, sig) {
|
|
|
593
619
|
if (email) {
|
|
594
620
|
const apiKey = generateApiKey();
|
|
595
621
|
apiKeys.set(apiKey, { email, plan, createdAt: nowISO(), calls: 0, limit: PLAN_LIMITS[plan] });
|
|
622
|
+
saveApiKeys();
|
|
596
623
|
await sendApiKeyEmail(email, apiKey, plan);
|
|
597
624
|
console.log('[tender] API key created for ' + email + ' (' + plan + ')');
|
|
598
625
|
return { success: true, email, plan };
|
|
@@ -656,10 +683,35 @@ const server = http.createServer(async (req, res) => {
|
|
|
656
683
|
if (req.url === '/stats' && req.method === 'GET') {
|
|
657
684
|
if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
|
|
658
685
|
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; });
|
|
686
|
+
const freeUniqueIPs = new Set(Array.from(freeTierUsage.keys()).map(k => k.split(':')[0])).size;
|
|
661
687
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
662
|
-
res.end(JSON.stringify({ free_tier_unique_ips:
|
|
688
|
+
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 }));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (req.url === '/trial-extension' && req.method === 'POST') {
|
|
693
|
+
let body = ''; req.on('data', c => body += c);
|
|
694
|
+
req.on('end', async () => {
|
|
695
|
+
try {
|
|
696
|
+
const { name, email, use_case } = JSON.parse(body);
|
|
697
|
+
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; }
|
|
698
|
+
const emailKey = 'trial:' + email.toLowerCase().trim();
|
|
699
|
+
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; }
|
|
700
|
+
const rawIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
701
|
+
const ip = rawIp.split(',')[0].trim();
|
|
702
|
+
const monthKey = getMonthKey(ip);
|
|
703
|
+
const currentCalls = freeTierUsage.get(monthKey) || 0;
|
|
704
|
+
freeTierUsage.set(monthKey, Math.max(0, currentCalls - TRIAL_EXTENSION_CALLS));
|
|
705
|
+
trialExtensions.set(emailKey, { name, email, use_case: use_case || '', ip, granted_at: nowISO() });
|
|
706
|
+
saveStats();
|
|
707
|
+
await sendEmail('ojas@kordagencies.com', 'Tender MCP -- Trial Extension: ' + name,
|
|
708
|
+
'<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>');
|
|
709
|
+
await sendEmail(email, TRIAL_EXTENSION_CALLS + ' extra free searches added -- Tender MCP',
|
|
710
|
+
'<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>');
|
|
711
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
712
|
+
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 }));
|
|
713
|
+
} catch(e) { res.writeHead(400, { ...cors, 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message, agent_action: 'RETRY_IN_2_MIN' })); }
|
|
714
|
+
});
|
|
663
715
|
return;
|
|
664
716
|
}
|
|
665
717
|
|
|
@@ -684,7 +736,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
684
736
|
let response;
|
|
685
737
|
|
|
686
738
|
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
|
|
739
|
+
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
740
|
} else if (request.method === 'notifications/initialized') {
|
|
689
741
|
res.writeHead(204, cors); res.end(); return;
|
|
690
742
|
} else if (request.method === 'tools/list') {
|
|
@@ -703,9 +755,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
703
755
|
return;
|
|
704
756
|
}
|
|
705
757
|
|
|
706
|
-
const
|
|
758
|
+
const rawIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
759
|
+
const ip = rawIp.split(',')[0].trim();
|
|
707
760
|
usageLog.push({ tool: name, tier: access.tier, time: nowISO(), ip: ip.slice(0, 8) + '...' });
|
|
708
761
|
if (usageLog.length > 1000) usageLog.shift();
|
|
762
|
+
toolUsageCounts[name] = (toolUsageCounts[name] || 0) + 1;
|
|
709
763
|
saveStats();
|
|
710
764
|
|
|
711
765
|
const result = await executeTool(name, toolArgs || {}, access.tier);
|
|
@@ -771,7 +825,7 @@ function setupStdio() {
|
|
|
771
825
|
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
826
|
let resp;
|
|
773
827
|
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
|
|
828
|
+
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
829
|
} else if (req.method === 'notifications/initialized') {
|
|
776
830
|
continue;
|
|
777
831
|
} else if (req.method === 'tools/list') {
|
|
@@ -801,6 +855,7 @@ setupStdio();
|
|
|
801
855
|
|
|
802
856
|
server.listen(PORT, () => {
|
|
803
857
|
loadStats();
|
|
858
|
+
loadApiKeys();
|
|
804
859
|
console.log('Tender MCP v' + VERSION + ' running on port ' + PORT);
|
|
805
860
|
console.log('Tools: 2 (search_tenders, get_tender_intelligence)');
|
|
806
861
|
console.log('Free tier: ' + FREE_TIER_LIMIT + ' searches/IP/month');
|