From a3dabb2c9f8371247d811af4ee8d3f696ab1e182 Mon Sep 17 00:00:00 2001 From: Marc Shade Date: Tue, 24 Feb 2026 07:29:31 -0500 Subject: [PATCH] feat: modal detail system, 80+ RSS feeds, service status tool (#56) - All external links open in iframe modal instead of navigating away - 9 drawer item types (cyber, health, AI, social, elections, etc.) expand into rich detail panel on click - RSS feeds expanded from ~20 to ~80 across 14 categories with source tier reliability scoring - New intel_service_status tool monitoring AWS/Azure/GCP/CF/GitHub - Cloud service incidents rendered in dashboard Security section - Zero target=_blank links remain in dashboard Co-Authored-By: Claude Opus 4.6 --- src/world_intel_mcp/dashboard/app.py | 2 + src/world_intel_mcp/dashboard/index.html | 341 +++++++++++++++++- src/world_intel_mcp/server.py | 27 +- src/world_intel_mcp/sources/news.py | 112 ++++++ src/world_intel_mcp/sources/service_status.py | 205 +++++++++++ 5 files changed, 671 insertions(+), 16 deletions(-) create mode 100644 src/world_intel_mcp/sources/service_status.py diff --git a/src/world_intel_mcp/dashboard/app.py b/src/world_intel_mcp/dashboard/app.py index a6fc2e7..1f6491b 100644 --- a/src/world_intel_mcp/dashboard/app.py +++ b/src/world_intel_mcp/dashboard/app.py @@ -40,6 +40,7 @@ from world_intel_mcp.sources import ( shipping, social, nuclear, + service_status, ) from world_intel_mcp.analysis.alerts import fetch_alert_digest, fetch_weekly_trends from world_intel_mcp.config.countries import INTEL_HOTSPOTS @@ -105,6 +106,7 @@ async def _fetch_overview() -> dict: "nuclear_monitor": nuclear.fetch_nuclear_monitor(fetcher), "alert_digest": fetch_alert_digest(fetcher), "weekly_trends": fetch_weekly_trends(fetcher), + "service_status": service_status.fetch_service_status(fetcher), } gathered = await asyncio.gather( diff --git a/src/world_intel_mcp/dashboard/index.html b/src/world_intel_mcp/dashboard/index.html index a678801..5441223 100644 --- a/src/world_intel_mcp/dashboard/index.html +++ b/src/world_intel_mcp/dashboard/index.html @@ -256,6 +256,59 @@ a { color: var(--accent); text-decoration: none; } } .detail-link:hover { background: rgba(0,229,255,0.15); } +/* ═══════════════ LINK MODAL (iframe overlay) ═══════════════ */ +#linkModal { + position: fixed; inset: 0; z-index: 200; + display: none; align-items: center; justify-content: center; + background: rgba(0,0,0,0.7); backdrop-filter: blur(6px); +} +#linkModal.open { display: flex; } +.lm-box { + width: min(90vw, 900px); height: min(85vh, 700px); + background: var(--card); border: 1px solid var(--glow); + border-radius: var(--radius); display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); + overflow: hidden; +} +.lm-bar { + display: flex; align-items: center; gap: 10px; + padding: 10px 16px; border-bottom: 1px solid var(--border); + background: var(--panel); flex-shrink: 0; +} +.lm-title { + flex: 1; font-size: 0.78rem; font-weight: 600; color: var(--bright); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.lm-url { + font-family: var(--mono); font-size: 0.6rem; color: var(--dim); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 300px; +} +.lm-btn { + background: rgba(0,229,255,0.08); border: 1px solid rgba(0,229,255,0.15); + border-radius: 5px; color: var(--accent); font-size: 0.68rem; + padding: 4px 10px; cursor: pointer; white-space: nowrap; + transition: background 0.2s; +} +.lm-btn:hover { background: rgba(0,229,255,0.18); } +.lm-close { + background: none; border: none; color: var(--dim); font-size: 1.4rem; + cursor: pointer; padding: 0 4px; transition: color 0.2s; +} +.lm-close:hover { color: var(--bright); } +.lm-frame { flex: 1; border: none; background: #111; } +.lm-blocked { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 16px; + color: var(--dim); font-size: 0.8rem; text-align: center; padding: 24px; +} +.lm-blocked .lm-btn { font-size: 0.8rem; padding: 8px 20px; } + +/* ═══════════════ CLICKABLE DRAWER ROWS ═══════════════ */ +.dtable tr[data-click] { cursor: pointer; } +.dtable tr[data-click]:hover td { background: rgba(0,229,255,0.04) !important; } +.drawer-item { cursor: pointer; transition: background 0.15s; border-radius: 4px; padding: 4px 6px; margin: 0 -6px; } +.drawer-item:hover { background: rgba(0,229,255,0.04); } + /* ═══════════════ BOTTOM TICKER ═══════════════ */ #ticker { position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; @@ -480,6 +533,23 @@ a { color: var(--accent); text-decoration: none; }
+ +
+
+
+ + + + +
+ + +
+
+
INTEL
@@ -496,7 +566,7 @@ a { color: var(--accent); text-decoration: none; } var $ = function(s) { return document.querySelector(s); }; var $$ = function(s) { return document.querySelectorAll(s); }; -var safe = function(h) { return DOMPurify.sanitize(h, {ALLOWED_TAGS: ['tr','td','th','table','thead','tbody','a','span','div','br'], ALLOWED_ATTR: ['class','style','href','target']}); }; +var safe = function(h) { return DOMPurify.sanitize(h, {ALLOWED_TAGS: ['tr','td','th','table','thead','tbody','a','span','div','br'], ALLOWED_ATTR: ['class','style','href','data-click','data-external']}); }; function fmtNum(n, d) { d = d != null ? d : 2; return n == null ? '\u2014' : Number(n).toLocaleString(undefined, {minimumFractionDigits: d, maximumFractionDigits: d}); } function fmtPct(n) { return n == null ? '\u2014' : (n >= 0 ? '+' : '') + fmtNum(n); } @@ -647,7 +717,7 @@ function showDetail(type, d) { return '
' + esc(f[0]) + '' + esc(String(f[1])) + '
'; }).join(''); - if (link) body += 'View Source \u2192'; + if (link) body += 'View Source \u2192'; $('#detailHeader').innerHTML = safe(hdr); $('#detailBody').innerHTML = safe(body); @@ -656,7 +726,220 @@ function showDetail(type, d) { function closeDetail() { $('#detail').classList.remove('open'); } $('#detailClose').addEventListener('click', closeDetail); -document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeDetail(); }); + +// ════════════ LINK MODAL (iframe for external URLs) ════════════ +var _lmUrl = ''; +function showLinkModal(url, title) { + _lmUrl = url; + $('#lmTitle').textContent = title || 'External Content'; + $('#lmUrl').textContent = url; + $('#lmFrame').style.display = ''; + $('#lmBlocked').style.display = 'none'; + $('#lmFrame').src = url; + $('#linkModal').classList.add('open'); + // Detect iframe load failure (many sites block it) + var frame = $('#lmFrame'); + var timer = setTimeout(function() { + // If we get here without load, show fallback + try { var doc = frame.contentDocument; if (!doc || !doc.body || doc.body.innerHTML === '') { _showLmBlocked(); } } + catch(e) { _showLmBlocked(); } + }, 5000); + frame.onload = function() { clearTimeout(timer); }; + frame.onerror = function() { clearTimeout(timer); _showLmBlocked(); }; +} +function _showLmBlocked() { + $('#lmFrame').style.display = 'none'; + $('#lmBlocked').style.display = 'flex'; +} +function closeLinkModal() { + $('#linkModal').classList.remove('open'); + $('#lmFrame').src = 'about:blank'; + _lmUrl = ''; +} +$('#lmClose').addEventListener('click', closeLinkModal); +$('#lmOpenTab').addEventListener('click', function() { if (_lmUrl) window.open(_lmUrl, '_blank'); }); +$('#lmFallbackOpen').addEventListener('click', function() { if (_lmUrl) { window.open(_lmUrl, '_blank'); closeLinkModal(); } }); +$('#linkModal').addEventListener('click', function(e) { if (e.target === this) closeLinkModal(); }); + +// ════════════ DRAWER DETAIL TYPES ════════════ +// Extend showDetail to handle drawer item types +COLORS.news = 'var(--accent)'; LABELS.news = 'NEWS ARTICLE'; +COLORS.cyber = 'var(--red)'; LABELS.cyber = 'CYBER THREAT'; +COLORS.health = 'var(--pink)'; LABELS.health = 'HEALTH ALERT'; +COLORS.ai = 'var(--purple)'; LABELS.ai = 'AI / ML'; +COLORS.social = 'var(--teal)'; LABELS.social = 'SOCIAL SIGNAL'; +COLORS.prediction = 'var(--gold)'; LABELS.prediction = 'PREDICTION MARKET'; +COLORS.election = 'var(--amber)'; LABELS.election = 'ELECTION'; +COLORS.outage = 'var(--amber)'; LABELS.outage = 'INFRASTRUCTURE'; +COLORS.navwarning = 'var(--blue)'; LABELS.navwarning = 'NAV WARNING'; +COLORS.displacement = 'var(--pink)'; LABELS.displacement = 'DISPLACEMENT'; +COLORS.space = 'var(--gold)'; LABELS.space = 'SPACE WEATHER'; +COLORS.shipping = 'var(--teal)'; LABELS.shipping = 'SHIPPING'; +COLORS.service = 'var(--blue)'; LABELS.service = 'SERVICE STATUS'; + +var _drawerItems = {}; +var _drawerIdx = 0; +function _storeItem(type, data) { + var id = 'di_' + (++_drawerIdx); + _drawerItems[id] = {type: type, data: data}; + return id; +} + +function showDrawerDetail(id) { + var item = _drawerItems[id]; + if (!item) return; + var type = item.type, d = item.data; + var title = '', subtitle = '', fields = [], link = ''; + var c = COLORS[type] || 'var(--accent)'; + + if (type === 'news') { + title = d.title || 'Article'; + subtitle = d.feed_name || d.source || ''; + fields = [ + ['Source', d.feed_name || d.source || '\u2014'], + ['Published', d.published ? new Date(d.published).toLocaleString() : (d.pub_date || '\u2014')], + ['Category', d.category || d.feed_category || '\u2014'], + ['Source Tier', d.source_tier || '\u2014'] + ]; + if (d.summary || d.description) fields.push(['Summary', trunc(d.summary || d.description || '', 500)]); + link = d.link || d.url || ''; + } else if (type === 'cyber') { + title = d.indicator || d.ioc || d.url || 'Threat'; + subtitle = d.threat_type || d.type || ''; + fields = [ + ['Indicator', d.indicator || d.ioc || d.url || '\u2014'], + ['Type', d.threat_type || d.type || '\u2014'], + ['Severity', d.severity || '\u2014', (d.severity||'').toLowerCase() === 'critical' ? 'crit' : (d.severity||'').toLowerCase() === 'high' ? 'high' : ''], + ['Source', d.source || d.reporter || '\u2014'], + ['First Seen', d.first_seen || d.date || '\u2014'], + ['Tags', (d.tags || []).join(', ') || '\u2014'] + ]; + link = d.reference || d.reference_url || ''; + } else if (type === 'health') { + title = d.title || 'Health Alert'; + subtitle = d.organization || ''; + fields = [ + ['Organization', d.organization || '\u2014'], + ['Published', d.published ? new Date(d.published).toLocaleString() : '\u2014'], + ['High Concern', d.high_concern ? 'YES' : 'No', d.high_concern ? 'crit' : 'ok'], + ['Summary', trunc(d.summary || d.description || '', 500)] + ]; + link = d.link || d.url || ''; + } else if (type === 'ai') { + title = d.title || 'AI Paper'; + subtitle = d.feed_name || ''; + fields = [ + ['Source', d.feed_name || '\u2014'], + ['Published', d.published ? new Date(d.published).toLocaleString() : '\u2014'], + ['Category', d.category || '\u2014'], + ['Summary', trunc(d.summary || d.description || '', 500)] + ]; + link = d.link || d.url || ''; + } else if (type === 'social') { + title = d.title || 'Post'; + subtitle = 'r/' + (d.subreddit || '?') + ' \u2022 Score: ' + (d.score || 0); + fields = [ + ['Subreddit', 'r/' + (d.subreddit || '\u2014')], + ['Score', String(d.score || 0)], + ['Comments', String(d.num_comments || d.comments || 0)], + ['Author', d.author || '\u2014'], + ['Created', d.created ? new Date(d.created * 1000).toLocaleString() : '\u2014'] + ]; + if (d.selftext) fields.push(['Text', trunc(d.selftext, 500)]); + link = d.url || d.permalink || ''; + } else if (type === 'prediction') { + var prob = d.probability || d.yes_price; + title = d.question || d.title || 'Market'; + subtitle = prob != null ? 'Probability: ' + (typeof prob === 'number' ? (prob * 100).toFixed(0) + '%' : String(prob)) : ''; + fields = [ + ['Question', d.question || d.title || '\u2014'], + ['Probability', prob != null ? (typeof prob === 'number' ? (prob * 100).toFixed(1) + '%' : String(prob)) : '\u2014'], + ['Volume', d.volume ? fmtBig(d.volume) : '\u2014'], + ['Market', d.market_slug || d.platform || '\u2014'], + ['End Date', d.end_date || d.close_time || '\u2014'] + ]; + link = d.url || ''; + } else if (type === 'election') { + title = (d.country || '?') + ' \u2014 ' + (d.election_type || 'Election'); + subtitle = d.date || ''; + fields = [ + ['Country', d.country || '\u2014'], + ['Type', d.election_type || '\u2014'], + ['Date', d.date || '\u2014'], + ['Days Until', d.days_until != null ? String(d.days_until) : '\u2014'], + ['Risk Score', d.risk_score != null ? String(d.risk_score) : '\u2014', (d.risk_score||0) >= 80 ? 'crit' : (d.risk_score||0) >= 50 ? 'high' : ''], + ['Impact', d.instability_impact || '\u2014'], + ['Description', d.description || '\u2014'] + ]; + } else if (type === 'outage') { + title = d.entity_name || d.country || d.location || 'Outage'; + subtitle = d.entity_type || ''; + fields = [ + ['Entity', d.entity_name || '\u2014'], + ['Type', d.entity_type || '\u2014'], + ['Country', d.country || '\u2014'], + ['Score', d.overall_score != null ? String(d.overall_score) : '\u2014'], + ['Start', d.from || d.start || '\u2014'], + ['End', d.until || d.end || '\u2014'] + ]; + } else if (type === 'navwarning') { + title = (d.navArea || d.area || 'Nav Warning'); + subtitle = ''; + fields = [ + ['Area', d.navArea || d.area || '\u2014'], + ['Number', d.number || d.id || '\u2014'], + ['Issued', d.dtg || d.date || '\u2014'], + ['Text', d.text || d.description || '\u2014'] + ]; + } else if (type === 'service') { + title = (d.provider || '?') + ' \u2014 ' + (d.title || 'Incident'); + subtitle = d.severity || ''; + fields = [ + ['Provider', d.provider || '\u2014'], + ['Title', d.title || '\u2014'], + ['Severity', d.severity || '\u2014', d.severity === 'critical' ? 'crit' : d.severity === 'high' ? 'high' : ''], + ['Published', d.published ? new Date(d.published).toLocaleString() : '\u2014'], + ['Summary', trunc(d.summary || '', 500)] + ]; + link = d.link || ''; + } else { + title = d.title || d.name || JSON.stringify(d).slice(0, 80); + fields = Object.keys(d).slice(0, 12).map(function(k) { return [k, trunc(String(d[k] || ''), 120)]; }); + } + + var hdr = '
' + esc(LABELS[type] || type.toUpperCase()) + '
' + + '
' + esc(title) + '
' + + (subtitle ? '
' + esc(subtitle) + '
' : ''); + + var body = fields.map(function(f) { + return '
' + esc(f[0]) + '' + esc(String(f[1])) + '
'; + }).join(''); + + if (link) body += 'View Source \u2192'; + + $('#detailHeader').innerHTML = safe(hdr); + $('#detailBody').innerHTML = safe(body); + $('#detail').classList.add('open'); +} + +// ════════════ GLOBAL LINK INTERCEPTOR ════════════ +document.addEventListener('click', function(e) { + var a = e.target.closest('a[href]'); + if (!a) return; + var href = a.getAttribute('href'); + if (!href || href === '#' || href.startsWith('javascript:')) return; + // External link detection + if (href.startsWith('http://') || href.startsWith('https://') || a.hasAttribute('data-external')) { + e.preventDefault(); + e.stopPropagation(); + var title = a.textContent || a.title || href; + showLinkModal(href, title); + } +}); + +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { closeLinkModal(); closeDetail(); } +}); // ════════════ MAP MARKER MANAGEMENT ════════════ @@ -864,6 +1147,8 @@ function updateHudStats(data) { // ════════════ DATA DRAWER ════════════ function updateDrawer(data) { + _drawerItems = {}; + _drawerIdx = 0; var h = ''; // ── ALERTS ── @@ -990,7 +1275,8 @@ function updateDrawer(data) { threats.slice(0, 15).forEach(function(t) { var sev = (t.severity || '').toLowerCase(); var sc = sev === 'critical' ? 'down' : sev === 'high' ? 'warn' : 'dim'; - h += '' + esc(trunc(t.indicator || t.url || t.ioc || '?', 30)) + '' + esc(t.threat_type || t.type || '\u2014') + '' + esc(t.severity || '\u2014') + ''; + var did = _storeItem('cyber', t); + h += '' + esc(trunc(t.indicator || t.url || t.ioc || '?', 30)) + '' + esc(t.threat_type || t.type || '\u2014') + '' + esc(t.severity || '\u2014') + ''; }); h += ''; } @@ -1015,12 +1301,26 @@ function updateDrawer(data) { h += ''; } } + if (data.service_status && !data.service_status.error) { + var svcInc = data.service_status.incidents || []; + var activeSvc = data.service_status.active_incidents || 0; + if (svcInc.length) { + h += '
Cloud Services' + (activeSvc > 0 ? ' (' + activeSvc + ' active)' : '') + '
'; + svcInc.slice(0, 10).forEach(function(s) { + var sevCls = s.severity === 'critical' ? 'down' : s.severity === 'high' ? 'warn' : 'dim'; + var did = _storeItem('service', s); + h += ''; + }); + h += '
ProviderIncidentSev
' + esc(s.provider || '?') + '' + esc(trunc(s.title || '\u2014', 30)) + '' + esc(s.severity || '\u2014') + '
'; + } + } if (data.nav_warnings && !data.nav_warnings.error) { var navs = data.nav_warnings.warnings || []; if (navs.length) { h += '
Nav Warnings (' + navs.length + ')
'; navs.slice(0, 8).forEach(function(w) { - h += ''; + var did = _storeItem('navwarning', w); + h += ''; }); h += '
AreaWarning
' + esc(w.navArea || w.area || '?') + '' + esc(trunc(w.text || w.description || '\u2014', 40)) + '
' + esc(w.navArea || w.area || '?') + '' + esc(trunc(w.text || w.description || '\u2014', 40)) + '
'; } @@ -1077,7 +1377,8 @@ function updateDrawer(data) { h += '
Prediction Markets
'; mkts.slice(0, 10).forEach(function(m) { var prob = m.probability || m.yes_price; - h += ''; + var did = _storeItem('prediction', m); + h += ''; }); h += '
MarketProbVol
' + esc(trunc(m.question || m.title || '?', 35)) + '' + (prob != null ? (typeof prob === 'number' ? (prob * 100).toFixed(0) + '%' : esc(String(prob))) : '\u2014') + '' + fmtBig(m.volume || 0) + '
' + esc(trunc(m.question || m.title || '?', 35)) + '' + (prob != null ? (typeof prob === 'number' ? (prob * 100).toFixed(0) + '%' : esc(String(prob))) : '\u2014') + '' + fmtBig(m.volume || 0) + '
'; } @@ -1088,7 +1389,8 @@ function updateDrawer(data) { h += '
UCDP Events (' + (data.ucdp_events.count || ucdp.length) + ')
'; ucdp.slice(0, 10).forEach(function(e) { var best = e.best || e.best_fatality_estimate || 0; - h += ''; + var did = _storeItem('conflict', e); + h += ''; }); h += '
ConflictDeaths
' + esc(trunc(e.dyad_name || e.side_a || '?', 30)) + '' + best + '
' + esc(trunc(e.dyad_name || e.side_a || '?', 30)) + '' + best + '
'; } @@ -1100,7 +1402,8 @@ function updateDrawer(data) { czones.forEach(function(e) { var sev = e.severity || 'unknown'; var cls = sev === 'critical' ? 'down' : sev === 'high' ? 'warn' : 'dim'; - h += '' + esc(trunc(e.country || '?', 25)) + '' + esc(sev) + ''; + var did = _storeItem('conflict', e); + h += '' + esc(trunc(e.country || '?', 25)) + '' + esc(sev) + ''; }); h += ''; } @@ -1174,7 +1477,8 @@ function updateDrawer(data) { if (aiItems.length) { h += ''; aiItems.slice(0, 12).forEach(function(item) { - h += ''; + var did = _storeItem('ai', item); + h += ''; }); h += '
Paper/PostSourceAge
' + esc(trunc(item.title || '?', 40)) + '' + esc(item.feed_name || '\u2014') + '' + ago(item.published) + '
' + esc(trunc(item.title || '?', 40)) + '' + esc(item.feed_name || '\u2014') + '' + ago(item.published) + '
'; } @@ -1205,7 +1509,8 @@ function updateDrawer(data) { h += ''; healthItems.slice(0, 12).forEach(function(item) { var isHigh = item.high_concern; - h += '' + esc(trunc(item.title || '?', 40)) + ''; + var did = _storeItem('health', item); + h += '' + esc(trunc(item.title || '?', 40)) + ''; }); h += '
OutbreakOrgAge
' + esc(item.organization || '\u2014') + '' + ago(item.published) + '
' + esc(item.organization || '\u2014') + '' + ago(item.published) + '
'; } @@ -1227,7 +1532,8 @@ function updateDrawer(data) { var rs = e.risk_score || 0; var rCls = rs >= 80 ? 'crit' : rs >= 50 ? 'high' : rs >= 30 ? 'high' : ''; var riskPct = Math.min(100, rs); - h += '' + esc(e.country || '?') + '' + esc(e.election_type || '\u2014') + '' + esc(e.date || '\u2014') + '' + + var did = _storeItem('election', e); + h += '' + esc(e.country || '?') + '' + esc(e.election_type || '\u2014') + '' + esc(e.date || '\u2014') + '' + '
' + ''; }); @@ -1281,7 +1587,8 @@ function updateDrawer(data) { if (posts.length) { h += ''; posts.slice(0, 10).forEach(function(p) { - h += ''; + var did = _storeItem('social', p); + h += ''; }); h += '
PostScoreSub
' + esc(trunc(p.title || '?', 35)) + '' + fmtBigPlain(p.score || 0) + '' + esc(p.subreddit || '\u2014') + '
' + esc(trunc(p.title || '?', 35)) + '' + fmtBigPlain(p.score || 0) + '' + esc(p.subreddit || '\u2014') + '
'; } @@ -1336,6 +1643,16 @@ function updateDrawer(data) { } $('#drawerBody').innerHTML = safe(h); + + // Attach click delegation for data-click rows + $('#drawerBody').addEventListener('click', function(e) { + var row = e.target.closest('[data-click]'); + if (row) { + e.preventDefault(); + e.stopPropagation(); + showDrawerDetail(row.getAttribute('data-click')); + } + }); } // ════════════ NEWS TICKER ════════════ diff --git a/src/world_intel_mcp/server.py b/src/world_intel_mcp/server.py index 40668c7..e30d5f1 100644 --- a/src/world_intel_mcp/server.py +++ b/src/world_intel_mcp/server.py @@ -14,6 +14,7 @@ Phase 4: Reports — daily brief, country dossier, threat landscape (+3 = 36 too Phase 5: Analysis — focal points, signal summary, temporal anomalies, CII v2 (+3 = 39 tools). Phase 6: Military & infrastructure intelligence (+6 = 45 tools). Phase 7: Health, sanctions, elections, shipping, social, nuclear, alerts, trends (+10 = 55 tools). +Phase 8: Service status monitoring, RSS expansion (80+ feeds, 14 categories) (+1 = 56 tools). """ import asyncio @@ -29,7 +30,7 @@ from mcp.types import Tool, TextContent from .cache import Cache from .circuit_breaker import CircuitBreaker from .fetcher import Fetcher -from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear +from .sources import markets, economic, seismology, wildfire, conflict, military, infrastructure, maritime, climate, news, intelligence, prediction, displacement, aviation, cyber, space_weather, ai_watch, health, sanctions, elections, shipping, social, nuclear, service_status from .reports import generator as report_gen logging.basicConfig( @@ -574,6 +575,17 @@ TOOLS: list[Tool] = [ description="Analyze weekly trends from temporal baselines. Reports volatility (coefficient of variation) and current anomalies across all tracked metrics.", inputSchema={"type": "object", "properties": {}}, ), + # --- Service Status (1 tool) --- + Tool( + name="intel_service_status", + description="Monitor cloud service provider status (AWS, Azure, GCP, Cloudflare, GitHub). Shows active incidents and recent outages. Optional: provider (aws/azure/gcp/cloudflare/github).", + inputSchema={ + "type": "object", + "properties": { + "provider": {"type": "string", "description": "Filter by provider (aws, azure, gcp, cloudflare, github)"}, + }, + }, + ), # --- System (1 tool) --- Tool( name="intel_status", @@ -810,6 +822,12 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: case "intel_threat_landscape": return await report_gen.generate_threat_landscape(output_dir=arguments.get("output_dir")) + # Service Status + case "intel_service_status": + return await service_status.fetch_service_status( + fetcher, provider=arguments.get("provider"), + ) + # System case "intel_status": return { @@ -820,8 +838,8 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: "economic": ["eia", "fred", "world-bank"], "natural": ["usgs", "nasa-firms"], "conflict": ["acled", "ucdp", "hdx"], - "military": ["opensky", "hexdb"], - "infrastructure": ["cloudflare-radar", "nga-msi"], + "military": ["opensky", "hexdb", "adsblol"], + "infrastructure": ["cloudflare-radar", "ioda", "nga-msi"], "maritime": ["nga-msi"], "climate": ["open-meteo"], "news": ["rss-aggregator", "gdelt"], @@ -832,12 +850,13 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any: "cyber": ["feodo-tracker", "cisa-kev", "sans-dshield", "urlhaus"], "space_weather": ["noaa-swpc"], "ai_watch": ["arxiv", "huggingface", "ai-news-rss"], - "health": ["who-don", "promed", "cidrap"], + "health": ["who-don", "cdc", "outbreak-news"], "sanctions": ["ofac-sdn"], "elections": ["election-calendar"], "shipping": ["yahoo-finance"], "social": ["reddit-public"], "nuclear": ["usgs-nuclear-monitor"], + "service_status": ["aws", "azure", "gcp", "cloudflare", "github"], }, } diff --git a/src/world_intel_mcp/sources/news.py b/src/world_intel_mcp/sources/news.py index bd31376..5de49b4 100644 --- a/src/world_intel_mcp/sources/news.py +++ b/src/world_intel_mcp/sources/news.py @@ -30,33 +30,144 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = { ("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"), ("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"), ("AP Top News", "https://rsshub.app/apnews/topics/apf-topnews"), + ("Reuters World", "https://www.reutersagency.com/feed/?taxonomy=best-sectors&post_type=best"), + ("The Guardian World", "https://www.theguardian.com/world/rss"), + ("DW News", "https://rss.dw.com/rss/en/top_news/rss-en-top"), + ("France24", "https://www.france24.com/en/rss"), ], "security": [ ("BleepingComputer", "https://www.bleepingcomputer.com/feed/"), ("Krebs on Security", "https://krebsonsecurity.com/feed/"), ("The Hacker News", "https://feeds.feedburner.com/TheHackersNews"), ("Schneier on Security", "https://www.schneier.com/feed/atom/"), + ("Dark Reading", "https://www.darkreading.com/rss.xml"), + ("Threatpost", "https://threatpost.com/feed/"), + ("CISA Alerts", "https://www.cisa.gov/cybersecurity-advisories/all.xml"), ], "technology": [ ("Ars Technica", "https://feeds.arstechnica.com/arstechnica/index"), ("TechCrunch", "https://techcrunch.com/feed/"), ("The Verge", "https://www.theverge.com/rss/index.xml"), + ("Wired", "https://www.wired.com/feed/rss"), + ("MIT Tech Review", "https://www.technologyreview.com/feed/"), ], "finance": [ ("CNBC", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114"), ("MarketWatch", "https://feeds.content.dowjones.io/public/rss/mw_topstories"), ("FT World", "https://www.ft.com/rss/home/uk"), + ("Bloomberg", "https://feeds.bloomberg.com/markets/news.rss"), + ("WSJ Markets", "https://feeds.a.dj.com/rss/RSSMarketsMain.xml"), + ("Zero Hedge", "https://feeds.feedburner.com/zerohedge/feed"), ], "military": [ ("Defense One", "https://www.defenseone.com/rss/"), ("War on the Rocks", "https://warontherocks.com/feed/"), ("The War Zone", "https://www.twz.com/feed"), + ("Breaking Defense", "https://breakingdefense.com/feed/"), + ("Defense News", "https://www.defensenews.com/arc/outboundfeeds/rss/category/land/?outputType=xml"), + ("Stars and Stripes", "https://www.stripes.com/rss"), + ("USNI News", "https://news.usni.org/feed"), ], "science": [ ("Nature", "https://www.nature.com/nature.rss"), ("Science", "https://www.science.org/action/showFeed?type=etoc&feed=rss&jc=science"), ("Phys.org", "https://phys.org/rss-feed/"), + ("New Scientist", "https://www.newscientist.com/feed/home/"), ], + "think_tanks": [ + ("RAND", "https://www.rand.org/blog.xml"), + ("Brookings", "https://www.brookings.edu/feed/"), + ("CSIS", "https://www.csis.org/analysis/feed"), + ("CFR", "https://www.cfr.org/rss.xml"), + ("Carnegie", "https://carnegieendowment.org/rss/solr.xml"), + ("Chatham House", "https://www.chathamhouse.org/rss.xml"), + ("Atlantic Council", "https://www.atlanticcouncil.org/feed/"), + ("IISS", "https://www.iiss.org/rss/"), + ], + "middle_east": [ + ("Al Monitor", "https://www.al-monitor.com/rss"), + ("Middle East Eye", "https://www.middleeasteye.net/rss"), + ("The National UAE", "https://www.thenationalnews.com/rss"), + ("Times of Israel", "https://www.timesofisrael.com/feed/"), + ("Iran Intl", "https://www.iranintl.com/en/feed"), + ], + "asia_pacific": [ + ("SCMP", "https://www.scmp.com/rss/91/feed"), + ("Nikkei Asia", "https://asia.nikkei.com/rss"), + ("The Diplomat", "https://thediplomat.com/feed/"), + ("Channel News Asia", "https://www.channelnewsasia.com/api/v1/rss-outbound-feed?_format=xml"), + ("East Asia Forum", "https://www.eastasiaforum.org/feed/"), + ], + "africa": [ + ("allAfrica", "https://allafrica.com/tools/headlines/rdf/latest/headlines.rdf"), + ("The Africa Report", "https://www.theafricareport.com/feed/"), + ("African Arguments", "https://africanarguments.org/feed/"), + ], + "latin_america": [ + ("Americas Quarterly", "https://www.americasquarterly.org/feed/"), + ("MercoPress", "https://en.mercopress.com/rss"), + ("Brazil Wire", "https://www.brasilwire.com/feed/"), + ], + "energy": [ + ("Oil Price", "https://oilprice.com/rss/main"), + ("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"), + ("Energy Intelligence", "https://www.energyintel.com/rss"), + ("Utility Dive", "https://www.utilitydive.com/feeds/news/"), + ], + "government": [ + ("State Dept", "https://www.state.gov/rss-feed/press-releases/feed/"), + ("DoD News", "https://www.defense.gov/DesktopModules/ArticleCS/RSS.ashx?ContentType=1&Site=945&max=10"), + ("UN News", "https://news.un.org/feed/subscribe/en/news/all/rss.xml"), + ("EU External Action", "https://www.eeas.europa.eu/eeas/rss-feeds_en"), + ("NATO News", "https://www.nato.int/cps/en/natohq/news.xml"), + ], + "crisis": [ + ("ReliefWeb", "https://reliefweb.int/updates/rss.xml"), + ("ICG", "https://www.crisisgroup.org/rss.xml"), + ("ACAPS", "https://www.acaps.org/en/rss/briefing-notes"), + ], +} + +# Source tier classification for propaganda/reliability scoring +SOURCE_TIERS: dict[str, str] = { + "AP Top News": "wire", + "Reuters World": "wire", + "BBC World": "major", + "Al Jazeera": "major", + "The Guardian World": "major", + "DW News": "major", + "France24": "major", + "CNBC": "major", + "FT World": "major", + "Bloomberg": "major", + "WSJ Markets": "major", + "Nature": "major", + "Science": "major", + "Defense One": "specialty", + "Breaking Defense": "specialty", + "USNI News": "specialty", + "War on the Rocks": "specialty", + "The War Zone": "specialty", + "RAND": "think_tank", + "Brookings": "think_tank", + "CSIS": "think_tank", + "CFR": "think_tank", + "Carnegie": "think_tank", + "Chatham House": "think_tank", + "Atlantic Council": "think_tank", + "IISS": "think_tank", + "ICG": "think_tank", + "BleepingComputer": "specialty", + "Krebs on Security": "specialty", + "The Hacker News": "specialty", + "CISA Alerts": "government", + "State Dept": "government", + "DoD News": "government", + "UN News": "government", + "NATO News": "government", + "ReliefWeb": "intl_org", + "ACAPS": "intl_org", + "Zero Hedge": "aggregator", } _STOPWORDS: set[str] = { @@ -209,6 +320,7 @@ async def fetch_news_feed( "summary": _truncate(summary_raw, 200), "feed_name": feed_name, "category": cat, + "source_tier": SOURCE_TIERS.get(feed_name, "unknown"), }) return items diff --git a/src/world_intel_mcp/sources/service_status.py b/src/world_intel_mcp/sources/service_status.py new file mode 100644 index 0000000..f8b0d52 --- /dev/null +++ b/src/world_intel_mcp/sources/service_status.py @@ -0,0 +1,205 @@ +"""Cloud service status monitoring source for world-intel-mcp. + +Monitors major cloud provider status pages (AWS, Azure, GCP, Cloudflare) +via their public RSS/Atom feeds. No API keys required. +""" + +import asyncio +import logging +from datetime import datetime, timezone + +from ..fetcher import Fetcher + +try: + import feedparser +except ImportError: + feedparser = None # type: ignore[assignment] + +logger = logging.getLogger("world-intel-mcp.sources.service_status") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_STATUS_FEEDS: list[dict] = [ + { + "provider": "AWS", + "url": "https://status.aws.amazon.com/rss/all.rss", + "icon": "aws", + }, + { + "provider": "Azure", + "url": "https://azurestatuscdn.azureedge.net/en-us/status/feed/", + "icon": "azure", + }, + { + "provider": "GCP", + "url": "https://status.cloud.google.com/feed.atom", + "icon": "gcp", + }, + { + "provider": "Cloudflare", + "url": "https://www.cloudflarestatus.com/history.rss", + "icon": "cloudflare", + }, + { + "provider": "GitHub", + "url": "https://www.githubstatus.com/history.rss", + "icon": "github", + }, +] + +_CACHE_TTL = 300 # 5 minutes + +_SEVERITY_KEYWORDS: dict[str, str] = { + "major": "critical", + "outage": "critical", + "disruption": "high", + "degraded": "high", + "degradation": "high", + "elevated error": "high", + "partial": "medium", + "intermittent": "medium", + "investigating": "medium", + "resolved": "resolved", + "monitoring": "low", + "maintenance": "info", +} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_published(entry: dict) -> str | None: + """Parse RSS entry published date to ISO 8601.""" + import time as _time + + for field in ("published_parsed", "updated_parsed"): + parsed_tuple = entry.get(field) + if parsed_tuple is not None: + try: + epoch = _time.mktime(parsed_tuple[:9]) + dt = datetime.fromtimestamp(epoch, tz=timezone.utc) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + except (ValueError, TypeError, OverflowError): + pass + + return entry.get("published") or entry.get("updated") + + +def _classify_severity(title: str, summary: str) -> str: + """Classify incident severity from title and summary text.""" + combined = f"{title} {summary}".lower() + for keyword, severity in _SEVERITY_KEYWORDS.items(): + if keyword in combined: + return severity + return "unknown" + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +async def fetch_service_status( + fetcher: Fetcher, + provider: str | None = None, + limit: int = 30, +) -> dict: + """Monitor cloud service provider status pages. + + Fetches RSS/Atom status feeds from AWS, Azure, GCP, Cloudflare, + and GitHub. Classifies incidents by severity. + + Args: + fetcher: Shared HTTP fetcher with caching and circuit breaking. + provider: Optional provider filter (aws, azure, gcp, cloudflare, github). + limit: Maximum incidents per provider. + + Returns: + Dict with incidents, by_provider, active_incidents, source. + """ + if feedparser is None: + return { + "error": "feedparser not installed — run: pip install feedparser", + "incidents": [], + "count": 0, + } + + feeds = _STATUS_FEEDS + if provider: + provider_lower = provider.lower().strip() + feeds = [f for f in feeds if f["provider"].lower() == provider_lower] + if not feeds: + return { + "incidents": [], + "count": 0, + "error": f"Unknown provider '{provider}'. Valid: {[f['provider'] for f in _STATUS_FEEDS]}", + "source": "service-status", + "timestamp": _utc_now_iso(), + } + + all_incidents: list[dict] = [] + + async def _fetch_provider(feed: dict) -> list[dict]: + safe_name = feed["provider"].lower() + xml_text = await fetcher.get_xml( + feed["url"], + source=f"status:{safe_name}", + cache_key=f"status:rss:{safe_name}", + cache_ttl=_CACHE_TTL, + ) + + if xml_text is None: + logger.debug("No data from %s status feed", feed["provider"]) + return [] + + parsed = feedparser.parse(xml_text) + incidents: list[dict] = [] + + for entry in parsed.get("entries", [])[:limit]: + title = entry.get("title", "") + summary = entry.get("summary") or entry.get("description") or "" + severity = _classify_severity(title, summary) + + incidents.append({ + "provider": feed["provider"], + "title": title, + "link": entry.get("link", ""), + "published": _parse_published(entry), + "summary": summary[:300] if len(summary) > 300 else summary, + "severity": severity, + }) + + return incidents + + tasks = [_fetch_provider(f) for f in feeds] + results = await asyncio.gather(*tasks) + for incidents in results: + all_incidents.extend(incidents) + + # Sort by published descending + all_incidents.sort(key=lambda i: i.get("published") or "", reverse=True) + + # Count by provider + by_provider: dict[str, int] = {} + active_count = 0 + for inc in all_incidents: + prov = inc.get("provider", "unknown") + by_provider[prov] = by_provider.get(prov, 0) + 1 + if inc.get("severity") not in ("resolved", "info", "unknown"): + active_count += 1 + + return { + "incidents": all_incidents, + "count": len(all_incidents), + "active_incidents": active_count, + "by_provider": by_provider, + "providers_checked": [f["provider"] for f in feeds], + "source": "service-status", + "timestamp": _utc_now_iso(), + }