Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/prompts/02-mcp-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Run once at workflow start, then proceed:
| **Statskontoret is a public non-MCP source.** Use `web_fetch` / primary URLs, cite report title + URL, record retrieval in `data-download-manifest.md`. |
| **Lagrådet is a public non-MCP source.** Required for major-bill propositions per [`03-data-download.md §Lagrådet enrichment`](03-data-download.md). Cite referral URL + yttrande publication date; tag `referral pending` when no yttrande yet exists. |
| **Prior-voteringar enrichment** is standard: `search_voteringar` keyed by **topic keyword** (`avser`) or **full proposition beteckning** (e.g. `bet: "2024/25:JuU17"`, never a bare committee prefix like `JuU`) over the last 4 `rm` (riksmöten), for every committee-report, motion, interpellation cycle. Feeds `historical-parallels.md`, `coalition-mathematics.md`, `swot-analysis.md` evidence rows. See [`03-data-download.md §Prior-voteringar enrichment`](03-data-download.md) for the full query-shape contract and fallback hierarchy. |
| **Calendar is fetched via the resilient CLI, not the raw tool.** The raw `get_calendar_events` MCP tool returns a *successful* empty `events: []` result (with an `error`/`rawHtml` sentinel) when `data.riksdagen.se/kalender/` serves HTML — silently masking the outage as a zero-sitting week. Source the forward calendar from the pre-warmed **`data/runtime/calendar-status.json`** (written by `news-prewarm`) and/or **`npx tsx scripts/calendar-fetch.ts --from <YYYY-MM-DD> --to <YYYY-MM-DD>`**, which falls back MCP→public-page scraper and reports `status` (`ok`/`error`) + `path` (`mcp-primary`/`web-fallback`/`none`). If `status: error` / `path: none`, record a `[DATA GAP: calendar source degraded]` in `data-download-manifest.md` — never assert an empty sitting calendar from the raw tool. |
| Treat mid-run MCP failure as partial data: continue with what you have, document gaps in `data-download-manifest.md`, never silently drop documents. |
| Source authority and no-fabrication rule: see [`00-base-contract.md`](00-base-contract.md) rules 1 + 3. |

Expand Down
10 changes: 8 additions & 2 deletions analysis/templates/session-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
- **Cross-reference baseline** — Every `dok_id` cited here must resolve via `get_dokument`; every MP reference must carry an `intressent_id`
- **MCP tools** — `get_calendar_events`, `search_dokument`, `search_voteringar`, `search_anforanden`, `get_betankanden`, `get_propositioner`, `get_motioner`, `get_fragor`, `get_interpellationer`, `search_regering`

> **📅 Calendar sourcing (degraded-source guard)** — The raw `get_calendar_events` MCP tool is **brittle**: when `data.riksdagen.se/kalender/` serves an HTML error page the server still returns a *successful* result with an empty `events: []` array plus an `error`/`rawHtml` sentinel, which silently reads as a legitimate zero-event window. **Do not** treat an empty `get_calendar_events` result as ground truth. Prefer, in order:
> 1. The pre-warmed artifact **`data/runtime/calendar-status.json`** (written by the `news-prewarm` action) — check its `status` (`ok`/`error`) and `path` (`mcp-primary`/`web-fallback`/`none`) fields.
> 2. The resilient CLI **`scripts/calendar-fetch.ts --from <YYYY-MM-DD> --to <YYYY-MM-DD>`**, which falls back from MCP to the public-page scraper and reports `status`/`path` honestly.
>
> If both report `status: error` / `path: none`, record the calendar as a **DATA GAP** (`[DATA GAP: calendar source degraded]`) and log it in `mcp-reliability-audit.md §Failure analysis` — never fabricate a zero-sitting week.

---

<!-- TEMPLATE_CONTRACT_V1 -->
Expand Down Expand Up @@ -76,7 +82,7 @@ ANTI-TEMPLATE — DO NOT:

