uk-parliament-mcp 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """UK Parliament MCP Server - bridges AI assistants with UK Parliament APIs."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,22 @@
1
+ """Entry point for the UK Parliament MCP Server."""
2
+ import logging
3
+ import sys
4
+
5
+ from uk_parliament_mcp.server import create_server
6
+
7
+
8
+ def main() -> None:
9
+ """Run the MCP server with stdio transport."""
10
+ # Configure logging to stderr (stdout is for MCP protocol)
11
+ logging.basicConfig(
12
+ level=logging.INFO,
13
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
14
+ stream=sys.stderr,
15
+ )
16
+
17
+ server = create_server()
18
+ server.run(transport="stdio")
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,159 @@
1
+ """HTTP client with retry logic for Parliament API requests."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+ from urllib.parse import urlencode
9
+
10
+ import httpx
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Configuration constants (matching C# implementation)
15
+ HTTP_TIMEOUT = 30.0 # seconds
16
+ MAX_RETRY_ATTEMPTS = 3
17
+ RETRY_DELAY_BASE = 1.0 # seconds
18
+
19
+ # HTTP status codes that should trigger a retry
20
+ TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
21
+
22
+
23
+ def build_url(base_url: str, parameters: dict[str, Any]) -> str:
24
+ """
25
+ Build URL with query parameters, filtering out None and empty values.
26
+
27
+ Equivalent to C# BaseTools.BuildUrl()
28
+
29
+ Args:
30
+ base_url: The base URL without query parameters
31
+ parameters: Dictionary of parameter names to values
32
+
33
+ Returns:
34
+ URL with query string, or just base_url if no valid parameters
35
+ """
36
+ valid_params = {
37
+ k: str(v).lower() if isinstance(v, bool) else str(v)
38
+ for k, v in parameters.items()
39
+ if v is not None and v != ""
40
+ }
41
+
42
+ if not valid_params:
43
+ return base_url
44
+
45
+ return f"{base_url}?{urlencode(valid_params)}"
46
+
47
+
48
+ def _is_retryable_status(status_code: int) -> bool:
49
+ """Check if HTTP status code is transient and should be retried."""
50
+ return status_code in TRANSIENT_STATUS_CODES
51
+
52
+
53
+ class ParliamentHTTPClient:
54
+ """Async HTTP client with retry logic for Parliament APIs."""
55
+
56
+ def __init__(self) -> None:
57
+ self._client: httpx.AsyncClient | None = None
58
+
59
+ async def _get_client(self) -> httpx.AsyncClient:
60
+ """Get or create the HTTP client."""
61
+ if self._client is None:
62
+ self._client = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
63
+ return self._client
64
+
65
+ async def close(self) -> None:
66
+ """Close the HTTP client."""
67
+ if self._client is not None:
68
+ await self._client.aclose()
69
+ self._client = None
70
+
71
+ async def get_result(self, url: str) -> str:
72
+ """
73
+ Make HTTP GET request with retry logic.
74
+
75
+ Returns JSON serialized response matching C# format:
76
+ - Success: {"url": "...", "data": "..."}
77
+ - Error: {"url": "...", "error": "...", "statusCode": N}
78
+
79
+ Equivalent to C# BaseTools.GetResult()
80
+ """
81
+ client = await self._get_client()
82
+
83
+ for attempt in range(MAX_RETRY_ATTEMPTS):
84
+ try:
85
+ logger.info(
86
+ "Making HTTP request to %s (attempt %d/%d)",
87
+ url,
88
+ attempt + 1,
89
+ MAX_RETRY_ATTEMPTS,
90
+ )
91
+
92
+ response = await client.get(url)
93
+
94
+ if response.is_success:
95
+ data = response.text
96
+ logger.info("Successfully retrieved data from %s", url)
97
+ return json.dumps({"url": url, "data": data})
98
+
99
+ if _is_retryable_status(response.status_code):
100
+ logger.warning(
101
+ "Transient failure for %s: %d. Attempt %d/%d",
102
+ url,
103
+ response.status_code,
104
+ attempt + 1,
105
+ MAX_RETRY_ATTEMPTS,
106
+ )
107
+ if attempt < MAX_RETRY_ATTEMPTS - 1:
108
+ await asyncio.sleep(RETRY_DELAY_BASE * (attempt + 1))
109
+ continue
110
+
111
+ # Non-retryable error or final attempt
112
+ error_message = (
113
+ f"HTTP request failed with status {response.status_code}: "
114
+ f"{response.reason_phrase}"
115
+ )
116
+ logger.error("Final failure for %s: %d", url, response.status_code)
117
+ return json.dumps(
118
+ {"url": url, "error": error_message, "statusCode": response.status_code}
119
+ )
120
+
121
+ except httpx.TimeoutException:
122
+ logger.warning(
123
+ "Request to %s timed out. Attempt %d/%d",
124
+ url,
125
+ attempt + 1,
126
+ MAX_RETRY_ATTEMPTS,
127
+ )
128
+ if attempt == MAX_RETRY_ATTEMPTS - 1:
129
+ return json.dumps(
130
+ {"url": url, "error": "Request timed out after multiple attempts"}
131
+ )
132
+ await asyncio.sleep(RETRY_DELAY_BASE * (attempt + 1))
133
+
134
+ except httpx.NetworkError as e:
135
+ logger.warning(
136
+ "Network error for %s: %s. Attempt %d/%d",
137
+ url,
138
+ str(e),
139
+ attempt + 1,
140
+ MAX_RETRY_ATTEMPTS,
141
+ )
142
+ if attempt == MAX_RETRY_ATTEMPTS - 1:
143
+ return json.dumps({"url": url, "error": f"Network error: {e!s}"})
144
+ await asyncio.sleep(RETRY_DELAY_BASE * (attempt + 1))
145
+
146
+ except Exception as e:
147
+ logger.error("Unexpected error for %s: %s", url, str(e))
148
+ return json.dumps({"url": url, "error": f"Unexpected error: {e!s}"})
149
+
150
+ return json.dumps({"url": url, "error": "Maximum retry attempts exceeded"})
151
+
152
+
153
+ # Global client instance for reuse across tools
154
+ _client = ParliamentHTTPClient()
155
+
156
+
157
+ async def get_result(url: str) -> str:
158
+ """Convenience function using global client."""
159
+ return await _client.get_result(url)
@@ -0,0 +1,42 @@
1
+ """FastMCP server configuration and tool registration."""
2
+ from mcp.server.fastmcp import FastMCP
3
+
4
+ from uk_parliament_mcp.tools import (
5
+ bills,
6
+ committees,
7
+ commons_votes,
8
+ core,
9
+ erskine_may,
10
+ hansard,
11
+ interests,
12
+ lords_votes,
13
+ members,
14
+ now,
15
+ oral_questions,
16
+ statutory_instruments,
17
+ treaties,
18
+ whatson,
19
+ )
20
+
21
+
22
+ def create_server() -> FastMCP:
23
+ """Create and configure the MCP server with all tools registered."""
24
+ mcp = FastMCP(name="uk-parliament-mcp")
25
+
26
+ # Register all tool modules
27
+ core.register_tools(mcp)
28
+ members.register_tools(mcp)
29
+ bills.register_tools(mcp)
30
+ committees.register_tools(mcp)
31
+ commons_votes.register_tools(mcp)
32
+ lords_votes.register_tools(mcp)
33
+ hansard.register_tools(mcp)
34
+ oral_questions.register_tools(mcp)
35
+ interests.register_tools(mcp)
36
+ now.register_tools(mcp)
37
+ whatson.register_tools(mcp)
38
+ statutory_instruments.register_tools(mcp)
39
+ treaties.register_tools(mcp)
40
+ erskine_may.register_tools(mcp)
41
+
42
+ return mcp
@@ -0,0 +1 @@
1
+ """UK Parliament MCP Server tools modules."""
@@ -0,0 +1,385 @@
1
+ """Bills API tools for legislation, amendments, and stages."""
2
+ from urllib.parse import quote
3
+
4
+ from mcp.server.fastmcp import FastMCP
5
+
6
+ from uk_parliament_mcp.http_client import build_url, get_result
7
+
8
+ BILLS_API_BASE = "https://bills-api.parliament.uk/api/v1"
9
+
10
+
11
+ def register_tools(mcp: FastMCP) -> None:
12
+ """Register bills tools with the MCP server."""
13
+
14
+ @mcp.tool()
15
+ async def get_recently_updated_bills(take: int = 10) -> str:
16
+ """Get most recently updated bills and current legislative activity | recent bills, new legislation, latest laws, parliamentary bills, legislative updates, current proposals | Use for tracking new legislation, monitoring bill progress, or finding recently introduced/updated laws | Returns bill titles, stages, sponsors, dates, and current status | Data freshness: updated frequently
17
+
18
+ Args:
19
+ take: Number of bills to return. Default: 10, recommended max: 50. Higher numbers may slow response.
20
+
21
+ Returns:
22
+ Bill titles, stages, sponsors, dates, and current status.
23
+ """
24
+ url = f"{BILLS_API_BASE}/Bills?SortOrder=DateUpdatedDescending&skip=0&take={take}"
25
+ return await get_result(url)
26
+
27
+ @mcp.tool()
28
+ async def search_bills(
29
+ search_term: str,
30
+ member_id: int | None = None,
31
+ ) -> str:
32
+ """Search for parliamentary bills by title, subject, or keyword. Use when researching proposed legislation, finding bills on specific topics, or tracking legislative progress.
33
+
34
+ Args:
35
+ search_term: Search term for bill titles or content (e.g. 'environment', 'health', 'finance').
36
+ member_id: Optional: member ID to filter bills sponsored by specific member.
37
+
38
+ Returns:
39
+ Matching bills with titles, stages, and sponsors.
40
+ """
41
+ url = f"{BILLS_API_BASE}/Bills?SearchTerm={quote(search_term)}"
42
+ return await get_result(url)
43
+
44
+ @mcp.tool()
45
+ async def bill_types() -> str:
46
+ """Get all types of bills that can be introduced in Parliament (e.g., Government Bill, Private Member's Bill). Use when you need to understand different categories of legislation.
47
+
48
+ Returns:
49
+ All bill types with descriptions.
50
+ """
51
+ url = f"{BILLS_API_BASE}/BillTypes"
52
+ return await get_result(url)
53
+
54
+ @mcp.tool()
55
+ async def bill_stages() -> str:
56
+ """Get all possible stages a bill can go through in its legislative journey. Use when tracking bill progress or understanding the legislative process (e.g., First Reading, Committee Stage, Royal Assent).
57
+
58
+ Returns:
59
+ All bill stages with descriptions.
60
+ """
61
+ url = f"{BILLS_API_BASE}/Stages"
62
+ return await get_result(url)
63
+
64
+ @mcp.tool()
65
+ async def get_bill_by_id(bill_id: int) -> str:
66
+ """Get detailed information about a specific bill by ID. Use when you need comprehensive bill details including title, sponsors, stages, summary, and current status.
67
+
68
+ Args:
69
+ bill_id: Unique bill ID number.
70
+
71
+ Returns:
72
+ Comprehensive bill details.
73
+ """
74
+ url = f"{BILLS_API_BASE}/Bills/{bill_id}"
75
+ return await get_result(url)
76
+
77
+ @mcp.tool()
78
+ async def get_bill_stages(
79
+ bill_id: int,
80
+ skip: int | None = None,
81
+ take: int | None = None,
82
+ ) -> str:
83
+ """Get all stages of a specific bill by bill ID. Use when tracking a bill's progress through Parliament, understanding its legislative journey, or finding specific stages like Committee Stage or Third Reading.
84
+
85
+ Args:
86
+ bill_id: Bill ID to get stages for.
87
+ skip: Optional: number of records to skip (for pagination).
88
+ take: Optional: number of records to return.
89
+
90
+ Returns:
91
+ All stages for the specified bill.
92
+ """
93
+ url = build_url(
94
+ f"{BILLS_API_BASE}/Bills/{bill_id}/Stages",
95
+ {"Skip": skip, "Take": take},
96
+ )
97
+ return await get_result(url)
98
+
99
+ @mcp.tool()
100
+ async def get_bill_stage_details(bill_id: int, bill_stage_id: int) -> str:
101
+ """Get detailed information about a specific stage of a bill. Use when you need complete details about a particular stage including timings, committee involvement, and related activities.
102
+
103
+ Args:
104
+ bill_id: Bill ID.
105
+ bill_stage_id: Bill stage ID to get details for.
106
+
107
+ Returns:
108
+ Detailed information about the bill stage.
109
+ """
110
+ url = f"{BILLS_API_BASE}/Bills/{bill_id}/Stages/{bill_stage_id}"
111
+ return await get_result(url)
112
+
113
+ @mcp.tool()
114
+ async def get_bill_stage_amendments(
115
+ bill_id: int,
116
+ bill_stage_id: int,
117
+ search_term: str | None = None,
118
+ amendment_number: str | None = None,
119
+ decision: str | None = None,
120
+ member_id: int | None = None,
121
+ skip: int | None = None,
122
+ take: int | None = None,
123
+ ) -> str:
124
+ """Get all amendments for a specific bill stage. Use when researching proposed changes to legislation, tracking amendment activity, or understanding what modifications are being suggested to a bill.
125
+
126
+ Args:
127
+ bill_id: Bill ID.
128
+ bill_stage_id: Bill stage ID to get amendments for.
129
+ search_term: Optional: search term for amendment content.
130
+ amendment_number: Optional: specific amendment number.
131
+ decision: Optional: amendment decision status.
132
+ member_id: Optional: member ID who proposed amendment.
133
+ skip: Optional: number of records to skip (for pagination).
134
+ take: Optional: number of records to return.
135
+
136
+ Returns:
137
+ Amendments for the specified bill stage.
138
+ """
139
+ url = build_url(
140
+ f"{BILLS_API_BASE}/Bills/{bill_id}/Stages/{bill_stage_id}/Amendments",
141
+ {
142
+ "SearchTerm": search_term,
143
+ "AmendmentNumber": amendment_number,
144
+ "Decision": decision,
145
+ "MemberId": member_id,
146
+ "Skip": skip,
147
+ "Take": take,
148
+ },
149
+ )
150
+ return await get_result(url)
151
+
152
+ @mcp.tool()
153
+ async def get_amendment_by_id(
154
+ bill_id: int,
155
+ bill_stage_id: int,
156
+ amendment_id: int,
157
+ ) -> str:
158
+ """Get detailed information about a specific amendment. Use when you need complete amendment details including text, sponsors, decision, and explanatory notes.
159
+
160
+ Args:
161
+ bill_id: Bill ID.
162
+ bill_stage_id: Bill stage ID.
163
+ amendment_id: Amendment ID to get details for.
164
+
165
+ Returns:
166
+ Detailed information about the amendment.
167
+ """
168
+ url = f"{BILLS_API_BASE}/Bills/{bill_id}/Stages/{bill_stage_id}/Amendments/{amendment_id}"
169
+ return await get_result(url)
170
+
171
+ @mcp.tool()
172
+ async def get_bill_stage_ping_pong_items(
173
+ bill_id: int,
174
+ bill_stage_id: int,
175
+ search_term: str | None = None,
176
+ amendment_number: str | None = None,
177
+ decision: str | None = None,
178
+ member_id: int | None = None,
179
+ skip: int | None = None,
180
+ take: int | None = None,
181
+ ) -> str:
182
+ """Get ping pong items (amendments and motions) for a bill stage. Use when researching the final stages of bills passing between Commons and Lords, including disagreements and agreements on amendments.
183
+
184
+ Args:
185
+ bill_id: Bill ID.
186
+ bill_stage_id: Bill stage ID to get ping pong items for.
187
+ search_term: Optional: search term for ping pong item content.
188
+ amendment_number: Optional: specific amendment number.
189
+ decision: Optional: ping pong item decision status.
190
+ member_id: Optional: member ID who proposed item.
191
+ skip: Optional: number of records to skip (for pagination).
192
+ take: Optional: number of records to return.
193
+
194
+ Returns:
195
+ Ping pong items for the specified bill stage.
196
+ """
197
+ url = build_url(
198
+ f"{BILLS_API_BASE}/Bills/{bill_id}/Stages/{bill_stage_id}/PingPongItems",
199
+ {
200
+ "SearchTerm": search_term,
201
+ "AmendmentNumber": amendment_number,
202
+ "Decision": decision,
203
+ "MemberId": member_id,
204
+ "Skip": skip,
205
+ "Take": take,
206
+ },
207
+ )
208
+ return await get_result(url)
209
+
210
+ @mcp.tool()
211
+ async def get_ping_pong_item_by_id(
212
+ bill_id: int,
213
+ bill_stage_id: int,
214
+ ping_pong_item_id: int,
215
+ ) -> str:
216
+ """Get detailed information about a specific ping pong item (amendment or motion). Use when you need complete details about final stage amendments or motions in the legislative process.
217
+
218
+ Args:
219
+ bill_id: Bill ID.
220
+ bill_stage_id: Bill stage ID.
221
+ ping_pong_item_id: Ping pong item ID to get details for.
222
+
223
+ Returns:
224
+ Detailed information about the ping pong item.
225
+ """
226
+ url = f"{BILLS_API_BASE}/Bills/{bill_id}/Stages/{bill_stage_id}/PingPongItems/{ping_pong_item_id}"
227
+ return await get_result(url)
228
+
229
+ @mcp.tool()
230
+ async def get_bill_publications(bill_id: int) -> str:
231
+ """Get all publications for a specific bill. Use when researching bill documents, impact assessments, explanatory notes, or tracking document versions throughout the legislative process.
232
+
233
+ Args:
234
+ bill_id: Bill ID to get publications for.
235
+
236
+ Returns:
237
+ Publications for the specified bill.
238
+ """
239
+ url = f"{BILLS_API_BASE}/Bills/{bill_id}/Publications"
240
+ return await get_result(url)
241
+
242
+ @mcp.tool()
243
+ async def get_bill_stage_publications(bill_id: int, stage_id: int) -> str:
244
+ """Get publications for a specific bill stage. Use when you need documents related to a particular stage of legislation, such as committee reports or stage-specific amendments.
245
+
246
+ Args:
247
+ bill_id: Bill ID.
248
+ stage_id: Stage ID to get publications for.
249
+
250
+ Returns:
251
+ Publications for the specified bill stage.
252
+ """
253
+ url = f"{BILLS_API_BASE}/Bills/{bill_id}/Stages/{stage_id}/Publications"
254
+ return await get_result(url)
255
+
256
+ @mcp.tool()
257
+ async def get_publication_document(publication_id: int, document_id: int) -> str:
258
+ """Get information about a specific publication document. Use when you need metadata about bill documents including filename, content type, and size.
259
+
260
+ Args:
261
+ publication_id: Publication ID.
262
+ document_id: Document ID to get details for.
263
+
264
+ Returns:
265
+ Metadata about the publication document.
266
+ """
267
+ url = f"{BILLS_API_BASE}/Publications/{publication_id}/Documents/{document_id}"
268
+ return await get_result(url)
269
+
270
+ @mcp.tool()
271
+ async def get_bill_news_articles(
272
+ bill_id: int,
273
+ skip: int | None = None,
274
+ take: int | None = None,
275
+ ) -> str:
276
+ """Get news articles related to a specific bill. Use when researching media coverage, press releases, or official communications about legislation.
277
+
278
+ Args:
279
+ bill_id: Bill ID to get news articles for.
280
+ skip: Optional: number of records to skip (for pagination).
281
+ take: Optional: number of records to return.
282
+
283
+ Returns:
284
+ News articles related to the bill.
285
+ """
286
+ url = build_url(
287
+ f"{BILLS_API_BASE}/Bills/{bill_id}/NewsArticles",
288
+ {"Skip": skip, "Take": take},
289
+ )
290
+ return await get_result(url)
291
+
292
+ @mcp.tool()
293
+ async def get_all_bills_rss() -> str:
294
+ """Get RSS feed of all bills. Use when you want to stay updated on all legislative activity through RSS feeds.
295
+
296
+ Returns:
297
+ RSS feed of all bills.
298
+ """
299
+ url = f"{BILLS_API_BASE}/Rss/allbills.rss"
300
+ return await get_result(url)
301
+
302
+ @mcp.tool()
303
+ async def get_public_bills_rss() -> str:
304
+ """Get RSS feed of public bills only. Use when you want to monitor government and public bills through RSS feeds, excluding private bills.
305
+
306
+ Returns:
307
+ RSS feed of public bills.
308
+ """
309
+ url = f"{BILLS_API_BASE}/Rss/publicbills.rss"
310
+ return await get_result(url)
311
+
312
+ @mcp.tool()
313
+ async def get_private_bills_rss() -> str:
314
+ """Get RSS feed of private bills only. Use when you want to monitor private member bills and private bills through RSS feeds.
315
+
316
+ Returns:
317
+ RSS feed of private bills.
318
+ """
319
+ url = f"{BILLS_API_BASE}/Rss/privatebills.rss"
320
+ return await get_result(url)
321
+
322
+ @mcp.tool()
323
+ async def get_bill_rss(bill_id: int) -> str:
324
+ """Get RSS feed for a specific bill by ID. Use when you want to track updates and changes to a particular piece of legislation through RSS feeds.
325
+
326
+ Args:
327
+ bill_id: Bill ID to get RSS feed for.
328
+
329
+ Returns:
330
+ RSS feed for the specified bill.
331
+ """
332
+ url = f"{BILLS_API_BASE}/Rss/Bills/{bill_id}.rss"
333
+ return await get_result(url)
334
+
335
+ @mcp.tool()
336
+ async def get_publication_types(
337
+ skip: int | None = None,
338
+ take: int | None = None,
339
+ ) -> str:
340
+ """Get all publication types available for bills. Use when you need to understand the different types of documents that can be associated with legislation.
341
+
342
+ Args:
343
+ skip: Optional: number of records to skip (for pagination).
344
+ take: Optional: number of records to return.
345
+
346
+ Returns:
347
+ All publication types with descriptions.
348
+ """
349
+ url = build_url(
350
+ f"{BILLS_API_BASE}/PublicationTypes",
351
+ {"Skip": skip, "Take": take},
352
+ )
353
+ return await get_result(url)
354
+
355
+ @mcp.tool()
356
+ async def get_sittings(
357
+ house: str | None = None,
358
+ date_from: str | None = None,
359
+ date_to: str | None = None,
360
+ skip: int | None = None,
361
+ take: int | None = None,
362
+ ) -> str:
363
+ """Get parliamentary sittings with optional filtering by house and date range. Use when researching when Parliament was in session, finding specific sitting dates, or tracking parliamentary activity.
364
+
365
+ Args:
366
+ house: Optional: house name ('Commons' or 'Lords').
367
+ date_from: Optional: start date in YYYY-MM-DD format.
368
+ date_to: Optional: end date in YYYY-MM-DD format.
369
+ skip: Optional: number of records to skip (for pagination).
370
+ take: Optional: number of records to return.
371
+
372
+ Returns:
373
+ Parliamentary sittings matching the criteria.
374
+ """
375
+ url = build_url(
376
+ f"{BILLS_API_BASE}/Sittings",
377
+ {
378
+ "House": house,
379
+ "DateFrom": date_from,
380
+ "DateTo": date_to,
381
+ "Skip": skip,
382
+ "Take": take,
383
+ },
384
+ )
385
+ return await get_result(url)