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

90.24% Statements 37/41
80% Branches 24/30
50% Functions 1/2
91.89% Lines 34/37

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                                  9x 9x 9x   9x                                           9x                         21x             21x 21x     21x 21x                   15x   15x 1x             7x 6x 7x   21x     14x 4x       10x 10x               10x 1x 1x     9x   21x 21x   6x 6x             6x 6x       3x 21x   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, 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',
      );
    }
    const events = inner['kalender'] ?? inner['events'];
    Eif (Array.isArray(events)) return events as unknown[];
    return [];
  }
 
  const direct = result['kalender'] ?? result['events'];
  if (Array.isArray(direct)) return direct as unknown[];
 
  return [];
}