## 1️⃣ Kammaren (Plenary Sittings)

> Record each sitting separately. Populate from `get_calendar_events` (org=kammaren) and `search_dokument`.
> Record each sitting separately. Populate from `get_calendar_events` (org=kammaren) and `search_dokument` — see the **📅 Calendar sourcing** guard above; prefer `data/runtime/calendar-status.json` / `scripts/calendar-fetch.ts` over the raw tool.

### Sitting 1 — `[REQUIRED: date]`

Expand Down Expand Up @@ -106,7 +112,7 @@ ANTI-TEMPLATE — DO NOT:

## 2️⃣ Utskott (Committee) Sessions

> Populate from `get_calendar_events` (org=UTSK), `get_betankanden`, and direct committee-page queries.
> Populate from `get_calendar_events` (org=UTSK), `get_betankanden`, and direct committee-page queries — see the **📅 Calendar sourcing** guard above; prefer `data/runtime/calendar-status.json` / `scripts/calendar-fetch.ts` over the raw tool.

| Utskott | Datum | Tid | Typ (beslutsmöte/sammanträde) | Dagordning | Antagna betänkanden | Ordförande | Party | Source |
|---------|:-----:|:---:|:-----------------------------:|------------|:-------------------:|------------|:-----:|--------|
Expand Down
1 change: 1 addition & 0 deletions scripts/fetch-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type {
export {
CalendarMcpError,
HTML_PREFIX_RE,
isDegradedKalenderSentinel,
isHtmlErrorResponse,
} from './fetch-calendar/mcp/errors.js';
export { callMcpCalendarEvents } from './fetch-calendar/mcp/client.js';
Expand Down
20 changes: 19 additions & 1 deletion scripts/fetch-calendar/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import type { CalendarFetchConfig } from '../types.js';
import { CalendarMcpError, isHtmlErrorResponse } from './errors.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';
Expand Down Expand Up @@ -125,11 +125,29 @@ export async function callMcpCalendarEvents(
'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[];

Expand Down
27 changes: 27 additions & 0 deletions scripts/fetch-calendar/mcp/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,33 @@ export function isHtmlErrorResponse(text: string): boolean {
return HTML_PREFIX_RE.test(text);
}

/**
* Detect the riksdag-regering **degraded-kalender sentinel** payload.
*
* When the upstream `data.riksdagen.se/kalender/` endpoint serves an HTML
* error page instead of JSON, the MCP server does not surface a JSON-RPC
* error — it returns a *successful* tool result whose inner content is a
* sentinel envelope such as:
*
* ```json
* { "count": 0, "events": [], "rawHtml": "<script…",
* "error": "Riksdagens kalender-API returnerade HTML istället för JSON.",
* "notice": "API:et fungerar inte korrekt för närvarande.",
* "suggestions": [ … ] }
* ```
*
* The empty `events: []` array would otherwise be read as a legitimate
* zero-event window, masking the outage and suppressing the web-scraper
* fallback. Treat the presence of a non-empty `error` string or a `rawHtml`
* field as a degraded signal so the orchestrator falls straight back to the
* public-page scraper.
*/
export function isDegradedKalenderSentinel(inner: Record<string, unknown>): boolean {
const hasErrorString = typeof inner['error'] === 'string' && inner['error'].trim().length > 0;
const hasRawHtml = typeof inner['rawHtml'] === 'string' && inner['rawHtml'].trim().length > 0;
return hasErrorString || hasRawHtml;
}

/** Typed error for MCP transport / protocol failures. */
export class CalendarMcpError extends Error {
/** Error category. */
Expand Down
16 changes: 16 additions & 0 deletions scripts/mcp-client/methods/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@
*/

import type { MCPTransportClient } from '../transport/jsonrpc.js';
import { isDegradedKalenderSentinel } from '../../fetch-calendar/mcp/errors.js';

/**
* Fetch parliamentary calendar events between two dates, optionally
* filtered by organ (committee) and aktivitet (event type).
*
* Throws when the riksdag-regering server returns its degraded-kalender
* sentinel (an empty `events` array alongside an `error`/`rawHtml` field,
* emitted when `data.riksdagen.se/kalender/` serves an HTML error page).
* Surfacing the failure lets callers fall back to the public-page scraper
* via {@link module:scripts/fetch-calendar} instead of trusting a fake
* zero-event window.
*/
export async function fetchCalendarEvents(
transport: MCPTransportClient,
Expand All @@ -24,5 +32,13 @@ export async function fetchCalendarEvents(
if (akt) params['akt'] = akt;

const response = await transport.request('get_calendar_events', params);

if (isDegradedKalenderSentinel(response)) {
const errorText = response['error'];
throw new Error(
`get_calendar_events degraded: ${typeof errorText === 'string' ? errorText : 'upstream HTML error from data.riksdagen.se/kalender/'}`,
);
}

return (response['kalender'] ?? response['events'] ?? []) as unknown[];
}
121 changes: 121 additions & 0 deletions tests/fetch-calendar/mcp/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,125 @@ describe('callMcpCalendarEvents', () => {
const result = await callMcpCalendarEvents('2026-04-28', '2026-05-04', config);
expect(result).toEqual([]);
});

it('throws CalendarMcpError(html) on the degraded-kalender sentinel in content text', async () => {
// The server wraps an upstream HTML error in a "successful" envelope whose
// empty events array must NOT be read as a legitimate zero-event window.
const sentinel = {
count: 0,
events: [],
rawHtml: '<script>window.location…</script>',
error: 'Riksdagens kalender-API returnerade HTML istället för JSON.',
};
const config = {
mcpUrl: 'https://mcp.test/mcp',
timeout: 3_000,
fetchFn: jsonFetch({
jsonrpc: '2.0',
id: 1,
result: { content: [{ text: JSON.stringify(sentinel) }] },
}),
};

await expect(
callMcpCalendarEvents('2026-04-28', '2026-05-04', config),
).rejects.toMatchObject({ kind: 'html' });
});

it('throws CalendarMcpError(html) on a degraded sentinel in a direct result (no content wrapper)', async () => {
const config = {
mcpUrl: 'https://mcp.test/mcp',
timeout: 3_000,
fetchFn: jsonFetch({
jsonrpc: '2.0',
id: 1,
result: {
count: 0,
events: [],
error: 'Riksdagens kalender-API returnerade HTML istället för JSON.',
},
}),
};

await expect(
callMcpCalendarEvents('2026-04-28', '2026-05-04', config),
).rejects.toMatchObject({ kind: 'html' });
});

it('throws CalendarMcpError(json) when content text is malformed JSON', async () => {
const config = {
mcpUrl: 'https://mcp.test/mcp',
timeout: 3_000,
fetchFn: jsonFetch({
jsonrpc: '2.0',
id: 1,
result: { content: [{ text: '{not valid json' }] },
}),
};

await expect(
callMcpCalendarEvents('2026-04-28', '2026-05-04', config),
).rejects.toMatchObject({ kind: 'json' });
});

it('includes rawHtml in responseText when sentinel has rawHtml', async () => {
const rawHtml = '<script>window.location="/"</script>';
const sentinel = {
count: 0,
events: [],
rawHtml,
error: 'Riksdagens kalender-API returnerade HTML.',
};
const config = {
mcpUrl: 'https://mcp.test/mcp',
timeout: 3_000,
fetchFn: jsonFetch({
jsonrpc: '2.0',
id: 1,
result: { content: [{ text: JSON.stringify(sentinel) }] },
}),
};

await expect(
callMcpCalendarEvents('2026-04-28', '2026-05-04', config),
).rejects.toMatchObject({ kind: 'html', responseText: rawHtml });
});

it('includes the error string in the thrown message for degraded sentinel', async () => {
const errorMsg = 'Riksdagens kalender-API returnerade HTML istället för JSON.';
const sentinel = { count: 0, events: [], error: errorMsg };
const config = {
mcpUrl: 'https://mcp.test/mcp',
timeout: 3_000,
fetchFn: jsonFetch({
jsonrpc: '2.0',
id: 1,
result: { content: [{ text: JSON.stringify(sentinel) }] },
}),
};

await expect(
callMcpCalendarEvents('2026-04-28', '2026-05-04', config),
).rejects.toThrow(errorMsg);
});

it('throws CalendarMcpError(html) on a rawHtml-only sentinel (no error field)', async () => {
const config = {
mcpUrl: 'https://mcp.test/mcp',
timeout: 3_000,
fetchFn: jsonFetch({
jsonrpc: '2.0',
id: 1,
result: {
count: 0,
events: [],
rawHtml: '<!DOCTYPE html><html><body>503 Service Unavailable</body></html>',
},
}),
};

await expect(
callMcpCalendarEvents('2026-04-28', '2026-05-04', config),
).rejects.toMatchObject({ kind: 'html' });
});
});
60 changes: 60 additions & 0 deletions tests/fetch-calendar/mcp/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { describe, it, expect } from 'vitest';
import {
isHtmlErrorResponse,
isDegradedKalenderSentinel,
CalendarMcpError,
HTML_PREFIX_RE,
} from '../../../scripts/fetch-calendar.js';
Expand Down Expand Up @@ -83,6 +84,65 @@ describe('HTML_PREFIX_RE (regex guard against future drift)', () => {
});
});

