#!/usr/bin/env node
/**
* @module Intelligence Operations/MCP Intelligence Server Client
* @category Intelligence Operations - MCP Intelligence Server Client
*
* @description
* JSON-RPC 2.0 client for the riksdag-regering-mcp server providing access to
* 32 specialized intelligence tools for Swedish parliamentary and government data.
* This module implements the MCP protocol layer enabling automated OSINT collection
* from the world's first parliament/government intelligence API.
*
* Intelligence Server Architecture:
*
* MCP Server: riksdag-regering-mcp
* URL: https://riksdag-regering-ai.onrender.com/mcp
* Protocol: JSON-RPC 2.0 (https://www.jsonrpc.org/specification)
* Tools: 32 specialized intelligence functions for Swedish political data
* Deployment: Render.com serverless platform
* Authentication: Optional token-based via MCP_AUTH_TOKEN
*
* @example Quick Start for GitHub Copilot Agents
*
* // In GitHub Actions workflows with MCP configured, use tools directly:
* const events = await mcp["riksdag-regering"].get_calendar_events({
* from: "2026-02-16",
* tom: "2026-02-16",
* limit: 50
* });
*
* // Or use this helper client for typed methods:
* import MCPClient from './scripts/mcp-client.js';
* const client = new MCPClient();
* const events = await client.fetchCalendarEvents({ from: today, tom: today });
*
* @example Handling Cold Starts (30-60 seconds)
*
* // Render.com serverless may have cold starts
* console.log("⏳ Warming up MCP server (may take 30-60s)...");
*
* // Start with a simple query to warm up
* const status = await client.request('get_sync_status', {});
* console.log("✅ MCP server ready");
*
* // Now batch your main queries (will be fast)
* const [events, votes, docs] = await Promise.all([
* client.fetchCalendarEvents({ from: today, tom: today }),
* client.fetchVotingRecords({ rm: "2025/26", limit: 20 }),
* client.searchDocuments({ from_date: today, limit: 30 })
* ]);
*
* @example Error Handling Strategy
*
* try {
* const data = await client.fetchCalendarEvents({ from: today, tom: today });
* } catch (error) {
* if (error.message.includes('timeout')) {
* // Cold start or server overload - retry after 60s
* console.log("⏳ Server cold start detected, retrying...");
* await new Promise(resolve => setTimeout(resolve, 60000));
* const data = await client.fetchCalendarEvents({ from: today, tom: today });
* } else if (error.message.includes('503')) {
* // Server maintenance - fall back to cached data
* console.log("⚠️ MCP server unavailable, using cached data");
* // Load from cache...
* } else {
* throw error;
* }
* }
*
* MCP Tool Categories (32 Total Tools):
*
* Riksdag Tools (15 tools):
* - get_ledamoter: Retrieve parliament member list with party affiliation
* - get_ledamot_details: Detailed biography and service history
* - search_ledamoter: Member search by name, party, constituency
* - get_motioner: All parliamentary motions with sponsorship
* - search_motioner: Full-text motion search
* - get_propositioner: Government legislative proposals
* - search_propositioner: Proposal search by type, status
* - get_dokument: Specific document retrieval
* - search_dokument: Document discovery with metadata
* - search_dokument_fulltext: Full-text document search
* - get_voteringar: Voting records and roll-call results
* - search_voteringar: Vote analysis by party, member, timeframe
* - get_anforanden: Parliamentary speeches and debates
* - search_anforanden: Speech search by speaker, topic, date
* - get_calendar_events: Parliamentary calendar and scheduling
*
* Government Tools (7 tools):
* - get_regering_document: Retrieve government documents
* - search_regering: Government document search
* - search_regering_by_department: Department-specific searches
* - summarize_regering_document: Automated government document summarization
* - get_g0v_document_content: Markdown conversion of government documents
* - get_g0v_document_types: List available document type categories
* - analyze_g0v_by_department: Department-level document analysis
*
* Statistical & Metadata Tools (5 tools):
* - get_utskott: Committee list and composition
* - get_betankanden: Committee reports and decisions
* - fetch_report: Statistical reports (demographics, activity metrics)
* - get_voting_group: Voting pattern analysis by party/constituency
* - get_sync_status: Data freshness and update schedule status
*
* Utility Tools (5 tools):
* - get_data_dictionary: Schema definitions for all data types
* - get_latest_update: Last successful data synchronization
* - list_reports: Available statistical report types
* - batch_fetch_documents: Efficient bulk document retrieval
* - fetch_paginated_documents: Pagination-based result streaming
*
* Protocol Details:
*
* Direct Server Mode (Recommended):
* - POST to https://riksdag-regering-ai.onrender.com/mcp
* - Use unprefixed tool names (e.g., 'get_calendar_events')
* - JSON-RPC 2.0 request format
* - Automatic timeout handling and retries
* - Lower latency (~200-500ms per request)
*
* MCP Gateway Mode (Agentic Workflows):
* - For agentic workflow sandbox environments with firewall container
* - POST to http://host.docker.internal:80/mcp/riksdag-regering
* - Use prefixed tool names: 'riksdag-regering--{tool_name}'
* - Additional proxy latency (~50-200ms overhead per request)
* - Auto-detection based on URL (checks for 'host.docker.internal')
* - Client automatically handles prefixing - no manual changes needed
*
* Architecture Comparison:
*
* Direct Mode:
* Agent → HTTPS → MCP Server (riksdag-regering-ai.onrender.com)
* - Pros: Lower latency, simpler auth, faster cold start recovery
* - Cons: Less network control, requires explicit domain allowlist
*
* Gateway Mode:
* Agent → Firewall → HTTP → Proxy → HTTPS → MCP Server
* - Pros: Security filtering, network audit, rate limiting
* - Cons: Higher latency, complex session mgmt, gateway timeout cascade
*
* Auto-detection based on error responses
*
* Intelligence Application Examples:
*
* Automated News Generation:
* - get_calendar_events → Week-ahead parliamentary schedule
* - search_dokument → Recent propositions and motions
* - search_anforanden → Parliamentary debate summaries
* - get_voteringar → Party voting patterns and consensus analysis
*
* Statistical Intelligence:
* - get_ledamoter → Member demographics (age, gender, party)
* - get_utskott → Committee composition and specialization
* - fetch_report → Aggregated voting and productivity statistics
* - get_voting_group → Coalition formation and party dynamics
*
* Government Transparency:
* - search_regering → Government policy announcements
* - get_regering_document → Complete policy documentation
* - summarize_regering_document → Automated policy summarization
* - analyze_g0v_by_department → Department-specific tracking
*
* Advanced Analysis:
* - search_dokument_fulltext → Cross-document pattern matching
* - search_voteringar → Historical voting pattern analysis
* - get_anforanden → Debate transcript analysis
* - batch_fetch_documents → Bulk data collection for trend analysis
*
* Error Handling Strategy:
* - Network errors: Automatic retries with exponential backoff (max 3 attempts)
* - Timeout: 30-second default (adjustable via MCP_CLIENT_TIMEOUT_MS)
* - Tool not found: Fallback from prefixed to non-prefixed names
* - Rate limits: Respect server 429 responses with adaptive delays
* - Server unavailable: Return cached data if available, else error
*
* @intelligence
* OSINT Collection Methodology:
*
* Continuous Monitoring Patterns:
* - Calendar-based collection: Daily checks for new parliamentary events
* - Document discovery: Automated searches for recent legislative action
* - Voting analysis: Pattern recognition across roll-call votes
* - Debate monitoring: Transcript collection for opinion tracking
* - Government watch: Policy announcement aggregation and analysis
*
* Data Correlation & Analysis:
* - Cross-reference voting records with parliamentary speeches
* - Link government documents to implementing legislation
* - Track committee work on government proposals
* - Analyze debate patterns for consensus/conflict identification
* - Map party positions across multiple votes and statements
*
* Source Validation Techniques:
* - Verify member information against parliament roster
* - Check document metadata against official Riksdagen database
* - Validate vote counts against parliamentary records
* - Cross-reference speeches with parliamentary debate archives
* - Confirm government document authenticity via Regeringen.se
*
* Intelligence Product Generation:
* - News article generation (weeks ahead, breaking analysis)
* - Trend analysis (voting pattern shifts, policy evolution)
* - Risk assessment (legislative timeline risks, coalition stability)
* - Forecasting (likely outcomes, coalition formations)
* - Comparative analysis (Sweden vs. other parliaments)
*
* @osint
* OSINT Collection Framework:
*
* Primary Intelligence Source: riksdag-regering-mcp
* - Official Swedish Parliament API (Riksdagen.se foundation)
* - Official Swedish Government API (Regeringen.se foundation)
* - Real-time parliamentary data (updated daily)
* - Complete historical records (from Riksdagen founding)
*
* Data Quality Assessment:
* - Source authenticity: Official parliament/government APIs
* - Data freshness: Check sync status before use
* - Completeness: Validate expected fields populated
* - Consistency: Cross-field validation (e.g., vote counts)
*
* Collection Prioritization:
* - Real-time: Calendar events (schedule changes)
* - Daily: Documents, votes, debates (legislative activity)
* - Weekly: Committee reports, government announcements
* - Monthly: Statistical analysis, trend reports
*
* Source Diversity Strategy:
* - Primary: MCP server (authoritative)
* - Secondary: Riksdagen.se direct access (validation)
* - Tertiary: News archives (context and verification)
*
* @risk
* Intelligence Threats & Risk Mitigations:
*
* Threat: MCP Server Unavailability
* - Render.com cold start or outage impacts news generation
* - Mitigation: Implement cache fallback, health checks, graceful degradation
* - Impact: Unable to generate new articles; fallback to cached data
*
* Threat: Stale Data
* - MCP data older than 24 hours for some information types
* - Mitigation: Check sync status, publish timestamps, verify freshness
* - Impact: Articles based on outdated information
*
* Threat: API Schema Drift
* - MCP response format changes breaking parser
* - Mitigation: Schema versioning, changelog monitoring, flexible parsing
* - Impact: Article generation failures without manual intervention
*
* Threat: Rate Limiting
* - Aggressive rate limiting during bulk data collection
* - Mitigation: Implement request throttling, exponential backoff
* - Impact: Delayed news generation during high-traffic periods
*
* Threat: Data Injection / Poisoning
* - Compromised MCP server returning modified data
* - Mitigation: Schema validation, integrity checking, source monitoring
* - Impact: Misinformation propagation in generated articles
*
* Threat: Intelligence Leakage
* - Pre-publication political intelligence leaked via API calls
* - Mitigation: HTTPS-only, authentication tokens, audit logging
* - Impact: Unauthorized disclosure of strategic intelligence
*
* Threat: Dependency on Single Source
* - Over-reliance on MCP for all parliamentary data
* - Mitigation: Maintain Riksdagen.se fallback, diversify sources
* - Impact: Limited operational resilience
*
* @gdpr
* GDPR Compliance in MCP Client:
*
* Data Processing Context:
* - Processes public parliamentary data only (Article 6(1)(e) public interest)
* - Public officials in official capacity (Article 9(2)(e) manifestly public)
* - No special category data beyond public voting records
* - Journalist/OSINT platform purpose (freedom of information)
*
* Data Subject Rights:
* - Right to access: Not applicable (aggregated public data)
* - Right to rectification: Not applicable (historical records)
* - Right to erasure: Not applicable (parliamentary records must be retained)
* - Right to data portability: Not applicable (not personally targeted)
*
* API Usage Constraints:
* - No personal data extraction beyond official records
* - No profiling or behavioral analysis
* - No linking with personal data from other sources
* - Clear separation: public officials vs. private individuals
*
* Transparency & Documentation:
* - Published source attribution in articles
* - API integration documented in privacy policy
* - MCP server terms of service reviewed (public API)
* - Processing impact assessment (low-risk: public data)
*
* @security
* Security Architecture & Controls:
*
* Transport Security:
* - HTTPS-only communication (TLS 1.2+)
* - Certificate pinning (optional for critical deployments)
* - No HTTP downgrade attacks
* - HSTS headers (if MCP server supports)
*
* Authentication & Authorization:
* - Optional bearer token via MCP_AUTH_TOKEN
* - Token rotation policies (if required by server)
* - No credentials in request URLs
* - Secure token storage (environment variables only)
*
* Input Validation:
* - Tool names validated against whitelist (32 known tools)
* - Parameter types checked before API calls
* - String inputs sanitized (no injection vectors)
* - Date inputs validated (ISO 8601 format)
*
* Output Sanitization:
* - Response schema validation
* - HTML entity escaping for content fields
* - No eval() or dynamic code execution
* - Response size limits (prevent DoS)
*
* Error Handling:
* - Detailed error logging (non-production only)
* - User-friendly error messages (no stack traces)
* - Structured error responses (JSON-RPC 2.0 format)
* - Rate limit respect (don't hammer server)
*
* Dependency Security:
* - No external dependencies (native Node.js fetch)
* - pinned package versions
* - Regular vulnerability scanning
* - Minimal trust chain
*
* @author Hack23 AB - Intelligence Operations Team
* @license Apache-2.0
* @version 2.0.0
*
* @see {@link https://riksdag-regering-ai.onrender.com/mcp} MCP Server
* @see {@link https://github.com/Hack23/riksdag-regering-mcp} MCP Server Repository
* @see {@link ./generate-news-enhanced.js} News generation using MCP client
* @see {@link ./data-transformers.js} Data transformation pipeline
* @see {@link docs/MCP_INTEGRATION.md} MCP integration guide
* @see {@link docs/INTELLIGENCE_API_GUIDE.md} API reference documentation
* @see {@link docs/OSINT_COLLECTION.md} OSINT collection methodology
* @see {@link https://www.jsonrpc.org/specification} JSON-RPC 2.0 Specification
*/
const DEFAULT_MCP_SERVER_URL = process.env.MCP_SERVER_URL || 'https://riksdag-regering-ai.onrender.com/mcp';
const DEFAULT_MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN || '';
const DEFAULT_MAX_RETRIES = 3;
const RETRY_DELAY = 2000; // 2 seconds (increased for server spin-up)
/**
* Get default request timeout from environment or use 30s default
* @returns {number} Timeout in milliseconds
*/
function getDefaultTimeout() {
// Default 30s timeout to match existing tests; override via MCP_CLIENT_TIMEOUT_MS (e.g., 60000 for cold starts)
return process.env.MCP_CLIENT_TIMEOUT_MS
? (Number.parseInt(process.env.MCP_CLIENT_TIMEOUT_MS, 10) || 30000)
: 30000;
}
// JSON-RPC 2.0 request ID counter
let jsonRpcId = 1;
/**
* MCP Client Class
*
* @example Basic usage with default configuration
* const client = new MCPClient();
*
* @example With custom configuration
* const client = new MCPClient({
* baseURL: 'https://custom-mcp-server.com/mcp',
* timeout: 60000,
* maxRetries: 5,
* authToken: 'Bearer xyz...',
* headers: {
* 'X-Custom-Header': 'value',
* 'X-API-Key': 'abc123'
* }
* });
*
* @example With headers from .github/copilot-mcp.json
* // Configuration in .github/copilot-mcp.json:
* // {
* // "mcpServers": {
* // "github": {
* // "type": "http",
* // "url": "https://api.githubcopilot.com/mcp/insiders",
* // "headers": {
* // "Authorization": "Bearer ${{ secrets.TOKEN }}",
* // "X-MCP-Toolsets": "all"
* // }
* // }
* // }
* // }
* // The client will automatically use these headers when configured via MCP
*/
export class MCPClient {
/**
* Create a new MCP client
*
* @param {Object|string} config - Configuration object or URL string
* @param {string} [config.baseURL] - MCP server base URL
* @param {string} [config.serverUrl] - Alias for baseURL
* @param {number} [config.timeout] - Request timeout in milliseconds (default: 30000)
* @param {number} [config.maxRetries] - Maximum retry attempts (default: 3)
* @param {string} [config.authToken] - Optional authentication token
* @param {Object} [config.headers] - Custom HTTP headers to include in all requests
*/
constructor(config = {}) {
// Support both object config and string URL for backwards compatibility
if (typeof config === 'string') {
this.baseURL = config;
this.timeout = getDefaultTimeout();
this.maxRetries = DEFAULT_MAX_RETRIES;
this.customHeaders = {};
} else {
this.baseURL = config.baseURL || config.serverUrl || DEFAULT_MCP_SERVER_URL;
this.timeout = config.timeout || getDefaultTimeout();
this.maxRetries = config.maxRetries || DEFAULT_MAX_RETRIES;
// Support custom headers from config (e.g., from .github/copilot-mcp.json)
this.customHeaders = config.headers || {};
}
this.requestCount = 0;
this.errorCount = 0;
this.authToken = (typeof config === 'object' && config.authToken) || DEFAULT_MCP_AUTH_TOKEN;
this.sessionId = null;
}
/**
* Make HTTP request to MCP server using JSON-RPC 2.0 protocol
*
* @param {string} tool - Tool name (e.g., 'get_calendar_events')
* @param {Object} params - Tool parameters
* @param {number} retryCount - Current retry attempt
* @returns {Promise<Object>} Tool response
*/
async request(tool, params = {}, retryCount = 0, skipPrefix = false) {
// Validate tool name to prevent path traversal
if (!tool || typeof tool !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(tool)) {
throw new Error(`Invalid tool name: ${tool}. Tool names must contain only alphanumeric characters, hyphens, and underscores.`);
}
// Only count the initial request, not retries
if (retryCount === 0 && !skipPrefix) {
this.requestCount++;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
// MCP uses JSON-RPC 2.0 protocol
// Call the tool using tools/call method
//
// Tool name prefixing rules:
// - MCP Gateway (host.docker.internal) expects prefixed names: "riksdag-regering--tool_name"
// - Direct MCP Server (onrender.com) expects unprefixed names: "tool_name"
// - skipPrefix is set to true on fallback retry to prevent infinite recursion
const isGateway = this.baseURL.includes('host.docker.internal') || this.baseURL.includes('/mcp/riksdag-regering');
const shouldPrefix = isGateway && !skipPrefix && !tool.includes('--');
const toolName = shouldPrefix ? `riksdag-regering--${tool}` : tool;
const jsonRpcRequest = {
jsonrpc: '2.0',
id: jsonRpcId++,
method: 'tools/call',
params: {
name: toolName,
arguments: params
}
};
// Initialize session if needed (Streamable HTTP MCP transport)
if (this.authToken && !this.sessionId) {
try {
await this.initializeSession();
} catch (e) {
// Session init is optional - continue without it
}
}
// Build headers: custom headers from config + runtime headers
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
...this.customHeaders // Spread custom headers from config first
};
// Runtime headers (override custom headers if present)
if (this.authToken) headers['Authorization'] = this.authToken;
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
const response = await fetch(this.baseURL, {
method: 'POST',
headers,
body: JSON.stringify(jsonRpcRequest),
signal: controller.signal
});
if (!response.ok) {
// Provide more detailed error information
let errorBody = '';
try {
errorBody = await response.text();
} catch (e) {
// Ignore if we can't read the body
}
throw new Error(`MCP server error: ${response.status} ${response.statusText}${errorBody ? ' - ' + errorBody : ''}`);
}
// Parse response - handle both JSON and SSE (text/event-stream) formats
const contentType = (response.headers && typeof response.headers.get === 'function')
? (response.headers.get('content-type') || '')
: '';
let jsonRpcResponse;
if (contentType.includes('text/event-stream')) {
const text = await response.text();
jsonRpcResponse = this.parseSSEResponse(text);
} else {
jsonRpcResponse = await response.json();
}
// Check for JSON-RPC error
if (jsonRpcResponse.error) {
const errorMsg = jsonRpcResponse.error.message || JSON.stringify(jsonRpcResponse.error);
// If tool error and we used prefix, try without prefix
// Server returns "not found" or "Internal error" for unrecognized prefixed tool names
// skipPrefix flag prevents infinite recursion on the fallback attempt
const isToolLookupError = errorMsg.includes('not found') || errorMsg.includes('Internal error') || errorMsg.includes('Unknown tool') || errorMsg.includes('unknown tool');
if (isToolLookupError && toolName.startsWith('riksdag-regering--') && !skipPrefix) {
const bareTool = toolName.replace(/^riksdag-regering--/, '');
console.warn(`⚠️ Tool '${toolName}' not found, retrying as '${bareTool}'...`);
return this.request(bareTool, params, retryCount, true);
}
// Handle session initialization error (Streamable HTTP transport)
if (errorMsg.includes('session initialization') || errorMsg.includes('Too Many Requests')) {
this.sessionId = null;
if (retryCount < 2) {
const delay = (retryCount + 1) * 2000;
console.warn(`⚠️ Session error, re-initializing after ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
await this.initializeSession();
return this.request(tool, params, retryCount + 1, skipPrefix);
}
}
throw new Error(`MCP tool error: ${errorMsg}`);
}
// Extract result from JSON-RPC response
// MCP tools/call returns content array with text field containing JSON
const result = jsonRpcResponse.result || {};
if (result.content && Array.isArray(result.content) && result.content[0]?.text) {
try {
const parsed = JSON.parse(result.content[0].text);
// Gateway returns large payloads via file path
if (parsed.payloadPath) {
const fs = await import('fs');
const payloadRaw = JSON.parse(fs.readFileSync(parsed.payloadPath, 'utf8'));
const payloadText = payloadRaw?.content?.[0]?.text;
if (payloadText) {
try { return JSON.parse(payloadText); } catch { return { text: payloadText }; }
}
return payloadRaw;
}
return parsed;
} catch (e) {
return { text: result.content[0].text };
}
}
return result;
} catch (error) {
// Retry on network errors (case-insensitive check)
// maxRetries represents total attempts, so we retry until retryCount reaches maxRetries - 1
const errorMsg = error.message ? error.message.toLowerCase() : '';
if (retryCount < this.maxRetries - 1 && (
error.name === 'AbortError' ||
errorMsg.includes('network') ||
errorMsg.includes('econnrefused') ||
errorMsg.includes('connection closed') ||
errorMsg.includes('too many requests')
)) {
const delay = RETRY_DELAY * Math.pow(2, retryCount);
console.warn(`⚠️ Request failed (${error.message.substring(0, 60)}), retrying after ${delay}ms (${retryCount + 1}/${this.maxRetries - 1})...`);
this.sessionId = null;
await this.sleep(delay);
return this.request(tool, params, retryCount + 1, skipPrefix);
}
// Only increment error count on final failure (not retries)
this.errorCount++;
// Provide helpful error message with troubleshooting hints
let errorMessage = `MCP request failed: ${error.message}`;
if (error.name === 'AbortError' || errorMsg.includes('timeout')) {
errorMessage += `\n\n💡 Troubleshooting tips:
- The MCP server may be cold starting (Render.com free tier)
- Try increasing timeout or waiting a few minutes
- Server URL: ${this.baseURL}
- Consider running workflow again in 5-10 minutes`;
} else if (errorMsg.includes('network') || errorMsg.includes('econnrefused') || errorMsg.includes('fetch failed')) {
errorMessage += `\n\n💡 Troubleshooting tips:
- Check if MCP server is accessible: ${this.baseURL}
- Verify network connectivity
- The server may be temporarily unavailable
- Try manual workflow dispatch with force_generation=true`;
}
throw new Error(errorMessage, { cause: error });
} finally {
// Always clear timeout to prevent timer leak
clearTimeout(timeoutId);
}
}
/**
* Sleep utility
*/
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Parse SSE (text/event-stream) response body into JSON-RPC response
*/
parseSSEResponse(text) {
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
return JSON.parse(line.substring(6));
}
}
// If no SSE format, try direct JSON parse
return JSON.parse(text);
}
/**
* Initialize MCP session for Streamable HTTP transport
*/
async initializeSession() {
if (this.sessionId || !this.authToken) return;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
// Build headers: custom headers from config + runtime headers
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
...this.customHeaders // Spread custom headers from config first
};
// Runtime headers (override custom headers if present)
if (this.authToken) headers['Authorization'] = this.authToken;
const response = await fetch(this.baseURL, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
id: jsonRpcId++,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'riksdagsmonitor-news', version: '1.0.0' }
}
}),
signal: controller.signal
});
if (!response.ok) {
throw new Error(`Session init failed: ${response.status} ${response.statusText}`);
}
const sessionId = (response.headers && typeof response.headers.get === 'function')
? response.headers.get('Mcp-Session-Id')
: null;
if (sessionId) {
this.sessionId = sessionId;
console.log(` 🔗 MCP session initialized: ${sessionId.substring(0, 8)}...`);
}
// Send notifications/initialized (required by MCP protocol)
await fetch(this.baseURL, {
method: 'POST',
headers: { ...headers, ...(this.sessionId ? { 'Mcp-Session-Id': this.sessionId } : {}) },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/initialized'
})
});
} finally {
clearTimeout(timeoutId);
}
}
/**
* Fetch calendar events (upcoming parliamentary activity)
*
* @param {string} from - Start date (YYYY-MM-DD)
* @param {string} tom - End date (YYYY-MM-DD)
* @param {string} org - Optional: Organ filter (e.g., 'kammaren', 'uu', 'fiu')
* @param {string} akt - Optional: Activity type
* @returns {Promise<Array>} Calendar events
*/
async fetchCalendarEvents(from, tom, org = null, akt = null) {
const params = { from, tom };
if (org) params.org = org;
if (akt) params.akt = akt;
const response = await this.request('get_calendar_events', params);
return response.kalender || response.events || [];
}
/**
* Fetch latest committee reports (betänkanden)
*
* @param {number} limit - Number of reports to fetch
* @param {string} rm - Optional: Riksmöte (e.g., '2025/26')
* @param {string} organ - Optional: Committee filter
* @returns {Promise<Array>} Committee reports
*/
async fetchCommitteeReports(limit = 10, rm = null, organ = null) {
const params = { limit };
if (rm) params.rm = rm;
if (organ) params.organ = organ;
const response = await this.request('get_betankanden', params);
return response.dokument || response.reports || [];
}
/**
* Fetch latest government propositions
*
* @param {number} limit - Number of propositions to fetch
* @param {string} rm - Optional: Riksmöte
* @returns {Promise<Array>} Propositions
*/
async fetchPropositions(limit = 10, rm = null) {
const params = { limit };
if (rm) params.rm = rm;
const response = await this.request('get_propositioner', params);
return response.dokument || response.propositions || [];
}
/**
* Fetch latest opposition motions
*
* @param {number} limit - Number of motions to fetch
* @param {string} rm - Optional: Riksmöte
* @returns {Promise<Array>} Motions
*/
async fetchMotions(limit = 10, rm = null) {
const params = { limit };
if (rm) params.rm = rm;
const response = await this.request('get_motioner', params);
return response.dokument || response.motions || [];
}
/**
* Search riksdag documents
*
* @param {Object} searchParams - Search parameters
* @param {string} searchParams.sok - Search query
* @param {string} searchParams.doktyp - Document type (mot, prop, bet, etc.)
* @param {string} searchParams.rm - Riksmöte
* @param {string} searchParams.from_date - From date
* @param {string} searchParams.to_date - To date
* @param {number} searchParams.limit - Result limit
* @returns {Promise<Array>} Documents
*/
async searchDocuments(searchParams) {
const response = await this.request('search_dokument', searchParams);
return response.documents || [];
}
/**
* Search speeches/debates (anföranden)
*
* @param {Object} searchParams - Search parameters
* @param {string} searchParams.sok - Search query
* @param {string} searchParams.rm - Riksmöte
* @param {string} searchParams.talare - Speaker name
* @param {string} searchParams.parti - Party
* @param {number} searchParams.limit - Result limit
* @returns {Promise<Array>} Speeches
*/
async searchSpeeches(searchParams) {
const response = await this.request('search_anforanden', searchParams);
return response.speeches || [];
}
/**
* Fetch MPs (ledamöter)
*
* @param {Object} filters - Optional filters
* @param {string} filters.parti - Party filter
* @param {string} filters.valkrets - Electoral district
* @param {string} filters.status - Status filter
* @param {number} filters.limit - Result limit
* @returns {Promise<Array>} MPs
*/
async fetchMPs(filters = {}) {
const response = await this.request('search_ledamoter', filters);
return response.mps || [];
}
/**
* Fetch voting records
*
* @param {Object} filters - Filter parameters
* @param {string} filters.rm - Riksmöte
* @param {string} filters.bet - Document reference
* @param {string} filters.punkt - Voting point
* @returns {Promise<Array>} Voting records
*/
async fetchVotingRecords(filters) {
const response = await this.request('search_voteringar', filters);
return response.votes || [];
}
/**
* Fetch government documents (pressmeddelanden, SOU, etc.)
*
* @param {Object} searchParams - Search parameters
* @param {string} searchParams.type - Document type
* @param {string} searchParams.title - Title search
* @param {string} searchParams.dateFrom - From date
* @param {string} searchParams.dateTo - To date
* @param {number} searchParams.limit - Result limit
* @returns {Promise<Array>} Government documents
*/
async fetchGovernmentDocuments(searchParams) {
const response = await this.request('search_regering', searchParams);
return response.documents || [];
}
/**
* Fetch detailed document with full content
*
* @param {string} dok_id - Document ID
* @param {boolean} include_full_text - Include full document text (default: true)
* @returns {Promise<Object>} Document with content
*/
async fetchDocumentDetails(dok_id, include_full_text = true) {
const response = await this.request('get_dokument_innehall', {
dok_id,
include_full_text
});
return response || {};
}
/**
* Batch fetch document details for multiple documents
* Fetches in parallel with rate limiting to avoid overwhelming the server
*
* @param {Array<Object>} documents - Array of document objects with dok_id
* @param {number} concurrency - Max parallel requests (default: 3)
* @returns {Promise<Array>} Documents with enriched content
*/
async enrichDocumentsWithContent(documents, concurrency = 3) {
// Validate and clamp concurrency to at least 1
concurrency = Math.max(1, Math.floor(concurrency));
const enriched = [];
// Process in batches to avoid overwhelming the MCP server
for (let i = 0; i < documents.length; i += concurrency) {
const batch = documents.slice(i, i + concurrency);
const batchResults = await Promise.allSettled(
batch.map(async (doc) => {
try {
const dok_id = doc.dokumentnamn || doc.dok_id || doc.id;
if (!dok_id) {
console.warn('⚠️ Document missing ID:', doc);
return { ...doc, contentFetchError: 'No document ID' };
}
const details = await this.fetchDocumentDetails(dok_id, false); // Start with metadata only
// Extract author and party information from document metadata
const intressent = details.intressent || {};
const author = intressent.tilltalsnamn
? `${intressent.tilltalsnamn} ${intressent.efternamn}`.trim()
: (doc.intressent_namn || intressent.namn || 'Unknown');
const party = intressent.parti || doc.parti || 'Unknown';
// Get summary from existing field or generate placeholder
const summary = details.summary || doc.summary || details.notis || doc.notis || '';
return {
...doc,
...details,
author,
parti: party,
intressent_namn: author,
summary,
contentFetched: true
};
} catch (error) {
console.error(`❌ Failed to enrich document ${dok_id || 'unknown'}:`, error.message);
return { ...doc, contentFetchError: error.message };
}
})
);
// Extract successful results
batchResults.forEach((result, idx) => {
if (result.status === 'fulfilled') {
enriched.push(result.value);
} else {
const failedDoc = batch[idx];
const failedDokId = failedDoc.dokumentnamn || failedDoc.dok_id || failedDoc.id || 'unknown';
console.error(`❌ Batch enrichment failed for document ${failedDokId}:`, result.reason);
enriched.push({ ...failedDoc, contentFetchError: result.reason.message });
}
});
// Small delay between batches to be respectful to the MCP server
if (i + concurrency < documents.length) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
return enriched;
}
/**
* Get request statistics
*
* @returns {Object} Statistics
*/
getStats() {
return {
requests: this.requestCount,
errors: this.errorCount,
successRate: this.requestCount > 0
? Math.round((this.requestCount - this.errorCount) / this.requestCount * 100) + '%'
: '0%'
};
}
/**
* Reset statistics
*/
resetStats() {
this.requestCount = 0;
this.errorCount = 0;
}
}
/**
* Singleton instance for convenience
*/
let defaultClient = null;
export function getDefaultClient() {
if (!defaultClient) {
defaultClient = new MCPClient();
}
return defaultClient;
}
/**
* Convenience functions using default client
*/
export async function fetchCalendarEvents(...args) {
return getDefaultClient().fetchCalendarEvents(...args);
}
export async function fetchCommitteeReports(...args) {
return getDefaultClient().fetchCommitteeReports(...args);
}
export async function fetchPropositions(...args) {
return getDefaultClient().fetchPropositions(...args);
}
export async function fetchMotions(...args) {
return getDefaultClient().fetchMotions(...args);
}
export async function searchDocuments(...args) {
return getDefaultClient().searchDocuments(...args);
}
export async function searchSpeeches(...args) {
return getDefaultClient().searchSpeeches(...args);
}
export async function fetchMPs(...args) {
return getDefaultClient().fetchMPs(...args);
}
export async function fetchVotingRecords(...args) {
return getDefaultClient().fetchVotingRecords(...args);
}
export async function fetchGovernmentDocuments(...args) {
return getDefaultClient().fetchGovernmentDocuments(...args);
}
export async function fetchDocumentDetails(...args) {
return getDefaultClient().fetchDocumentDetails(...args);
}
export async function enrichDocumentsWithContent(...args) {
return getDefaultClient().enrichDocumentsWithContent(...args);
}
export default MCPClient;