uk-parliament-mcp 1.1.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.1.0"
@@ -0,0 +1,23 @@
1
+ """Entry point for the UK Parliament MCP Server."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ from uk_parliament_mcp.server import create_server
7
+
8
+
9
+ def main() -> None:
10
+ """Run the MCP server with stdio transport."""
11
+ # Configure logging to stderr (stdout is for MCP protocol)
12
+ logging.basicConfig(
13
+ level=logging.INFO,
14
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
15
+ stream=sys.stderr,
16
+ )
17
+
18
+ server = create_server()
19
+ server.run(transport="stdio")
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
@@ -0,0 +1,20 @@
1
+ """Centralized configuration for UK Parliament MCP Server."""
2
+
3
+ # API Base URLs
4
+ MEMBERS_API_BASE = "https://members-api.parliament.uk/api"
5
+ BILLS_API_BASE = "https://bills-api.parliament.uk/api/v1"
6
+ COMMONS_VOTES_API_BASE = "http://commonsvotes-api.parliament.uk/data"
7
+ LORDS_VOTES_API_BASE = "https://lordsvotes-api.parliament.uk/data"
8
+ COMMITTEES_API_BASE = "https://committees-api.parliament.uk/api"
9
+ HANSARD_API_BASE = "https://hansard-api.parliament.uk/api/v1"
10
+ INTERESTS_API_BASE = "https://interests-api.parliament.uk/api/v1"
11
+ NOW_API_BASE = "https://now-api.parliament.uk/api"
12
+ WHATSON_API_BASE = "https://whatson-api.parliament.uk/calendar"
13
+ STATUTORY_INSTRUMENTS_API_BASE = "https://statutoryinstruments-api.parliament.uk/api/v2"
14
+ TREATIES_API_BASE = "https://treaties-api.parliament.uk/api"
15
+ ERSKINE_MAY_API_BASE = "https://erskinemay-api.parliament.uk/api"
16
+ ORAL_QUESTIONS_API_BASE = "https://oralquestionsandmotions-api.parliament.uk"
17
+
18
+ # Common constants
19
+ HOUSE_COMMONS = 1
20
+ HOUSE_LORDS = 2
@@ -0,0 +1,237 @@
1
+ """HTTP client with retry logic for Parliament API requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from datetime import datetime, timedelta
9
+ from typing import Any, NotRequired, TypedDict
10
+ from urllib.parse import urlencode
11
+
12
+ import httpx
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # Response type definitions
18
+ class SuccessResponse(TypedDict):
19
+ """Response structure for successful API calls."""
20
+
21
+ url: str
22
+ data: str
23
+
24
+
25
+ class ErrorResponse(TypedDict):
26
+ """Response structure for failed API calls."""
27
+
28
+ url: str
29
+ error: str
30
+ statusCode: NotRequired[int]
31
+
32
+
33
+ # Union type for API responses
34
+ APIResponse = SuccessResponse | ErrorResponse
35
+
36
+
37
+ class CacheEntry(TypedDict):
38
+ """Cache entry structure for storing API responses."""
39
+
40
+ data: str
41
+ expires: datetime
42
+
43
+
44
+ # Configuration constants (matching C# implementation)
45
+ HTTP_TIMEOUT = 30.0 # seconds
46
+ MAX_RETRY_ATTEMPTS = 3
47
+ RETRY_DELAY_BASE = 1.0 # seconds
48
+
49
+ # HTTP status codes that should trigger a retry
50
+ TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
51
+
52
+ # Cache configuration
53
+ CACHE_TTL = timedelta(minutes=15)
54
+ _cache: dict[str, CacheEntry] = {}
55
+
56
+
57
+ def build_url(base_url: str, parameters: dict[str, Any]) -> str:
58
+ """
59
+ Build URL with query parameters, filtering out None and empty values.
60
+
61
+ Equivalent to C# BaseTools.BuildUrl()
62
+
63
+ Args:
64
+ base_url: The base URL without query parameters
65
+ parameters: Dictionary of parameter names to values
66
+
67
+ Returns:
68
+ URL with query string, or just base_url if no valid parameters
69
+ """
70
+ valid_params = {
71
+ k: str(v).lower() if isinstance(v, bool) else str(v)
72
+ for k, v in parameters.items()
73
+ if v is not None and v != ""
74
+ }
75
+
76
+ if not valid_params:
77
+ return base_url
78
+
79
+ return f"{base_url}?{urlencode(valid_params)}"
80
+
81
+
82
+ def _is_retryable_status(status_code: int) -> bool:
83
+ """Check if HTTP status code is transient and should be retried."""
84
+ return status_code in TRANSIENT_STATUS_CODES
85
+
86
+
87
+ class ParliamentHTTPClient:
88
+ """Async HTTP client with retry logic for Parliament APIs."""
89
+
90
+ def __init__(self) -> None:
91
+ self._client: httpx.AsyncClient | None = None
92
+
93
+ async def _get_client(self) -> httpx.AsyncClient:
94
+ """Get or create the HTTP client."""
95
+ if self._client is None:
96
+ self._client = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
97
+ return self._client
98
+
99
+ async def close(self) -> None:
100
+ """Close the HTTP client."""
101
+ if self._client is not None:
102
+ await self._client.aclose()
103
+ self._client = None
104
+
105
+ async def get_result(self, url: str) -> str:
106
+ """
107
+ Make HTTP GET request with retry logic.
108
+
109
+ Returns JSON serialized response matching C# format:
110
+ - Success: {"url": "...", "data": "..."}
111
+ - Error: {"url": "...", "error": "...", "statusCode": N}
112
+
113
+ Equivalent to C# BaseTools.GetResult()
114
+ """
115
+ client = await self._get_client()
116
+
117
+ for attempt in range(MAX_RETRY_ATTEMPTS):
118
+ try:
119
+ logger.info(
120
+ "Making HTTP request to %s (attempt %d/%d)",
121
+ url,
122
+ attempt + 1,
123
+ MAX_RETRY_ATTEMPTS,
124
+ )
125
+
126
+ response = await client.get(url)
127
+
128
+ if response.is_success:
129
+ data = response.text
130
+ logger.info("Successfully retrieved data from %s", url)
131
+ return json.dumps({"url": url, "data": data})
132
+
133
+ if _is_retryable_status(response.status_code):
134
+ logger.warning(
135
+ "Transient failure for %s: %d. Attempt %d/%d",
136
+ url,
137
+ response.status_code,
138
+ attempt + 1,
139
+ MAX_RETRY_ATTEMPTS,
140
+ )
141
+ if attempt < MAX_RETRY_ATTEMPTS - 1:
142
+ await asyncio.sleep(RETRY_DELAY_BASE * (attempt + 1))
143
+ continue
144
+
145
+ # Non-retryable error or final attempt
146
+ error_message = (
147
+ f"HTTP request failed with status {response.status_code}: "
148
+ f"{response.reason_phrase}"
149
+ )
150
+ logger.error("Final failure for %s: %d", url, response.status_code)
151
+ return json.dumps(
152
+ {"url": url, "error": error_message, "statusCode": response.status_code}
153
+ )
154
+
155
+ except httpx.TimeoutException:
156
+ logger.warning(
157
+ "Request to %s timed out. Attempt %d/%d",
158
+ url,
159
+ attempt + 1,
160
+ MAX_RETRY_ATTEMPTS,
161
+ )
162
+ if attempt == MAX_RETRY_ATTEMPTS - 1:
163
+ return json.dumps(
164
+ {"url": url, "error": "Request timed out after multiple attempts"}
165
+ )
166
+ await asyncio.sleep(RETRY_DELAY_BASE * (attempt + 1))
167
+
168
+ except httpx.NetworkError as e:
169
+ logger.warning(
170
+ "Network error for %s: %s. Attempt %d/%d",
171
+ url,
172
+ str(e),
173
+ attempt + 1,
174
+ MAX_RETRY_ATTEMPTS,
175
+ )
176
+ if attempt == MAX_RETRY_ATTEMPTS - 1:
177
+ return json.dumps({"url": url, "error": f"Network error: {e!s}"})
178
+ await asyncio.sleep(RETRY_DELAY_BASE * (attempt + 1))
179
+
180
+ except Exception as e:
181
+ logger.error("Unexpected error for %s: %s", url, str(e))
182
+ return json.dumps({"url": url, "error": f"Unexpected error: {e!s}"})
183
+
184
+ return json.dumps({"url": url, "error": "Maximum retry attempts exceeded"})
185
+
186
+
187
+ # Global client instance for reuse across tools
188
+ _client = ParliamentHTTPClient()
189
+
190
+
191
+ async def get_result(url: str) -> str:
192
+ """Convenience function using global client."""
193
+ return await _client.get_result(url)
194
+
195
+
196
+ async def get_result_cached(url: str, cache_key: str | None = None) -> str:
197
+ """
198
+ Get result with optional caching for reference data.
199
+
200
+ Use this for frequently accessed reference data that rarely changes,
201
+ such as bill types, bill stages, committee types, etc.
202
+
203
+ Args:
204
+ url: API URL to fetch
205
+ cache_key: Optional cache key. If None, no caching is used.
206
+
207
+ Returns:
208
+ JSON response string in the same format as get_result()
209
+ """
210
+ key = cache_key or url
211
+
212
+ # Check cache
213
+ if key in _cache:
214
+ entry = _cache[key]
215
+ if datetime.now() < entry["expires"]:
216
+ logger.debug("Cache hit for %s", key)
217
+ return entry["data"]
218
+ else:
219
+ # Expired entry, remove it
220
+ del _cache[key]
221
+ logger.debug("Cache expired for %s", key)
222
+
223
+ # Fetch fresh data
224
+ result = await get_result(url)
225
+
226
+ # Cache if successful and cache_key provided
227
+ if cache_key and '"error"' not in result:
228
+ _cache[key] = {"data": result, "expires": datetime.now() + CACHE_TTL}
229
+ logger.debug("Cached result for %s", key)
230
+
231
+ return result
232
+
233
+
234
+ def clear_cache() -> None:
235
+ """Clear all cached data."""
236
+ _cache.clear()
237
+ logger.debug("Cache cleared")
@@ -0,0 +1,49 @@
1
+ """FastMCP server configuration and tool registration."""
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from uk_parliament_mcp.tools import (
6
+ bills,
7
+ committees,
8
+ commons_votes,
9
+ composite,
10
+ core,
11
+ erskine_may,
12
+ hansard,
13
+ interests,
14
+ lords_votes,
15
+ members,
16
+ now,
17
+ oral_questions,
18
+ statutory_instruments,
19
+ treaties,
20
+ whatson,
21
+ )
22
+ from uk_parliament_mcp.tools.core import SYSTEM_PROMPT
23
+
24
+
25
+ def create_server() -> FastMCP:
26
+ """Create and configure the MCP server with all tools registered."""
27
+ mcp = FastMCP(name="uk-parliament-mcp", instructions=SYSTEM_PROMPT)
28
+
29
+ # Register all tool modules
30
+ core.register_tools(mcp)
31
+ composite.register_tools(mcp)
32
+ members.register_tools(mcp)
33
+ bills.register_tools(mcp)
34
+ committees.register_tools(mcp)
35
+ commons_votes.register_tools(mcp)
36
+ lords_votes.register_tools(mcp)
37
+ hansard.register_tools(mcp)
38
+ oral_questions.register_tools(mcp)
39
+ interests.register_tools(mcp)
40
+ now.register_tools(mcp)
41
+ whatson.register_tools(mcp)
42
+ statutory_instruments.register_tools(mcp)
43
+ treaties.register_tools(mcp)
44
+ erskine_may.register_tools(mcp)
45
+
46
+ # Register prompts (agent skills)
47
+ core.register_prompts(mcp)
48
+
49
+ return mcp
@@ -0,0 +1 @@
1
+ """UK Parliament MCP Server tools modules."""