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,293 @@
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.config import (
12
+ BILLS_API_BASE,
13
+ COMMITTEES_API_BASE,
14
+ COMMONS_VOTES_API_BASE,
15
+ INTERESTS_API_BASE,
16
+ MEMBERS_API_BASE,
17
+ )
18
+ from uk_parliament_mcp.http_client import build_url, get_result
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
+ latest_membership = basic_info.get("latestHouseMembership") or {}
79
+ house = latest_membership.get("house", 1)
80
+
81
+ # Step 2: Parallel requests for details
82
+ biography_url = f"{MEMBERS_API_BASE}/Members/{member_id}/Biography"
83
+ interests_url = f"{INTERESTS_API_BASE}/Interests/?MemberId={member_id}"
84
+ voting_url = build_url(
85
+ f"{MEMBERS_API_BASE}/Members/{member_id}/Voting", {"house": house, "page": 1}
86
+ )
87
+
88
+ biography_task = get_result(biography_url)
89
+ interests_task = get_result(interests_url)
90
+ voting_task = get_result(voting_url)
91
+
92
+ biography_response, interests_response, voting_response = await asyncio.gather(
93
+ biography_task, interests_task, voting_task
94
+ )
95
+
96
+ return json.dumps(
97
+ {
98
+ "member_id": member_id,
99
+ "basic_info": basic_info,
100
+ "biography": _parse_response(biography_response),
101
+ "registered_interests": _parse_response(interests_response),
102
+ "recent_voting": _parse_response(voting_response),
103
+ "sources": {
104
+ "search": search_url,
105
+ "biography": biography_url,
106
+ "interests": interests_url,
107
+ "voting": voting_url,
108
+ },
109
+ }
110
+ )
111
+
112
+ @mcp.tool()
113
+ async def check_mp_vote(mp_name: str, topic: str) -> str:
114
+ """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
115
+
116
+ Args:
117
+ mp_name: Name of the MP to look up (e.g., 'Boris Johnson', 'Keir Starmer').
118
+ topic: Topic or keyword to search for in Commons divisions (e.g., 'climate', 'NHS', 'brexit').
119
+
120
+ Returns:
121
+ MP info and divisions matching the topic with the MP's vote.
122
+ """
123
+ from urllib.parse import quote
124
+
125
+ # Step 1: Search for member
126
+ search_url = f"{MEMBERS_API_BASE}/Members/Search?Name={quote(mp_name)}"
127
+ search_response = await get_result(search_url)
128
+ member_data = _parse_response(search_response)
129
+
130
+ member_id = _extract_member_id(member_data)
131
+ if not member_id:
132
+ return json.dumps(
133
+ {"error": f"No member found matching '{mp_name}'", "search_result": member_data}
134
+ )
135
+
136
+ basic_info = member_data.get("items", [{}])[0].get("value", {})
137
+
138
+ # Step 2: Search for divisions on topic with this member
139
+ divisions_url = build_url(
140
+ f"{COMMONS_VOTES_API_BASE}/divisions.json/search",
141
+ {
142
+ "queryParameters.searchTerm": topic,
143
+ "memberId": member_id,
144
+ },
145
+ )
146
+ divisions_response = await get_result(divisions_url)
147
+ divisions_data = _parse_response(divisions_response)
148
+
149
+ return json.dumps(
150
+ {
151
+ "member_id": member_id,
152
+ "member_info": basic_info,
153
+ "topic_searched": topic,
154
+ "divisions": divisions_data,
155
+ "sources": {"member_search": search_url, "divisions": divisions_url},
156
+ }
157
+ )
158
+
159
+ @mcp.tool()
160
+ async def get_bill_overview(search_term: str) -> str:
161
+ """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
162
+
163
+ Args:
164
+ search_term: Search term for bill title or content (e.g., 'Online Safety', 'Environment', 'Finance').
165
+
166
+ Returns:
167
+ Combined bill data: details, stages, and publications.
168
+ """
169
+ from urllib.parse import quote
170
+
171
+ # Step 1: Search for bills
172
+ search_url = f"{BILLS_API_BASE}/Bills?SearchTerm={quote(search_term)}"
173
+ search_response = await get_result(search_url)
174
+ bills_data = _parse_response(search_response)
175
+
176
+ items = bills_data.get("items", [])
177
+ if not items:
178
+ return json.dumps(
179
+ {"error": f"No bills found matching '{search_term}'", "search_result": bills_data}
180
+ )
181
+
182
+ # Get first matching bill
183
+ bill = items[0]
184
+ bill_id = bill.get("billId")
185
+ if not bill_id:
186
+ return json.dumps({"error": "Could not extract bill ID", "search_result": bills_data})
187
+
188
+ # Step 2: Parallel requests for details, stages, publications
189
+ details_url = f"{BILLS_API_BASE}/Bills/{bill_id}"
190
+ stages_url = f"{BILLS_API_BASE}/Bills/{bill_id}/Stages"
191
+ publications_url = f"{BILLS_API_BASE}/Bills/{bill_id}/Publications"
192
+
193
+ details_task = get_result(details_url)
194
+ stages_task = get_result(stages_url)
195
+ publications_task = get_result(publications_url)
196
+
197
+ details_response, stages_response, publications_response = await asyncio.gather(
198
+ details_task, stages_task, publications_task
199
+ )
200
+
201
+ return json.dumps(
202
+ {
203
+ "bill_id": bill_id,
204
+ "search_summary": bill,
205
+ "details": _parse_response(details_response),
206
+ "stages": _parse_response(stages_response),
207
+ "publications": _parse_response(publications_response),
208
+ "other_matches": len(items) - 1,
209
+ "sources": {
210
+ "search": search_url,
211
+ "details": details_url,
212
+ "stages": stages_url,
213
+ "publications": publications_url,
214
+ },
215
+ }
216
+ )
217
+
218
+ @mcp.tool()
219
+ async def get_committee_summary(topic: str) -> str:
220
+ """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
221
+
222
+ Args:
223
+ topic: Search term for committee name or subject area (e.g., 'Treasury', 'Health', 'Defence').
224
+
225
+ Returns:
226
+ Combined committee data: details, evidence, and publications.
227
+ """
228
+ from urllib.parse import quote
229
+
230
+ # Step 1: Search for committees
231
+ search_url = f"{COMMITTEES_API_BASE}/Committees?SearchTerm={quote(topic)}"
232
+ search_response = await get_result(search_url)
233
+ committees_data = _parse_response(search_response)
234
+
235
+ items = committees_data.get("items", [])
236
+ if not items:
237
+ return json.dumps(
238
+ {
239
+ "error": f"No committees found matching '{topic}'",
240
+ "search_result": committees_data,
241
+ }
242
+ )
243
+
244
+ # Get first matching committee
245
+ committee = items[0]
246
+ committee_id = committee.get("id")
247
+ if not committee_id:
248
+ return json.dumps(
249
+ {"error": "Could not extract committee ID", "search_result": committees_data}
250
+ )
251
+
252
+ # Step 2: Parallel requests for details, evidence, publications
253
+ details_url = f"{COMMITTEES_API_BASE}/Committees/{committee_id}"
254
+ oral_evidence_url = build_url(
255
+ f"{COMMITTEES_API_BASE}/OralEvidence", {"CommitteeId": committee_id, "Take": 10}
256
+ )
257
+ written_evidence_url = build_url(
258
+ f"{COMMITTEES_API_BASE}/WrittenEvidence", {"CommitteeId": committee_id, "Take": 10}
259
+ )
260
+ publications_url = build_url(
261
+ f"{COMMITTEES_API_BASE}/Publications", {"CommitteeId": committee_id, "Take": 10}
262
+ )
263
+
264
+ details_task = get_result(details_url)
265
+ oral_task = get_result(oral_evidence_url)
266
+ written_task = get_result(written_evidence_url)
267
+ publications_task = get_result(publications_url)
268
+
269
+ (
270
+ details_response,
271
+ oral_response,
272
+ written_response,
273
+ publications_response,
274
+ ) = await asyncio.gather(details_task, oral_task, written_task, publications_task)
275
+
276
+ return json.dumps(
277
+ {
278
+ "committee_id": committee_id,
279
+ "search_summary": committee,
280
+ "details": _parse_response(details_response),
281
+ "oral_evidence": _parse_response(oral_response),
282
+ "written_evidence": _parse_response(written_response),
283
+ "publications": _parse_response(publications_response),
284
+ "other_matches": len(items) - 1,
285
+ "sources": {
286
+ "search": search_url,
287
+ "details": details_url,
288
+ "oral_evidence": oral_evidence_url,
289
+ "written_evidence": written_evidence_url,
290
+ "publications": publications_url,
291
+ },
292
+ }
293
+ )