tender-mcp 1.2.9 → 1.2.10

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 CHANGED
@@ -1,3 +1,18 @@
1
+ ## [1.2.10] - 2026-06-04
2
+
3
+ ### Added
4
+ - Upstash Redis persistence: free tier usage, API keys, session logs survive redeploys
5
+ - `loadFreeTierFromRedis()` / `saveFreeTierToRedis()` with Math.max merge pattern
6
+ - `saveKeyToRedis()` / `loadApiKeysFromRedis()` with prefix `tender`
7
+ - `appendSessionLog(ip, tool)` with 24h TTL per IP per day
8
+ - `/session-log` endpoint (requires x-stats-key)
9
+ - `free_tier_breakdown` per-IP object on `/stats` response
10
+ - `getEffectiveLimit(ip)` helper — returns base + trial extension if applicable
11
+
12
+ ### Changed
13
+ - Tool descriptions rewritten for orchestral agent runtime selection: state-based triggers, chaining instructions, DO NOT USE conditions
14
+ - `VERSION` bumped to `1.2.10`
15
+
1
16
  ## [1.2.9] - 2026-06-02
2
17
 
3
18
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tender-mcp",
3
3
  "mcpName": "io.github.OjasKord/tender-mcp",
4
- "version": "1.2.9",
4
+ "version": "1.2.10",
5
5
  "description": "Government tender search and AI opportunity scoring for AI agents. UK Contracts Finder, EU TED, US SAM.gov.",
6
6
  "main": "src/server.js",
7
7
  "scripts": {
package/src/server.js CHANGED
@@ -3,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.9';
6
+ const VERSION = '1.2.10';
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 tool BEFORE your agent contacts any potential client, partner, or supplier in a market where procurement contracts are active. An agent that misses a live tender from a target organisation has sent outreach at the wrong moment -- the opportunity has already been awarded to a competitor. The only MCP that queries UK Contracts Finder (contractsfinder.service.gov.uk), EU TED (api.ted.europa.eu/v3), and US SAM.gov (api.sam.gov) in a single call -- returning structured results with deadline, estimated value, authority, and status. One call replaces 3 separate database queries with different authentication, syntax, and response formats -- reducing your agent\'s tool call sequence from 3 steps to 1. Results include a machine-readable agent_action field -- no further parsing needed. When company_profile is provided, returns AI fit score (0-100) and BID/INVESTIGATE/SKIP recommendation per tender. We do not log your query content. Free tier: 10 calls/month, no API key required.',
398
+ description: 'Call this at the moment your workflow needs to identify procurement opportunities in one or more markets. Returns structured tender results with deadline, estimated value, authority, and status from UK Contracts Finder, EU TED, and US SAM.gov in one call. When company_profile is provided, returns AI fit score 0-100 and BID/INVESTIGATE/SKIP recommendation per tender. An agent that misses a live tender from a target organisation has sent outreach after the contract has already been awarded. Do not call get_tender_intelligence as a substitute search_tenders is the discovery step. Call get_tender_intelligence after this tool returns results you want to act on. We do not log your query content. Free tier: 10 calls/month, no API key required.',
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 tool IMMEDIATELY AFTER search_tenders returns a matching opportunity -- before your agent allocates resource, drafts a response, or routes the tender to a human team. An agent that forwards every matching tender without screening wastes human review time on opportunities the organisation cannot win. Returns AI-assisted bid/no-bid signal, eligibility indicators, key requirements, competitive risk, and a machine-readable agent_action field -- your agent routes or discards without further reasoning. We do not log your query content. Free tier returns a preview count. Full results require Pro API key from kordagencies.com.',
415
+ description: 'Call this standalone to get structured tender intelligence without running a search. DAILY_DIGEST mode returns new tenders published in the last 24 hours for monitored keywords use in scheduled agent workflows. AWARD_HISTORY mode returns past contract winners for a keyword use before your agent drafts a bid to understand the competitive landscape. Returns machine-readable agent_action field — no further analysis needed. Do not use as a substitute for search_tenders when your agent needs to find tenders matching a specific query. We do not log your query content. Free tier returns a preview count. Full results require Pro API key from kordagencies.com.',
304
416
  inputSchema: {
305
417
  type: 'object',
306
418
  properties: {
@@ -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
- apiKeys.set(apiKey, { email, plan, createdAt: nowISO(), calls: 0, limit: PLAN_LIMITS[plan] });
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
 
@@ -761,6 +906,7 @@ const server = http.createServer(async (req, res) => {
761
906
  if (usageLog.length > 1000) usageLog.shift();
762
907
  toolUsageCounts[name] = (toolUsageCounts[name] || 0) + 1;
763
908
  saveStats();
909
+ appendSessionLog(ip, name).catch((e) => console.error('[SessionLog] appendSessionLog failed:', e));
764
910
 
765
911
  const result = await executeTool(name, toolArgs || {}, access.tier);
766
912
  if (access.warning) result._notice = access.warning;
@@ -853,9 +999,11 @@ function setupStdio() {
853
999
 
854
1000
  setupStdio();
855
1001
 
856
- server.listen(PORT, () => {
1002
+ server.listen(PORT, async () => {
857
1003
  loadStats();
858
1004
  loadApiKeys();
1005
+ await loadApiKeysFromRedis();
1006
+ await loadFreeTierFromRedis();
859
1007
  console.log('Tender MCP v' + VERSION + ' running on port ' + PORT);
860
1008
  console.log('Tools: 2 (search_tenders, get_tender_intelligence)');
861
1009
  console.log('Free tier: ' + FREE_TIER_LIMIT + ' searches/IP/month');