Source: scripts/mcp-client.js

#!/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;