uk-parliament-mcp 1.0.1__py3-none-any.whl → 1.2.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.
@@ -1,3 +1,3 @@
1
1
  """UK Parliament MCP Server - bridges AI assistants with UK Parliament APIs."""
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "1.2.0"
@@ -0,0 +1,21 @@
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
+ WRITTEN_QUESTIONS_API_BASE = "https://writtenquestions-api.parliament.uk/api"
18
+
19
+ # Common constants
20
+ HOUSE_COMMONS = 1
21
+ HOUSE_LORDS = 2
@@ -5,13 +5,42 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
- from typing import Any
8
+ from datetime import datetime, timedelta
9
+ from typing import Any, NotRequired, TypedDict
9
10
  from urllib.parse import urlencode
10
11
 
11
12
  import httpx
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
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
+
15
44
  # Configuration constants (matching C# implementation)
16
45
  HTTP_TIMEOUT = 30.0 # seconds
17
46
  MAX_RETRY_ATTEMPTS = 3
@@ -20,6 +49,10 @@ RETRY_DELAY_BASE = 1.0 # seconds
20
49
  # HTTP status codes that should trigger a retry
21
50
  TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
22
51
 
52
+ # Cache configuration
53
+ CACHE_TTL = timedelta(minutes=15)
54
+ _cache: dict[str, CacheEntry] = {}
55
+
23
56
 
24
57
  def build_url(base_url: str, parameters: dict[str, Any]) -> str:
