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.
- uk_parliament_mcp/__init__.py +3 -0
- uk_parliament_mcp/__main__.py +23 -0
- uk_parliament_mcp/config.py +20 -0
- uk_parliament_mcp/http_client.py +237 -0
- uk_parliament_mcp/server.py +49 -0
- uk_parliament_mcp/tools/__init__.py +1 -0
- uk_parliament_mcp/tools/bills.py +385 -0
- uk_parliament_mcp/tools/committees.py +385 -0
- uk_parliament_mcp/tools/commons_votes.py +138 -0
- uk_parliament_mcp/tools/composite.py +293 -0
- uk_parliament_mcp/tools/core.py +880 -0
- uk_parliament_mcp/tools/erskine_may.py +25 -0
- uk_parliament_mcp/tools/hansard.py +39 -0
- uk_parliament_mcp/tools/interests.py +43 -0
- uk_parliament_mcp/tools/lords_votes.py +149 -0
- uk_parliament_mcp/tools/members.py +439 -0
- uk_parliament_mcp/tools/now.py +30 -0
- uk_parliament_mcp/tools/oral_questions.py +55 -0
- uk_parliament_mcp/tools/statutory_instruments.py +38 -0
- uk_parliament_mcp/tools/treaties.py +25 -0
- uk_parliament_mcp/tools/whatson.py +72 -0
- uk_parliament_mcp/validators.py +58 -0
- uk_parliament_mcp-1.1.0.dist-info/METADATA +408 -0
- uk_parliament_mcp-1.1.0.dist-info/RECORD +26 -0
- uk_parliament_mcp-1.1.0.dist-info/WHEEL +4 -0
- uk_parliament_mcp-1.1.0.dist-info/entry_points.txt +2 -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."""
|