describe('isDegradedKalenderSentinel', () => {
it('returns true when an upstream error string is present', () => {
expect(
isDegradedKalenderSentinel({
count: 0,
events: [],
error: 'Riksdagens kalender-API returnerade HTML istället för JSON.',
}),
).toBe(true);
});

it('returns true when a rawHtml field is present', () => {
expect(
isDegradedKalenderSentinel({ count: 0, events: [], rawHtml: '<script>…</script>' }),
).toBe(true);
});

it('returns true when both error and rawHtml are present', () => {
expect(
isDegradedKalenderSentinel({
count: 0,
events: [],
error: 'API degraded',
rawHtml: '<!DOCTYPE html><html></html>',
notice: 'API:et fungerar inte korrekt för närvarande.',
suggestions: ['Försök igen senare'],
}),
).toBe(true);
});

it('returns true for rawHtml-only sentinel without error field', () => {
expect(
isDegradedKalenderSentinel({ count: 0, events: [], rawHtml: '<html><body>503</body></html>' }),
).toBe(true);
});

it('returns false for a legitimate empty calendar window', () => {
expect(isDegradedKalenderSentinel({ count: 0, events: [] })).toBe(false);
expect(isDegradedKalenderSentinel({ kalender: [] })).toBe(false);
});

it('returns false when error/rawHtml are empty or whitespace', () => {
expect(isDegradedKalenderSentinel({ events: [], error: '' })).toBe(false);
expect(isDegradedKalenderSentinel({ events: [], rawHtml: ' ' })).toBe(false);
});

it('returns false when error/rawHtml are non-string types', () => {
expect(isDegradedKalenderSentinel({ events: [], error: null })).toBe(false);
expect(isDegradedKalenderSentinel({ events: [], rawHtml: 0 })).toBe(false);
expect(isDegradedKalenderSentinel({ events: [], error: undefined })).toBe(false);
expect(isDegradedKalenderSentinel({ events: [], rawHtml: true })).toBe(false);
expect(isDegradedKalenderSentinel({ events: [], error: [] })).toBe(false);
});

it('returns false for an empty object', () => {
expect(isDegradedKalenderSentinel({})).toBe(false);
});
});

describe('CalendarMcpError', () => {
it('has the correct name and kind', () => {
const err = new CalendarMcpError('test error', 'html', '<html>error</html>');
Expand Down
Loading
Loading