25
58
  """
@@ -158,3 +191,47 @@ _client = ParliamentHTTPClient()
158
191
  async def get_result(url: str) -> str:
159
192
  """Convenience function using global client."""
160
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")
@@ -2,7 +2,6 @@
2
2
 
3
3
  from mcp.server.fastmcp import FastMCP
4
4
 
5
- from uk_parliament_mcp.tools.core import SYSTEM_PROMPT
6
5
  from uk_parliament_mcp.tools import (
7
6
  bills,
8
7
  committees,
@@ -19,7 +18,9 @@ from uk_parliament_mcp.tools import (
19
18
  statutory_instruments,
20
19
  treaties,
21
20
  whatson,
21
+ written_questions,
22
22
  )
23
+ from uk_parliament_mcp.tools.core import SYSTEM_PROMPT
23
24
 
24
25
 
25
26
  def create_server() -> FastMCP:
@@ -42,6 +43,7 @@ def create_server() -> FastMCP:
42
43
  statutory_instruments.register_tools(mcp)
43
44
  treaties.register_tools(mcp)
44
45
  erskine_may.register_tools(mcp)
46
+ written_questions.register_tools(mcp)
45
47
 
46
48
  # Register prompts (agent skills)
47
49
  core.register_prompts(mcp)
@@ -4,10 +4,9 @@ from urllib.parse import quote
4
4
 
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
+ from uk_parliament_mcp.config import BILLS_API_BASE
7
8
  from uk_parliament_mcp.http_client import build_url, get_result
8
9
 
9
- BILLS_API_BASE = "https://bills-api.parliament.uk/api/v1"
10
-
11
10
 
12
11
  def register_tools(mcp: FastMCP) -> None:
13
12
  """Register bills tools with the MCP server."""
@@ -4,10 +4,9 @@ from urllib.parse import quote
4
4
 
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
+ from uk_parliament_mcp.config import COMMITTEES_API_BASE
7
8
  from uk_parliament_mcp.http_client import build_url, get_result
8
9
 
9
- COMMITTEES_API_BASE = "https://committees-api.parliament.uk/api"
10
-
11
10
 
12
11
  def register_tools(mcp: FastMCP) -> None:
13
12
  """Register committees tools with the MCP server."""
@@ -2,10 +2,9 @@
2
2
 
3
3
  from mcp.server.fastmcp import FastMCP
4
4
 
5
+ from uk_parliament_mcp.config import COMMONS_VOTES_API_BASE
5
6
  from uk_parliament_mcp.http_client import build_url, get_result
6
7
 
7
- COMMONS_VOTES_API_BASE = "http://commonsvotes-api.parliament.uk/data"
8
-
9
8
 
10
9
  def register_tools(mcp: FastMCP) -> None:
11
10
  """Register Commons votes tools with the MCP server."""
@@ -8,15 +8,15 @@ from typing import Any
8
8
 
9
9
  from mcp.server.fastmcp import FastMCP
10
10
 
11
+ from uk_parliament_mcp.config import (
12
+ BILLS_API_BASE,
13
+ COMMITTEES_API_BASE,
14
+ COMMONS_VOTES_API_BASE,
15
+ INTERESTS_API_BASE,
16
+ MEMBERS_API_BASE,
17
+ )
11
18
  from uk_parliament_mcp.http_client import build_url, get_result
12
19
 
13
- # API bases
14
- MEMBERS_API_BASE = "https://members-api.parliament.uk/api"
15
- BILLS_API_BASE = "https://bills-api.parliament.uk/api/v1"
16
- COMMONS_VOTES_API_BASE = "http://commonsvotes-api.parliament.uk/data"
17
- COMMITTEES_API_BASE = "https://committees-api.parliament.uk/api"
18
- INTERESTS_API_BASE = "https://interests-api.parliament.uk/api/v1"
19
-
20
20
 
21
21
  def _parse_response(response: str) -> dict[str, Any]:
22
22
  """Parse JSON response and extract data."""
@@ -75,7 +75,8 @@ def register_tools(mcp: FastMCP) -> None:
75
75
 
76
76
  # Get basic info from search result
77
77
  basic_info = member_data.get("items", [{}])[0].get("value", {})
78
- house = 1 if basic_info.get("latestHouseMembership", {}).get("house") == 1 else 2
78
+ latest_membership = basic_info.get("latestHouseMembership") or {}
79
+ house = latest_membership.get("house", 1)
79
80
 
80
81
  # Step 2: Parallel requests for details
81
82
  biography_url = f"{MEMBERS_API_BASE}/Members/{member_id}/Biography"
@@ -16,7 +16,7 @@ Convert raw data into human-readable summaries while preserving accuracy, but al
16
16
 
17
17
  GOODBYE_PROMPT = """You are now interacting as a normal assistant. There are no special restrictions or requirements for using UK Parliament MCP data. You may answer questions using any available data or knowledge, and you do not need to append MCP API URLs or limit yourself to MCP sources. Resume normal assistant behavior."""
18
18
 
19
- QUICK_REFERENCE = """## Quick Reference: UK Parliament MCP Tools (94 tools)
19
+ QUICK_REFERENCE = """## Quick Reference: UK Parliament MCP Tools (92 tools)
20
20
 
21
21
  ### Composite Tools (Start Here for Common Queries!)
22
22
  These tools combine multiple API calls - use them first for efficiency:
@@ -35,7 +35,7 @@ These tools combine multiple API calls - use them first for efficiency:
35
35
  | Module | Tools | Start With |
36
36
  |--------|-------|------------|
37
37
  | composite | 4 | get_mp_profile(name) |
38
- | members | 26 | get_member_by_name(name) |
38
+ | members | 25 | get_member_by_name(name) |
39
39
  | bills | 21 | search_bills(search_term) |
40
40
  | committees | 12 | search_committees(search_term) |
41
41
  | commons_votes | 5 | search_commons_divisions(search_term) |
@@ -93,7 +93,7 @@ Use the individual tools (in members, bills, etc.) when you need:
93
93
  - Pagination through large result sets
94
94
  - Access to specific endpoints not covered by composite tools
95
95
  - More control over which data is fetched""",
96
- "members": """## Members Tools (26 tools)
96
+ "members": """## Members Tools (25 tools)
97
97
 
98
98
  ### Primary Search Tools
99
99
  - search_members(name, location, party_id, house, is_current_member, skip, take) - Comprehensive member search with filters
@@ -376,12 +376,12 @@ International agreements requiring parliamentary scrutiny:
376
376
  5. Third Reading: Final debate
377
377
  6. Lords/Commons stages: Mirror process in other House
