All files / src/tools getLatestVotes.ts

100% Statements 18/18
90% Branches 9/10
100% Functions 5/5
100% Lines 18/18

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                                                          7x 7x         3x           4x                                                                   11x                                                                     13x 13x   8x 7x   8x 8x     7x               1x     5x 5x                 4x   1x                         4x                                                                                      
/**
 * MCP Tool: get_latest_votes
 *
 * Retrieve the latest plenary votes from European Parliament DOCEO XML documents.
 * This provides more recent data than the standard EP Open Data API, which has
 * a publication delay of several weeks for roll-call voting data.
 *
 * **Data Sources:**
 * - RCV (Roll-Call Votes): Individual MEP positions by political group
 * - VOT (Vote Results): Aggregate tallies with vote outcomes
 *
 * **Intelligence Perspective:** Near-real-time voting intelligence enabling rapid
 * coalition analysis, political group cohesion tracking, and early detection of
 * political shifts within hours of votes being cast.
 *
 * **Business Perspective:** Premium data freshness differentiator — provides
 * vote data days/weeks before the EP Open Data API publishes it.
 *
 * ISMS Policy: SC-002 (Input Validation), AC-003 (Least Privilege)
 */
 
import { z } from 'zod';
import { doceoClient } from '../clients/ep/doceoClient.js';
import { buildToolResponse } from './shared/responseBuilder.js';
import { ToolError } from './shared/errors.js';
import type { ToolResult } from './shared/types.js';
 
/** Returns true when str is a real calendar date (rejects e.g. "2026-13-40"). */
function isValidCalendarDate(str: string): boolean {
  const d = new Date(`${str}T00:00:00Z`);
  return !isNaN(d.getTime()) && d.toISOString().slice(0, 10) === str;
}
 
/** Returns true when str parses to a Monday (UTC). */
function isMonday(str: string): boolean {
  return new Date(`${str}T00:00:00Z`).getUTCDay() === 1;
}
 
/**
 * Input schema for get_latest_votes tool.
 */
export const GetLatestVotesSchema = z.object({
  date: z.string()
    .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format')
    .refine(isValidCalendarDate, { message: 'date must be a valid calendar date' })
    .optional()
    .describe('Specific date to fetch votes for (YYYY-MM-DD). Mutually exclusive with weekStart. If omitted, fetches the requested or most recent plenary week.'),
  weekStart: z.string()
    .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format')
    .refine(isValidCalendarDate, { message: 'weekStart must be a valid calendar date' })
    .refine(isMonday, { message: 'weekStart must be a Monday (the start of the plenary week)' })
    .optional()
    .describe('Monday of a specific plenary week (YYYY-MM-DD). Mutually exclusive with date. Fetches Mon-Thu of that week.'),
  term: z.number()
    .int()
    .min(1)
    .max(15)
    .optional()
    .describe('Parliamentary term number (defaults to 10 for current 2024-2029 term)'),
  includeIndividualVotes: z.boolean()
    .optional()
    .default(true)
    .describe('Include individual MEP vote positions from roll-call data (default: true)'),
  limit: z.number()
    .int()
    .min(1)
    .max(100)
    .default(50)
    .describe('Maximum number of vote records to return'),
  offset: z.number()
    .int()
    .min(0)
    .default(0)
    .describe('Pagination offset'),
}).strict().refine(
  (data) => data.date === undefined || data.weekStart === undefined,
  {
    message: 'date and weekStart are mutually exclusive; provide only one, or omit both for the most recent plenary week',
    path: ['weekStart'],
  }
);
 
/**
 * Handles the get_latest_votes MCP tool request.
 *
 * Fetches the latest plenary vote data from the EP DOCEO XML system,
 * providing near-real-time access to roll-call votes and vote results.
 * This data source is fresher than the EP Open Data API which has a
 * multi-week publication delay.
 *
 * @param args - Raw tool arguments, validated against GetLatestVotesSchema
 * @returns MCP tool result with latest vote records
 *
 * @example
 * ```typescript
 * // Get votes from the most recent plenary week
 * const result = await handleGetLatestVotes({});
 *
 * // Get votes for a specific date
 * const result = await handleGetLatestVotes({ date: '2026-04-27' });
 *
 * // Get a specific plenary week without individual MEP positions
 * const result = await handleGetLatestVotes({
 *   weekStart: '2026-04-27',
 *   includeIndividualVotes: false
 * });
 * ```
 */
export async function handleGetLatestVotes(args: unknown): Promise<ToolResult> {
  let params: z.infer<typeof GetLatestVotesSchema>;
  try {
    params = GetLatestVotesSchema.parse(args);
  } catch (error: unknown) {
    if (error instanceof z.ZodError) {
      const fieldErrors = error.issues
        .map(issue => {
          const pathStr = issue.path.length > 0 ? issue.path.join('.') : '(root)';
          return `${pathStr}: ${issue.message}`;
        })
        .join('; ');
      throw new ToolError({
        toolName: 'get_latest_votes',
        operation: 'validateInput',
        message: `Invalid parameters: ${fieldErrors}`,
        isRetryable: false,
        cause: error,
      });
    }
    throw error;
  }
 
  try {
    const result = await doceoClient.getLatestVotes({
      date: params.date,
      weekStart: params.weekStart,
      term: params.term,
      includeIndividualVotes: params.includeIndividualVotes,
      limit: params.limit,
      offset: params.offset,
    });
 
    return buildToolResponse(result);
  } catch (error: unknown) {
    throw new ToolError({
      toolName: 'get_latest_votes',
      operation: 'fetchData',
      message: 'Failed to retrieve latest votes from DOCEO',
      isRetryable: true,
      cause: error instanceof Error ? error : undefined,
    });
  }
}
 
/**
 * Tool metadata for MCP registration
 */
export const getLatestVotesToolMetadata = {
  name: 'get_latest_votes',
  description: 'Retrieve the latest plenary votes from EP DOCEO XML documents. Provides near-real-time access to roll-call votes (individual MEP positions by political group) and vote results (aggregate tallies). This data source is fresher than the EP Open Data API which has a multi-week publication delay. Use for up-to-date political intelligence on voting patterns, coalition analysis, and group cohesion tracking.',
  inputSchema: {
    type: 'object' as const,
    properties: {
      date: {
        type: 'string',
        description: 'Specific date to fetch votes for (YYYY-MM-DD). Mutually exclusive with weekStart. If omitted, fetches the requested or most recent plenary week (Mon-Thu).',
        pattern: '^\\d{4}-\\d{2}-\\d{2}$'
      },
      weekStart: {
        type: 'string',
        description: 'Monday of a specific plenary week (YYYY-MM-DD). Mutually exclusive with date. Fetches Mon-Thu of that week.',
        pattern: '^\\d{4}-\\d{2}-\\d{2}$'
      },
      term: {
        type: 'number',
        description: 'Parliamentary term number (defaults to 10 for current 2024-2029 term)',
        minimum: 1,
        maximum: 15
      },
      includeIndividualVotes: {
        type: 'boolean',
        description: 'Include individual MEP vote positions from roll-call data (default: true). Set to false for aggregate-only results.',
        default: true
      },
      limit: {
        type: 'number',
        description: 'Maximum number of vote records to return (1-100)',
        minimum: 1,
        maximum: 100,
        default: 50
      },
      offset: {
        type: 'number',
        description: 'Pagination offset',
        minimum: 0,
        default: 0
      }
    }
  }
};