# All files / src/tools getEventsFeed.ts

98.24% Statements 56/57
85.13% Branches 63/74
100% Functions 11/11
98.07% Lines 51/52

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
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291

    1x

    1x

    1x

    1x
    1x

    6x

    3x

    3x
    2x

    1x
    1x

    2x
    2x
    1x
    1x

    3x

    3x
    3x

    3x
    3x

    1x

    8x
    2x

    6x
    1x

    5x
    3x

    2x
    1x

    1x

    21x
    21x

    1x
    1x
    1x

    20x
    20x
    20x
    20x
    20x
    20x

    12x
    1x
    1x

    11x
    11x
    11x

    8x
    8x
    1x

    4x

     /**
     * MCP Tool: get_events_feed
     *
     * Get recently updated European Parliament events from the feed.
     *
     * **EP API Endpoint:**
     * - `GET /events/feed`
     *
     * ISMS Policy: SC-002 (Input Validation), AC-003 (Least Privilege)
     */

    import { GetEventsFeedSchema } from '../schemas/europeanParliament.js';
    import { epClient } from '../clients/europeanParliamentClient.js';
    import { ToolError } from './shared/errors.js';
    import {
      isUpstream404,
      buildEmptyFeedResponse,
      isErrorInBody,
      buildFeedSuccessResponse,
      extractUpstreamStatusCode,
      type FeedErrorMeta,
    } from './shared/feedUtils.js';
    import { APIError } from '../clients/ep/baseClient.js';
    import { TimeoutError } from '../utils/timeout.js';
    import { z } from 'zod';
    import type { ToolResult } from './shared/types.js';

    type EventsFeedParams = ReturnType<typeof GetEventsFeedSchema.parse>;
    type EventsFeedTimeframe = EventsFeedParams['timeframe'];

    /**
     * Build an in-band response for an error-in-body reply.
     *
     * @param rawError - Raw EP API error string from the response body
     * @internal
     */
    function buildEnrichmentFailedResponse(rawError: string): ToolResult {
      const upstreamStatusCode = extractUpstreamStatusCode(rawError);
      const upstream =
        upstreamStatusCode !== undefined || rawError !== ''
          ? {
              ...(upstreamStatusCode !== undefined && { statusCode: upstreamStatusCode }),
              ...(rawError !== '' && { errorMessage: rawError }),
            }
          : undefined;
      const meta: FeedErrorMeta = {
        errorCode: 'ENRICHMENT_FAILED',
        retryable: true,
        ...(upstream !== undefined ? { upstream } : {}),
      };
      const errorSuffix = rawError ? ` (upstream: ${rawError})` : '';
      return buildEmptyFeedResponse(
        `EP API returned an error-in-body response for get_events_feed — the upstream enrichment step may have failed${errorSuffix}.`,
        meta,
      );
    }

    /**
     * Detect timeout failures surfaced either directly or via the EP API wrapper.
     *
     * @param error - Error thrown by the EP API client or timeout utility
     * @returns true when the failure should be classified as UPSTREAM_TIMEOUT
     * @internal
     */
    function isTimeoutLikeError(error: unknown): boolean {
      return (
        error instanceof TimeoutError ||
        (error instanceof APIError && error.statusCode === 408)
      );
    }

    /**
     * Detect whether an APIError(429) originated from the local token-bucket
     * rate limiter rather than an upstream EP API response.
     *
     * BaseEPClient throws `APIError('Rate limit exceeded. Retry after …', 429)`
     * when the local limiter rejects a request *before* any HTTP call is made.
     * Upstream 429s use the standard `EP API request failed: 429` format.
     *
     * @internal
     */
    function isLocalRateLimit(error: APIError): boolean {
      return error.message.startsWith('Rate limit exceeded');
    }

    /**
     * Extract the suggested retry delay (in ms) from a rate-limit APIError.
     *
     * BaseEPClient attaches `{ retryAfterMs, remainingTokens }` to `error.details`
     * for local token-bucket rejections; this helper reads from `details` first
     * and falls back to parsing the `Retry after <n>ms` substring from the
     * message when `details` is unavailable (e.g. mocked test fixtures).
     *
     * @internal
     */
    function extractRetryAfterMsFromDetails(details: unknown): number | undefined {
      if (details === null || typeof details !== 'object' || !('retryAfterMs' in details)) {
        return undefined;
      }
      const ms = (details as { retryAfterMs?: unknown }).retryAfterMs;
      return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : undefined;
    }

    function extractRetryAfterMsFromMessage(message: string): number | undefined {
      const match = /Retry after (\d+)\s*ms/i.exec(message);
      if (match?.[1] === undefined) return undefined;
      const parsed = parseInt(match[1], 10);
      return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
    }

    function extractRetryAfterMs(error: APIError): number | undefined {
      return extractRetryAfterMsFromDetails(error.details) ?? extractRetryAfterMsFromMessage(error.message);
    }

    /**
     * Build the uniform feed envelope for HTTP 429 rate limits.
     *
     * Distinguishes local token-bucket rejections from upstream EP API 429s so
     * downstream consumers get accurate diagnostics. When the underlying error
     * exposes a `retryAfterMs` hint (always present for local limiter rejections),
     * surface it in both the human-readable `reason` and the machine-readable
     * `retryAfterMs` metadata field so automated clients can schedule retries
     * precisely instead of guessing.
     *
     * @param error - APIError carrying the rate-limit failure details
     * @returns MCP ToolResult with RATE_LIMIT metadata for downstream retry logic
     * @internal
     */
    function buildRateLimitResponse(error: APIError): ToolResult {
      const local = isLocalRateLimit(error);
      const retryAfterMs = extractRetryAfterMs(error);
      const retryHint =
        retryAfterMs !== undefined ? `retry after ${String(retryAfterMs)}ms` : 'retry after a short delay';
      return buildEmptyFeedResponse(
        local
          ? `Local rate limit reached for get_events_feed — ${retryHint}.`
          : `EP API rate limit reached for get_events_feed — ${retryHint}.`,
        {
          errorCode: 'RATE_LIMIT',
          retryable: true,
          ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
          ...(!local
            ? {
                upstream: {
                  statusCode: 429,
                  ...(error.message ? { errorMessage: error.message } : {}),
                },
              }
            : {}),
        },
      );
    }

    /**
     * Build the uniform feed envelope for retryable upstream 5xx failures.
     *
     * @param error - APIError carrying the upstream failure details
     * @param statusCode - HTTP 5xx status code to expose in the envelope
     * @returns MCP ToolResult with UPSTREAM_ERROR metadata
     * @internal
     */
    function buildUpstreamErrorResponse(error: APIError, statusCode: number): ToolResult {
      return buildEmptyFeedResponse(
        `EP API upstream error for get_events_feed — retry later or use get_events with limit/offset as a fallback.`,
        {
          errorCode: 'UPSTREAM_ERROR',
          retryable: true,
          upstream: {
            statusCode,
            ...(error.message ? { errorMessage: error.message } : {}),
          },
        },
      );
    }

    /**
     * Classify transient upstream errors into the uniform feed envelope.
     *
     * @param error - Caught error from the EP API client
     * @returns In-band ToolResult for known transient failures, or null
     * @internal
     */
    function handleUpstreamCatchError(error: unknown): ToolResult | null {
      if (isUpstream404(error)) {
        return buildEmptyFeedResponse(
          `EP API returned 404 for get_events_feed — no data available for the requested feed window.`,
          {
            errorCode: 'NOT_FOUND',
            retryable: false,
            upstream: {
              statusCode: 404,
              ...(error instanceof APIError && error.message ? { errorMessage: error.message } : {}),
            },
          },
        );
      }

      if (isTimeoutLikeError(error)) {
        return buildEmptyFeedResponse(
          `EP API request timed out for get_events_feed — the endpoint is known to be slow. ` +
            `Consider retrying with timeframe="one-week" or using get_events with limit/offset as a fallback.`,
          { errorCode: 'UPSTREAM_TIMEOUT', retryable: true },
        );
      }

      if (error instanceof APIError && error.statusCode === 429) {
        return buildRateLimitResponse(error);
      }

      if (error instanceof APIError && error.statusCode !== undefined && error.statusCode >= 500) {
        return buildUpstreamErrorResponse(error, error.statusCode);
      }

      return null;
    }

    /**
     * Handles the get_events_feed MCP tool request.
     *
     * @param args - Raw tool arguments, validated against {@link GetEventsFeedSchema}
     * @returns MCP tool result containing recently updated event data
     * @security Input is validated with Zod before any API call.
     */
    export async function handleGetEventsFeed(args: unknown): Promise<ToolResult> {
      let params: EventsFeedParams;
      try {
        params = GetEventsFeedSchema.parse(args);
      } catch (error: unknown) {
        Eif (error instanceof z.ZodError) {
          const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
          throw new ToolError({
            toolName: 'get_events_feed',
            operation: 'validateInput',
            message: `Invalid parameters: ${fieldErrors}`,
            isRetryable: false,
            cause: error,
          });
        }
        throw error;
      }

      try {
        const apiParams: Record<string, unknown> = {};
        apiParams['timeframe'] = params.timeframe;
        if (params.startDate !== undefined) apiParams['startDate'] = params.startDate;
        if (params.activityType !== undefined) apiParams['activityType'] = params.activityType;
        const result = await epClient.getEventsFeed(
          apiParams
        );
        if (isErrorInBody(result)) {
          const rawError = typeof result['error'] === 'string' ? result['error'] : '';
          return buildEnrichmentFailedResponse(rawError);
        }
        const timeframeLabel: EventsFeedTimeframe = params.timeframe;
        const emptyReason = `EP API events/feed returned no data for timeframe '${timeframeLabel}' — no events were updated in the requested period. Use get_events (with limit/offset) to browse a paginated list of events as a fallback.`;
        return buildFeedSuccessResponse(result, [], emptyReason);
      } catch (error: unknown) {
        const inBand = handleUpstreamCatchError(error);
        if (inBand !== null) return inBand;
        throw new ToolError({
          toolName: 'get_events_feed',
          operation: 'fetchData',
          message: 'Failed to retrieve events feed',
          isRetryable: true,
          cause: error,
        });
      }
    }
    /** Tool metadata for get_events_feed */
    export const getEventsFeedToolMetadata = {
      name: 'get_events_feed',
      description:
        'Get recently updated European Parliament events from the feed. Returns events published or updated during the specified timeframe. Data source: European Parliament Open Data Portal. NOTE: The EP API events/feed endpoint is significantly slower than other feeds, so this tool uses the global EP request timeout (default 60 seconds) and normalizes timeout/rate-limit/upstream failures into the feed envelope. For faster fallback browsing, use get_events with limit/offset.',
      inputSchema: {
        type: 'object' as const,
        properties: {
          timeframe: {
            type: 'string',
            description: 'Timeframe for the feed (today, one-day, one-week, one-month, custom)',
            enum: ['today', 'one-day', 'one-week', 'one-month', 'custom'],
            default: 'one-week',
          },
          startDate: {
            type: 'string',
            description: 'Start date (YYYY-MM-DD) — required when timeframe is "custom"',
          },
          activityType: { type: 'string', description: 'Activity type filter' },
        },
      },
    };