378
378
  7. Royal Assent: Becomes law""",
379
- "all": """## All UK Parliament MCP Tools (94 tools)
379
+ "all": """## All UK Parliament MCP Tools (92 tools)
380
380
 
381
381
  ### Composite (4 tools) - Use These First!
382
382
  get_mp_profile, check_mp_vote, get_bill_overview, get_committee_summary
383
383
 
384
- ### Members (26 tools)
384
+ ### Members (25 tools)
385
385
  Search: search_members, get_member_by_name, search_members_historical
386
386
  Details: get_member_by_id, get_members_biography, get_members_contact, get_member_synopsis, get_member_experience, get_member_focus
387
387
  Activity: get_member_voting, get_commons_voting_record_for_member, get_lords_voting_record_for_member, get_member_written_questions, get_contributions, edms_for_member_id
@@ -4,10 +4,9 @@ from urllib.parse import quote
4
4
 
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
+ from uk_parliament_mcp.config import ERSKINE_MAY_API_BASE
7
8
  from uk_parliament_mcp.http_client import get_result
8
9
 
9
- ERSKINE_MAY_API_BASE = "https://erskinemay-api.parliament.uk/api"
10
-
11
10
 
12
11
  def register_tools(mcp: FastMCP) -> None:
13
12
  """Register Erskine May tools with the MCP server."""
@@ -2,10 +2,9 @@
2
2
 
3
3
  from mcp.server.fastmcp import FastMCP
4
4
 
5
+ from uk_parliament_mcp.config import HANSARD_API_BASE
5
6
  from uk_parliament_mcp.http_client import build_url, get_result
6
7
 
7
- HANSARD_API_BASE = "https://hansard-api.parliament.uk"
8
-
9
8
 
10
9
  def register_tools(mcp: FastMCP) -> None:
11
10
  """Register Hansard tools with the MCP server."""
@@ -16,6 +15,9 @@ def register_tools(mcp: FastMCP) -> None:
16
15
  start_date: str,
17
16
  end_date: str,
18
17
  search_term: str,
18
+ member_id: int | None = None,
19
+ skip: int = 0,
20
+ take: int = 20,
19
21
  ) -> str:
20
22
  """Search Hansard (official parliamentary record) for speeches and debates. Use when researching what was said in Parliament on specific topics, by specific members, or in specific time periods. House: 1=Commons, 2=Lords.
21
23
 
@@ -24,6 +26,9 @@ def register_tools(mcp: FastMCP) -> None:
24
26
  start_date: Start date in YYYY-MM-DD format.
25
27
  end_date: End date in YYYY-MM-DD format.
26
28
  search_term: Search term for speeches or debates (e.g. 'climate change', 'NHS').
29
+ member_id: Optional member ID to filter results to a specific MP/Lord.
30
+ skip: Number of results to skip for pagination (default 0).
31
+ take: Number of results to return (default 20, max varies by endpoint).
27
32
 
28
33
  Returns:
29
34
  Hansard records matching the search criteria.
@@ -35,6 +40,133 @@ def register_tools(mcp: FastMCP) -> None:
35
40
  "queryParameters.startDate": start_date,
36
41
  "queryParameters.endDate": end_date,
37
42
  "queryParameters.searchTerm": search_term,
