All files / scripts/fetch-calendar/mcp client.ts

93.33% Statements 42/45
85.71% Branches 36/42
50% Functions 1/2
95.12% Lines 39/41

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156                                  9x 9x 9x   9x                                           9x                         28x             28x 28x     28x 28x                   22x   22x 1x             7x 6x 7x   28x     21x 4x       17x 17x               17x 1x 1x     16x   28x 28x   11x 11x   1x               10x 4x           6x 11x       5x 2x             3x 28x   1x    
/**
 * @module scripts/fetch-calendar/mcp/client
 * @description JSON-RPC 2.0 client for the riksdag-regering MCP
 * `get_calendar_events` tool.
 *
 * Throws a typed `CalendarMcpError` on transport, HTTP and protocol errors
 * so the orchestrator can distinguish HTML error pages (no retry — fall
 * straight back to the web scraper) from network blips (retry).
 *
 * @author Hack23 AB
 * @license Apache-2.0
 */
 
import type { CalendarFetchConfig } from '../types.js';
import { CalendarMcpError, isDegradedKalenderSentinel, isHtmlErrorResponse } from './errors.js';
 
export const DEFAULT_MCP_URL =
  process.env['MCP_SERVER_URL'] ?? 'https://riksdag-regering-ai.onrender.com/mcp';
export const DEFAULT_TIMEOUT = 15_000;
export const DEFAULT_MAX_RETRIES = 2;
/** Retry base delay (ms); doubled on each subsequent attempt. */
export const RETRY_BASE_DELAY_MS = 1_000;
 
/** Minimum JSON-RPC 2.0 envelope for a `tools/call` request. */
interface JsonRpcRequest {
  jsonrpc: '2.0';
  id: number;
  method: 'tools/call';
  params: { name: string; arguments: Record<string, unknown> };
}
 
/** Partial shape of a JSON-RPC 2.0 response (only the fields we use). */
interface JsonRpcResponse {
  result?: {
    content?: Array<{ text?: string }>;
    kalender?: unknown[];
    events?: unknown[];
    [key: string]: unknown;
  };
  error?: { message?: string; [key: string]: unknown };
  [key: string]: unknown;
}
 
let _rpcId = 1;
 
/**
 * Call the riksdag-regering MCP `get_calendar_events` tool via a single
 * JSON-RPC 2.0 POST.  Throws a typed `CalendarMcpError` on any transport,
 * HTTP, or protocol error so callers can distinguish HTML responses from
 * genuine tool failures.
 */
export async function callMcpCalendarEvents(
  from: string,
  tom: string,
  config: Required<Pick<CalendarFetchConfig, 'mcpUrl' | 'timeout' | 'fetchFn'>>,
): Promise<unknown[]> {
  const body: JsonRpcRequest = {
    jsonrpc: '2.0',
    id: _rpcId++,
    method: 'tools/call',
    params: { name: 'get_calendar_events', arguments: { from, tom } },
  };
 
  const controller = new AbortController();
  const tid = setTimeout(() => controller.abort(), config.timeout);
 
  let responseText: string;
  try {
    const response = await config.fetchFn(config.mcpUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json, text/event-stream',
      },
      body: JSON.stringify(body),
      signal: controller.signal,
    });
 
    responseText = await response.text();
 
    if (!response.ok) {
      throw new CalendarMcpError(
        `MCP HTTP error: ${response.status} ${response.statusText}`,
        isHtmlErrorResponse(responseText) ? 'html' : 'http',
        responseText,
      );
    }
  } catch (err) {
    if (err instanceof CalendarMcpError) throw err;
    const msg = err instanceof Error ? err.message : String(err);
    throw new CalendarMcpError(`MCP fetch failed: ${msg}`, 'network');
  } finally {
    clearTimeout(tid);
  }
 
  if (isHtmlErrorResponse(responseText)) {
    throw new CalendarMcpError('MCP returned HTML instead of JSON', 'html', responseText);
  }
 
  let rpc: JsonRpcResponse;
  try {
    rpc = JSON.parse(responseText) as JsonRpcResponse;
  } catch {
    throw new CalendarMcpError(
      `MCP response is not valid JSON: ${responseText.slice(0, 120)}`,
      'json',
    );
  }
 
  if (rpc.error) {
    const msg = rpc.error.message ?? JSON.stringify(rpc.error);
    throw new CalendarMcpError(`MCP tool error: ${msg}`, 'tool');
  }
 
  const result = rpc.result ?? {};
 
  const content = result['content'] as Array<{ text?: string }> | undefined;
  if (Array.isArray(content) && content[0]?.text) {
    let inner: Record<string, unknown>;
    try {
      inner = JSON.parse(content[0].text) as Record<string, unknown>;
    } catch {
      throw new CalendarMcpError(
        `MCP content text is not valid JSON: ${content[0].text.slice(0, 120)}`,
        'json',
      );
    }
    // The server wraps an upstream HTML error in a "successful" envelope with
    // an empty events array — detect it so the orchestrator falls back to the
    // web scraper instead of trusting a fake zero-event window.
    if (isDegradedKalenderSentinel(inner)) {
      throw new CalendarMcpError(
        `MCP kalender API degraded: ${String(inner['error'] ?? 'upstream HTML error')}`,
        'html',
        typeof inner['rawHtml'] === 'string' ? inner['rawHtml'] : undefined,
      );
    }
    const events = inner['kalender'] ?? inner['events'];
    if (Array.isArray(events)) return events as unknown[];
    return [];
  }
 
  if (isDegradedKalenderSentinel(result)) {
    throw new CalendarMcpError(
      `MCP kalender API degraded: ${String(result['error'] ?? 'upstream HTML error')}`,
      'html',
      typeof result['rawHtml'] === 'string' ? result['rawHtml'] : undefined,
    );
  }
 
  const direct = result['kalender'] ?? result['events'];
  if (Array.isArray(direct)) return direct as unknown[];
 
  return [];
}