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.
- uk_parliament_mcp/__init__.py +1 -1
- uk_parliament_mcp/__main__.py +1 -0
- uk_parliament_mcp/http_client.py +1 -0
- uk_parliament_mcp/server.py +8 -1
- uk_parliament_mcp/tools/bills.py +1 -0
- uk_parliament_mcp/tools/committees.py +1 -0
- uk_parliament_mcp/tools/commons_votes.py +1 -0
- uk_parliament_mcp/tools/composite.py +292 -0
- uk_parliament_mcp/tools/core.py +854 -2
- uk_parliament_mcp/tools/erskine_may.py +1 -0
- uk_parliament_mcp/tools/hansard.py +1 -0
- uk_parliament_mcp/tools/interests.py +1 -0
- uk_parliament_mcp/tools/lords_votes.py +1 -0
- uk_parliament_mcp/tools/members.py +1 -0
- uk_parliament_mcp/tools/now.py +1 -0
- uk_parliament_mcp/tools/oral_questions.py +1 -0
- uk_parliament_mcp/tools/statutory_instruments.py +1 -0
- uk_parliament_mcp/tools/treaties.py +1 -0
- uk_parliament_mcp/tools/whatson.py +1 -0
- {uk_parliament_mcp-1.0.0.dist-info → uk_parliament_mcp-1.0.1.dist-info}/METADATA +23 -54
- uk_parliament_mcp-1.0.1.dist-info/RECORD +24 -0
- uk_parliament_mcp-1.0.0.dist-info/RECORD +0 -23
- {uk_parliament_mcp-1.0.0.dist-info → uk_parliament_mcp-1.0.1.dist-info}/WHEEL +0 -0
- {uk_parliament_mcp-1.0.0.dist-info → uk_parliament_mcp-1.0.1.dist-info}/entry_points.txt +0 -0
uk_parliament_mcp/__init__.py
CHANGED
uk_parliament_mcp/__main__.py
CHANGED
uk_parliament_mcp/http_client.py
CHANGED
uk_parliament_mcp/server.py
CHANGED
|
@@ -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
|
uk_parliament_mcp/tools/bills.py
CHANGED
|
@@ -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
|
+
)
|