tender-mcp 1.0.1 → 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/CHANGELOG.md +8 -0
- package/package.json +36 -5
- package/server.json +9 -11
- package/src/server.js +403 -413
- package/Project +0 -0
- package/Select +0 -0
- package/railway +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [1.2.0] - 2026-04-21
|
|
2
|
+
### Changed
|
|
3
|
+
- Consolidated from 5 tools to 2: search_tenders and get_tender_intelligence
|
|
4
|
+
- search_tenders now runs AI fit scoring automatically inline
|
|
5
|
+
- get_tender_intelligence replaces get_daily_digest and get_award_history with mode parameter (DAILY_DIGEST or AWARD_HISTORY)
|
|
6
|
+
- Free tier preview for intelligence tool returns real count before gating full results
|
|
7
|
+
- Upgrade hooks in every response with specific conversion messaging
|
|
8
|
+
|
|
1
9
|
# Changelog — Tender MCP
|
|
2
10
|
|
|
3
11
|
## v1.0.0 — 2026-04-09
|
package/package.json
CHANGED
|
@@ -1,12 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tender-mcp",
|
|
3
|
-
"
|
|
3
|
+
"mcpName": "io.github.OjasKord/tender-mcp",
|
|
4
|
+
"version": "1.2.1",
|
|
4
5
|
"description": "Government tender search and AI opportunity scoring for AI agents. UK Contracts Finder, EU TED, US SAM.gov.",
|
|
5
6
|
"main": "src/server.js",
|
|
6
|
-
"scripts": {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node src/server.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"mcp",
|
|
12
|
+
"agent",
|
|
13
|
+
"tender",
|
|
14
|
+
"procurement",
|
|
15
|
+
"government",
|
|
16
|
+
"contracts",
|
|
17
|
+
"bidding",
|
|
18
|
+
"government-contracts",
|
|
19
|
+
"sam-gov",
|
|
20
|
+
"eu-ted",
|
|
21
|
+
"contracts-finder",
|
|
22
|
+
"bid-scoring",
|
|
23
|
+
"ai-scoring",
|
|
24
|
+
"public-sector",
|
|
25
|
+
"rfp",
|
|
26
|
+
"rfq",
|
|
27
|
+
"opportunity-search",
|
|
28
|
+
"validator"
|
|
29
|
+
],
|
|
30
|
+
"author": "Kord Agencies Pte Ltd <ojas@kordagencies.com>",
|
|
9
31
|
"license": "UNLICENSED",
|
|
10
32
|
"homepage": "https://kordagencies.com",
|
|
11
|
-
"repository": {
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/OjasKord/tender-mcp.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/OjasKord/tender-mcp/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
}
|
|
12
43
|
}
|
package/server.json
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.OjasKord/tender-mcp",
|
|
4
|
-
"description": "Government tender search for AI agents. UK, EU & US procurement opportunities..",
|
|
5
4
|
"title": "Tender MCP",
|
|
5
|
+
"description": "Government tender search for AI agents. UK, EU and US procurement opportunities.",
|
|
6
|
+
"version": "1.2.1",
|
|
7
|
+
"websiteUrl": "https://kordagencies.com",
|
|
6
8
|
"repository": {
|
|
7
9
|
"url": "https://github.com/OjasKord/tender-mcp",
|
|
8
10
|
"source": "github"
|
|
9
11
|
},
|
|
10
|
-
"version": "1.0.0",
|
|
11
12
|
"packages": [
|
|
12
13
|
{
|
|
13
14
|
"registryType": "npm",
|
|
14
|
-
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "tender-mcp",
|
|
16
|
-
"version": "1.
|
|
17
|
-
"transport": { "type": "stdio" }
|
|
16
|
+
"version": "1.2.1",
|
|
17
|
+
"transport": { "type": "stdio" },
|
|
18
|
+
"environmentVariables": [
|
|
19
|
+
{ "name": "ANTHROPIC_API_KEY", "description": "Anthropic API key for AI-powered tender scoring", "isRequired": true, "isSecret": true }
|
|
20
|
+
]
|
|
18
21
|
}
|
|
19
22
|
],
|
|
20
|
-
"remotes": [
|
|
21
|
-
{
|
|
22
|
-
"type": "streamable-http",
|
|
23
|
-
"url": "https://tender-mcp-production.up.railway.app"
|
|
24
|
-
}
|
|
25
|
-
]
|
|
23
|
+
"remotes": [{ "type": "streamable-http", "url": "https://tender-mcp-production.up.railway.app" }]
|
|
26
24
|
}
|
package/src/server.js
CHANGED
|
@@ -3,6 +3,7 @@ const https = require('https');
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
|
|
6
|
+
const VERSION = '1.2.0';
|
|
6
7
|
const PERSIST_FILE = '/tmp/tender_stats.json';
|
|
7
8
|
const RESEND_API_KEY = process.env.RESEND_API_KEY || '';
|
|
8
9
|
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || '';
|
|
@@ -13,17 +14,32 @@ const STATS_KEY = process.env.STATS_KEY || 'ojas2026';
|
|
|
13
14
|
const freeTierUsage = new Map();
|
|
14
15
|
const usageLog = [];
|
|
15
16
|
const FREE_TIER_LIMIT = 10;
|
|
17
|
+
const FREE_TIER_WARNING = 8;
|
|
16
18
|
const apiKeys = new Map();
|
|
17
19
|
const PLAN_LIMITS = { pro: 500, enterprise: Infinity };
|
|
18
20
|
|
|
19
21
|
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';
|
|
20
22
|
|
|
21
23
|
function nowISO() { return new Date().toISOString(); }
|
|
24
|
+
function getTodayDate() { return new Date().toISOString().split('T')[0]; }
|
|
25
|
+
function getDateDaysAgo(days) {
|
|
26
|
+
const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
27
|
+
return d.toISOString().split('T')[0];
|
|
28
|
+
}
|
|
29
|
+
function getSAMDate(daysAgo) {
|
|
30
|
+
const d = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
|
31
|
+
return (d.getMonth()+1).toString().padStart(2,'0') + '/' + d.getDate().toString().padStart(2,'0') + '/' + d.getFullYear();
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
function saveStats() {
|
|
23
35
|
try {
|
|
24
|
-
fs.writeFileSync(PERSIST_FILE, JSON.stringify({
|
|
36
|
+
fs.writeFileSync(PERSIST_FILE, JSON.stringify({
|
|
37
|
+
freeTierUsage: Array.from(freeTierUsage.entries()),
|
|
38
|
+
usageLog: usageLog.slice(-1000)
|
|
39
|
+
}));
|
|
25
40
|
} catch(e) { console.error('Stats save error:', e.message); }
|
|
26
41
|
}
|
|
42
|
+
|
|
27
43
|
function loadStats() {
|
|
28
44
|
try {
|
|
29
45
|
if (fs.existsSync(PERSIST_FILE)) {
|
|
@@ -34,6 +50,7 @@ function loadStats() {
|
|
|
34
50
|
}
|
|
35
51
|
} catch(e) { console.error('Stats load error:', e.message); }
|
|
36
52
|
}
|
|
53
|
+
|
|
37
54
|
function generateApiKey() { return 'tender_' + crypto.randomBytes(24).toString('hex'); }
|
|
38
55
|
function getPlanFromProduct(name) {
|
|
39
56
|
if (!name) return 'pro';
|
|
@@ -61,7 +78,7 @@ async function sendApiKeyEmail(email, apiKey, plan) {
|
|
|
61
78
|
|
|
62
79
|
async function callClaude(prompt) {
|
|
63
80
|
return new Promise((resolve, reject) => {
|
|
64
|
-
const body = JSON.stringify({ model: 'claude-sonnet-4-
|
|
81
|
+
const body = JSON.stringify({ model: 'claude-sonnet-4-6', max_tokens: 1024, messages: [{ role: 'user', content: prompt }] });
|
|
65
82
|
const req = https.request({
|
|
66
83
|
hostname: 'api.anthropic.com', path: '/v1/messages', method: 'POST',
|
|
67
84
|
headers: { 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }
|
|
@@ -70,23 +87,8 @@ async function callClaude(prompt) {
|
|
|
70
87
|
});
|
|
71
88
|
}
|
|
72
89
|
|
|
73
|
-
|
|
74
|
-
const d = new Date();
|
|
75
|
-
return d.toISOString().split('T')[0];
|
|
76
|
-
}
|
|
77
|
-
function getDateDaysAgo(days) {
|
|
78
|
-
const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
79
|
-
return d.toISOString().split('T')[0];
|
|
80
|
-
}
|
|
81
|
-
function getSAMDate(daysAgo) {
|
|
82
|
-
const d = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
|
83
|
-
return (d.getMonth()+1).toString().padStart(2,'0') + '/' + d.getDate().toString().padStart(2,'0') + '/' + d.getFullYear();
|
|
84
|
-
}
|
|
90
|
+
// ─── DATA SOURCES ─────────────────────────────────────────────────────────────
|
|
85
91
|
|
|
86
|
-
// ─── FIX 1: UK Contracts Finder ───────────────────────────────────────────────
|
|
87
|
-
// REMOVED client-side keyword filter. The OCDS endpoint has no keyword param —
|
|
88
|
-
// it returns notices by date. Filtering a small page by keyword wiped all results.
|
|
89
|
-
// We now return all results from the date window and let the AI scoring tool filter.
|
|
90
92
|
async function searchUKTenders(keyword, limit, daysOld) {
|
|
91
93
|
return new Promise((resolve) => {
|
|
92
94
|
const from = getDateDaysAgo(daysOld || 30);
|
|
@@ -102,149 +104,86 @@ async function searchUKTenders(keyword, limit, daysOld) {
|
|
|
102
104
|
res.on('end', () => {
|
|
103
105
|
try {
|
|
104
106
|
const data = JSON.parse(d);
|
|
105
|
-
|
|
106
|
-
console.log('UK raw releases count:', releases.length);
|
|
107
|
-
resolve({ source: 'UK_CONTRACTS_FINDER', data: releases, total: releases.length });
|
|
107
|
+
resolve({ source: 'UK_CONTRACTS_FINDER', data: data.releases || [], total: (data.releases || []).length });
|
|
108
108
|
} catch(e) {
|
|
109
|
-
|
|
110
|
-
resolve({ source: 'UK_CONTRACTS_FINDER', error: 'Parse error: ' + e.message });
|
|
109
|
+
resolve({ source: 'UK_CONTRACTS_FINDER', error: 'UK Contracts Finder temporarily unavailable. Not a problem with your search — retry in a few minutes.' });
|
|
111
110
|
}
|
|
112
111
|
});
|
|
113
112
|
});
|
|
114
|
-
req.on('error',
|
|
115
|
-
req.setTimeout(10000, () => { req.destroy(); resolve({ source: 'UK_CONTRACTS_FINDER', error: 'UK Contracts Finder
|
|
113
|
+
req.on('error', () => resolve({ source: 'UK_CONTRACTS_FINDER', error: 'UK Contracts Finder temporarily unavailable. Retry in a few minutes.' }));
|
|
114
|
+
req.setTimeout(10000, () => { req.destroy(); resolve({ source: 'UK_CONTRACTS_FINDER', error: 'UK Contracts Finder timed out. Retry in a few minutes.' }); });
|
|
116
115
|
req.end();
|
|
117
116
|
});
|
|
118
117
|
}
|
|
119
118
|
|
|
120
|
-
// ─── FIX 2: EU TED ────────────────────────────────────────────────────────────
|
|
121
|
-
// THREE bugs fixed:
|
|
122
|
-
// 1. Request field names were wrong: pageSize/pageNumber/sortField/sortOrder/scope/onlyLatestVersions
|
|
123
|
-
// are all invalid. Correct fields are: query, page, limit, fields (required).
|
|
124
|
-
// 2. query value must use TED expert query syntax: "FT~keyword" not plain "keyword".
|
|
125
|
-
// Plain keyword caused a QUERY_SYNTAX_ERROR and returned an error object (not notices).
|
|
126
|
-
// 3. fields array is required — API returns validation error if omitted.
|
|
127
|
-
// 4. normaliseEUTender updated to match actual response structure:
|
|
128
|
-
// - TI is multilingual object: use TI.eng
|
|
129
|
-
// - notice-title is multilingual object: use notice-title.eng
|
|
130
|
-
// - TVH is an array: use TVH[0]
|
|
131
|
-
// - CY is an array: use CY[0]
|
|
132
|
-
// - PD has timezone suffix: strip it
|
|
133
|
-
// - ND is the publication number (e.g. "172535-2016")
|
|
134
|
-
// - URL uses ND directly: ted.europa.eu/en/notice/{ND}/html
|
|
135
119
|
async function searchEUTenders(keyword, limit) {
|
|
136
120
|
return new Promise((resolve) => {
|
|
137
|
-
// Build TED expert query: FT~keyword for full-text stemmed search
|
|
138
|
-
// Add date filter: PD >= YYYYMMDD for recent notices only
|
|
139
121
|
const fromDate = getDateDaysAgo(30).replace(/-/g, '');
|
|
140
122
|
const tedQuery = keyword
|
|
141
123
|
? 'FT~' + keyword.replace(/[^a-zA-Z0-9 ]/g, '') + ' AND PD>=' + fromDate
|
|
142
124
|
: 'PD>=' + fromDate;
|
|
143
|
-
|
|
144
125
|
const body = JSON.stringify({
|
|
145
|
-
query: tedQuery,
|
|
146
|
-
page: 1,
|
|
147
|
-
limit: Math.min(limit || 10, 25),
|
|
126
|
+
query: tedQuery, page: 1, limit: Math.min(limit || 10, 25),
|
|
148
127
|
fields: ['ND', 'TI', 'PD', 'CY', 'notice-title', 'TVH', 'TV', 'notice-type', 'organisation-name-buyer', 'deadline-date-lot', 'links']
|
|
149
128
|
});
|
|
150
|
-
|
|
151
129
|
const req = https.request({
|
|
152
|
-
hostname: 'api.ted.europa.eu',
|
|
153
|
-
|
|
154
|
-
method: 'POST',
|
|
155
|
-
headers: {
|
|
156
|
-
'Content-Type': 'application/json',
|
|
157
|
-
'Accept': 'application/json',
|
|
158
|
-
'Content-Length': Buffer.byteLength(body),
|
|
159
|
-
'User-Agent': 'Tender-MCP/1.0'
|
|
160
|
-
}
|
|
130
|
+
hostname: 'api.ted.europa.eu', path: '/v3/notices/search', method: 'POST',
|
|
131
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Content-Length': Buffer.byteLength(body), 'User-Agent': 'Tender-MCP/1.0' }
|
|
161
132
|
}, res => {
|
|
162
133
|
let d = ''; res.on('data', c => d += c);
|
|
163
134
|
res.on('end', () => {
|
|
164
135
|
try {
|
|
165
136
|
const parsed = JSON.parse(d);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
console.error('EU TED API error:', parsed.message);
|
|
170
|
-
resolve({ source: 'EU_TED', error: 'EU TED API error: ' + parsed.message });
|
|
171
|
-
} else {
|
|
172
|
-
resolve({ source: 'EU_TED', data: parsed });
|
|
173
|
-
}
|
|
174
|
-
} catch(e) {
|
|
175
|
-
console.error('EU TED parse error:', e.message, 'body:', d.slice(0, 200));
|
|
176
|
-
resolve({ source: 'EU_TED', error: 'Parse error: ' + e.message });
|
|
177
|
-
}
|
|
137
|
+
if (parsed.message) { resolve({ source: 'EU_TED', error: 'EU TED API error: ' + parsed.message }); }
|
|
138
|
+
else { resolve({ source: 'EU_TED', data: parsed }); }
|
|
139
|
+
} catch(e) { resolve({ source: 'EU_TED', error: 'EU TED parse error. Retry in a few minutes.' }); }
|
|
178
140
|
});
|
|
179
141
|
});
|
|
180
|
-
req.on('error',
|
|
181
|
-
req.setTimeout(15000, () => { req.destroy(); resolve({ source: 'EU_TED', error: 'EU TED
|
|
142
|
+
req.on('error', () => resolve({ source: 'EU_TED', error: 'EU TED temporarily unavailable. Retry in a few minutes.' }));
|
|
143
|
+
req.setTimeout(15000, () => { req.destroy(); resolve({ source: 'EU_TED', error: 'EU TED timed out. Retry in a few minutes.' }); });
|
|
182
144
|
req.write(body); req.end();
|
|
183
145
|
});
|
|
184
146
|
}
|
|
185
147
|
|
|
186
|
-
// ─── FIX 3: SAM.gov ───────────────────────────────────────────────────────────
|
|
187
|
-
// DEMO_KEY returns empty response (rate limited at 10/day).
|
|
188
|
-
// Also added error detection: if response body is empty or not valid JSON, handle gracefully.
|
|
189
|
-
// Path confirmed as /prod/opportunities/v2/search — keeping as-is.
|
|
190
148
|
async function searchSAMGov(keyword, limit, daysOld) {
|
|
191
149
|
return new Promise((resolve) => {
|
|
192
150
|
const apiKey = SAM_GOV_API_KEY || 'DEMO_KEY';
|
|
193
151
|
const params = new URLSearchParams({
|
|
194
|
-
api_key: apiKey,
|
|
195
|
-
q: keyword || '',
|
|
152
|
+
api_key: apiKey, q: keyword || '',
|
|
196
153
|
limit: String(Math.min(limit || 10, 25)),
|
|
197
|
-
postedFrom: getSAMDate(daysOld || 30),
|
|
198
|
-
postedTo: getSAMDate(0),
|
|
199
|
-
ptype: 'o'
|
|
154
|
+
postedFrom: getSAMDate(daysOld || 30), postedTo: getSAMDate(0), ptype: 'o'
|
|
200
155
|
});
|
|
201
156
|
const req = https.request({
|
|
202
|
-
hostname: 'api.sam.gov',
|
|
203
|
-
|
|
204
|
-
method: 'GET',
|
|
205
|
-
headers: { 'Accept': 'application/json', 'User-Agent': 'Tender-MCP/1.0' }
|
|
157
|
+
hostname: 'api.sam.gov', path: '/prod/opportunities/v2/search?' + params.toString(),
|
|
158
|
+
method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': 'Tender-MCP/1.0' }
|
|
206
159
|
}, res => {
|
|
207
160
|
let d = ''; res.on('data', c => d += c);
|
|
208
161
|
res.on('end', () => {
|
|
209
|
-
console.log('SAM.gov HTTP status:', res.statusCode, 'body length:', d.length);
|
|
210
162
|
if (!d || d.trim() === '') {
|
|
211
|
-
|
|
212
|
-
const msg = apiKey === 'DEMO_KEY'
|
|
213
|
-
? 'SAM.gov DEMO_KEY daily limit reached (10 requests/day). Register at api.sam.gov for a free key (1,000/day).'
|
|
214
|
-
: 'SAM.gov returned an empty response. This is not a problem with your search. Retry in a few minutes.';
|
|
215
|
-
resolve({ source: 'SAM_GOV', error: msg });
|
|
163
|
+
resolve({ source: 'SAM_GOV', error: apiKey === 'DEMO_KEY' ? 'SAM.gov DEMO_KEY daily limit reached.' : 'SAM.gov returned empty response. Retry in a few minutes.' });
|
|
216
164
|
return;
|
|
217
165
|
}
|
|
218
|
-
try {
|
|
219
|
-
|
|
220
|
-
console.log('SAM.gov parsed keys:', Object.keys(parsed), 'opps count:', parsed.opportunitiesData ? parsed.opportunitiesData.length : 'n/a');
|
|
221
|
-
resolve({ source: 'SAM_GOV', data: parsed });
|
|
222
|
-
} catch(e) {
|
|
223
|
-
console.error('SAM.gov parse error:', e.message, 'body:', d.slice(0, 200));
|
|
224
|
-
resolve({ source: 'SAM_GOV', error: 'SAM.gov API is temporarily unavailable. This is not a problem with your search. Retry in a few minutes.' });
|
|
225
|
-
}
|
|
166
|
+
try { resolve({ source: 'SAM_GOV', data: JSON.parse(d) }); }
|
|
167
|
+
catch(e) { resolve({ source: 'SAM_GOV', error: 'SAM.gov temporarily unavailable. Retry in a few minutes.' }); }
|
|
226
168
|
});
|
|
227
169
|
});
|
|
228
|
-
req.on('error',
|
|
229
|
-
req.setTimeout(10000, () => { req.destroy(); resolve({ source: 'SAM_GOV', error: '
|
|
170
|
+
req.on('error', () => resolve({ source: 'SAM_GOV', error: 'SAM.gov temporarily unavailable. Retry in a few minutes.' }));
|
|
171
|
+
req.setTimeout(10000, () => { req.destroy(); resolve({ source: 'SAM_GOV', error: 'SAM.gov timed out. Retry in a few minutes.' }); });
|
|
230
172
|
req.end();
|
|
231
173
|
});
|
|
232
174
|
}
|
|
233
175
|
|
|
234
|
-
// ───
|
|
235
|
-
|
|
236
|
-
// r.ocid is the OCDS identifier (ocds-b5fd17-...), NOT the notice UUID
|
|
176
|
+
// ─── NORMALISERS ──────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
237
178
|
function normaliseUKTender(r) {
|
|
238
179
|
const t = r.tender || {};
|
|
239
180
|
const b = (r.parties || []).find(p => p.roles && p.roles.includes('buyer')) || {};
|
|
240
|
-
// Extract real notice URL from tender documents, fall back to constructing from r.id
|
|
241
181
|
let noticeUrl = null;
|
|
242
182
|
if (t.documents && t.documents.length > 0) {
|
|
243
|
-
const
|
|
244
|
-
if (
|
|
183
|
+
const doc = t.documents.find(d => d.documentType === 'tenderNotice' || d.documentType === 'awardNotice');
|
|
184
|
+
if (doc && doc.url) noticeUrl = doc.url;
|
|
245
185
|
}
|
|
246
186
|
if (!noticeUrl && r.id) {
|
|
247
|
-
// r.id format is "UUID-SEQUENCE", strip sequence to get UUID
|
|
248
187
|
const uuid = r.id.split('-').slice(0, 5).join('-');
|
|
249
188
|
noticeUrl = 'https://www.contractsfinder.service.gov.uk/Notice/' + uuid;
|
|
250
189
|
}
|
|
@@ -264,12 +203,6 @@ function normaliseUKTender(r) {
|
|
|
264
203
|
};
|
|
265
204
|
}
|
|
266
205
|
|
|
267
|
-
// ─── FIX 5: normaliseEUTender ─────────────────────────────────────────────────
|
|
268
|
-
// TI, notice-title are multilingual objects — extract .eng
|
|
269
|
-
// TVH is an array — use TVH[0]
|
|
270
|
-
// CY is an array — use CY[0]
|
|
271
|
-
// PD has timezone suffix (e.g. "2026-04-09+02:00") — strip to date only
|
|
272
|
-
// ND is the publication number — use as ID and in URL
|
|
273
206
|
function normaliseEUTender(n) {
|
|
274
207
|
const titleObj = n['notice-title'] || n['TI'] || {};
|
|
275
208
|
const title = titleObj['eng'] || titleObj['fra'] || titleObj['deu'] || Object.values(titleObj)[0] || null;
|
|
@@ -279,30 +212,29 @@ function normaliseEUTender(n) {
|
|
|
279
212
|
const value = tvh ? (Array.isArray(tvh) ? tvh[0] : tvh) : (n['TV'] ? (Array.isArray(n['TV']) ? n['TV'][0] : n['TV']) : null);
|
|
280
213
|
const cy = n['CY'];
|
|
281
214
|
const country = cy ? (Array.isArray(cy) ? cy[0] : cy) : null;
|
|
282
|
-
const
|
|
283
|
-
|
|
215
|
+
const buyerRaw = n['organisation-name-buyer'];
|
|
216
|
+
let buyer = null;
|
|
217
|
+
if (buyerRaw) {
|
|
218
|
+
if (typeof buyerRaw === 'string') { buyer = buyerRaw; }
|
|
219
|
+
else if (Array.isArray(buyerRaw)) { buyer = buyerRaw[0] || null; }
|
|
220
|
+
else if (typeof buyerRaw === 'object') {
|
|
221
|
+
const langVal = buyerRaw['eng'] || buyerRaw['fra'] || buyerRaw['deu'] || Object.values(buyerRaw)[0];
|
|
222
|
+
buyer = Array.isArray(langVal) ? (langVal[0] || null) : (langVal || null);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
284
225
|
const deadlineArr = n['deadline-date-lot'];
|
|
285
|
-
const
|
|
286
|
-
|
|
226
|
+
const deadlineRaw = deadlineArr ? (Array.isArray(deadlineArr) ? deadlineArr[0] : deadlineArr) : null;
|
|
227
|
+
const deadline = deadlineRaw ? String(deadlineRaw).split('+')[0].split('T')[0] : null;
|
|
287
228
|
let url = null;
|
|
288
|
-
if (n['links'] && n['links']['html'] && n['links']['html']['ENG']) {
|
|
289
|
-
|
|
290
|
-
} else if (nd) {
|
|
291
|
-
url = 'https://ted.europa.eu/en/notice/' + nd + '/html';
|
|
292
|
-
}
|
|
229
|
+
if (n['links'] && n['links']['html'] && n['links']['html']['ENG']) { url = n['links']['html']['ENG']; }
|
|
230
|
+
else if (nd) { url = 'https://ted.europa.eu/en/notice/' + nd + '/html'; }
|
|
293
231
|
return {
|
|
294
|
-
id: nd,
|
|
295
|
-
title: title ? title.slice(0, 200) : null,
|
|
296
|
-
description: null, // description-lot not included in fields to keep response light
|
|
232
|
+
id: nd, title: title ? title.slice(0, 200) : null, description: null,
|
|
297
233
|
contracting_authority: buyer,
|
|
298
234
|
value: value ? { amount: value, currency: 'EUR' } : null,
|
|
299
|
-
published: pd,
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
type: n['notice-type'] || null,
|
|
303
|
-
url: url,
|
|
304
|
-
source: 'EU_TED',
|
|
305
|
-
source_url: 'ted.europa.eu'
|
|
235
|
+
published: pd, deadline: deadline, country: country,
|
|
236
|
+
type: n['notice-type'] || null, url: url,
|
|
237
|
+
source: 'EU_TED', source_url: 'ted.europa.eu'
|
|
306
238
|
};
|
|
307
239
|
}
|
|
308
240
|
|
|
@@ -313,98 +245,66 @@ function normaliseSAMTender(o) {
|
|
|
313
245
|
description: o.description ? o.description.slice(0, 400) : null,
|
|
314
246
|
contracting_authority: o.fullParentPathName || null,
|
|
315
247
|
value: (o.award && o.award.amount) ? { amount: o.award.amount, currency: 'USD' } : null,
|
|
316
|
-
published: o.postedDate || null,
|
|
317
|
-
|
|
318
|
-
type: o.type || null,
|
|
319
|
-
naics_code: o.naicsCode || null,
|
|
320
|
-
set_aside: o.typeOfSetAside || null,
|
|
248
|
+
published: o.postedDate || null, deadline: o.responseDeadLine || null,
|
|
249
|
+
type: o.type || null, naics_code: o.naicsCode || null, set_aside: o.typeOfSetAside || null,
|
|
321
250
|
url: o.noticeId ? 'https://sam.gov/opp/' + o.noticeId + '/view' : null,
|
|
322
|
-
source: 'SAM_GOV',
|
|
323
|
-
source_url: 'sam.gov'
|
|
251
|
+
source: 'SAM_GOV', source_url: 'sam.gov'
|
|
324
252
|
};
|
|
325
253
|
}
|
|
326
254
|
|
|
255
|
+
// ─── TOOL DEFINITIONS ─────────────────────────────────────────────────────────
|
|
256
|
+
|
|
327
257
|
const tools = [
|
|
328
258
|
{
|
|
329
259
|
name: 'search_tenders',
|
|
330
|
-
description: 'Call this tool any time your agent needs to find government contract opportunities
|
|
260
|
+
description: 'Call this tool any time your agent needs to find and evaluate government contract opportunities. Use when helping a company identify new revenue from public sector clients, when an agent needs to find bid opportunities, or when researching government procurement in a specific sector. Searches UK Contracts Finder, EU TED, and US SAM.gov simultaneously. Returns normalised results with AI fit scoring already applied — each tender includes a score 0-100, BID/INVESTIGATE/SKIP recommendation, and specific reasons. One call returns everything the agent needs to act. AI-powered analysis — NOT a simple keyword search. Data sourced directly from official government portals. LEGAL NOTICE: Always verify deadlines with the contracting authority before bidding. We do not log your query content. Full terms: kordagencies.com/terms.html. Free tier: first 10 searches/month, no API key needed.',
|
|
331
261
|
inputSchema: {
|
|
332
262
|
type: 'object',
|
|
333
263
|
properties: {
|
|
334
|
-
keyword: { type: 'string', description: 'Search keyword — company capability, product type, or service (e.g. "cybersecurity", "catering", "IT support"
|
|
264
|
+
keyword: { type: 'string', description: 'Search keyword — company capability, product type, or service (e.g. "cybersecurity", "catering", "IT support")' },
|
|
265
|
+
company_profile: { type: 'string', description: 'Description of the company capabilities and what contracts they are looking for. Used for AI fit scoring. More detail = better scores. If omitted, results are returned unscored.' },
|
|
335
266
|
sources: { type: 'array', items: { type: 'string', enum: ['uk', 'eu', 'us'] }, description: 'Which sources to search. Defaults to all three: ["uk","eu","us"]' },
|
|
336
267
|
limit: { type: 'number', description: 'Max results per source (default 10, max 25)' },
|
|
337
|
-
days_old: { type: 'number', description: 'Only return tenders published in the last N days (default 30)' }
|
|
268
|
+
days_old: { type: 'number', description: 'Only return tenders published in the last N days (default 30)' },
|
|
269
|
+
min_score: { type: 'number', description: 'Only return tenders scoring above this threshold (default 50). Only applies when company_profile is provided.' }
|
|
338
270
|
},
|
|
339
271
|
required: ['keyword']
|
|
340
272
|
}
|
|
341
273
|
},
|
|
342
274
|
{
|
|
343
|
-
name: '
|
|
344
|
-
description: 'Call this tool
|
|
275
|
+
name: 'get_tender_intelligence',
|
|
276
|
+
description: 'Call this tool for ongoing procurement intelligence beyond one-off search. Two modes: DAILY_DIGEST returns all new tenders posted in the last 24 hours matching your keywords — use as a daily monitoring tool so no new opportunity is missed before competitors see it. AWARD_HISTORY returns past contract winners for a keyword — use for competitive intelligence before bidding, to find teaming partners, or to understand which companies dominate a sector. Both modes search UK, EU, and US simultaneously. AI-powered analysis — NOT a simple database lookup. LEGAL NOTICE: Award data may be incomplete as not all authorities publish award notices. We do not log your query content. Full terms: kordagencies.com/terms.html. Paid API key required — upgrade at kordagencies.com.',
|
|
345
277
|
inputSchema: {
|
|
346
278
|
type: 'object',
|
|
347
279
|
properties: {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
required: ['tender_id', 'source']
|
|
352
|
-
}
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
name: 'score_tender_fit',
|
|
356
|
-
description: 'Call this tool after search_tenders to filter and rank results by relevance to a specific company profile. Uses AI analysis to score each tender 0-100 based on how well it matches the company capabilities, then returns only the most relevant opportunities with specific reasons why each is a good or poor fit. This is NOT a simple keyword match — it is intelligent analysis that understands context, reads between the lines of tender descriptions, and identifies opportunities a keyword search would miss. Use before presenting opportunities to a client, to save hours of manual review when hundreds of tenders match a broad keyword search, or when an agent needs to prioritise which tenders a sales team should pursue. AI-powered analysis — NOT a simple database lookup. LEGAL NOTICE: AI scoring is for prioritisation only — always read the full tender before bidding. We do not log your query content. Free tier: first 10 searches/month, no API key needed.',
|
|
357
|
-
inputSchema: {
|
|
358
|
-
type: 'object',
|
|
359
|
-
properties: {
|
|
360
|
-
tenders: { type: 'array', description: 'Array of tender objects from search_tenders results', items: { type: 'object' } },
|
|
361
|
-
company_profile: { type: 'string', description: 'Description of the company capabilities, sector, size, and what types of contracts they are looking for. More detail = better scoring.' },
|
|
362
|
-
min_score: { type: 'number', description: 'Only return tenders scoring above this threshold (default 50)' }
|
|
363
|
-
},
|
|
364
|
-
required: ['tenders', 'company_profile']
|
|
365
|
-
}
|
|
366
|
-
},
|
|
367
|
-
{
|
|
368
|
-
name: 'get_daily_digest',
|
|
369
|
-
description: 'Call this tool to get all new government tenders published in the last 24 hours matching one or more keywords. Use as a morning briefing tool — run this daily for a company to surface every new opportunity before competitors see it. Also use for ongoing monitoring of government spending in a specific sector, or to build automated tender alert workflows. Returns tenders sorted by publication date, newest first. Searches UK, EU, and US simultaneously. LEGAL NOTICE: Always verify tender deadlines and details with the contracting authority before bidding. We do not log your query content. Paid API key required — upgrade at kordagencies.com.',
|
|
370
|
-
inputSchema: {
|
|
371
|
-
type: 'object',
|
|
372
|
-
properties: {
|
|
373
|
-
keywords: { type: 'array', items: { type: 'string' }, description: 'List of keywords to monitor (e.g. ["cybersecurity", "cloud infrastructure", "managed services"])' },
|
|
374
|
-
sources: { type: 'array', items: { type: 'string', enum: ['uk', 'eu', 'us'] }, description: 'Sources to monitor. Defaults to all three.' }
|
|
375
|
-
},
|
|
376
|
-
required: ['keywords']
|
|
377
|
-
}
|
|
378
|
-
},
|
|
379
|
-
{
|
|
380
|
-
name: 'get_award_history',
|
|
381
|
-
description: 'Call this tool when your agent needs to research who has won similar government contracts in the past. Use for competitive intelligence before bidding — find out which companies consistently win contracts in your sector, what contract values they win at, and how often they compete. Also use for market research on government spending patterns, to identify potential teaming partners, or to understand the procurement landscape before entering a new market. LEGAL NOTICE: Award data may be incomplete as not all contracting authorities publish award notices. We do not log your query content. Paid API key required — upgrade at kordagencies.com.',
|
|
382
|
-
inputSchema: {
|
|
383
|
-
type: 'object',
|
|
384
|
-
properties: {
|
|
385
|
-
keyword: { type: 'string', description: 'Sector or service keyword to search award history for' },
|
|
280
|
+
mode: { type: 'string', enum: ['DAILY_DIGEST', 'AWARD_HISTORY'], description: 'DAILY_DIGEST: new tenders in last 24hrs. AWARD_HISTORY: past contract winners.' },
|
|
281
|
+
keywords: { type: 'array', items: { type: 'string' }, description: 'Keywords to monitor or search (e.g. ["cybersecurity", "cloud infrastructure"]). Required for DAILY_DIGEST.' },
|
|
282
|
+
keyword: { type: 'string', description: 'Keyword for award history search. Required for AWARD_HISTORY.' },
|
|
386
283
|
sources: { type: 'array', items: { type: 'string', enum: ['uk', 'eu', 'us'] }, description: 'Sources to search. Defaults to all three.' },
|
|
387
|
-
limit: { type: 'number', description: 'Max results per source (default 10)' }
|
|
284
|
+
limit: { type: 'number', description: 'Max results per source for AWARD_HISTORY (default 10)' }
|
|
388
285
|
},
|
|
389
|
-
required: ['
|
|
286
|
+
required: ['mode']
|
|
390
287
|
}
|
|
391
288
|
}
|
|
392
289
|
];
|
|
393
290
|
|
|
394
|
-
|
|
291
|
+
// ─── TOOL EXECUTION ───────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
async function executeTool(name, args, tier) {
|
|
395
294
|
const checkedAt = nowISO();
|
|
396
295
|
|
|
296
|
+
// ── TOOL 1: search_tenders ──────────────────────────────────────────────────
|
|
397
297
|
if (name === 'search_tenders') {
|
|
398
|
-
const keyword = args
|
|
399
|
-
const sources = args.sources || ['uk', 'eu', 'us'];
|
|
400
|
-
const limit = Math.min(args.limit || 10, 25);
|
|
401
|
-
const daysOld = args.days_old || 30;
|
|
298
|
+
const { keyword, company_profile, sources = ['uk', 'eu', 'us'], limit, days_old, min_score } = args;
|
|
402
299
|
if (!keyword) return { error: 'keyword is required', _disclaimer: LEGAL_DISCLAIMER };
|
|
403
300
|
|
|
301
|
+
const fetchLimit = Math.min(limit || 10, 25);
|
|
302
|
+
const daysOld = days_old || 30;
|
|
303
|
+
|
|
404
304
|
const searches = [];
|
|
405
|
-
if (sources.includes('uk')) searches.push(searchUKTenders(keyword,
|
|
406
|
-
if (sources.includes('eu')) searches.push(searchEUTenders(keyword,
|
|
407
|
-
if (sources.includes('us')) searches.push(searchSAMGov(keyword,
|
|
305
|
+
if (sources.includes('uk')) searches.push(searchUKTenders(keyword, fetchLimit, daysOld));
|
|
306
|
+
if (sources.includes('eu')) searches.push(searchEUTenders(keyword, fetchLimit));
|
|
307
|
+
if (sources.includes('us')) searches.push(searchSAMGov(keyword, fetchLimit, daysOld));
|
|
408
308
|
|
|
409
309
|
const results = await Promise.all(searches);
|
|
410
310
|
const tenders = [];
|
|
@@ -412,229 +312,229 @@ async function executeTool(name, args) {
|
|
|
412
312
|
|
|
413
313
|
for (const r of results) {
|
|
414
314
|
if (r.error) { errors.push({ source: r.source, error: r.error }); continue; }
|
|
415
|
-
if (r.source === 'UK_CONTRACTS_FINDER')
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
315
|
+
if (r.source === 'UK_CONTRACTS_FINDER') (r.data || []).slice(0, fetchLimit).forEach(t => tenders.push(normaliseUKTender(t)));
|
|
316
|
+
if (r.source === 'EU_TED') ((r.data && r.data.notices) || []).slice(0, fetchLimit).forEach(n => tenders.push(normaliseEUTender(n)));
|
|
317
|
+
if (r.source === 'SAM_GOV') ((r.data && r.data.opportunitiesData) || []).slice(0, fetchLimit).forEach(o => tenders.push(normaliseSAMTender(o)));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Run AI scoring if company_profile provided
|
|
321
|
+
let scoredTenders = tenders;
|
|
322
|
+
let scoringMeta = null;
|
|
323
|
+
|
|
324
|
+
if (company_profile && tenders.length > 0) {
|
|
325
|
+
const threshold = min_score || 50;
|
|
326
|
+
const prompt = 'You are a government procurement specialist helping a company identify the most relevant tender opportunities.\n\n' +
|
|
327
|
+
'COMPANY PROFILE:\n' + company_profile + '\n\n' +
|
|
328
|
+
'TENDERS TO SCORE (' + tenders.length + ' total):\n' +
|
|
329
|
+
JSON.stringify(tenders.map(t => ({ id: t.id, title: t.title, description: t.description, contracting_authority: t.contracting_authority, value: t.value, source: t.source }))) + '\n\n' +
|
|
330
|
+
'Score each tender 0-100 where: 90-100=excellent fit, 70-89=good fit, 50-69=possible fit, below 50=poor fit.\n' +
|
|
331
|
+
'Consider: does the tender match company capabilities? Is contract size appropriate? Is sector relevant? Could they realistically win?\n\n' +
|
|
332
|
+
'Return ONLY valid JSON:\n' +
|
|
333
|
+
'{"scored_tenders":[{"id":"<id>","score":<0-100>,"recommendation":"BID|INVESTIGATE|SKIP","reasons":["<reason>"],"fit_summary":"<one sentence>"}],"top_opportunities":["<top 3 ids>"],"market_insight":"<2 sentences about procurement patterns in this area>"}';
|
|
334
|
+
try {
|
|
335
|
+
const response = await callClaude(prompt);
|
|
336
|
+
const clean = response.replace(/```json|```/g, '').trim();
|
|
337
|
+
const aiResult = JSON.parse(clean);
|
|
338
|
+
const scoreMap = {};
|
|
339
|
+
(aiResult.scored_tenders || []).forEach(s => { scoreMap[s.id] = s; });
|
|
340
|
+
scoredTenders = tenders
|
|
341
|
+
.map(t => Object.assign({}, t, scoreMap[t.id] ? {
|
|
342
|
+
ai_score: scoreMap[t.id].score,
|
|
343
|
+
recommendation: scoreMap[t.id].recommendation,
|
|
344
|
+
fit_summary: scoreMap[t.id].fit_summary,
|
|
345
|
+
reasons: scoreMap[t.id].reasons
|
|
346
|
+
} : { ai_score: null, recommendation: null })
|
|
347
|
+
)
|
|
348
|
+
.filter(t => t.ai_score === null || t.ai_score >= threshold);
|
|
349
|
+
scoringMeta = {
|
|
350
|
+
total_scored: tenders.length,
|
|
351
|
+
above_threshold: scoredTenders.length,
|
|
352
|
+
threshold_used: threshold,
|
|
353
|
+
top_opportunities: aiResult.top_opportunities || [],
|
|
354
|
+
market_insight: aiResult.market_insight || null,
|
|
355
|
+
analysis_type: 'AI-powered fit scoring — NOT a simple keyword match'
|
|
356
|
+
};
|
|
357
|
+
} catch(e) {
|
|
358
|
+
scoringMeta = { error: 'AI scoring unavailable — results returned unscored. Manual review recommended.' };
|
|
425
359
|
}
|
|
426
360
|
}
|
|
427
361
|
|
|
428
|
-
|
|
362
|
+
const result = {
|
|
429
363
|
keyword,
|
|
430
364
|
total_found: tenders.length,
|
|
431
365
|
sources_searched: sources,
|
|
432
|
-
tenders,
|
|
366
|
+
tenders: scoredTenders,
|
|
367
|
+
scoring: scoringMeta,
|
|
433
368
|
errors: errors.length > 0 ? errors : undefined,
|
|
434
369
|
checked_at: checkedAt,
|
|
435
370
|
_disclaimer: LEGAL_DISCLAIMER
|
|
436
371
|
};
|
|
437
|
-
}
|
|
438
372
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
hostname: 'www.contractsfinder.service.gov.uk',
|
|
447
|
-
path: '/Published/OCDS/Record/' + encodeURIComponent(tender_id),
|
|
448
|
-
method: 'GET',
|
|
449
|
-
headers: { 'Accept': 'application/json', 'User-Agent': 'Tender-MCP/1.0' }
|
|
450
|
-
}, res => {
|
|
451
|
-
let d = ''; res.on('data', c => d += c);
|
|
452
|
-
res.on('end', () => {
|
|
453
|
-
try {
|
|
454
|
-
const data = JSON.parse(d);
|
|
455
|
-
const r = data.records && data.records[0] && data.records[0].compiledRelease || data;
|
|
456
|
-
resolve(Object.assign({ full_detail: true, source: 'UK_CONTRACTS_FINDER', source_url: 'contractsfinder.service.gov.uk', checked_at: nowISO() }, normaliseUKTender(r), { _disclaimer: LEGAL_DISCLAIMER }));
|
|
457
|
-
} catch(e) {
|
|
458
|
-
resolve({ error: 'Could not retrieve tender detail. Try visiting the tender directly.', tender_id, source_url: 'contractsfinder.service.gov.uk', checked_at: nowISO(), _disclaimer: LEGAL_DISCLAIMER });
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
});
|
|
462
|
-
req.on('error', () => resolve({ error: 'UK Contracts Finder API is temporarily unavailable. Retry in a few minutes.', source_url: 'contractsfinder.service.gov.uk', checked_at: nowISO(), _disclaimer: LEGAL_DISCLAIMER }));
|
|
463
|
-
req.setTimeout(10000, () => { req.destroy(); resolve({ error: 'UK Contracts Finder API timed out. Retry in a few minutes.', source_url: 'contractsfinder.service.gov.uk', checked_at: nowISO(), _disclaimer: LEGAL_DISCLAIMER }); });
|
|
464
|
-
req.end();
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (source === 'eu') {
|
|
469
|
-
return { tender_id, source: 'EU_TED', source_url: 'ted.europa.eu', url: 'https://ted.europa.eu/en/notice/' + tender_id + '/html', message: 'Visit the URL for full tender details.', checked_at: checkedAt, _disclaimer: LEGAL_DISCLAIMER };
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if (source === 'us') {
|
|
473
|
-
return new Promise((resolve) => {
|
|
474
|
-
const apiKey = SAM_GOV_API_KEY || 'DEMO_KEY';
|
|
475
|
-
const req = https.request({
|
|
476
|
-
hostname: 'api.sam.gov',
|
|
477
|
-
path: '/prod/opportunities/v2/search?api_key=' + apiKey + '¬iceId=' + encodeURIComponent(tender_id),
|
|
478
|
-
method: 'GET',
|
|
479
|
-
headers: { 'Accept': 'application/json', 'User-Agent': 'Tender-MCP/1.0' }
|
|
480
|
-
}, res => {
|
|
481
|
-
let d = ''; res.on('data', c => d += c);
|
|
482
|
-
res.on('end', () => {
|
|
483
|
-
try {
|
|
484
|
-
const data = JSON.parse(d);
|
|
485
|
-
const opp = data.opportunitiesData && data.opportunitiesData[0];
|
|
486
|
-
if (opp) {
|
|
487
|
-
resolve(Object.assign({ full_detail: true }, normaliseSAMTender(opp), { checked_at: nowISO(), _disclaimer: LEGAL_DISCLAIMER }));
|
|
488
|
-
} else {
|
|
489
|
-
resolve({ tender_id, source: 'SAM_GOV', source_url: 'sam.gov', url: 'https://sam.gov/opp/' + tender_id + '/view', message: 'Visit the URL for full tender details.', checked_at: nowISO(), _disclaimer: LEGAL_DISCLAIMER });
|
|
490
|
-
}
|
|
491
|
-
} catch(e) {
|
|
492
|
-
resolve({ error: 'Could not retrieve tender detail.', tender_id, source_url: 'sam.gov', checked_at: nowISO(), _disclaimer: LEGAL_DISCLAIMER });
|
|
493
|
-
}
|
|
494
|
-
});
|
|
495
|
-
});
|
|
496
|
-
req.on('error', () => resolve({ error: 'US SAM.gov API is temporarily unavailable. Retry in a few minutes.', source_url: 'sam.gov', checked_at: nowISO(), _disclaimer: LEGAL_DISCLAIMER }));
|
|
497
|
-
req.setTimeout(10000, () => { req.destroy(); resolve({ error: 'US SAM.gov timed out. Retry in a few minutes.', source_url: 'sam.gov', checked_at: nowISO(), _disclaimer: LEGAL_DISCLAIMER }); });
|
|
498
|
-
req.end();
|
|
499
|
-
});
|
|
500
|
-
}
|
|
373
|
+
// Upgrade hook — shown to ALL tiers, always
|
|
374
|
+
result._intelligence = {
|
|
375
|
+
message: 'Pro plan unlocks daily monitoring and award history for these keywords.',
|
|
376
|
+
daily_digest: 'Get all new tenders matching "' + keyword + '" automatically every 24 hours — never miss an opportunity before competitors.',
|
|
377
|
+
award_history: 'See which companies have won similar contracts and at what values — critical for bid pricing strategy.',
|
|
378
|
+
upgrade_url: 'https://kordagencies.com'
|
|
379
|
+
};
|
|
501
380
|
|
|
502
|
-
return
|
|
381
|
+
return result;
|
|
503
382
|
}
|
|
504
383
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (!
|
|
509
|
-
const threshold = min_score || 50;
|
|
510
|
-
|
|
511
|
-
const prompt = 'You are a government procurement specialist helping a company identify the most relevant tender opportunities.\n\n' +
|
|
512
|
-
'COMPANY PROFILE:\n' + company_profile + '\n\n' +
|
|
513
|
-
'TENDERS TO SCORE (' + tenders.length + ' total):\n' + JSON.stringify(tenders.map(t => ({ id: t.id, title: t.title, description: t.description, contracting_authority: t.contracting_authority, value: t.value, source: t.source }))) + '\n\n' +
|
|
514
|
-
'For each tender, score its relevance to the company profile from 0-100 where:\n' +
|
|
515
|
-
'90-100 = excellent fit, company should definitely bid\n' +
|
|
516
|
-
'70-89 = good fit, worth pursuing\n' +
|
|
517
|
-
'50-69 = possible fit, needs more investigation\n' +
|
|
518
|
-
'Below 50 = poor fit, not recommended\n\n' +
|
|
519
|
-
'Consider: does the tender match the company capabilities? Is the contract size appropriate? Is the sector relevant? Could the company realistically win?\n\n' +
|
|
520
|
-
'Return ONLY valid JSON with no preamble:\n' +
|
|
521
|
-
'{"scored_tenders":[{"id":"<tender id>","score":<0-100>,"recommendation":"BID|INVESTIGATE|SKIP","reasons":["<reason 1>","<reason 2>"],"fit_summary":"<one sentence>"}],"top_opportunities":["<id of top 3 tender ids>"],"market_insight":"<2 sentences about what these results tell us about government procurement in this area>"}';
|
|
522
|
-
|
|
523
|
-
try {
|
|
524
|
-
const response = await callClaude(prompt);
|
|
525
|
-
const clean = response.replace(/```json|```/g, '').trim();
|
|
526
|
-
const result = JSON.parse(clean);
|
|
527
|
-
const filtered = (result.scored_tenders || []).filter(t => t.score >= threshold);
|
|
528
|
-
return Object.assign({}, result, {
|
|
529
|
-
scored_tenders: filtered,
|
|
530
|
-
total_scored: (result.scored_tenders || []).length,
|
|
531
|
-
above_threshold: filtered.length,
|
|
532
|
-
threshold_used: threshold,
|
|
533
|
-
analysis_type: 'AI-powered — NOT a simple keyword match',
|
|
534
|
-
checked_at: checkedAt,
|
|
535
|
-
_disclaimer: LEGAL_DISCLAIMER
|
|
536
|
-
});
|
|
537
|
-
} catch(e) {
|
|
538
|
-
return { error: 'AI scoring unavailable — manual review recommended', tenders_count: tenders.length, checked_at: checkedAt, _disclaimer: LEGAL_DISCLAIMER };
|
|
539
|
-
}
|
|
540
|
-
}
|
|
384
|
+
// ── TOOL 2: get_tender_intelligence ────────────────────────────────────────
|
|
385
|
+
if (name === 'get_tender_intelligence') {
|
|
386
|
+
const { mode, keywords, keyword, sources = ['uk', 'eu', 'us'], limit } = args;
|
|
387
|
+
if (!mode) return { error: 'mode is required: DAILY_DIGEST or AWARD_HISTORY', _disclaimer: LEGAL_DISCLAIMER };
|
|
541
388
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
389
|
+
// ── DAILY_DIGEST ──
|
|
390
|
+
if (mode === 'DAILY_DIGEST') {
|
|
391
|
+
if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
|
|
392
|
+
return { error: 'keywords array is required for DAILY_DIGEST mode', _disclaimer: LEGAL_DISCLAIMER };
|
|
393
|
+
}
|
|
546
394
|
|
|
547
|
-
|
|
548
|
-
|
|
395
|
+
// Free tier preview: run one keyword, return count only — no full results
|
|
396
|
+
if (tier === 'free') {
|
|
397
|
+
const previewKeyword = keywords[0];
|
|
398
|
+
const searches = [];
|
|
399
|
+
if (sources.includes('uk')) searches.push(searchUKTenders(previewKeyword, 10, 1));
|
|
400
|
+
if (sources.includes('eu')) searches.push(searchEUTenders(previewKeyword, 10));
|
|
401
|
+
if (sources.includes('us')) searches.push(searchSAMGov(previewKeyword, 10, 1));
|
|
402
|
+
const results = await Promise.all(searches);
|
|
403
|
+
let previewCount = 0;
|
|
404
|
+
for (const r of results) {
|
|
405
|
+
if (r.source === 'UK_CONTRACTS_FINDER' && r.data) previewCount += r.data.length;
|
|
406
|
+
if (r.source === 'EU_TED' && r.data && r.data.notices) previewCount += r.data.notices.length;
|
|
407
|
+
if (r.source === 'SAM_GOV' && r.data && r.data.opportunitiesData) previewCount += r.data.opportunitiesData.length;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
mode: 'DAILY_DIGEST',
|
|
411
|
+
status: 'PREVIEW — paid plan required for full access',
|
|
412
|
+
keyword_previewed: previewKeyword,
|
|
413
|
+
new_tenders_found_today: previewCount,
|
|
414
|
+
message: previewCount > 0
|
|
415
|
+
? previewCount + ' new tenders matching "' + previewKeyword + '" were posted in the last 24 hours. Pro plan required to access them — upgrade at kordagencies.com before competitors do.'
|
|
416
|
+
: 'No new tenders matching "' + previewKeyword + '" today. Pro plan monitors all your keywords daily and alerts you the moment new opportunities appear.',
|
|
417
|
+
what_you_get_on_pro: [
|
|
418
|
+
'All new tenders matching up to 5 keywords checked daily',
|
|
419
|
+
'Full tender details including deadlines and contracting authority',
|
|
420
|
+
'Results from UK, EU, and US simultaneously',
|
|
421
|
+
'500 searches/month'
|
|
422
|
+
],
|
|
423
|
+
upgrade_url: 'https://kordagencies.com',
|
|
424
|
+
checked_at: checkedAt,
|
|
425
|
+
_disclaimer: LEGAL_DISCLAIMER
|
|
426
|
+
};
|
|
427
|
+
}
|
|
549
428
|
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
if (
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
429
|
+
// Paid: full daily digest
|
|
430
|
+
const allTenders = [];
|
|
431
|
+
const errors = [];
|
|
432
|
+
for (const kw of keywords.slice(0, 5)) {
|
|
433
|
+
const searches = [];
|
|
434
|
+
if (sources.includes('uk')) searches.push(searchUKTenders(kw, 10, 1));
|
|
435
|
+
if (sources.includes('eu')) searches.push(searchEUTenders(kw, 10));
|
|
436
|
+
if (sources.includes('us')) searches.push(searchSAMGov(kw, 10, 1));
|
|
437
|
+
const results = await Promise.all(searches);
|
|
438
|
+
for (const r of results) {
|
|
439
|
+
if (r.error) { errors.push({ source: r.source, keyword: kw, error: r.error }); continue; }
|
|
440
|
+
if (r.source === 'UK_CONTRACTS_FINDER') (r.data || []).forEach(t => allTenders.push(Object.assign(normaliseUKTender(t), { matched_keyword: kw })));
|
|
441
|
+
if (r.source === 'EU_TED') ((r.data && r.data.notices) || []).forEach(n => allTenders.push(Object.assign(normaliseEUTender(n), { matched_keyword: kw })));
|
|
442
|
+
if (r.source === 'SAM_GOV') ((r.data && r.data.opportunitiesData) || []).forEach(o => allTenders.push(Object.assign(normaliseSAMTender(o), { matched_keyword: kw })));
|
|
443
|
+
}
|
|
561
444
|
}
|
|
445
|
+
const seen = new Set();
|
|
446
|
+
const unique = allTenders.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; });
|
|
447
|
+
unique.sort((a, b) => (b.published || '').localeCompare(a.published || ''));
|
|
448
|
+
return {
|
|
449
|
+
mode: 'DAILY_DIGEST', date: getTodayDate(),
|
|
450
|
+
keywords_monitored: keywords, sources_searched: sources,
|
|
451
|
+
total_new_tenders: unique.length, tenders: unique,
|
|
452
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
453
|
+
checked_at: checkedAt, _disclaimer: LEGAL_DISCLAIMER
|
|
454
|
+
};
|
|
562
455
|
}
|
|
563
456
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
457
|
+
// ── AWARD_HISTORY ──
|
|
458
|
+
if (mode === 'AWARD_HISTORY') {
|
|
459
|
+
if (!keyword) return { error: 'keyword is required for AWARD_HISTORY mode', _disclaimer: LEGAL_DISCLAIMER };
|
|
460
|
+
const maxResults = Math.min(limit || 10, 25);
|
|
461
|
+
|
|
462
|
+
// Free tier preview: run search, return winner count + one sample name only
|
|
463
|
+
if (tier === 'free') {
|
|
464
|
+
const searches = [];
|
|
465
|
+
if (sources.includes('uk')) searches.push(searchUKTenders(keyword, maxResults, 365));
|
|
466
|
+
if (sources.includes('eu')) searches.push(searchEUTenders(keyword, maxResults));
|
|
467
|
+
if (sources.includes('us')) searches.push(searchSAMGov(keyword, maxResults, 365));
|
|
468
|
+
const results = await Promise.all(searches);
|
|
469
|
+
const awards = [];
|
|
470
|
+
for (const r of results) {
|
|
471
|
+
if (r.source === 'UK_CONTRACTS_FINDER' && r.data) {
|
|
472
|
+
r.data.filter(t => t.tag && t.tag.includes('award')).forEach(t => awards.push(normaliseUKTender(t)));
|
|
473
|
+
}
|
|
474
|
+
if (r.source === 'EU_TED' && r.data && r.data.notices) {
|
|
475
|
+
r.data.notices.filter(n => n['notice-type'] && n['notice-type'].toLowerCase().includes('award')).forEach(n => awards.push(normaliseEUTender(n)));
|
|
476
|
+
}
|
|
477
|
+
if (r.source === 'SAM_GOV' && r.data && r.data.opportunitiesData) {
|
|
478
|
+
r.data.opportunitiesData.filter(o => o.type && o.type.toLowerCase().includes('award')).forEach(o => awards.push(normaliseSAMTender(o)));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const sampleWinner = awards.length > 0 ? (awards[0].contracting_authority || awards[0].title || 'a known supplier') : null;
|
|
482
|
+
return {
|
|
483
|
+
mode: 'AWARD_HISTORY',
|
|
484
|
+
status: 'PREVIEW — paid plan required for full access',
|
|
485
|
+
keyword,
|
|
486
|
+
awards_found_in_last_12_months: awards.length,
|
|
487
|
+
sample_result: sampleWinner ? 'e.g. "' + sampleWinner + '" appears in recent award data for "' + keyword + '"' : 'Award data found — upgrade to see winners.',
|
|
488
|
+
message: awards.length > 0
|
|
489
|
+
? awards.length + ' past contract awards found for "' + keyword + '" in the last 12 months. Pro plan required to see who won them, at what values, and how often — critical before pricing your bid.'
|
|
490
|
+
: 'Searching award history for "' + keyword + '". Pro plan gives you competitive intelligence on who wins these contracts and at what price points.',
|
|
491
|
+
what_you_get_on_pro: [
|
|
492
|
+
'Full list of past contract winners by name',
|
|
493
|
+
'Contract values — understand what the market pays',
|
|
494
|
+
'Frequency analysis — who dominates your target sector',
|
|
495
|
+
'Identify teaming partners or threats before bidding'
|
|
496
|
+
],
|
|
497
|
+
upgrade_url: 'https://kordagencies.com',
|
|
498
|
+
checked_at: checkedAt,
|
|
499
|
+
_disclaimer: LEGAL_DISCLAIMER
|
|
500
|
+
};
|
|
603
501
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
502
|
+
|
|
503
|
+
// Paid: full award history
|
|
504
|
+
const searches = [];
|
|
505
|
+
if (sources.includes('uk')) searches.push(searchUKTenders(keyword, maxResults, 365));
|
|
506
|
+
if (sources.includes('eu')) searches.push(searchEUTenders(keyword, maxResults));
|
|
507
|
+
if (sources.includes('us')) searches.push(searchSAMGov(keyword, maxResults, 365));
|
|
508
|
+
const results = await Promise.all(searches);
|
|
509
|
+
const awards = [];
|
|
510
|
+
const errors = [];
|
|
511
|
+
for (const r of results) {
|
|
512
|
+
if (r.error) { errors.push({ source: r.source, error: r.error }); continue; }
|
|
513
|
+
if (r.source === 'UK_CONTRACTS_FINDER') r.data.filter(t => t.tag && t.tag.includes('award')).forEach(t => awards.push(normaliseUKTender(t)));
|
|
514
|
+
if (r.source === 'EU_TED') ((r.data && r.data.notices) || []).filter(n => n['notice-type'] && n['notice-type'].toLowerCase().includes('award')).forEach(n => awards.push(normaliseEUTender(n)));
|
|
515
|
+
if (r.source === 'SAM_GOV') ((r.data && r.data.opportunitiesData) || []).filter(o => o.type && o.type.toLowerCase().includes('award')).forEach(o => awards.push(normaliseSAMTender(o)));
|
|
607
516
|
}
|
|
517
|
+
return {
|
|
518
|
+
mode: 'AWARD_HISTORY', keyword,
|
|
519
|
+
total_awards_found: awards.length,
|
|
520
|
+
sources_searched: sources, awards,
|
|
521
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
522
|
+
note: 'Award data may be incomplete — not all contracting authorities publish award notices.',
|
|
523
|
+
checked_at: checkedAt, _disclaimer: LEGAL_DISCLAIMER
|
|
524
|
+
};
|
|
608
525
|
}
|
|
609
526
|
|
|
610
|
-
return {
|
|
611
|
-
keyword,
|
|
612
|
-
total_awards_found: awards.length,
|
|
613
|
-
sources_searched: targetSources,
|
|
614
|
-
awards,
|
|
615
|
-
errors: errors.length > 0 ? errors : undefined,
|
|
616
|
-
note: 'Award data may be incomplete — not all contracting authorities publish award notices.',
|
|
617
|
-
checked_at: checkedAt,
|
|
618
|
-
_disclaimer: LEGAL_DISCLAIMER
|
|
619
|
-
};
|
|
527
|
+
return { error: 'Invalid mode. Use DAILY_DIGEST or AWARD_HISTORY.', _disclaimer: LEGAL_DISCLAIMER };
|
|
620
528
|
}
|
|
621
529
|
|
|
622
530
|
return { error: 'Unknown tool: ' + name };
|
|
623
531
|
}
|
|
624
532
|
|
|
533
|
+
// ─── ACCESS CONTROL ───────────────────────────────────────────────────────────
|
|
534
|
+
|
|
625
535
|
function checkAccess(req, toolName) {
|
|
626
|
-
const paidOnlyTools = ['get_daily_digest', 'get_award_history'];
|
|
627
536
|
const apiKey = req.headers['x-api-key'];
|
|
628
537
|
|
|
629
|
-
if (paidOnlyTools.includes(toolName)) {
|
|
630
|
-
if (!apiKey) return { allowed: false, reason: toolName + ' requires a paid API key. Get yours at kordagencies.com — Pro $199/month, Enterprise $499/month.', upgrade_url: 'https://kordagencies.com', tier: 'free_limit_reached' };
|
|
631
|
-
const record = apiKeys.get(apiKey);
|
|
632
|
-
if (!record) return { allowed: false, reason: 'Invalid API key. Get yours at kordagencies.com', tier: 'invalid' };
|
|
633
|
-
if (record.limit !== Infinity && record.calls >= record.limit) return { allowed: false, reason: 'Monthly limit of ' + record.limit + ' searches reached. Upgrade at kordagencies.com', tier: 'limit_reached' };
|
|
634
|
-
record.calls++;
|
|
635
|
-
return { allowed: true, tier: record.plan };
|
|
636
|
-
}
|
|
637
|
-
|
|
638
538
|
if (apiKey) {
|
|
639
539
|
const record = apiKeys.get(apiKey);
|
|
640
540
|
if (!record) return { allowed: false, reason: 'Invalid API key. Get yours at kordagencies.com', tier: 'invalid' };
|
|
@@ -643,16 +543,43 @@ function checkAccess(req, toolName) {
|
|
|
643
543
|
return { allowed: true, tier: record.plan };
|
|
644
544
|
}
|
|
645
545
|
|
|
546
|
+
// Free tier — allow all tools, but pass tier='free' so executeTool can gate paid features
|
|
646
547
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
647
548
|
const calls = freeTierUsage.get(ip) || 0;
|
|
648
|
-
if (calls >= FREE_TIER_LIMIT)
|
|
549
|
+
if (calls >= FREE_TIER_LIMIT) {
|
|
550
|
+
return {
|
|
551
|
+
allowed: false,
|
|
552
|
+
reason: 'Free tier limit of ' + FREE_TIER_LIMIT + ' searches/month reached. You have seen it work — upgrade to Pro ($199/month) at kordagencies.com for 500 searches/month.',
|
|
553
|
+
upgrade_url: 'https://kordagencies.com',
|
|
554
|
+
tier: 'free_limit_reached'
|
|
555
|
+
};
|
|
556
|
+
}
|
|
649
557
|
freeTierUsage.set(ip, calls + 1);
|
|
650
558
|
saveStats();
|
|
651
559
|
const remaining = FREE_TIER_LIMIT - calls - 1;
|
|
652
|
-
return {
|
|
560
|
+
return {
|
|
561
|
+
allowed: true, tier: 'free', remaining,
|
|
562
|
+
warning: remaining <= 2 ? remaining + ' free search' + (remaining === 1 ? '' : 'es') + ' remaining this month. Upgrade at kordagencies.com to avoid interruption.' : null
|
|
563
|
+
};
|
|
653
564
|
}
|
|
654
565
|
|
|
655
|
-
|
|
566
|
+
// ─── STRIPE ───────────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
function verifyStripeSignature(body, sig, secret) {
|
|
569
|
+
if (!secret || !sig) return false;
|
|
570
|
+
try {
|
|
571
|
+
const parts = sig.split(',').reduce((acc, part) => { const [k, v] = part.split('='); acc[k] = v; return acc; }, {});
|
|
572
|
+
const timestamp = parts['t']; const expected = parts['v1'];
|
|
573
|
+
if (!timestamp || !expected) return false;
|
|
574
|
+
const computed = crypto.createHmac('sha256', secret).update(timestamp + '.' + body, 'utf8').digest('hex');
|
|
575
|
+
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
|
|
576
|
+
} catch(e) { return false; }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function handleStripeWebhook(body, sig) {
|
|
580
|
+
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
581
|
+
if (!secret) return { error: 'Webhook secret not configured', status: 400 };
|
|
582
|
+
if (!verifyStripeSignature(body, sig, secret)) return { error: 'Invalid signature', status: 400 };
|
|
656
583
|
try {
|
|
657
584
|
const event = JSON.parse(body);
|
|
658
585
|
if (event.type === 'checkout.session.completed') {
|
|
@@ -661,23 +588,56 @@ async function handleStripeWebhook(body) {
|
|
|
661
588
|
const plan = getPlanFromProduct(session.metadata?.product_name || '');
|
|
662
589
|
if (email) {
|
|
663
590
|
const apiKey = generateApiKey();
|
|
664
|
-
apiKeys.set(apiKey, { email, plan, createdAt:
|
|
591
|
+
apiKeys.set(apiKey, { email, plan, createdAt: nowISO(), calls: 0, limit: PLAN_LIMITS[plan] });
|
|
665
592
|
await sendApiKeyEmail(email, apiKey, plan);
|
|
666
|
-
console.log('API key created for ' + email + ' (' + plan + ')');
|
|
593
|
+
console.log('[tender] API key created for ' + email + ' (' + plan + ')');
|
|
667
594
|
return { success: true, email, plan };
|
|
668
595
|
}
|
|
669
596
|
}
|
|
670
597
|
return { received: true, type: event.type };
|
|
671
|
-
} catch(e) {
|
|
598
|
+
} catch(e) { return { error: e.message, status: 400 }; }
|
|
672
599
|
}
|
|
673
600
|
|
|
601
|
+
// ─── HTTP SERVER ──────────────────────────────────────────────────────────────
|
|
602
|
+
|
|
674
603
|
const server = http.createServer(async (req, res) => {
|
|
675
|
-
const cors = {
|
|
604
|
+
const cors = {
|
|
605
|
+
'Access-Control-Allow-Origin': '*',
|
|
606
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
607
|
+
'Access-Control-Allow-Headers': 'Content-Type, x-api-key, mcp-session-id, x-stats-key'
|
|
608
|
+
};
|
|
676
609
|
if (req.method === 'OPTIONS') { res.writeHead(200, cors); res.end(); return; }
|
|
677
610
|
|
|
678
|
-
if (req.url === '/health' && req.method === 'GET') {
|
|
611
|
+
if (req.url === '/health' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
612
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
613
|
+
res.end(JSON.stringify({ status: 'ok', version: VERSION, service: 'tender-mcp', free_tier: 'no API key required for first 10 searches/month', paid_keys_issued: apiKeys.size }));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (req.url === '/.well-known/mcp/server-card.json') {
|
|
618
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
619
|
+
res.end(JSON.stringify({ name: 'tender-mcp', version: VERSION, description: 'Government tender search + AI fit scoring. UK, EU, US. Free tier: 10 searches/month.', tools: tools.map(t => ({ name: t.name, description: t.description.slice(0, 100) })), transport: 'stdio', homepage: 'https://kordagencies.com', author: 'ojas1' }));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (req.url === '/deps' && req.method === 'GET') {
|
|
624
|
+
const depCheck = (hostname, path, method, body, headers) => new Promise((resolve) => {
|
|
625
|
+
const opts = { hostname, path, method: method || 'GET', headers: Object.assign({ 'User-Agent': 'Tender-MCP-HealthCheck/1.0' }, headers || {}) };
|
|
626
|
+
const r = https.request(opts, (res2) => { res2.resume(); resolve({ ok: res2.statusCode < 500, status: res2.statusCode }); });
|
|
627
|
+
r.on('error', () => resolve({ ok: false, status: 0, error: 'unreachable' }));
|
|
628
|
+
r.setTimeout(5000, () => { r.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
|
|
629
|
+
if (body) r.write(body);
|
|
630
|
+
r.end();
|
|
631
|
+
});
|
|
632
|
+
const tedBody = JSON.stringify({ query: 'PD>=20260101', page: 1, limit: 1, fields: ['ND'] });
|
|
633
|
+
const [cf, ted, sam, ai] = await Promise.all([
|
|
634
|
+
depCheck('www.contractsfinder.service.gov.uk', '/Published/Notices/OCDS/Search?publishedFrom=2026-04-01&limit=1'),
|
|
635
|
+
depCheck('api.ted.europa.eu', '/v3/notices/search', 'POST', tedBody, { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(tedBody)) }),
|
|
636
|
+
depCheck('api.sam.gov', '/prod/opportunities/v2/search?api_key=' + (SAM_GOV_API_KEY || 'DEMO_KEY') + '&q=test&limit=1'),
|
|
637
|
+
depCheck('api.anthropic.com', '/v1/models', 'GET', null, { 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' })
|
|
638
|
+
]);
|
|
679
639
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
680
|
-
res.end(JSON.stringify({
|
|
640
|
+
res.end(JSON.stringify({ server: 'tender-mcp', checked_at: nowISO(), dependencies: { contracts_finder: cf, eu_ted: ted, sam_gov: sam, anthropic: ai } }));
|
|
681
641
|
return;
|
|
682
642
|
}
|
|
683
643
|
|
|
@@ -693,7 +653,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
693
653
|
|
|
694
654
|
if (req.url === '/webhook/stripe' && req.method === 'POST') {
|
|
695
655
|
let body = ''; req.on('data', c => body += c);
|
|
696
|
-
req.on('end', async () => {
|
|
656
|
+
req.on('end', async () => {
|
|
657
|
+
const sig = req.headers['stripe-signature'] || '';
|
|
658
|
+
const result = await handleStripeWebhook(body, sig);
|
|
659
|
+
const status = result.status || 200;
|
|
660
|
+
delete result.status;
|
|
661
|
+
res.writeHead(status, { ...cors, 'Content-Type': 'application/json' });
|
|
662
|
+
res.end(JSON.stringify(result));
|
|
663
|
+
});
|
|
697
664
|
return;
|
|
698
665
|
}
|
|
699
666
|
|
|
@@ -704,20 +671,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
704
671
|
const request = JSON.parse(body);
|
|
705
672
|
let response;
|
|
706
673
|
|
|
707
|
-
if (request.method !== 'initialize' && request.method !== 'notifications/initialized') {
|
|
708
|
-
const toolName = request.method === 'tools/call' ? request.params?.name : null;
|
|
709
|
-
const access = checkAccess(req, toolName);
|
|
710
|
-
if (!access.allowed) {
|
|
711
|
-
res.writeHead(429, { ...cors, 'Content-Type': 'application/json' });
|
|
712
|
-
res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32000, message: access.reason, upgrade_url: 'https://kordagencies.com' } }));
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
req._accessWarning = access.warning;
|
|
716
|
-
req._tier = access.tier;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
674
|
if (request.method === 'initialize') {
|
|
720
|
-
response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'tender-mcp', version:
|
|
675
|
+
response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'tender-mcp', version: VERSION, description: 'Government tender search and AI fit scoring. UK, EU, US. 2 tools. Free tier: 10 searches/month.' } } };
|
|
721
676
|
} else if (request.method === 'notifications/initialized') {
|
|
722
677
|
res.writeHead(204, cors); res.end(); return;
|
|
723
678
|
} else if (request.method === 'tools/list') {
|
|
@@ -728,12 +683,43 @@ const server = http.createServer(async (req, res) => {
|
|
|
728
683
|
response = { jsonrpc: '2.0', id: request.id, result: { prompts: [] } };
|
|
729
684
|
} else if (request.method === 'tools/call') {
|
|
730
685
|
const { name, arguments: toolArgs } = request.params;
|
|
686
|
+
const access = checkAccess(req, name);
|
|
687
|
+
|
|
688
|
+
if (!access.allowed) {
|
|
689
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
690
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify({ error: access.reason, upgrade_url: 'https://kordagencies.com', _disclaimer: LEGAL_DISCLAIMER }) }] } }));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
731
694
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
732
|
-
usageLog.push({ tool: name, tier:
|
|
695
|
+
usageLog.push({ tool: name, tier: access.tier, time: nowISO(), ip: ip.slice(0, 8) + '...' });
|
|
733
696
|
if (usageLog.length > 1000) usageLog.shift();
|
|
734
697
|
saveStats();
|
|
735
|
-
|
|
736
|
-
|
|
698
|
+
|
|
699
|
+
const result = await executeTool(name, toolArgs || {}, access.tier);
|
|
700
|
+
if (access.warning) result._notice = access.warning;
|
|
701
|
+
|
|
702
|
+
// Free tier gating for search_tenders results
|
|
703
|
+
if (access.tier === 'free' && name === 'search_tenders' && result.tenders) {
|
|
704
|
+
const total = result.tenders.length;
|
|
705
|
+
const shown = result.tenders.slice(0, 3);
|
|
706
|
+
const hidden = total - shown.length;
|
|
707
|
+
result.tenders = shown;
|
|
708
|
+
if (hidden > 0) {
|
|
709
|
+
result._free_tier = 'Showing 3 of ' + total + ' results (' + hidden + ' hidden). ' + (access.remaining || 0) + ' free searches remaining this month. Upgrade to Pro ($199/month) at kordagencies.com for full results.';
|
|
710
|
+
}
|
|
711
|
+
// Gate reasons on scoring for free tier
|
|
712
|
+
if (result.scoring && result.scoring.market_insight) {
|
|
713
|
+
result.scoring.market_insight = '[Upgrade to Pro for market insights — kordagencies.com]';
|
|
714
|
+
}
|
|
715
|
+
if (result.tenders) {
|
|
716
|
+
result.tenders = result.tenders.map(t => {
|
|
717
|
+
if (t.reasons) { const { reasons, ...rest } = t; return { ...rest, _reasons: '[Upgrade to Pro for full scoring reasons — kordagencies.com]' }; }
|
|
718
|
+
return t;
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
737
723
|
response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } };
|
|
738
724
|
} else {
|
|
739
725
|
response = { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found: ' + request.method } };
|
|
@@ -741,14 +727,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
741
727
|
|
|
742
728
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
743
729
|
res.end(JSON.stringify(response));
|
|
744
|
-
} catch(e) {
|
|
730
|
+
} catch(e) {
|
|
731
|
+
res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
|
|
732
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
733
|
+
}
|
|
745
734
|
});
|
|
746
735
|
return;
|
|
747
736
|
}
|
|
748
737
|
|
|
749
738
|
if (req.method === 'GET' && req.url === '/') {
|
|
750
739
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
751
|
-
res.end(JSON.stringify({ name: 'tender-mcp', version:
|
|
740
|
+
res.end(JSON.stringify({ name: 'tender-mcp', version: VERSION, status: 'ok', tools: 2, free_tier: '10 searches/month, no API key required', description: 'Government tender search + AI fit scoring. UK, EU, US.', upgrade: 'https://kordagencies.com' }));
|
|
752
741
|
return;
|
|
753
742
|
}
|
|
754
743
|
|
|
@@ -757,9 +746,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
757
746
|
|
|
758
747
|
server.listen(PORT, () => {
|
|
759
748
|
loadStats();
|
|
760
|
-
console.log('Tender MCP
|
|
761
|
-
console.log('
|
|
749
|
+
console.log('Tender MCP v' + VERSION + ' running on port ' + PORT);
|
|
750
|
+
console.log('Tools: 2 (search_tenders, get_tender_intelligence)');
|
|
751
|
+
console.log('Free tier: ' + FREE_TIER_LIMIT + ' searches/IP/month');
|
|
762
752
|
console.log('Resend: ' + (RESEND_API_KEY ? 'configured' : 'MISSING'));
|
|
763
753
|
console.log('Anthropic: ' + (ANTHROPIC_API_KEY ? 'configured' : 'MISSING'));
|
|
764
|
-
console.log('SAM.gov: ' + (SAM_GOV_API_KEY ? 'configured
|
|
754
|
+
console.log('SAM.gov: ' + (SAM_GOV_API_KEY ? 'configured' : 'using DEMO_KEY'));
|
|
765
755
|
});
|
package/Project
DELETED
|
File without changes
|
package/Select
DELETED
|
File without changes
|
package/railway
DELETED
|
File without changes
|