43
+ "queryParameters.memberId": member_id,
44
+ "queryParameters.skip": skip,
45
+ "queryParameters.take": take,
46
+ },
47
+ )
48
+ return await get_result(url)
49
+
50
+ @mcp.tool()
51
+ async def get_debate_by_id(debate_section_id: str) -> str:
52
+ """Get full debate transcript | Hansard, speeches, contributions |
53
+ Use after search_hansard to get complete debate with all member speeches.
54
+ Returns debate title, date, house, and all contributions.
55
+
56
+ Args:
57
+ debate_section_id: External ID from search_hansard results.
58
+
59
+ Returns:
60
+ Full debate with all member contributions.
61
+ """
62
+ url = f"{HANSARD_API_BASE}/debates/debate/{debate_section_id}.json"
63
+ return await get_result(url)
64
+
65
+ @mcp.tool()
66
+ async def get_member_hansard_contributions(
67
+ member_id: int,
68
+ debate_section_id: str,
69
+ ) -> str:
70
+ """Get all speeches by a specific MP/Lord in a debate | Hansard, member speeches |
71
+ Use to extract just one member's contributions from a debate.
72
+ Returns all contributions by that member in the specified debate.
73
+
74
+ Args:
75
+ member_id: Parliament member ID (from members API).
76
+ debate_section_id: External ID of debate section (from search_hansard).
77
+
78
+ Returns:
79
+ All contributions by that member in the debate.
80
+ """
81
+ url = f"{HANSARD_API_BASE}/debates/memberdebatecontributions/{member_id}.json?debateSectionExtId={debate_section_id}"
82
+ return await get_result(url)
83
+
84
+ @mcp.tool()
85
+ async def get_debate_divisions(debate_section_id: str) -> str:
86
+ """Get votes that occurred during a debate | Hansard, divisions, voting |
87
+ Use to find divisions (votes) that took place in a specific debate.
88
+ Returns list of divisions with aye/noe counts.
89
+
90
+ Args:
91
+ debate_section_id: External ID of debate section (from search_hansard).
92
+
93
+ Returns:
94
+ List of divisions with vote counts.
95
+ """
96
+ url = f"{HANSARD_API_BASE}/debates/divisions/{debate_section_id}.json"
97
+ return await get_result(url)
98
+
99
+ @mcp.tool()
100
+ async def get_division_details(
101
+ division_id: str,
102
+ is_evel: bool = False,
103
+ ) -> str:
104
+ """Get full division details including how each member voted | Hansard, division, voting records |
105
+ Use to see individual voting records for a specific division.
106
+ Returns division with debate title, counts, and member voting records.
107
+
108
+ Args:
109
+ division_id: External ID of division (from get_debate_divisions).
110
+ is_evel: Filter to EVEL (English Votes for English Laws) voters only.
111
+
112
+ Returns:
113
+ Division details with member voting records.
114
+ """
115
+ url = build_url(
116
+ f"{HANSARD_API_BASE}/debates/division/{division_id}.json",
117
+ {"isEvel": is_evel if is_evel else None},
118
+ )
119
+ return await get_result(url)
120
+
121
+ @mcp.tool()
122
+ async def get_hansard_sitting_day(
123
+ sitting_date: str,
124
+ house: int,
125
+ ) -> str:
126
+ """Get full agenda/sections for a sitting day | Hansard, daily business, agenda |
127
+ Use to see all debates and business for a specific day.
128
+ Returns all debate sections for that day.
129
+
130
+ Args:
131
+ sitting_date: Date in YYYY-MM-DD format.
132
+ house: House number: 1 for Commons, 2 for Lords.
133
+
134
+ Returns:
135
+ All debate sections for that day.
136
+ """
137
+ url = build_url(
138
+ f"{HANSARD_API_BASE}/overview/sectionsforday.json",
139
+ {
140
+ "date": sitting_date,
141
+ "house": house,
142
+ },
143
+ )
144
+ return await get_result(url)
145
+
146
+ @mcp.tool()
147
+ async def get_hansard_calendar(
148
+ year: int,
149
+ month: int,
150
+ house: int,
151
+ ) -> str:
152
+ """Get all sitting dates for a month | Hansard, calendar, sitting days |
153
+ Use to discover which days have Hansard records available.
154
+ Returns list of sitting dates with Hansard available.
155
+
156
+ Args:
157
+ year: Year (e.g. 2024).
158
+ month: Month number (1-12).
159
+ house: House number: 1 for Commons, 2 for Lords.
160
+
161
+ Returns:
162
+ List of sitting dates for the month.
163
+ """
164
+ url = build_url(
165
+ f"{HANSARD_API_BASE}/overview/calendar.json",
166
+ {
167
+ "year": year,
168
+ "month": month,
169
+ "house": house,
38
170
  },
39
171
  )
40
172
  return await get_result(url)
