tender-mcp 1.0.1 → 1.2.0

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/package.json CHANGED
@@ -1,12 +1,26 @@
1
1
  {
2
2
  "name": "tender-mcp",
3
- "version": "1.0.1","mcpName": "io.github.OjasKord/tender-mcp",
3
+ "version": "1.2.0",
4
+ "mcpName": "io.github.OjasKord/tender-mcp",
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": { "start": "node src/server.js" },
7
- "keywords": ["mcp", "agent", "tender", "procurement", "government", "contracts", "bidding"],
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
+ ],
8
19
  "author": "Kord Agencies",
9
20
  "license": "UNLICENSED",
10
21
  "homepage": "https://kordagencies.com",
11
- "repository": { "type": "git", "url": "https://github.com/OjasKord/tender-mcp.git" }
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/OjasKord/tender-mcp.git"
25
+ }
12
26
  }
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "url": "https://github.com/OjasKord/tender-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.0.0",
10
+ "version": "1.0.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "tender-mcp",
16
- "version": "1.0.0",
16
+ "version": "1.0.1",
17
17
  "transport": { "type": "stdio" }
18
18
  }
19
19
  ],
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({ freeTierUsage: Array.from(freeTierUsage.entries()), usageLog: usageLog.slice(-1000) }));
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-20250514', max_tokens: 1024, messages: [{ role: 'user', content: prompt }] });
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
- function getTodayDate() {
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
- const releases = data.releases || [];
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
- console.error('UK parse error:', e.message, 'body:', d.slice(0, 200));
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', e => resolve({ source: 'UK_CONTRACTS_FINDER', error: 'UK Contracts Finder API is temporarily unavailable. This is not a problem with your search. Retry in a few minutes.' }));
115
- req.setTimeout(10000, () => { req.destroy(); resolve({ source: 'UK_CONTRACTS_FINDER', error: 'UK Contracts Finder API timed out. Retry in a few minutes.' }); });
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
- path: '/v3/notices/search',
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
- console.log('EU TED raw response keys:', Object.keys(parsed), 'notices count:', parsed.notices ? parsed.notices.length : 'n/a');
167
- if (parsed.message) {
168
- // API returned an error (syntax error, validation error, etc.)
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', e => resolve({ source: 'EU_TED', error: 'EU TED API is temporarily unavailable. This is not a problem with your search. Retry in a few minutes.' }));
181
- req.setTimeout(15000, () => { req.destroy(); resolve({ source: 'EU_TED', error: 'EU TED API timed out. Retry in a few minutes.' }); });
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
- path: '/prod/opportunities/v2/search?' + params.toString(),
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
- // Empty response DEMO_KEY daily limit hit or endpoint issue
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
- const parsed = JSON.parse(d);
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', e => resolve({ source: 'SAM_GOV', error: 'US SAM.gov API is temporarily unavailable. Retry in a few minutes.' }));
229
- req.setTimeout(10000, () => { req.destroy(); resolve({ source: 'SAM_GOV', error: 'US SAM.gov API timed out. Retry in a few minutes.' }); });
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
- // ─── FIX 4: normaliseUKTender ─────────────────────────────────────────────────
235
- // URL: use tender.documents[0].url (real notice URL) with fallback to UUID from r.id
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 noticDoc = t.documents.find(doc => doc.documentType === 'tenderNotice' || doc.documentType === 'awardNotice');
244
- if (noticDoc && noticDoc.url) noticeUrl = noticDoc.url;
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 buyerArr = n['organisation-name-buyer'];
283
- const buyer = buyerArr ? (Array.isArray(buyerArr) ? buyerArr[0] : buyerArr) : null;
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 deadline = deadlineArr ? (Array.isArray(deadlineArr) ? deadlineArr[0] : deadlineArr) : null;
286
- // Best URL: English HTML link from links object, fallback to standard pattern
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
- url = n['links']['html']['ENG'];
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
- deadline: deadline,
301
- country: country,
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
- deadline: o.responseDeadLine || null,
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 for a business, product, or service. Use when helping a company identify new revenue opportunities from public sector clients, when monitoring government spending in a specific sector, when an agent needs to find bid opportunities for a client, when researching which companies win government contracts in a particular area, or when building procurement intelligence for any organisation that sells to governments. Searches UK Contracts Finder (contractsfinder.service.gov.uk), EU TED (ted.europa.eu), and US SAM.gov simultaneously. Returns normalised results from all three sources. Every response includes source_url and checked_at so agents can verify exactly where data came from. LEGAL NOTICE: Always verify tender deadlines and details directly with the contracting authority before bidding — deadlines change. We do not log your query content. Results are informational only. Full terms: kordagencies.com/terms.html. Free tier: first 10 searches/month, no API key needed.',
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", "construction")' },
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: 'get_tender_detail',
344
- description: 'Call this tool when your agent has found a tender from search_tenders and needs the full details before deciding whether to bid or present it to a client. Returns complete tender documentation including full description, all deadlines, contact details, award criteria, and direct link to the official notice. Use to enrich search results with actionable information, or when an agent needs to summarise a specific opportunity for a decision-maker. LEGAL NOTICE: Always verify details directly with the contracting authority before bidding information may have changed. We do not log your query content. Free tier: first 10 searches/month, no API key needed.',
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
- tender_id: { type: 'string', description: 'Tender ID or OCID from search_tenders results' },
349
- source: { type: 'string', enum: ['uk', 'eu', 'us'], description: 'Source system the tender came from' }
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: ['keyword']
286
+ required: ['mode']
390
287
  }
391
288
  }
