tender-mcp 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog — Tender MCP
2
+
3
+ ## v1.0.0 — 2026-04-09
4
+
5
+ ### Added
6
+ - Initial release
7
+ - `search_tenders` — keyword search across UK Contracts Finder, EU TED, US SAM.gov simultaneously
8
+ - `get_tender_detail` — full tender details by ID from any source
9
+ - `score_tender_fit` — AI-powered relevance scoring 0-100 with BID/INVESTIGATE/SKIP recommendation
10
+ - `get_daily_digest` — new tenders in last 24 hours matching keywords (paid only)
11
+ - `get_award_history` — past award winners for competitive intelligence (paid only)
12
+ - Free tier: 10 searches/month, no API key required
13
+ - source_url and checked_at in every response
14
+ - Honest timeout error messages for all three government APIs
15
+ - Legal disclaimer in every response
16
+ - Stats endpoint protected by STATS_KEY
17
+ - Stripe webhook API key email delivery
package/LICENSE ADDED
@@ -0,0 +1,6 @@
1
+ UNLICENSED
2
+
3
+ Copyright (c) 2026 Kord Agencies
4
+
5
+ All rights reserved. This software and its source code are proprietary and confidential.
6
+ Unauthorized copying, modification, distribution, or use is strictly prohibited.
package/Project ADDED
File without changes
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # Tender MCP — Government Opportunity Intelligence for AI Agents
2
+
3
+ Find, score, and monitor government contract opportunities across UK, EU, and US. AI-powered relevance scoring so your agent surfaces the right opportunities — not just keyword matches.
4
+
5
+ **Free tier: 10 searches/month. No API key required. Just connect and go.**
6
+
7
+ ## Quick Start
8
+
9
+ ```json
10
+ {
11
+ "tender": {
12
+ "url": "https://tender-mcp-production.up.railway.app"
13
+ }
14
+ }
15
+ ```
16
+
17
+ Or via Smithery:
18
+
19
+ ```bash
20
+ npx -y @smithery/cli@latest mcp add OjasKord/tender-mcp
21
+ ```
22
+
23
+ ## Why Use This
24
+
25
+ Any business that sells to government needs to monitor tender opportunities. But searching three separate government portals daily, reading hundreds of notices, and manually judging relevance takes hours. Tender MCP does it in seconds — search UK, EU, and US simultaneously, then let AI score which opportunities actually match your capabilities.
26
+
27
+ ## Tools
28
+
29
+ ### `search_tenders`
30
+ Search active government tenders across UK Contracts Finder, EU TED, and US SAM.gov simultaneously. Returns normalised results with source_url and checked_at.
31
+
32
+ ```json
33
+ { "keyword": "cybersecurity", "sources": ["uk", "eu"], "days_old": 14 }
34
+ ```
35
+
36
+ ### `get_tender_detail`
37
+ Full details for a specific tender — complete description, all deadlines, contact details, award criteria.
38
+
39
+ ```json
40
+ { "tender_id": "ocds-h6vhtk-...", "source": "uk" }
41
+ ```
42
+
43
+ ### `score_tender_fit` *(AI-powered — NOT a keyword match)*
44
+ AI scores each tender 0-100 against a company capability profile. Returns BID/INVESTIGATE/SKIP recommendation with specific reasons. Saves hours of manual review.
45
+
46
+ ```json
47
+ {
48
+ "tenders": [...],
49
+ "company_profile": "We are a 50-person UK cybersecurity firm specialising in penetration testing and SOC services for public sector clients. We hold SC clearance.",
50
+ "min_score": 60
51
+ }
52
+ ```
53
+
54
+ ### `get_daily_digest` *(Paid only)*
55
+ All new tenders published in the last 24 hours matching your keywords. Run daily as a morning briefing.
56
+
57
+ ```json
58
+ { "keywords": ["cybersecurity", "managed SOC", "penetration testing"] }
59
+ ```
60
+
61
+ ### `get_award_history` *(Paid only)*
62
+ Past award winners for a keyword. Use for competitive intelligence before bidding.
63
+
64
+ ```json
65
+ { "keyword": "cybersecurity", "sources": ["uk"] }
66
+ ```
67
+
68
+ ## Recommended Workflow
69
+
70
+ **Find and score opportunities (3 calls):**
71
+ 1. `search_tenders` — find active tenders matching your sector
72
+ 2. `score_tender_fit` — AI ranks by relevance, filters noise
73
+ 3. `get_tender_detail` — full details on top matches
74
+
75
+ **Daily monitoring (1 call):**
76
+ - `get_daily_digest` — new tenders every morning before competitors see them
77
+
78
+ ## Data Sources
79
+
80
+ | Source | Coverage | Update Frequency |
81
+ |---|---|---|
82
+ | UK Contracts Finder (contractsfinder.service.gov.uk) | All UK public sector contracts | Real-time |
83
+ | EU TED (ted.europa.eu) | All EU member state procurement | Real-time |
84
+ | US SAM.gov (sam.gov) | All US federal opportunities | Daily |
85
+
86
+ Every response includes `source_url` and `checked_at`.
87
+
88
+ ## Pricing
89
+
90
+ | Plan | Searches | Features | Price |
91
+ |---|---|---|---|
92
+ | Free | 10/month | search_tenders, get_tender_detail, score_tender_fit | No API key required |
93
+ | Pro | 500/month | All tools including daily digest + award history | $199/month |
94
+ | Enterprise | Unlimited | All tools + priority support | $499/month |
95
+
96
+ Upgrade at **[kordagencies.com](https://kordagencies.com)**
97
+
98
+ ## Reliability
99
+
100
+ - Uptime monitored every 5 minutes
101
+ - Version history in [CHANGELOG.md](CHANGELOG.md)
102
+ - Health endpoint: `GET /health`
103
+ - Note: Government portal APIs experience occasional downtime — errors include explanation and retry guidance
104
+
105
+ ## Legal
106
+
107
+ Tender data sourced directly from official government portals. We do not log or store your query content. **Always verify tender deadlines and details directly with the contracting authority before submitting a bid — deadlines change.** Results are for informational purposes only. Maximum liability limited to 3 months subscription fees. Full terms: [kordagencies.com/terms.html](https://kordagencies.com/terms.html)
108
+
109
+ ## Connect
110
+
111
+ - Website: [kordagencies.com](https://kordagencies.com)
112
+ - Contact: ojas@kordagencies.com
package/Select ADDED
File without changes
package/glama.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "tender-mcp",
3
+ "description": "Government tender search and AI opportunity scoring for AI agents. UK Contracts Finder, EU TED, US SAM.gov. AI-powered relevance scoring.",
4
+ "url": "https://tender-mcp-production.up.railway.app",
5
+ "categories": ["procurement", "government", "business-intelligence"],
6
+ "license": "UNLICENSED",
7
+ "homepage": "https://kordagencies.com"
8
+ }
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "tender-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Government tender search and AI opportunity scoring for AI agents. UK Contracts Finder, EU TED, US SAM.gov.",
5
+ "main": "src/server.js",
6
+ "scripts": { "start": "node src/server.js" },
7
+ "keywords": ["mcp", "agent", "tender", "procurement", "government", "contracts", "bidding"],
8
+ "author": "Kord Agencies",
9
+ "license": "UNLICENSED",
10
+ "homepage": "https://kordagencies.com",
11
+ "repository": { "type": "git", "url": "https://github.com/OjasKord/tender-mcp.git" }
12
+ }
package/railway ADDED
File without changes
package/server.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.OjasKord/tender-mcp",
4
+ "description": "Government tender search and AI opportunity scoring. UK Contracts Finder, EU TED, US SAM.gov. Find and score government contract opportunities.",
5
+ "title": "Tender MCP",
6
+ "repository": {
7
+ "url": "https://github.com/OjasKord/tender-mcp",
8
+ "source": "github"
9
+ },
10
+ "version": "1.0.0",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "registryBaseUrl": "https://registry.npmjs.org",
15
+ "identifier": "tender-mcp",
16
+ "version": "1.0.0",
17
+ "transport": { "type": "stdio" }
18
+ }
19
+ ],
20
+ "remotes": [
21
+ {
22
+ "type": "streamable-http",
23
+ "url": "https://tender-mcp-production.up.railway.app"
24
+ }
25
+ ]
26
+ }
package/smithery.yaml ADDED
@@ -0,0 +1,79 @@
1
+ startCommand:
2
+ type: http
3
+ url: https://tender-mcp-production.up.railway.app
4
+ configSchema:
5
+ type: object
6
+ properties:
7
+ apiKey:
8
+ type: string
9
+ description: "Tender MCP API key from kordagencies.com. Leave blank for free tier (10 searches/month)."
10
+ x-from:
11
+ header: "x-api-key"
12
+ required: []
13
+
14
+ systemPrompt: |
15
+ Tender MCP is a government opportunity intelligence layer for AI agents. Use it any time your agent needs to find, evaluate, or monitor government contract opportunities.
16
+
17
+ THREE LAYERS OF INTELLIGENCE
18
+
19
+ Layer 1 - Search (search_tenders, get_tender_detail): Find active tenders from UK, EU, and US government portals simultaneously.
20
+ Layer 2 - AI Scoring (score_tender_fit): NOT a keyword match — AI analyses each tender against a company profile to score relevance 0-100 and recommend BID/INVESTIGATE/SKIP.
21
+ Layer 3 - Monitoring (get_daily_digest, get_award_history): Daily alerts for new tenders, competitive intelligence on past award winners.
22
+
23
+ WHEN TO USE THIS SERVER
24
+
25
+ Use these tools whenever your agent is helping:
26
+ - Any business that sells products or services to government
27
+ - A BD or sales agent finding new revenue opportunities
28
+ - A procurement intelligence agent monitoring competitor wins
29
+ - An investor researching government spending trends
30
+ - A consultant helping clients enter the public sector market
31
+ - Any workflow that needs to know what governments are buying
32
+
33
+ RECOMMENDED WORKFLOW
34
+
35
+ When helping a company find relevant government opportunities:
36
+ 1. search_tenders(keyword, sources) - find active tenders
37
+ 2. score_tender_fit(tenders, company_profile) - AI ranks by relevance, filters noise
38
+ 3. get_tender_detail(id, source) - full details on top matches
39
+ 4. get_daily_digest(keywords) - set up ongoing monitoring (paid)
40
+
41
+ TOOLS
42
+
43
+ search_tenders
44
+ - Searches UK Contracts Finder, EU TED, and US SAM.gov simultaneously
45
+ - Returns normalised results with source_url and checked_at
46
+ - Free tier: first 10 searches/month, no API key needed
47
+
48
+ get_tender_detail
49
+ - Full tender details for a specific opportunity
50
+ - Free tier: first 10 searches/month, no API key needed
51
+
52
+ score_tender_fit (AI-POWERED)
53
+ - AI relevance scoring — NOT a simple keyword match
54
+ - Scores each tender 0-100 against a company capability profile
55
+ - Returns BID/INVESTIGATE/SKIP recommendation with specific reasons
56
+ - Saves hours of manual review when hundreds of tenders match a keyword
57
+ - Free tier: first 10 searches/month, no API key needed
58
+
59
+ get_daily_digest
60
+ - All new tenders published in the last 24 hours matching keywords
61
+ - Run daily as a morning briefing for any company bidding on government work
62
+ - Paid API key required
63
+
64
+ get_award_history
65
+ - Past award winners for a keyword — competitive intelligence
66
+ - Use to research who wins in a sector before bidding
67
+ - Paid API key required
68
+
69
+ DATA SOURCES
70
+ - UK: contractsfinder.service.gov.uk — all UK public sector contracts
71
+ - EU: ted.europa.eu — all EU member state public procurement
72
+ - US: sam.gov — all US federal contract opportunities
73
+
74
+ IMPORTANT LEGAL NOTICE
75
+ Tender deadlines and contract values change — always verify directly with the contracting authority before submitting a bid. Results are for informational purposes only. We do not log or store your query content. Full terms: kordagencies.com/terms.html
76
+
77
+ FREE TIER
78
+ 10 searches/month with no API key.
79
+ Upgrade at kordagencies.com - Pro $199/month (500 searches), Enterprise $499/month (unlimited + daily digest + award history).
package/src/server.js ADDED
@@ -0,0 +1,765 @@
1
+ const http = require('http');
2
+ const https = require('https');
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+
6
+ const PERSIST_FILE = '/tmp/tender_stats.json';
7
+ const RESEND_API_KEY = process.env.RESEND_API_KEY || '';
8
+ const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || '';
9
+ const SAM_GOV_API_KEY = process.env.SAM_GOV_API_KEY || '';
10
+ const PORT = process.env.PORT || 3000;
11
+ const STATS_KEY = process.env.STATS_KEY || 'ojas2026';
12
+
13
+ const freeTierUsage = new Map();
14
+ const usageLog = [];
15
+ const FREE_TIER_LIMIT = 10;
16
+ const apiKeys = new Map();
17
+ const PLAN_LIMITS = { pro: 500, enterprise: Infinity };
18
+
19
+ 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
+
21
+ function nowISO() { return new Date().toISOString(); }
22
+ function saveStats() {
23
+ try {
24
+ fs.writeFileSync(PERSIST_FILE, JSON.stringify({ freeTierUsage: Array.from(freeTierUsage.entries()), usageLog: usageLog.slice(-1000) }));
25
+ } catch(e) { console.error('Stats save error:', e.message); }
26
+ }
27
+ function loadStats() {
28
+ try {
29
+ if (fs.existsSync(PERSIST_FILE)) {
30
+ const data = JSON.parse(fs.readFileSync(PERSIST_FILE, 'utf8'));
31
+ if (data.freeTierUsage) data.freeTierUsage.forEach(([k, v]) => freeTierUsage.set(k, v));
32
+ if (data.usageLog) usageLog.push(...data.usageLog);
33
+ console.log('Stats loaded: ' + freeTierUsage.size + ' IPs, ' + usageLog.length + ' calls');
34
+ }
35
+ } catch(e) { console.error('Stats load error:', e.message); }
36
+ }
37
+ function generateApiKey() { return 'tender_' + crypto.randomBytes(24).toString('hex'); }
38
+ function getPlanFromProduct(name) {
39
+ if (!name) return 'pro';
40
+ return name.toLowerCase().includes('enterprise') ? 'enterprise' : 'pro';
41
+ }
42
+
43
+ async function sendEmail(to, subject, html) {
44
+ return new Promise((resolve) => {
45
+ const body = JSON.stringify({ from: 'Tender MCP <ojas@kordagencies.com>', to: [to], subject, html });
46
+ const req = https.request({
47
+ hostname: 'api.resend.com', path: '/emails', method: 'POST',
48
+ headers: { 'Authorization': 'Bearer ' + RESEND_API_KEY, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
49
+ }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ status: res.statusCode, body: d })); });
50
+ req.on('error', e => resolve({ error: e.message }));
51
+ req.write(body); req.end();
52
+ });
53
+ }
54
+
55
+ async function sendApiKeyEmail(email, apiKey, plan) {
56
+ const planLabel = plan === 'enterprise' ? 'Enterprise' : 'Pro';
57
+ const limit = plan === 'enterprise' ? 'Unlimited' : '500';
58
+ const html = '<!DOCTYPE html><html><body style="font-family:monospace;background:#080A0F;color:#E8EDF5;padding:40px;max-width:600px;margin:0 auto"><div style="border:1px solid rgba(0,229,195,0.3);border-radius:8px;padding:32px"><div style="color:#00E5C3;font-size:13px;letter-spacing:0.2em;text-transform:uppercase;margin-bottom:24px">Tender MCP - ' + planLabel + ' Plan</div><h1 style="font-size:24px;font-weight:700;margin-bottom:8px;color:#FFFFFF">Your API key is ready.</h1><div style="background:#141B24;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:20px;margin-bottom:24px"><div style="color:#5A6478;font-size:11px;text-transform:uppercase;margin-bottom:8px">Your API Key</div><div style="color:#00E5C3;font-size:14px;word-break:break-all">' + apiKey + '</div></div><div style="background:#141B24;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:20px;margin-bottom:24px"><div style="color:#5A6478;font-size:11px;text-transform:uppercase;margin-bottom:8px">MCP Config</div><div style="color:#86EFAC;font-size:12px">{"tender":{"url":"https://tender-mcp-production.up.railway.app","headers":{"x-api-key":"' + apiKey + '"}}}</div></div><div style="background:#141B24;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:20px;margin-bottom:24px"><div style="color:#E8EDF5;font-size:13px">Plan: ' + planLabel + ' | Searches: ' + limit + '/month</div></div><div style="background:#0D1219;border-radius:6px;padding:16px;margin-bottom:24px;font-size:11px;color:#5A6478;line-height:1.7">Tender data is sourced from official government portals. Deadlines may change — always verify with the contracting authority before bidding. We do not log your query content. Liability capped at 3 months fees. Full terms: kordagencies.com/terms.html</div><p style="color:#5A6478;font-size:12px">Questions? ojas@kordagencies.com</p></div></body></html>';
59
+ return sendEmail(email, 'Your Tender MCP ' + planLabel + ' API Key', html);
60
+ }
61
+
62
+ async function callClaude(prompt) {
63
+ return new Promise((resolve, reject) => {
64
+ const body = JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 1024, messages: [{ role: 'user', content: prompt }] });
65
+ const req = https.request({
66
+ hostname: 'api.anthropic.com', path: '/v1/messages', method: 'POST',
67
+ headers: { 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }
68
+ }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d).content?.[0]?.text || ''); } catch(e) { reject(e); } }); });
69
+ req.on('error', reject); req.write(body); req.end();
70
+ });
71
+ }
72
+
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
+ }
85
+
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
+ async function searchUKTenders(keyword, limit, daysOld) {
91
+ return new Promise((resolve) => {
92
+ const from = getDateDaysAgo(daysOld || 30);
93
+ const fetchLimit = Math.min(limit || 10, 25);
94
+ const params = 'publishedFrom=' + from + '&limit=' + fetchLimit;
95
+ const req = https.request({
96
+ hostname: 'www.contractsfinder.service.gov.uk',
97
+ path: '/Published/Notices/OCDS/Search?' + params,
98
+ method: 'GET',
99
+ headers: { 'Accept': 'application/json', 'User-Agent': 'Tender-MCP/1.0' }
100
+ }, res => {
101
+ let d = ''; res.on('data', c => d += c);
102
+ res.on('end', () => {
103
+ try {
104
+ 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 });
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 });
111
+ }
112
+ });
113
+ });
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.' }); });
116
+ req.end();
117
+ });
118
+ }
119
+
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
+ async function searchEUTenders(keyword, limit) {
136
+ 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
+ const fromDate = getDateDaysAgo(30).replace(/-/g, '');
140
+ const tedQuery = keyword
141
+ ? 'FT~' + keyword.replace(/[^a-zA-Z0-9 ]/g, '') + ' AND PD>=' + fromDate
142
+ : 'PD>=' + fromDate;
143
+
144
+ const body = JSON.stringify({
145
+ query: tedQuery,
146
+ page: 1,
147
+ limit: Math.min(limit || 10, 25),
148
+ fields: ['ND', 'TI', 'PD', 'CY', 'notice-title', 'TVH', 'TV', 'notice-type', 'organisation-name-buyer', 'deadline-date-lot', 'links']
149
+ });
150
+
151
+ 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
+ }
161
+ }, res => {
162
+ let d = ''; res.on('data', c => d += c);
163
+ res.on('end', () => {
164
+ try {
165
+ 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
+ }
178
+ });
179
+ });
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.' }); });
182
+ req.write(body); req.end();
183
+ });
184
+ }
185
+
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
+ async function searchSAMGov(keyword, limit, daysOld) {
191
+ return new Promise((resolve) => {
192
+ const apiKey = SAM_GOV_API_KEY || 'DEMO_KEY';
193
+ const params = new URLSearchParams({
194
+ api_key: apiKey,
195
+ q: keyword || '',
196
+ limit: String(Math.min(limit || 10, 25)),
197
+ postedFrom: getSAMDate(daysOld || 30),
198
+ postedTo: getSAMDate(0),
199
+ ptype: 'o'
200
+ });
201
+ 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' }
206
+ }, res => {
207
+ let d = ''; res.on('data', c => d += c);
208
+ res.on('end', () => {
209
+ console.log('SAM.gov HTTP status:', res.statusCode, 'body length:', d.length);
210
+ 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 });
216
+ return;
217
+ }
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
+ }
226
+ });
227
+ });
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.' }); });
230
+ req.end();
231
+ });
232
+ }
233
+
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
237
+ function normaliseUKTender(r) {
238
+ const t = r.tender || {};
239
+ 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
+ let noticeUrl = null;
242
+ 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;
245
+ }
246
+ if (!noticeUrl && r.id) {
247
+ // r.id format is "UUID-SEQUENCE", strip sequence to get UUID
248
+ const uuid = r.id.split('-').slice(0, 5).join('-');
249
+ noticeUrl = 'https://www.contractsfinder.service.gov.uk/Notice/' + uuid;
250
+ }
251
+ return {
252
+ id: r.ocid || r.id,
253
+ title: t.title || null,
254
+ description: t.description ? t.description.slice(0, 400) : null,
255
+ contracting_authority: b.name || null,
256
+ value: t.value ? { amount: t.value.amount, currency: t.value.currency || 'GBP' } : null,
257
+ published: r.date || null,
258
+ deadline: t.tenderPeriod ? t.tenderPeriod.endDate : null,
259
+ status: t.status || null,
260
+ type: (r.tag || []).join(', ') || null,
261
+ url: noticeUrl,
262
+ source: 'UK_CONTRACTS_FINDER',
263
+ source_url: 'contractsfinder.service.gov.uk'
264
+ };
265
+ }
266
+
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
+ function normaliseEUTender(n) {
274
+ const titleObj = n['notice-title'] || n['TI'] || {};
275
+ const title = titleObj['eng'] || titleObj['fra'] || titleObj['deu'] || Object.values(titleObj)[0] || null;
276
+ const nd = n['ND'] || n['publication-number'] || null;
277
+ const pd = n['PD'] ? String(n['PD']).split('+')[0].split('T')[0] : null;
278
+ const tvh = n['TVH'];
279
+ const value = tvh ? (Array.isArray(tvh) ? tvh[0] : tvh) : (n['TV'] ? (Array.isArray(n['TV']) ? n['TV'][0] : n['TV']) : null);
280
+ const cy = n['CY'];
281
+ 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;
284
+ 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
287
+ 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
+ }
293
+ 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
297
+ contracting_authority: buyer,
298
+ 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'
306
+ };
307
+ }
308
+
309
+ function normaliseSAMTender(o) {
310
+ return {
311
+ id: o.noticeId || o.solicitationNumber,
312
+ title: o.title || null,
313
+ description: o.description ? o.description.slice(0, 400) : null,
314
+ contracting_authority: o.fullParentPathName || null,
315
+ 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,
321
+ url: o.noticeId ? 'https://sam.gov/opp/' + o.noticeId + '/view' : null,
322
+ source: 'SAM_GOV',
323
+ source_url: 'sam.gov'
324
+ };
325
+ }
326
+
327
+ const tools = [
328
+ {
329
+ 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.',
331
+ inputSchema: {
332
+ type: 'object',
333
+ properties: {
334
+ keyword: { type: 'string', description: 'Search keyword — company capability, product type, or service (e.g. "cybersecurity", "catering", "IT support", "construction")' },
335
+ sources: { type: 'array', items: { type: 'string', enum: ['uk', 'eu', 'us'] }, description: 'Which sources to search. Defaults to all three: ["uk","eu","us"]' },
336
+ 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)' }
338
+ },
339
+ required: ['keyword']
340
+ }
341
+ },
342
+ {
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.',
345
+ inputSchema: {
346
+ type: 'object',
347
+ 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' },
386
+ 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)' }
388
+ },
389
+ required: ['keyword']
390
+ }
391
+ }
392
+ ];
393
+
394
+ async function executeTool(name, args) {
395
+ const checkedAt = nowISO();
396
+
397
+ 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;
402
+ if (!keyword) return { error: 'keyword is required', _disclaimer: LEGAL_DISCLAIMER };
403
+
404
+ 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));
408
+
409
+ const results = await Promise.all(searches);
410
+ const tenders = [];
411
+ const errors = [];
412
+
413
+ for (const r of results) {
414
+ 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)));
425
+ }
426
+ }
427
+
428
+ return {
429
+ keyword,
430
+ total_found: tenders.length,
431
+ sources_searched: sources,
432
+ tenders,
433
+ errors: errors.length > 0 ? errors : undefined,
434
+ checked_at: checkedAt,
435
+ _disclaimer: LEGAL_DISCLAIMER
436
+ };
437
+ }
438
+
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
+ }
501
+
502
+ return { error: 'Invalid source. Use: uk, eu, or us', _disclaimer: LEGAL_DISCLAIMER };
503
+ }
504
+
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
+ }
541
+
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'];
546
+
547
+ const allTenders = [];
548
+ const errors = [];
549
+
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 })));
561
+ }
562
+ }
563
+
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)));
603
+ }
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)));
607
+ }
608
+ }
609
+
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
+ };
620
+ }
621
+
622
+ return { error: 'Unknown tool: ' + name };
623
+ }
624
+
625
+ function checkAccess(req, toolName) {
626
+ const paidOnlyTools = ['get_daily_digest', 'get_award_history'];
627
+ const apiKey = req.headers['x-api-key'];
628
+
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
+ if (apiKey) {
639
+ const record = apiKeys.get(apiKey);
640
+ if (!record) return { allowed: false, reason: 'Invalid API key. Get yours at kordagencies.com', tier: 'invalid' };
641
+ 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' };
642
+ record.calls++;
643
+ return { allowed: true, tier: record.plan };
644
+ }
645
+
646
+ const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
647
+ 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' };
649
+ freeTierUsage.set(ip, calls + 1);
650
+ saveStats();
651
+ 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 };
653
+ }
654
+
655
+ async function handleStripeWebhook(body) {
656
+ try {
657
+ const event = JSON.parse(body);
658
+ if (event.type === 'checkout.session.completed') {
659
+ const session = event.data.object;
660
+ const email = session.customer_email || session.customer_details?.email;
661
+ const plan = getPlanFromProduct(session.metadata?.product_name || '');
662
+ if (email) {
663
+ const apiKey = generateApiKey();
664
+ apiKeys.set(apiKey, { email, plan, createdAt: new Date().toISOString(), calls: 0, limit: PLAN_LIMITS[plan] });
665
+ await sendApiKeyEmail(email, apiKey, plan);
666
+ console.log('API key created for ' + email + ' (' + plan + ')');
667
+ return { success: true, email, plan };
668
+ }
669
+ }
670
+ return { received: true, type: event.type };
671
+ } catch(e) { console.error('Webhook error:', e.message); return { error: e.message }; }
672
+ }
673
+
674
+ 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' };
676
+ if (req.method === 'OPTIONS') { res.writeHead(200, cors); res.end(); return; }
677
+
678
+ if (req.url === '/health' && req.method === 'GET') {
679
+ 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 }));
681
+ return;
682
+ }
683
+
684
+ if (req.url === '/stats' && req.method === 'GET') {
685
+ if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
686
+ const totalFreeCalls = Array.from(freeTierUsage.values()).reduce((a, b) => a + b, 0);
687
+ const toolCounts = {};
688
+ usageLog.forEach(e => { toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1; });
689
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
690
+ res.end(JSON.stringify({ free_tier_unique_ips: freeTierUsage.size, free_tier_total_calls: totalFreeCalls, paid_keys_issued: apiKeys.size, tool_usage: toolCounts, recent_calls: usageLog.slice(-20).reverse() }));
691
+ return;
692
+ }
693
+
694
+ if (req.url === '/webhook/stripe' && req.method === 'POST') {
695
+ 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)); });
697
+ return;
698
+ }
699
+
700
+ if (req.method === 'POST') {
701
+ let body = ''; req.on('data', c => body += c);
702
+ req.on('end', async () => {
703
+ try {
704
+ const request = JSON.parse(body);
705
+ let response;
706
+
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
+ 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.' } } };
721
+ } else if (request.method === 'notifications/initialized') {
722
+ res.writeHead(204, cors); res.end(); return;
723
+ } else if (request.method === 'tools/list') {
724
+ response = { jsonrpc: '2.0', id: request.id, result: { tools } };
725
+ } else if (request.method === 'resources/list') {
726
+ response = { jsonrpc: '2.0', id: request.id, result: { resources: [] } };
727
+ } else if (request.method === 'prompts/list') {
728
+ response = { jsonrpc: '2.0', id: request.id, result: { prompts: [] } };
729
+ } else if (request.method === 'tools/call') {
730
+ const { name, arguments: toolArgs } = request.params;
731
+ 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) + '...' });
733
+ if (usageLog.length > 1000) usageLog.shift();
734
+ saveStats();
735
+ const result = await executeTool(name, toolArgs || {});
736
+ if (req._accessWarning) result._notice = req._accessWarning;
737
+ response = { jsonrpc: '2.0', id: request.id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } };
738
+ } else {
739
+ response = { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found: ' + request.method } };
740
+ }
741
+
742
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
743
+ res.end(JSON.stringify(response));
744
+ } catch(e) { res.writeHead(400, { ...cors, 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
745
+ });
746
+ return;
747
+ }
748
+
749
+ if (req.method === 'GET' && req.url === '/') {
750
+ 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' }));
752
+ return;
753
+ }
754
+
755
+ res.writeHead(404, cors); res.end(JSON.stringify({ error: 'Not found' }));
756
+ });
757
+
758
+ server.listen(PORT, () => {
759
+ 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');
762
+ console.log('Resend: ' + (RESEND_API_KEY ? 'configured' : 'MISSING'));
763
+ 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'));
765
+ });