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)' : '') + '
| Provider | Incident | Sev |
';
+ svcInc.slice(0, 10).forEach(function(s) {
+ var sevCls = s.severity === 'critical' ? 'down' : s.severity === 'high' ? 'warn' : 'dim';
+ var did = _storeItem('service', s);
+ h += '| ' + esc(s.provider || '?') + ' | ' + esc(trunc(s.title || '\u2014', 30)) + ' | ' + esc(s.severity || '\u2014') + ' |
';
+ });
+ h += '
';
+ }
+ }
if (data.nav_warnings && !data.nav_warnings.error) {
var navs = data.nav_warnings.warnings || [];
if (navs.length) {
h += '
Nav Warnings (' + navs.length + ')
| Area | Warning |
';
navs.slice(0, 8).forEach(function(w) {
- h += '| ' + esc(w.navArea || w.area || '?') + ' | ' + esc(trunc(w.text || w.description || '\u2014', 40)) + ' |
';
+ var did = _storeItem('navwarning', w);
+ h += '| ' + esc(w.navArea || w.area || '?') + ' | ' + esc(trunc(w.text || w.description || '\u2014', 40)) + ' |
';
});
h += '
';
}
@@ -1077,7 +1377,8 @@ function updateDrawer(data) {
h += '
Prediction Markets
| Market | Prob | Vol |
';
mkts.slice(0, 10).forEach(function(m) {
var prob = m.probability || m.yes_price;
- h += '| ' + esc(trunc(m.question || m.title || '?', 35)) + ' | ' + (prob != null ? (typeof prob === 'number' ? (prob * 100).toFixed(0) + '%' : esc(String(prob))) : '\u2014') + ' | ' + fmtBig(m.volume || 0) + ' |
';
+ var did = _storeItem('prediction', m);
+ h += '| ' + esc(trunc(m.question || m.title || '?', 35)) + ' | ' + (prob != null ? (typeof prob === 'number' ? (prob * 100).toFixed(0) + '%' : esc(String(prob))) : '\u2014') + ' | ' + fmtBig(m.volume || 0) + ' |
';
});
h += '
';
}
@@ -1088,7 +1389,8 @@ function updateDrawer(data) {
h += '
UCDP Events (' + (data.ucdp_events.count || ucdp.length) + ')
| Conflict | Deaths |
';
ucdp.slice(0, 10).forEach(function(e) {
var best = e.best || e.best_fatality_estimate || 0;
- h += '| ' + esc(trunc(e.dyad_name || e.side_a || '?', 30)) + ' | ' + best + ' |
';
+ var did = _storeItem('conflict', e);
+ h += '| ' + esc(trunc(e.dyad_name || e.side_a || '?', 30)) + ' | ' + best + ' |
';
});
h += '
';
}
@@ -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 += '
| Paper/Post | Source | Age |
';
aiItems.slice(0, 12).forEach(function(item) {
- h += '| ' + esc(trunc(item.title || '?', 40)) + ' | ' + esc(item.feed_name || '\u2014') + ' | ' + ago(item.published) + ' |
';
+ var did = _storeItem('ai', item);
+ h += '| ' + esc(trunc(item.title || '?', 40)) + ' | ' + esc(item.feed_name || '\u2014') + ' | ' + ago(item.published) + ' |
';
});
h += '
';
}
@@ -1205,7 +1509,8 @@ function updateDrawer(data) {
h += '
| Outbreak | Org | Age |
';
healthItems.slice(0, 12).forEach(function(item) {
var isHigh = item.high_concern;
- h += '| ' + esc(trunc(item.title || '?', 40)) + ' | ' + esc(item.organization || '\u2014') + ' | ' + ago(item.published) + ' |
';
+ var did = _storeItem('health', item);
+ h += '| ' + esc(trunc(item.title || '?', 40)) + ' | ' + esc(item.organization || '\u2014') + ' | ' + ago(item.published) + ' |
';
});
h += '
';
}
@@ -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 += '
| Post | Score | Sub |
';
posts.slice(0, 10).forEach(function(p) {
- h += '| ' + esc(trunc(p.title || '?', 35)) + ' | ' + fmtBigPlain(p.score || 0) + ' | ' + esc(p.subreddit || '\u2014') + ' |
';
+ var did = _storeItem('social', p);
+ h += '| ' + esc(trunc(p.title || '?', 35)) + ' | ' + fmtBigPlain(p.score || 0) + ' | ' + esc(p.subreddit || '\u2014') + ' |
';
});
h += '
';
}
@@ -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(),
+ }