# All files / src/tools getAdoptedTextsFeed.ts

92.72% Statements 51/55
79.41% Branches 27/34
100% Functions 5/5
97.82% Lines 45/46

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

    4x

    12x
    12x
    12x
    12x
    12x
    11x
    11x
    11x
    11x
    11x

    12x
    12x
    12x

    2x
    1x

    1x

    1x
    12x

    12x

    12x

    10x
    10x

    10x

    16x
    16x

    1x
    1x
    1x

    15x
    15x
    15x
    15x
    15x
    15x

    13x
    13x
    16x
    16x
    12x
    12x

    1x

    2x
    1x

    4x

     /**
     * MCP Tool: get_adopted_texts_feed
     *
     * Get recently updated European Parliament adopted texts from the feed.
     *
     * **EP API Endpoint:**
     * - `GET /adopted-texts/feed`
     *
     * **Freshness fallback (PRIORITY):** The EP `/adopted-texts/feed` endpoint
     * has been observed returning historical backfill (TA-9-2024 / TA-10-2025)
     * even under `timeframe: "today"` since at least 2026-04-14, with no
     * current-year items reaching consumers. See
     * Hack23/euparliamentmonitor 2026-04-24 breaking audit §1.2 and
     * 2026-04-24 propositions audit Defect #7. To make recent documents
     * findable while the upstream feed is degraded, this handler augments the
     * feed payload with the current calendar year of `/adopted-texts` (which
     * accepts a `year` filter) whenever the feed contains no current-year
     * items, and surfaces a `dataQualityWarning` so callers can distinguish
     * fallback augmentation from a healthy feed response.
     *
     * ISMS Policy: SC-002 (Input Validation), AC-003 (Least Privilege)
     */

    import { GetAdoptedTextsFeedSchema } from '../schemas/europeanParliament.js';
    import { epClient } from '../clients/europeanParliamentClient.js';
    import { ToolError } from './shared/errors.js';
    import { isUpstream404, buildEmptyFeedResponse, buildFeedSuccessResponse } from './shared/feedUtils.js';
    import { z } from 'zod';
    import type { ToolResult } from './shared/types.js';

    /**
     * Maximum number of current-year `/adopted-texts` items to merge into the
     * feed payload when the feed itself returned no fresh items.  Bounded so
     * the augmented response stays the same order of magnitude as a normal
     * feed reply.
     */
    const FRESHNESS_FALLBACK_LIMIT = 50;

    /**
     * Returns `true` when the supplied feed item carries a current-year
     * marker. We inspect the canonical `dateAdopted` field, the generated
     * TA-identifier (`TA-{term}-{year}-{nnnn}`), and the `reference` field
     * because the EP feed payload is JSON-LD and individual items may omit
     * some of these markers. Anything else (missing string, malformed) is
     * treated as "not current-year", so this function returns `false` and
     * the freshness fallback may run.
     *
     * @internal
     */
    function isCurrentYearItem(item: unknown, currentYear: number): boolean {
      Iif (item === null || typeof item !== 'object') return false;
      const obj = item as Record<string, unknown>;
      const yearStr = String(currentYear);
      const dateAdopted = obj['dateAdopted'];
      if (typeof dateAdopted === 'string' && dateAdopted.startsWith(yearStr)) return true;
      const id = obj['id'];
      Iif (typeof id === 'string' && id.includes(`-${yearStr}-`)) return true;
      const reference = obj['reference'];
      Iif (typeof reference === 'string' && reference.includes(`(${yearStr})`)) return true;
      return false;
    }

    /**
     * Best-effort freshness augmentation: pull the current calendar year of
     * `/adopted-texts` and merge into the feed payload when no current-year
     * items were returned.  Failures are swallowed (the feed response is
     * still returned) but a warning is added.
     *
     * @internal
     */
    async function augmentWithCurrentYear(
      feedResult: Record<string, unknown>,
      currentYear: number,
    ): Promise<{ result: Record<string, unknown>; warnings: string[] }> {
      const warnings: string[] = [];
      try {
        const recent = await epClient.getAdoptedTexts({
          year: currentYear,
          limit: FRESHNESS_FALLBACK_LIMIT,
          offset: 0,
        });
        if (recent.data.length === 0) {
          warnings.push(
            `FRESHNESS_FALLBACK_FAILED: EP /adopted-texts/feed returned no items from the current year (${String(currentYear)}), ` +
              `/adopted-texts?year=${String(currentYear)} also returned 0 items, and the original feed response is being ` +
              `returned unchanged — consider retrying later or widening the search.`,
          );
          return { result: feedResult, warnings };
        }
        const existingData = Array.isArray(feedResult['data']) ? (feedResult['data'] as unknown[]) : [];
        const augmented = {
          ...feedResult,
          data: [...recent.data, ...existingData],
        };
        warnings.push(
          `FRESHNESS_FALLBACK: EP /adopted-texts/feed returned no items from the current year (${String(currentYear)}). ` +
            `Augmented response with ${String(recent.data.length)} item(s) from /adopted-texts?year=${String(currentYear)} ` +
            `(non-feed endpoint, sorted by EP API default). Items prefixed before any existing feed items.`,
        );
        return { result: augmented, warnings };
      } catch (error: unknown) {
        const msg = error instanceof Error ? error.message : 'unknown error';
        warnings.push(
          `FRESHNESS_FALLBACK_FAILED: EP /adopted-texts/feed returned no current-year items and the freshness ` +
            `fallback against /adopted-texts?year=${String(currentYear)} also failed (${msg}). ` +
            `Caller should retry or widen the timeframe.`,
        );
        return { result: feedResult, warnings };
      }
    }

    /**
     * Handles the get_adopted_texts_feed MCP tool request.
     *
     * @param args - Raw tool arguments, validated against {@link GetAdoptedTextsFeedSchema}
     * @returns MCP tool result containing recently updated adopted text data
     * @security Input is validated with Zod before any API call.
     */
    export async function handleGetAdoptedTextsFeed(args: unknown): Promise<ToolResult> {
      let params: ReturnType<typeof GetAdoptedTextsFeedSchema.parse>;
      try {
        params = GetAdoptedTextsFeedSchema.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_adopted_texts_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.workType !== undefined) apiParams['workType'] = params.workType;
        const result = await epClient.getAdoptedTextsFeed(apiParams);

        const currentYear = new Date().getUTCFullYear();
        const items = Array.isArray(result['data']) ? (result['data'] as unknown[]) : [];
        const hasCurrentYear = items.some((item) => isCurrentYearItem(item, currentYear));
        if (!hasCurrentYear) {
          const { result: augmented, warnings } = await augmentWithCurrentYear(result, currentYear);
          return buildFeedSuccessResponse(augmented, warnings);
        }

        return buildFeedSuccessResponse(result);
      } catch (error: unknown) {
        if (isUpstream404(error)) return buildEmptyFeedResponse();
        throw new ToolError({
          toolName: 'get_adopted_texts_feed',
          operation: 'fetchData',
          message: 'Failed to retrieve adopted texts feed',
          isRetryable: true,
          cause: error,
        });
      }
    }
    /** Tool metadata for get_adopted_texts_feed */
    export const getAdoptedTextsFeedToolMetadata = {
      name: 'get_adopted_texts_feed',
      description:
        'Get recently updated European Parliament adopted texts from the feed. Returns adopted texts published or updated during the specified timeframe. Data source: European Parliament Open Data Portal. NOTE: When the EP /adopted-texts/feed endpoint returns no items from the current calendar year (a known degraded-upstream pattern), the response is automatically augmented with /adopted-texts?year={currentYear} so callers can still discover recent documents — a FRESHNESS_FALLBACK warning is surfaced in dataQualityWarnings whenever this happens.',
      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"',
          },
          workType: { type: 'string', description: 'Work type filter' },
        },
      },
    };