392
289
  ];
393
290
 
394
- async function executeTool(name, args) {
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.keyword;
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, limit, daysOld));
406
- if (sources.includes('eu')) searches.push(searchEUTenders(keyword, limit));
407
- if (sources.includes('us')) searches.push(searchSAMGov(keyword, limit, daysOld));
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
- (r.data || []).slice(0, limit).forEach(t => tenders.push(normaliseUKTender(t)));
417
- }
418
- if (r.source === 'EU_TED') {
419
- const notices = (r.data && r.data.notices) || [];
420
- notices.slice(0, limit).forEach(n => tenders.push(normaliseEUTender(n)));
421
- }
422
- if (r.source === 'SAM_GOV') {
423
- const opps = (r.data && r.data.opportunitiesData) || [];
424
- opps.slice(0, limit).forEach(o => tenders.push(normaliseSAMTender(o)));
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
- return {
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
- if (name === 'get_tender_detail') {
440
- const { tender_id, source } = args;
441
- if (!tender_id || !source) return { error: 'tender_id and source are required', _disclaimer: LEGAL_DISCLAIMER };
442
-
443
- if (source === 'uk') {
444
- return new Promise((resolve) => {
445
- const req = https.request({
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 + '&noticeId=' + 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 { error: 'Invalid source. Use: uk, eu, or us', _disclaimer: LEGAL_DISCLAIMER };
381
+ return result;
503
382
  }
504
383
 
505
- if (name === 'score_tender_fit') {
506
- const { tenders, company_profile, min_score } = args;
507
- if (!tenders || !Array.isArray(tenders) || tenders.length === 0) return { error: 'tenders array is required', _disclaimer: LEGAL_DISCLAIMER };
508
- if (!company_profile) return { error: 'company_profile is required', _disclaimer: LEGAL_DISCLAIMER };
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
- if (name === 'get_daily_digest') {
543
- const { keywords, sources } = args;
544
- if (!keywords || !Array.isArray(keywords) || keywords.length === 0) return { error: 'keywords array is required', _disclaimer: LEGAL_DISCLAIMER };
545
- const targetSources = sources || ['uk', 'eu', 'us'];
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
- const allTenders = [];
548
- const errors = [];
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
- for (const keyword of keywords.slice(0, 5)) {
551
- const searches = [];
552
- if (targetSources.includes('uk')) searches.push(searchUKTenders(keyword, 10, 1));
553
- if (targetSources.includes('eu')) searches.push(searchEUTenders(keyword, 10));
554
- if (targetSources.includes('us')) searches.push(searchSAMGov(keyword, 10, 1));
555
- const results = await Promise.all(searches);
556
- for (const r of results) {
557
- if (r.error) { errors.push({ source: r.source, keyword, error: r.error }); continue; }
558
- if (r.source === 'UK_CONTRACTS_FINDER') (r.data || []).forEach(t => allTenders.push(Object.assign(normaliseUKTender(t), { matched_keyword: keyword })));
559
- if (r.source === 'EU_TED') ((r.data && r.data.notices) || []).forEach(n => allTenders.push(Object.assign(normaliseEUTender(n), { matched_keyword: keyword })));
560
- if (r.source === 'SAM_GOV') ((r.data && r.data.opportunitiesData) || []).forEach(o => allTenders.push(Object.assign(normaliseSAMTender(o), { matched_keyword: keyword })));
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
- const seen = new Set();
565
- const unique = allTenders.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; });
566
- unique.sort((a, b) => (b.published || '').localeCompare(a.published || ''));
567
-
568
- return {
569
- date: getTodayDate(),
570
- keywords_monitored: keywords,
571
- sources_searched: targetSources,
572
- total_new_tenders: unique.length,
573
- tenders: unique,
574
- errors: errors.length > 0 ? errors : undefined,
575
- checked_at: checkedAt,
576
- _disclaimer: LEGAL_DISCLAIMER
577
- };
578
- }
579
-
580
- if (name === 'get_award_history') {
581
- const { keyword, sources, limit } = args;
582
- if (!keyword) return { error: 'keyword is required', _disclaimer: LEGAL_DISCLAIMER };
583
- const targetSources = sources || ['uk', 'eu', 'us'];
584
- const maxResults = Math.min(limit || 10, 25);
585
-
586
- const searches = [];
587
- if (targetSources.includes('uk')) searches.push(searchUKTenders(keyword, maxResults, 365));
588
- if (targetSources.includes('eu')) searches.push(searchEUTenders(keyword, maxResults));
589
- if (targetSources.includes('us')) searches.push(searchSAMGov(keyword, maxResults, 365));
590
-
591
- const results = await Promise.all(searches);
592
- const awards = [];
593
- const errors = [];
594
-
595
- for (const r of results) {
596
- if (r.error) { errors.push({ source: r.source, error: r.error }); continue; }
597
- if (r.source === 'UK_CONTRACTS_FINDER') {
598
- (r.data || []).filter(t => t.tag && t.tag.includes('award')).forEach(t => awards.push(normaliseUKTender(t)));
599
- }
600
- if (r.source === 'EU_TED') {
601
- const notices = (r.data && r.data.notices) || [];
602
- notices.filter(n => n['notice-type'] && n['notice-type'].toLowerCase().includes('award')).forEach(n => awards.push(normaliseEUTender(n)));
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
- if (r.source === 'SAM_GOV') {
605
- const opps = (r.data && r.data.opportunitiesData) || [];
606
- opps.filter(o => o.type && o.type.toLowerCase().includes('award')).forEach(o => awards.push(normaliseSAMTender(o)));
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) return { allowed: false, 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.', upgrade_url: 'https://kordagencies.com', tier: 'free_limit_reached' };
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 { allowed: true, tier: 'free', remaining, warning: remaining < 3 ? remaining + ' free searches remaining. Upgrade at kordagencies.com' : null };
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
- async function handleStripeWebhook(body) {
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: new Date().toISOString(), calls: 0, limit: PLAN_LIMITS[plan] });
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) { console.error('Webhook error:', e.message); return { error: e.message }; }
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 = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, x-api-key, mcp-session-id, x-stats-key' };
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({ status: 'ok', version: '1.0.1', service: 'tender-mcp', free_tier: 'no API key required for first 10 searches/month', paid_keys_issued: apiKeys.size }));
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 () => { const result = await handleStripeWebhook(body); res.writeHead(200, { ...cors, 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); });
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: '1.0.1', description: 'Government tender search and AI relevance scoring for AI agents. UK Contracts Finder, EU TED, US SAM.gov. AI-powered opportunity scoring. Free tier: 10 searches/month.' } } };
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: req._tier || 'paid', time: new Date().toISOString(), ip: ip.slice(0, 8) + '...' });
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
- const result = await executeTool(name, toolArgs || {});
736
- if (req._accessWarning) result._notice = req._accessWarning;
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) { res.writeHead(400, { ...cors, 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
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: '1.0.1', status: 'ok', tools: 5, free_tier: '10 searches/month, no API key required', description: 'Government tender search + AI scoring. UK, EU, US.', upgrade: 'https://kordagencies.com' }));
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 v1.0.1 running on port ' + PORT);
761
- console.log('Free tier: ' + FREE_TIER_LIMIT + ' searches/IP/month, no API key required');
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 (production key)' : 'using DEMO_KEY — register at api.sam.gov for 1000/day'));
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