@@ -2,10 +2,9 @@
2
2
 
3
3
  from mcp.server.fastmcp import FastMCP
4
4
 
5
+ from uk_parliament_mcp.config import INTERESTS_API_BASE
5
6
  from uk_parliament_mcp.http_client import get_result
6
7
 
7
- INTERESTS_API_BASE = "https://interests-api.parliament.uk/api/v1"
8
-
9
8
 
10
9
  def register_tools(mcp: FastMCP) -> None:
11
10
  """Register interests tools with the MCP server."""
@@ -4,10 +4,9 @@ from urllib.parse import quote
4
4
 
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
+ from uk_parliament_mcp.config import LORDS_VOTES_API_BASE
7
8
  from uk_parliament_mcp.http_client import build_url, get_result
8
9
 
9
- LORDS_VOTES_API_BASE = "http://lordsvotes-api.parliament.uk/data"
10
-
11
10
 
12
11
  def register_tools(mcp: FastMCP) -> None:
13
12
  """Register Lords votes tools with the MCP server."""
@@ -4,10 +4,9 @@ from urllib.parse import quote
4
4
 
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
+ from uk_parliament_mcp.config import MEMBERS_API_BASE
7
8
  from uk_parliament_mcp.http_client import build_url, get_result
8
9
 
9
- MEMBERS_API_BASE = "https://members-api.parliament.uk/api"
10
-
11
10
 
12
11
  def register_tools(mcp: FastMCP) -> None:
13
12
  """Register member tools with the MCP server."""
@@ -2,10 +2,9 @@
2
2
 
3
3
  from mcp.server.fastmcp import FastMCP
4
4
 
5
+ from uk_parliament_mcp.config import NOW_API_BASE
5
6
  from uk_parliament_mcp.http_client import get_result
6
7
 
7
- NOW_API_BASE = "https://now-api.parliament.uk/api"
8
-
9
8
 
10
9
  def register_tools(mcp: FastMCP) -> None:
11
10
  """Register now tools with the MCP server."""
@@ -4,10 +4,9 @@ from urllib.parse import quote
4
4
 
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
+ from uk_parliament_mcp.config import ORAL_QUESTIONS_API_BASE
7
8
  from uk_parliament_mcp.http_client import get_result
8
9
 
9
- ORAL_QUESTIONS_API_BASE = "https://oralquestionsandmotions-api.parliament.uk"
10
-
11
10
 
12
11
  def register_tools(mcp: FastMCP) -> None:
13
12
  """Register oral questions tools with the MCP server."""
@@ -4,10 +4,9 @@ from urllib.parse import quote
4
4
 
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
+ from uk_parliament_mcp.config import STATUTORY_INSTRUMENTS_API_BASE
7
8
  from uk_parliament_mcp.http_client import get_result
8
9
 
9
- STATUTORY_INSTRUMENTS_API_BASE = "https://statutoryinstruments-api.parliament.uk/api/v2"
10
-
11
10
 
12
11
  def register_tools(mcp: FastMCP) -> None:
13
12
  """Register statutory instruments tools with the MCP server."""
@@ -4,10 +4,9 @@ from urllib.parse import quote
4
4
 
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
+ from uk_parliament_mcp.config import TREATIES_API_BASE
7
8
  from uk_parliament_mcp.http_client import get_result
8
9
 
9
- TREATIES_API_BASE = "https://treaties-api.parliament.uk/api"
10
-
11
10
 
12
11
  def register_tools(mcp: FastMCP) -> None:
13
12
  """Register treaties tools with the MCP server."""
@@ -2,10 +2,9 @@
2
2
 
3
3
  from mcp.server.fastmcp import FastMCP
4
4
 
5
+ from uk_parliament_mcp.config import WHATSON_API_BASE
5
6
  from uk_parliament_mcp.http_client import build_url, get_result
6
7
 
7
- WHATSON_API_BASE = "https://whatson-api.parliament.uk/calendar"
8
-
9
8
 
10
9
  def register_tools(mcp: FastMCP) -> None:
11
10
  """Register whatson tools with the MCP server."""