tender-mcp 1.2.9 → 1.2.13
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 +24 -0
- package/package.json +6 -11
- package/smithery.yaml +7 -58
- package/src/server.js +207 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## [1.2.13] - 2026-06-08
|
|
2
|
+
- fix: BEFORE trigger language, consequence-first limit error
|
|
3
|
+
|
|
4
|
+
## [1.2.12] - 2026-06-05
|
|
5
|
+
- feat: Smithery optimisation - updated package.json description/keywords and smithery.yaml with system prompt
|
|
6
|
+
|
|
7
|
+
## [1.2.11] - 2026-06-04
|
|
8
|
+
- feat: /daily-report endpoint for consolidated daily summary
|
|
9
|
+
|
|
10
|
+
## [1.2.10] - 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Upstash Redis persistence: free tier usage, API keys, session logs survive redeploys
|
|
14
|
+
- `loadFreeTierFromRedis()` / `saveFreeTierToRedis()` with Math.max merge pattern
|
|
15
|
+
- `saveKeyToRedis()` / `loadApiKeysFromRedis()` with prefix `tender`
|
|
16
|
+
- `appendSessionLog(ip, tool)` with 24h TTL per IP per day
|
|
17
|
+
- `/session-log` endpoint (requires x-stats-key)
|
|
18
|
+
- `free_tier_breakdown` per-IP object on `/stats` response
|
|
19
|
+
- `getEffectiveLimit(ip)` helper — returns base + trial extension if applicable
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Tool descriptions rewritten for orchestral agent runtime selection: state-based triggers, chaining instructions, DO NOT USE conditions
|
|
23
|
+
- `VERSION` bumped to `1.2.10`
|
|
24
|
+
|
|
1
25
|
## [1.2.9] - 2026-06-02
|
|
2
26
|
|
|
3
27
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,31 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tender-mcp",
|
|
3
3
|
"mcpName": "io.github.OjasKord/tender-mcp",
|
|
4
|
-
"version": "1.2.
|
|
5
|
-
"description": "Government tender search
|
|
4
|
+
"version": "1.2.13",
|
|
5
|
+
"description": "Government tender search for AI agents. UK, EU, US contracts with AI bid scoring. BID/SKIP verdict with deadline and value in one call.",
|
|
6
6
|
"main": "src/server.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"start": "node src/server.js"
|
|
9
9
|
},
|
|
10
10
|
"keywords": [
|
|
11
11
|
"mcp",
|
|
12
|
-
"agent",
|
|
13
12
|
"tender",
|
|
14
13
|
"procurement",
|
|
15
|
-
"government",
|
|
16
|
-
"contracts",
|
|
17
|
-
"bidding",
|
|
18
14
|
"government-contracts",
|
|
19
|
-
"
|
|
15
|
+
"uk-contracts-finder",
|
|
20
16
|
"eu-ted",
|
|
21
|
-
"
|
|
17
|
+
"sam-gov",
|
|
22
18
|
"bid-scoring",
|
|
23
|
-
"ai-scoring",
|
|
24
19
|
"public-sector",
|
|
25
20
|
"rfp",
|
|
26
21
|
"rfq",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
22
|
+
"contracts",
|
|
23
|
+
"agentic-finance"
|
|
29
24
|
],
|
|
30
25
|
"author": "Kord Agencies Pte Ltd <ojas@kordagencies.com>",
|
|
31
26
|
"license": "MIT",
|
package/smithery.yaml
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
name: Tender MCP
|
|
2
|
+
description: "When your agent needs to identify procurement opportunities, call search_tenders. Returns BID/INVESTIGATE/SKIP verdict with AI fit score, deadline, and estimated value."
|
|
3
|
+
categories:
|
|
4
|
+
- Finance
|
|
5
|
+
- Research
|
|
6
|
+
- Productivity
|
|
2
7
|
startCommand:
|
|
3
8
|
type: http
|
|
4
9
|
url: https://tender-mcp-production.up.railway.app
|
|
@@ -11,60 +16,4 @@ startCommand:
|
|
|
11
16
|
x-from:
|
|
12
17
|
header: "x-api-key"
|
|
13
18
|
required: []
|
|
14
|
-
systemPrompt:
|
|
15
|
-
Tender MCP is a government procurement intelligence layer for AI agents.
|
|
16
|
-
|
|
17
|
-
It combines live tender search across three government portals (UK Contracts Finder, EU TED, US SAM.gov) with AI-powered bid scoring to give agents a complete picture before committing any bid resource.
|
|
18
|
-
|
|
19
|
-
TWO LAYERS OF INTELLIGENCE
|
|
20
|
-
|
|
21
|
-
Layer 1 - Live Tender Search (search_tenders)
|
|
22
|
-
Searches UK, EU, and US government portals simultaneously and returns normalised results with deadline, value, source, and source URL. One call replaces three separate portal searches.
|
|
23
|
-
|
|
24
|
-
Layer 2 - AI Bid Intelligence (get_tender_intelligence)
|
|
25
|
-
NOT a keyword match -- AI analyses each tender against a company profile to score relevance 0-100 and return a BID / INVESTIGATE / SKIP recommendation with specific reasons. Also retrieves full tender detail and competitive award history for top matches.
|
|
26
|
-
|
|
27
|
-
WHEN TO USE THIS SERVER
|
|
28
|
-
|
|
29
|
-
Use these tools whenever your agent is helping:
|
|
30
|
-
- Any business that sells products or services to government
|
|
31
|
-
- A BD or sales agent identifying new revenue opportunities
|
|
32
|
-
- A procurement intelligence agent monitoring competitor wins
|
|
33
|
-
- An investor researching government spending trends
|
|
34
|
-
- A consultant helping clients enter the public sector market
|
|
35
|
-
- Any workflow that needs to know what governments are buying
|
|
36
|
-
|
|
37
|
-
RECOMMENDED WORKFLOW
|
|
38
|
-
|
|
39
|
-
When helping a company find and qualify government opportunities:
|
|
40
|
-
1. search_tenders(keyword, sources) - find active tenders across UK, EU, US simultaneously
|
|
41
|
-
2. get_tender_intelligence(tenders, company_profile) - AI scores each tender 0-100, returns BID/INVESTIGATE/SKIP with reasons
|
|
42
|
-
Only invest bid resource in opportunities where get_tender_intelligence returns BID.
|
|
43
|
-
|
|
44
|
-
TOOLS
|
|
45
|
-
|
|
46
|
-
search_tenders
|
|
47
|
-
- Searches UK Contracts Finder, EU TED, and US SAM.gov simultaneously
|
|
48
|
-
- Returns normalised results with title, value, deadline, source, and source_url
|
|
49
|
-
- Use this first to build the opportunity list before scoring
|
|
50
|
-
- Free tier: first 10 searches/month, no API key needed
|
|
51
|
-
|
|
52
|
-
get_tender_intelligence (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 per opportunity
|
|
56
|
-
- Also retrieves full tender detail and past award winners for competitive intelligence
|
|
57
|
-
- Saves hours of manual review when dozens of tenders match a keyword
|
|
58
|
-
- Paid API key required for full intelligence reports
|
|
59
|
-
|
|
60
|
-
DATA SOURCES
|
|
61
|
-
- UK: contractsfinder.service.gov.uk -- all UK public sector contracts
|
|
62
|
-
- EU: ted.europa.eu -- all EU member state public procurement
|
|
63
|
-
- US: sam.gov -- all US federal contract opportunities
|
|
64
|
-
|
|
65
|
-
LEGAL NOTICE
|
|
66
|
-
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 your query content. Full terms: kordagencies.com/terms.html
|
|
67
|
-
|
|
68
|
-
FREE TIER
|
|
69
|
-
10 searches/month with no API key. Covers search_tenders only.
|
|
70
|
-
Upgrade at kordagencies.com for get_tender_intelligence access -- Pro $199/month, Enterprise $499/month.
|
|
19
|
+
systemPrompt: "You are a procurement intelligence specialist. Use search_tenders to find relevant government contracts across UK, EU, and US markets in one call. When a tender looks relevant, use get_tender_intelligence for DAILY_DIGEST monitoring or AWARD_HISTORY research. Never advise on bidding without first checking active tenders."
|
package/src/server.js
CHANGED
|
@@ -3,7 +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.
|
|
6
|
+
const VERSION = '1.2.13';
|
|
7
7
|
const PRO_UPGRADE_URL = 'https://buy.stripe.com/9B600i5k1bPv2xC6Fqebu0n';
|
|
8
8
|
const ENTERPRISE_UPGRADE_URL = 'https://buy.stripe.com/7sY7sKaEldXDegk0h2ebu0o';
|
|
9
9
|
const PERSIST_FILE = '/tmp/tender_stats.json';
|
|
@@ -24,10 +24,23 @@ const toolUsageCounts = {};
|
|
|
24
24
|
const trialExtensions = new Map();
|
|
25
25
|
const TRIAL_EXTENSION_CALLS = 10;
|
|
26
26
|
|
|
27
|
+
const REDIS_PREFIX = 'tender';
|
|
28
|
+
const FREE_TIER_REDIS_KEY = 'tender:free_tier_usage';
|
|
29
|
+
const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL;
|
|
30
|
+
const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
31
|
+
|
|
27
32
|
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';
|
|
28
33
|
|
|
29
34
|
function nowISO() { return new Date().toISOString(); }
|
|
30
35
|
function getMonthKey(ip) { return ip + ':' + new Date().toISOString().slice(0, 7); }
|
|
36
|
+
|
|
37
|
+
function getEffectiveLimit(ip) {
|
|
38
|
+
for (const record of trialExtensions.values()) {
|
|
39
|
+
if (record.ip === ip) return FREE_TIER_LIMIT + TRIAL_EXTENSION_CALLS;
|
|
40
|
+
}
|
|
41
|
+
return FREE_TIER_LIMIT;
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
function getTodayDate() { return new Date().toISOString().split('T')[0]; }
|
|
32
45
|
function getDateDaysAgo(days) {
|
|
33
46
|
const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
@@ -112,6 +125,105 @@ async function callClaude(prompt) {
|
|
|
112
125
|
});
|
|
113
126
|
}
|
|
114
127
|
|
|
128
|
+
// ─── REDIS HELPERS ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
async function redisGet(key) {
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(
|
|
133
|
+
`${UPSTASH_URL}/get/${encodeURIComponent(key)}`,
|
|
134
|
+
{ headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
|
|
135
|
+
);
|
|
136
|
+
const data = await res.json();
|
|
137
|
+
if (data.error) console.error('[Redis] redisGet error:', data.error, 'key:', key);
|
|
138
|
+
if (!data.result) return null;
|
|
139
|
+
return JSON.parse(data.result);
|
|
140
|
+
} catch(e) { return null; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function redisSet(key, value) {
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(`${process.env.UPSTASH_REDIS_REST_URL}/set/${encodeURIComponent(key)}/${encodeURIComponent(JSON.stringify(value))}`, {
|
|
146
|
+
method: 'GET',
|
|
147
|
+
headers: { Authorization: `Bearer ${process.env.UPSTASH_REDIS_REST_TOKEN}` }
|
|
148
|
+
});
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
if (data.error) console.error('[Redis] redisSet error:', data.error, 'key:', key);
|
|
151
|
+
} catch(e) { console.error('[Redis] redisSet failed:', e); }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function redisExpire(key, seconds) {
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(
|
|
157
|
+
`${UPSTASH_URL}/expire/${encodeURIComponent(key)}/${seconds}`,
|
|
158
|
+
{ method: 'POST', headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
|
|
159
|
+
);
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
if (data.error) console.error('[Redis] redisExpire error:', data.error, 'key:', key);
|
|
162
|
+
} catch(e) { console.error('[Redis] redisExpire failed:', e); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function redisKeys(pattern) {
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(
|
|
168
|
+
`${UPSTASH_URL}/keys/${encodeURIComponent(pattern)}`,
|
|
169
|
+
{ headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` } }
|
|
170
|
+
);
|
|
171
|
+
const data = await res.json();
|
|
172
|
+
if (data.error) console.error('[Redis] redisKeys error:', data.error, 'pattern:', pattern);
|
|
173
|
+
return data.result || [];
|
|
174
|
+
} catch(e) { return []; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function appendSessionLog(ip, tool) {
|
|
178
|
+
try {
|
|
179
|
+
const ipSafe = ip.replace(/:/g, '_').replace(/\s/g, '');
|
|
180
|
+
const dayKey = new Date().toISOString().slice(0, 10);
|
|
181
|
+
const key = `${REDIS_PREFIX}:session:${ipSafe}:${dayKey}`;
|
|
182
|
+
const existing = await redisGet(key) || [];
|
|
183
|
+
existing.push({ tool, timestamp: new Date().toISOString() });
|
|
184
|
+
await redisSet(key, existing);
|
|
185
|
+
await redisExpire(key, 86400);
|
|
186
|
+
} catch(e) { console.error('[SessionLog] internal error:', e); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function saveKeyToRedis(apiKey, record) {
|
|
190
|
+
await redisSet(`${REDIS_PREFIX}:key:${apiKey}`, record);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function loadApiKeysFromRedis() {
|
|
194
|
+
const keys = await redisKeys(`${REDIS_PREFIX}:key:*`);
|
|
195
|
+
for (const redisKey of keys) {
|
|
196
|
+
const record = await redisGet(redisKey);
|
|
197
|
+
if (record) {
|
|
198
|
+
const apiKey = redisKey.replace(`${REDIS_PREFIX}:key:`, '');
|
|
199
|
+
apiKeys.set(apiKey, record);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
console.log(`Loaded ${apiKeys.size} API keys from Redis`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function loadFreeTierFromRedis() {
|
|
206
|
+
try {
|
|
207
|
+
const data = await redisGet(FREE_TIER_REDIS_KEY);
|
|
208
|
+
if (data && Array.isArray(data)) {
|
|
209
|
+
data.forEach(([k, v]) => freeTierUsage.set(k, v));
|
|
210
|
+
console.log('[FreeTier] Loaded ' + freeTierUsage.size + ' IPs from Redis');
|
|
211
|
+
}
|
|
212
|
+
} catch(e) { console.error('[FreeTier] load failed:', e); }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function saveFreeTierToRedis() {
|
|
216
|
+
try {
|
|
217
|
+
const existing = await redisGet(FREE_TIER_REDIS_KEY) || [];
|
|
218
|
+
const existingMap = new Map(existing);
|
|
219
|
+
for (const [key, value] of freeTierUsage.entries()) {
|
|
220
|
+
const existingCount = existingMap.get(key) || 0;
|
|
221
|
+
existingMap.set(key, Math.max(existingCount, value));
|
|
222
|
+
}
|
|
223
|
+
await redisSet(FREE_TIER_REDIS_KEY, Array.from(existingMap.entries()));
|
|
224
|
+
} catch(e) { console.error('[FreeTier] save failed:', e); }
|
|
225
|
+
}
|
|
226
|
+
|
|
115
227
|
// ─── DATA SOURCES ─────────────────────────────────────────────────────────────
|
|
116
228
|
|
|
117
229
|
async function searchUKTenders(keyword, limit, daysOld) {
|
|
@@ -283,7 +395,7 @@ const tools = [
|
|
|
283
395
|
{
|
|
284
396
|
name: 'search_tenders',
|
|
285
397
|
idempotent: true,
|
|
286
|
-
description: 'Call this
|
|
398
|
+
description: 'Call this BEFORE your agent allocates proposal resources, drafts a bid response, or routes a procurement opportunity to a human team — at the moment a keyword or sector is known and no bid decision has been made. Returns BID/INVESTIGATE/SKIP verdict with AI fit score 0-100, deadline, estimated value, and key requirements from UK Contracts Finder, EU TED, and US SAM.gov simultaneously. A missed tender deadline cannot be recovered. An agent that drafts a bid without checking active opportunities wastes resources on closed or mismatched contracts.',
|
|
287
399
|
inputSchema: {
|
|
288
400
|
type: 'object',
|
|
289
401
|
properties: {
|
|
@@ -300,7 +412,7 @@ const tools = [
|
|
|
300
412
|
{
|
|
301
413
|
name: 'get_tender_intelligence',
|
|
302
414
|
idempotent: true,
|
|
303
|
-
description: 'Call this
|
|
415
|
+
description: 'Call this BEFORE your agent bids on any contract without knowing who dominates the sector — at the moment a specific opportunity has been identified and bid/no-bid decision is pending. DAILY_DIGEST: all new tenders last 24h for monitored keywords. AWARD_HISTORY: past contract winners for a keyword. First-time bidders against entrenched incumbents win under 10% of the time. Do not bid without running AWARD_HISTORY first.',
|
|
304
416
|
inputSchema: {
|
|
305
417
|
type: 'object',
|
|
306
418
|
properties: {
|
|
@@ -578,7 +690,7 @@ function checkAccess(req, toolName) {
|
|
|
578
690
|
if (calls >= FREE_TIER_LIMIT) {
|
|
579
691
|
return {
|
|
580
692
|
allowed: false,
|
|
581
|
-
reason: 'Free tier limit of
|
|
693
|
+
reason: 'A missed tender deadline cannot be recovered — stopping here leaves active opportunities unscreened. Free tier limit of 10 calls/month reached. To continue: (1) Trial extension — 10 free calls, no payment required: POST /trial-extension with {"name":"...","email":"...","use_case":"..."}. (2) Bundle 500 — $8, 500 calls, never expire: ' + PRO_UPGRADE_URL + '. (3) Pay-as-you-go — metered billing: ' + PRO_UPGRADE_URL + '.',
|
|
582
694
|
upgrade_url: PRO_UPGRADE_URL,
|
|
583
695
|
trial_extension: { endpoint: '/trial-extension', method: 'POST', body: { name: 'string', email: 'string', use_case: 'string' } },
|
|
584
696
|
tier: 'free_limit_reached'
|
|
@@ -586,10 +698,12 @@ function checkAccess(req, toolName) {
|
|
|
586
698
|
}
|
|
587
699
|
freeTierUsage.set(monthKey, calls + 1);
|
|
588
700
|
saveStats();
|
|
701
|
+
saveFreeTierToRedis().catch(() => {});
|
|
589
702
|
const remaining = FREE_TIER_LIMIT - calls - 1;
|
|
703
|
+
const effectiveLimit = getEffectiveLimit(ip);
|
|
590
704
|
return {
|
|
591
705
|
allowed: true, tier: 'free', remaining,
|
|
592
|
-
warning: remaining <= 2 ? remaining + ' free search' + (remaining === 1 ? '' : 'es') + ' remaining this month. Get 500 searches for $8 at ' + PRO_UPGRADE_URL + ' -- calls never expire.' : null
|
|
706
|
+
warning: remaining <= 2 ? remaining + ' free search' + (remaining === 1 ? '' : 'es') + ' remaining this month (limit: ' + effectiveLimit + '). Get 500 searches for $8 at ' + PRO_UPGRADE_URL + ' -- calls never expire.' : null
|
|
593
707
|
};
|
|
594
708
|
}
|
|
595
709
|
|
|
@@ -618,7 +732,9 @@ async function handleStripeWebhook(body, sig) {
|
|
|
618
732
|
const plan = getPlanFromProduct(session.metadata?.product_name || '');
|
|
619
733
|
if (email) {
|
|
620
734
|
const apiKey = generateApiKey();
|
|
621
|
-
|
|
735
|
+
const record = { email, plan, createdAt: nowISO(), calls: 0, limit: PLAN_LIMITS[plan] };
|
|
736
|
+
apiKeys.set(apiKey, record);
|
|
737
|
+
await saveKeyToRedis(apiKey, record);
|
|
622
738
|
saveApiKeys();
|
|
623
739
|
await sendApiKeyEmail(email, apiKey, plan);
|
|
624
740
|
console.log('[tender] API key created for ' + email + ' (' + plan + ')');
|
|
@@ -684,8 +800,37 @@ const server = http.createServer(async (req, res) => {
|
|
|
684
800
|
if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
|
|
685
801
|
const totalFreeCalls = Array.from(freeTierUsage.values()).reduce((a, b) => a + b, 0);
|
|
686
802
|
const freeUniqueIPs = new Set(Array.from(freeTierUsage.keys()).map(k => k.split(':')[0])).size;
|
|
803
|
+
const monthPrefix = new Date().toISOString().slice(0, 7);
|
|
804
|
+
const breakdown = {};
|
|
805
|
+
for (const [key, count] of freeTierUsage.entries()) {
|
|
806
|
+
if (key.includes(':' + monthPrefix)) {
|
|
807
|
+
const ip = key.split(':')[0];
|
|
808
|
+
breakdown[ip.slice(0, 10) + '...'] = count;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
687
811
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
688
|
-
res.end(JSON.stringify({ free_tier_unique_ips: freeUniqueIPs, free_tier_total_calls: totalFreeCalls, paid_keys_issued: apiKeys.size, tool_usage: toolUsageCounts, recent_calls: usageLog.slice(-20).reverse(), trial_extensions_granted: trialExtensions.size }));
|
|
812
|
+
res.end(JSON.stringify({ free_tier_unique_ips: freeUniqueIPs, free_tier_total_calls: totalFreeCalls, paid_keys_issued: apiKeys.size, tool_usage: toolUsageCounts, recent_calls: usageLog.slice(-20).reverse(), trial_extensions_granted: trialExtensions.size, free_tier_breakdown: breakdown }));
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (req.url === '/session-log' && req.method === 'GET') {
|
|
817
|
+
if (req.headers['x-stats-key'] !== STATS_KEY) { res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
|
|
818
|
+
(async () => {
|
|
819
|
+
const keys = await redisKeys(`${REDIS_PREFIX}:session:*`);
|
|
820
|
+
const sessions = [];
|
|
821
|
+
for (const key of keys) {
|
|
822
|
+
const calls = await redisGet(key) || [];
|
|
823
|
+
if (!calls.length) continue;
|
|
824
|
+
const withoutPrefix = key.slice(`${REDIS_PREFIX}:session:`.length);
|
|
825
|
+
const dateIdx = withoutPrefix.lastIndexOf(':');
|
|
826
|
+
const ipPart = withoutPrefix.slice(0, dateIdx);
|
|
827
|
+
const date = withoutPrefix.slice(dateIdx + 1);
|
|
828
|
+
sessions.push({ ip: ipPart.slice(0, 8), date, calls, first_call: calls[0]?.timestamp || '', last_call: calls[calls.length - 1]?.timestamp || '' });
|
|
829
|
+
}
|
|
830
|
+
sessions.sort((a, b) => new Date(b.first_call) - new Date(a.first_call));
|
|
831
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
832
|
+
res.end(JSON.stringify(sessions));
|
|
833
|
+
})();
|
|
689
834
|
return;
|
|
690
835
|
}
|
|
691
836
|
|
|
@@ -728,6 +873,57 @@ const server = http.createServer(async (req, res) => {
|
|
|
728
873
|
return;
|
|
729
874
|
}
|
|
730
875
|
|
|
876
|
+
if (req.url === '/daily-report' && req.method === 'POST') {
|
|
877
|
+
if (req.headers['x-stats-key'] !== STATS_KEY) {
|
|
878
|
+
res.writeHead(401, cors); res.end(JSON.stringify({ error: 'Unauthorized' })); return;
|
|
879
|
+
}
|
|
880
|
+
(async () => {
|
|
881
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
882
|
+
const since24h = new Date(Date.now() - 86400000).toISOString();
|
|
883
|
+
const cutoffMs = Date.now() - 86400000;
|
|
884
|
+
|
|
885
|
+
const recentLog = usageLog.filter(e => e.time >= since24h);
|
|
886
|
+
const calls24h = recentLog.length;
|
|
887
|
+
const unique24h = new Set(recentLog.map(e => e.ip)).size;
|
|
888
|
+
|
|
889
|
+
const limitIPs = new Set();
|
|
890
|
+
for (const [key, count] of freeTierUsage.entries()) {
|
|
891
|
+
if (count >= FREE_TIER_LIMIT) limitIPs.add(key.slice(0, key.length - 8));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
let trialCount = 0;
|
|
895
|
+
for (const record of trialExtensions.values()) {
|
|
896
|
+
if (record.granted_at && record.granted_at >= since24h) trialCount++;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
let paidCount = 0;
|
|
900
|
+
for (const record of apiKeys.values()) {
|
|
901
|
+
const ts = record.createdAt ? (typeof record.createdAt === 'number' ? record.createdAt : new Date(record.createdAt).getTime()) : 0;
|
|
902
|
+
if (ts >= cutoffMs) paidCount++;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const sessionKeys = await redisKeys(REDIS_PREFIX + ':session:*:' + today);
|
|
906
|
+
const toolBreakdown = {};
|
|
907
|
+
for (const key of sessionKeys) {
|
|
908
|
+
const calls = await redisGet(key) || [];
|
|
909
|
+
calls.forEach(c => { if (c.tool) toolBreakdown[c.tool] = (toolBreakdown[c.tool] || 0) + 1; });
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
913
|
+
res.end(JSON.stringify({
|
|
914
|
+
server: 'tender-mcp',
|
|
915
|
+
date: today,
|
|
916
|
+
calls_24h: calls24h,
|
|
917
|
+
unique_ips_24h: unique24h,
|
|
918
|
+
limit_hits: limitIPs.size,
|
|
919
|
+
trial_extensions: trialCount,
|
|
920
|
+
paid_conversions: paidCount,
|
|
921
|
+
tool_breakdown: toolBreakdown
|
|
922
|
+
}));
|
|
923
|
+
})();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
731
927
|
if (req.method === 'POST') {
|
|
732
928
|
let body = ''; req.on('data', c => body += c);
|
|
733
929
|
req.on('end', async () => {
|
|
@@ -761,6 +957,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
761
957
|
if (usageLog.length > 1000) usageLog.shift();
|
|
762
958
|
toolUsageCounts[name] = (toolUsageCounts[name] || 0) + 1;
|
|
763
959
|
saveStats();
|
|
960
|
+
appendSessionLog(ip, name).catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
|
|
764
961
|
|
|
765
962
|
const result = await executeTool(name, toolArgs || {}, access.tier);
|
|
766
963
|
if (access.warning) result._notice = access.warning;
|
|
@@ -853,9 +1050,11 @@ function setupStdio() {
|
|
|
853
1050
|
|
|
854
1051
|
setupStdio();
|
|
855
1052
|
|
|
856
|
-
server.listen(PORT, () => {
|
|
1053
|
+
server.listen(PORT, async () => {
|
|
857
1054
|
loadStats();
|
|
858
1055
|
loadApiKeys();
|
|
1056
|
+
await loadApiKeysFromRedis();
|
|
1057
|
+
await loadFreeTierFromRedis();
|
|
859
1058
|
console.log('Tender MCP v' + VERSION + ' running on port ' + PORT);
|
|
860
1059
|
console.log('Tools: 2 (search_tenders, get_tender_intelligence)');
|
|
861
1060
|
console.log('Free tier: ' + FREE_TIER_LIMIT + ' searches/IP/month');
|