uk-parliament-mcp 1.0.0__py3-none-any.whl → 1.0.1__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.0"
3
+ __version__ = "1.0.1"
@@ -1,4 +1,5 @@
1
1
  """Entry point for the UK Parliament MCP Server."""
2
+
2
3
  import logging
3
4
  import sys
4
5
 
@@ -1,4 +1,5 @@
1
1
  """HTTP client with retry logic for Parliament API requests."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import asyncio
@@ -1,10 +1,13 @@
1
1
  """FastMCP server configuration and tool registration."""
2
+
2
3
  from mcp.server.fastmcp import FastMCP
3
4
 
5
+ from uk_parliament_mcp.tools.core import SYSTEM_PROMPT
4
6
  from uk_parliament_mcp.tools import (
5
7
  bills,
6
8
  committees,
7
9
  commons_votes,
10
+ composite,
8
11
  core,
9
12
  erskine_may,
10
13
  hansard,
@@ -21,10 +24,11 @@ from uk_parliament_mcp.tools import (
21
24
 
22
25
  def create_server() -> FastMCP:
23
26
  """Create and configure the MCP server with all tools registered."""
24
- mcp = FastMCP(name="uk-parliament-mcp")
27
+ mcp = FastMCP(name="uk-parliament-mcp", instructions=SYSTEM_PROMPT)
25
28
 
26
29
  # Register all tool modules
27
30
  core.register_tools(mcp)
31
+ composite.register_tools(mcp)
28
32
  members.register_tools(mcp)
29
33
  bills.register_tools(mcp)
30
34
  committees.register_tools(mcp)
@@ -39,4 +43,7 @@ def create_server() -> FastMCP:
39
43
  treaties.register_tools(mcp)
40
44
  erskine_may.register_tools(mcp)
41
45
 
46
+ # Register prompts (agent skills)
47
+ core.register_prompts(mcp)
48
+
42
49
  return mcp
@@ -1,4 +1,5 @@
1
1
  """Bills API tools for legislation, amendments, and stages."""
2
+
2
3
  from urllib.parse import quote
3
4
 
4
5
  from mcp.server.fastmcp import FastMCP
@@ -1,4 +1,5 @@
1
1
  """Committees API tools for committee info, meetings, and evidence."""
2
+
2
3
  from urllib.parse import quote
3
4
 
4
5
  from mcp.server.fastmcp import FastMCP
@@ -1,4 +1,5 @@
1
1
  """Commons Votes API tools for House of Commons divisions and voting records."""
2
+
2
3
  from mcp.server.fastmcp import FastMCP
3
4
 
4
5
  from uk_parliament_mcp.http_client import build_url, get_result
@@ -0,0 +1,292 @@
1
+ """Composite tools that combine multiple API calls for common workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any
8
+
9
+ from mcp.server.fastmcp import FastMCP
10
+
11
+ from uk_parliament_mcp.http_client import build_url, get_result
12
+
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
+
21
+ def _parse_response(response: str) -> dict[str, Any]:
22
+ """Parse JSON response and extract data."""
23
+ try:
24
+ parsed: dict[str, Any] = json.loads(response)
25
+ if "data" in parsed:
26
+ # Data is a JSON string, parse it
27
+ data = parsed["data"]
28
+ if isinstance(data, str):
29
+ result: dict[str, Any] = json.loads(data)
30
+ return result
31
+ return dict(data) if isinstance(data, dict) else {"data": data}
32
+ return parsed
33
+ except (json.JSONDecodeError, TypeError):
34
+ return {"error": "Failed to parse response"}
35
+
36
+
37
+ def _extract_member_id(member_response: dict[str, Any]) -> int | None:
38
+ """Extract member_id from member search response."""
39
+ try:
40
+ items = member_response.get("items", [])
41
+ if items:
42
+ member_id = items[0].get("value", {}).get("id")
43
+ if isinstance(member_id, int):
44
+ return member_id
45
+ except (KeyError, IndexError, TypeError):
46
+ pass
47
+ return None
48
+
49
+
50
+ def register_tools(mcp: FastMCP) -> None:
51
+ """Register composite tools with the MCP server."""
52
+
53
+ @mcp.tool()
54
+ async def get_mp_profile(name: str) -> str:
55
+ """Get comprehensive MP/Lord profile in one call - combines search, details, biography, interests, and voting summary | complete MP info, full member profile, MP background, politician details, comprehensive member data | Use when you need a complete picture of an MP or Lord without multiple tool calls | Returns combined data: basic info, biography, registered interests, and recent voting activity
56
+
57
+ Args:
58
+ name: Full or partial name of the MP or Lord to look up (e.g., 'Keir Starmer', 'Boris Johnson').
59
+
60
+ Returns:
61
+ Combined profile with basic info, biography, interests, and voting summary.
62
+ """
63
+ from urllib.parse import quote
64
+
65
+ # Step 1: Search for member
66
+ search_url = f"{MEMBERS_API_BASE}/Members/Search?Name={quote(name)}"
67
+ search_response = await get_result(search_url)
68
+ member_data = _parse_response(search_response)
69
+
70
+ member_id = _extract_member_id(member_data)
71
+ if not member_id:
72
+ return json.dumps(
73
+ {"error": f"No member found matching '{name}'", "search_result": member_data}
74
+ )
75
+
76
+ # Get basic info from search result
77
+ basic_info = member_data.get("items", [{}])[0].get("value", {})
78
+ house = 1 if basic_info.get("latestHouseMembership", {}).get("house") == 1 else 2
79
+
80
+ # Step 2: Parallel requests for details
81
+ biography_url = f"{MEMBERS_API_BASE}/Members/{member_id}/Biography"
82
+ interests_url = f"{INTERESTS_API_BASE}/Interests/?MemberId={member_id}"
83
+ voting_url = build_url(
84
+ f"{MEMBERS_API_BASE}/Members/{member_id}/Voting", {"house": house, "page": 1}
85
+ )
86
+
87
+ biography_task = get_result(biography_url)
88
+ interests_task = get_result(interests_url)
89
+ voting_task = get_result(voting_url)
90
+
91
+ biography_response, interests_response, voting_response = await asyncio.gather(
92
+ biography_task, interests_task, voting_task
93
+ )
94
+
95
+ return json.dumps(
96
+ {
97
+ "member_id": member_id,
98
+ "basic_info": basic_info,
99
+ "biography": _parse_response(biography_response),
100
+ "registered_interests": _parse_response(interests_response),
101
+ "recent_voting": _parse_response(voting_response),
102
+ "sources": {
103
+ "search": search_url,
104
+ "biography": biography_url,
105
+ "interests": interests_url,
106
+ "voting": voting_url,
107
+ },
108
+ }
109
+ )
110
+
111
+ @mcp.tool()
112
+ async def check_mp_vote(mp_name: str, topic: str) -> str:
113
+ """Check how an MP voted on a specific topic - combines member search and division lookup | MP voting stance, how did MP vote, voting record on topic, division lookup | Use when you need to know how a specific MP voted on a particular issue | Returns MP details and matching divisions with their vote
114
+
115
+ Args:
116
+ mp_name: Name of the MP to look up (e.g., 'Boris Johnson', 'Keir Starmer').
117
+ topic: Topic or keyword to search for in Commons divisions (e.g., 'climate', 'NHS', 'brexit').
118
+
119
+ Returns:
120
+ MP info and divisions matching the topic with the MP's vote.
121
+ """
122
+ from urllib.parse import quote
123
+
124
+ # Step 1: Search for member
125
+ search_url = f"{MEMBERS_API_BASE}/Members/Search?Name={quote(mp_name)}"
126
+ search_response = await get_result(search_url)
127
+ member_data = _parse_response(search_response)
128
+
129
+ member_id = _extract_member_id(member_data)
130
+ if not member_id:
131
+ return json.dumps(
132
+ {"error": f"No member found matching '{mp_name}'", "search_result": member_data}
133
+ )
134
+
135
+ basic_info = member_data.get("items", [{}])[0].get("value", {})
136
+
137
+ # Step 2: Search for divisions on topic with this member
138
+ divisions_url = build_url(
139
+ f"{COMMONS_VOTES_API_BASE}/divisions.json/search",
140
+ {
141
+ "queryParameters.searchTerm": topic,
142
+ "memberId": member_id,
143
+ },
144
+ )
145
+ divisions_response = await get_result(divisions_url)
146
+ divisions_data = _parse_response(divisions_response)
147
+
148
+ return json.dumps(
149
+ {
150
+ "member_id": member_id,
151
+ "member_info": basic_info,
152
+ "topic_searched": topic,
153
+ "divisions": divisions_data,
154
+ "sources": {"member_search": search_url, "divisions": divisions_url},
155
+ }
156
+ )
157
+
158
+ @mcp.tool()
159
+ async def get_bill_overview(search_term: str) -> str:
160
+ """Get comprehensive bill overview in one call - combines search, details, stages, and publications | complete bill info, legislation overview, bill progress, full bill details | Use when you need complete information about a bill without multiple tool calls | Returns bill details, legislative stages, and associated publications
161
+
162
+ Args:
163
+ search_term: Search term for bill title or content (e.g., 'Online Safety', 'Environment', 'Finance').
164
+
165
+ Returns:
166
+ Combined bill data: details, stages, and publications.
167
+ """
168
+ from urllib.parse import quote
169
+
170
+ # Step 1: Search for bills
171
+ search_url = f"{BILLS_API_BASE}/Bills?SearchTerm={quote(search_term)}"
172
+ search_response = await get_result(search_url)
173
+ bills_data = _parse_response(search_response)
174
+
175
+ items = bills_data.get("items", [])
176
+ if not items:
177
+ return json.dumps(
178
+ {"error": f"No bills found matching '{search_term}'", "search_result": bills_data}
179
+ )
180
+
181
+ # Get first matching bill
182
+ bill = items[0]
183
+ bill_id = bill.get("billId")
184
+ if not bill_id:
185
+ return json.dumps({"error": "Could not extract bill ID", "search_result": bills_data})
186
+
187
+ # Step 2: Parallel requests for details, stages, publications
188
+ details_url = f"{BILLS_API_BASE}/Bills/{bill_id}"
189
+ stages_url = f"{BILLS_API_BASE}/Bills/{bill_id}/Stages"
190
+ publications_url = f"{BILLS_API_BASE}/Bills/{bill_id}/Publications"
191
+
192
+ details_task = get_result(details_url)
193
+ stages_task = get_result(stages_url)
194
+ publications_task = get_result(publications_url)
195
+
196
+ details_response, stages_response, publications_response = await asyncio.gather(
197
+ details_task, stages_task, publications_task
198
+ )
199
+
200
+ return json.dumps(
201
+ {
202
+ "bill_id": bill_id,
203
+ "search_summary": bill,
204
+ "details": _parse_response(details_response),
205
+ "stages": _parse_response(stages_response),
206
+ "publications": _parse_response(publications_response),
207
+ "other_matches": len(items) - 1,
208
+ "sources": {
209
+ "search": search_url,
210
+ "details": details_url,
211
+ "stages": stages_url,
212
+ "publications": publications_url,
213
+ },
214
+ }
215
+ )
216
+
217
+ @mcp.tool()
218
+ async def get_committee_summary(topic: str) -> str:
219
+ """Get comprehensive committee summary in one call - combines search, details, evidence, and publications | complete committee info, inquiry overview, committee research, full committee details | Use when you need complete information about a committee's work without multiple tool calls | Returns committee details, oral/written evidence, and publications
220
+
221
+ Args:
222
+ topic: Search term for committee name or subject area (e.g., 'Treasury', 'Health', 'Defence').
223
+
224
+ Returns:
225
+ Combined committee data: details, evidence, and publications.
226
+ """
227
+ from urllib.parse import quote
228
+
229
+ # Step 1: Search for committees
230
+ search_url = f"{COMMITTEES_API_BASE}/Committees?SearchTerm={quote(topic)}"
231
+ search_response = await get_result(search_url)
232
+ committees_data = _parse_response(search_response)
233
+
234
+ items = committees_data.get("items", [])
235
+ if not items:
236
+ return json.dumps(
237
+ {
238
+ "error": f"No committees found matching '{topic}'",
239
+ "search_result": committees_data,
240
+ }
241
+ )
242
+
243
+ # Get first matching committee
244
+ committee = items[0]
245
+ committee_id = committee.get("id")
246
+ if not committee_id:
247
+ return json.dumps(
248
+ {"error": "Could not extract committee ID", "search_result": committees_data}
249
+ )
250
+
251
+ # Step 2: Parallel requests for details, evidence, publications
252
+ details_url = f"{COMMITTEES_API_BASE}/Committees/{committee_id}"
253
+ oral_evidence_url = build_url(
254
+ f"{COMMITTEES_API_BASE}/OralEvidence", {"CommitteeId": committee_id, "Take": 10}
255
+ )
256
+ written_evidence_url = build_url(
257
+ f"{COMMITTEES_API_BASE}/WrittenEvidence", {"CommitteeId": committee_id, "Take": 10}
258
+ )
259
+ publications_url = build_url(
260
+ f"{COMMITTEES_API_BASE}/Publications", {"CommitteeId": committee_id, "Take": 10}
261
+ )
262
+
263
+ details_task = get_result(details_url)
264
+ oral_task = get_result(oral_evidence_url)
265
+ written_task = get_result(written_evidence_url)
266
+ publications_task = get_result(publications_url)
267
+
268
+ (
269
+ details_response,
270
+ oral_response,
271
+ written_response,
272
+ publications_response,
273
+ ) = await asyncio.gather(details_task, oral_task, written_task, publications_task)
274
+
275
+ return json.dumps(
276
+ {
277
+ "committee_id": committee_id,
278
+ "search_summary": committee,
279
+ "details": _parse_response(details_response),
280
+ "oral_evidence": _parse_response(oral_response),
281
+ "written_evidence": _parse_response(written_response),
282
+ "publications": _parse_response(publications_response),
283
+ "other_matches": len(items) - 1,
284
+ "sources": {
285
+ "search": search_url,
286
+ "details": details_url,
287
+ "oral_evidence": oral_evidence_url,
288
+ "written_evidence": written_evidence_url,
289
+ "publications": publications_url,
290
+ },
291
+ }
292
+ )