Tom Claude commited on
Commit
fd111d2
·
1 Parent(s): 9911e3e

Add Mistral AI + OpenParlData MCP chatbot

Browse files

- Integrated Mistral-7B-Instruct for natural language query understanding
- Connected to real OpenParlData API (https://api.openparldata.ch)
- Full MCP integration for Swiss parliamentary data access
- Multi-language support (EN/DE/FR/IT)
- Optional debug mode to show tool execution
- Real-time data from 74 Swiss parliaments (federal, cantonal, municipal)

Features:
- Search parliamentary votes, parliamentarians, motions, and debates
- Automatic query transformation via Mistral AI
- Clean Gradio ChatInterface with examples
- Professional formatting of parliamentary data

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (7) hide show
  1. .env.example +7 -0
  2. .gitignore +26 -0
  3. app.py +350 -0
  4. mcp/openparldata_mcp.py +603 -0
  5. mcp/requirements.txt +14 -0
  6. mcp_integration.py +276 -0
  7. requirements.txt +23 -0
.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Hugging Face API Token
2
+ # Get your token from: https://huggingface.co/settings/tokens
3
+ HF_TOKEN=your_huggingface_token_here
4
+
5
+ # Optional: Set this if using OpenAI or Anthropic models
6
+ # OPENAI_API_KEY=your_openai_key_here
7
+ # ANTHROPIC_API_KEY=your_anthropic_key_here
.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ .venv/
10
+
11
+ # Environment variables
12
+ .env
13
+
14
+ # Gradio
15
+ gradio_cached_examples/
16
+ flagged/
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+
24
+ # OS
25
+ .DS_Store
26
+ .cache/
app.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CoJournalist Data - Swiss Parliamentary Data Chatbot
3
+ Powered by Mistral AI and OpenParlData MCP
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import gradio as gr
9
+ from huggingface_hub import InferenceClient
10
+ from dotenv import load_dotenv
11
+ from mcp_integration import execute_mcp_query, OpenParlDataClient
12
+ import asyncio
13
+
14
+ # Load environment variables
15
+ load_dotenv()
16
+
17
+ # Initialize Hugging Face Inference Client
18
+ HF_TOKEN = os.getenv("HF_TOKEN")
19
+ if not HF_TOKEN:
20
+ print("Warning: HF_TOKEN not found. Please set it in .env file or Hugging Face Space secrets.")
21
+
22
+ client = InferenceClient(token=HF_TOKEN)
23
+
24
+ # Available languages
25
+ LANGUAGES = {
26
+ "English": "en",
27
+ "Deutsch": "de",
28
+ "Français": "fr",
29
+ "Italiano": "it"
30
+ }
31
+
32
+ # System prompt for Mistral
33
+ SYSTEM_PROMPT = """You are a helpful assistant that helps users query Swiss parliamentary data.
34
+
35
+ You have access to the following tools from the OpenParlData MCP server:
36
+
37
+ 1. **openparldata_search_parliamentarians** - Search for Swiss parliamentarians
38
+ Parameters: query (name/party), canton (2-letter code), party, active_only, language, limit
39
+
40
+ 2. **openparldata_get_parliamentarian** - Get detailed info about a specific parliamentarian
41
+ Parameters: person_id, include_votes, include_motions, language
42
+
43
+ 3. **openparldata_search_votes** - Search parliamentary votes
44
+ Parameters: query (title/description), date_from (YYYY-MM-DD), date_to, vote_type, language, limit
45
+
46
+ 4. **openparldata_get_vote_details** - Get detailed vote information
47
+ Parameters: vote_id, include_individual_votes, language
48
+
49
+ 5. **openparldata_search_motions** - Search motions and proposals
50
+ Parameters: query, status, date_from, date_to, submitter_id, language, limit
51
+
52
+ 6. **openparldata_search_debates** - Search debate transcripts
53
+ Parameters: query, date_from, date_to, speaker_id, language, limit
54
+
55
+ When a user asks a question about Swiss parliamentary data:
56
+ 1. Analyze what information they need
57
+ 2. Determine which tool(s) to use
58
+ 3. Extract the relevant parameters from their question
59
+ 4. Respond with a JSON object containing the tool call
60
+
61
+ Your response should be in this exact format:
62
+ {
63
+ "tool": "tool_name",
64
+ "arguments": {
65
+ "param1": "value1",
66
+ "param2": "value2"
67
+ },
68
+ "explanation": "Brief explanation of what you're searching for"
69
+ }
70
+
71
+ If the user's question is not about Swiss parliamentary data or you cannot determine the right tool, respond with:
72
+ {
73
+ "response": "Your natural language response here"
74
+ }
75
+
76
+ Example:
77
+ User: "Who are the parliamentarians from Zurich?"
78
+ Assistant:
79
+ {
80
+ "tool": "openparldata_search_parliamentarians",
81
+ "arguments": {
82
+ "canton": "ZH",
83
+ "language": "en",
84
+ "limit": 20
85
+ },
86
+ "explanation": "Searching for active parliamentarians from Canton Zurich"
87
+ }
88
+ """
89
+
90
+ # Example queries
91
+ EXAMPLES = {
92
+ "en": [
93
+ "Who are the parliamentarians from Zurich?",
94
+ "Show me recent votes about climate policy",
95
+ "What motions were submitted about healthcare in 2024?",
96
+ "Find debates about immigration reform"
97
+ ],
98
+ "de": [
99
+ "Wer sind die Parlamentarier aus Zürich?",
100
+ "Zeige mir aktuelle Abstimmungen zur Klimapolitik",
101
+ "Welche Anträge zum Gesundheitswesen wurden 2024 eingereicht?",
102
+ "Finde Debatten über Migrationsreform"
103
+ ],
104
+ "fr": [
105
+ "Qui sont les parlementaires de Zurich?",
106
+ "Montrez-moi les votes récents sur la politique climatique",
107
+ "Quelles motions sur la santé ont été soumises en 2024?",
108
+ "Trouvez les débats sur la réforme de l'immigration"
109
+ ],
110
+ "it": [
111
+ "Chi sono i parlamentari di Zurigo?",
112
+ "Mostrami i voti recenti sulla politica climatica",
113
+ "Quali mozioni sulla sanità sono state presentate nel 2024?",
114
+ "Trova i dibattiti sulla riforma dell'immigrazione"
115
+ ]
116
+ }
117
+
118
+
119
+ async def query_mistral_async(message: str, language: str = "en") -> dict:
120
+ """Query Mistral model to interpret user intent and determine tool calls."""
121
+
122
+ try:
123
+ # Create messages for chat completion
124
+ messages = [
125
+ {"role": "system", "content": SYSTEM_PROMPT},
126
+ {"role": "user", "content": f"Language: {language}\nQuestion: {message}"}
127
+ ]
128
+
129
+ # Call Mistral via HuggingFace Inference API
130
+ response = client.chat_completion(
131
+ model="mistralai/Mistral-7B-Instruct-v0.3",
132
+ messages=messages,
133
+ max_tokens=500,
134
+ temperature=0.3
135
+ )
136
+
137
+ # Extract response
138
+ assistant_message = response.choices[0].message.content
139
+
140
+ # Try to parse as JSON
141
+ try:
142
+ # Clean up response (sometimes models add markdown code blocks)
143
+ clean_response = assistant_message.strip()
144
+ if clean_response.startswith("```json"):
145
+ clean_response = clean_response[7:]
146
+ if clean_response.startswith("```"):
147
+ clean_response = clean_response[3:]
148
+ if clean_response.endswith("```"):
149
+ clean_response = clean_response[:-3]
150
+ clean_response = clean_response.strip()
151
+
152
+ return json.loads(clean_response)
153
+ except json.JSONDecodeError:
154
+ # If not valid JSON, treat as natural language response
155
+ return {"response": assistant_message}
156
+
157
+ except Exception as e:
158
+ return {"error": f"Error querying Mistral: {str(e)}"}
159
+
160
+
161
+ def query_mistral(message: str, language: str = "en") -> dict:
162
+ """Synchronous wrapper for async Mistral query."""
163
+ return asyncio.run(query_mistral_async(message, language))
164
+
165
+
166
+ async def execute_tool_async(tool_name: str, arguments: dict, show_debug: bool) -> tuple:
167
+ """Execute MCP tool asynchronously."""
168
+ return await execute_mcp_query("", tool_name, arguments, show_debug)
169
+
170
+
171
+ def chat_response(message: str, history: list, language: str, show_debug: bool) -> str:
172
+ """
173
+ Main chat response function.
174
+
175
+ Args:
176
+ message: User's message
177
+ history: Chat history
178
+ language: Selected language
179
+ show_debug: Whether to show debug information
180
+
181
+ Returns:
182
+ Response string
183
+ """
184
+ try:
185
+ # Get language code
186
+ lang_code = LANGUAGES.get(language, "en")
187
+
188
+ # Query Mistral to interpret intent
189
+ mistral_response = query_mistral(message, lang_code)
190
+
191
+ # Check if it's a direct response (no tool call needed)
192
+ if "response" in mistral_response:
193
+ return mistral_response["response"]
194
+
195
+ # Check for error
196
+ if "error" in mistral_response:
197
+ return f"❌ {mistral_response['error']}"
198
+
199
+ # Execute tool call
200
+ if "tool" in mistral_response and "arguments" in mistral_response:
201
+ tool_name = mistral_response["tool"]
202
+ arguments = mistral_response["arguments"]
203
+ explanation = mistral_response.get("explanation", "")
204
+
205
+ # Ensure language is set in arguments
206
+ if "language" not in arguments:
207
+ arguments["language"] = lang_code
208
+
209
+ # Execute the tool
210
+ try:
211
+ response, debug_info = asyncio.run(
212
+ execute_tool_async(tool_name, arguments, show_debug)
213
+ )
214
+
215
+ # Build final response
216
+ final_response = ""
217
+
218
+ if explanation:
219
+ final_response += f"*{explanation}*\n\n"
220
+
221
+ if show_debug and debug_info:
222
+ final_response += f"### 🔧 Debug Information\n{debug_info}\n\n---\n\n"
223
+
224
+ final_response += f"### 📊 Results\n{response}"
225
+
226
+ return final_response
227
+
228
+ except Exception as e:
229
+ return f"❌ Error executing tool '{tool_name}': {str(e)}"
230
+
231
+ # Fallback
232
+ return "I couldn't determine how to process your request. Please try rephrasing your question."
233
+
234
+ except Exception as e:
235
+ return f"❌ An error occurred: {str(e)}"
236
+
237
+
238
+ # Custom CSS
239
+ custom_css = """
240
+ .gradio-container {
241
+ font-family: 'Inter', sans-serif;
242
+ }
243
+ .chatbot-header {
244
+ text-align: center;
245
+ padding: 20px;
246
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
247
+ color: white;
248
+ border-radius: 10px;
249
+ margin-bottom: 20px;
250
+ }
251
+ """
252
+
253
+ # Build Gradio interface
254
+ with gr.Blocks(css=custom_css, title="CoJournalist Data") as demo:
255
+ gr.Markdown(
256
+ """
257
+ <div class="chatbot-header">
258
+ <h1>🏛️ CoJournalist Data</h1>
259
+ <p>Ask questions about Swiss parliamentary data in natural language</p>
260
+ </div>
261
+ """
262
+ )
263
+
264
+ with gr.Row():
265
+ with gr.Column(scale=3):
266
+ chatbot = gr.Chatbot(
267
+ height=500,
268
+ label="Chat with CoJournalist",
269
+ show_label=False
270
+ )
271
+
272
+ with gr.Row():
273
+ msg = gr.Textbox(
274
+ placeholder="Ask a question about Swiss parliamentary data...",
275
+ show_label=False,
276
+ scale=4
277
+ )
278
+ submit = gr.Button("Send", variant="primary", scale=1)
279
+
280
+ with gr.Column(scale=1):
281
+ gr.Markdown("### ⚙️ Settings")
282
+
283
+ language = gr.Radio(
284
+ choices=list(LANGUAGES.keys()),
285
+ value="English",
286
+ label="Language",
287
+ info="Select response language"
288
+ )
289
+
290
+ show_debug = gr.Checkbox(
291
+ label="Show debug info",
292
+ value=False,
293
+ info="Display tool calls and parameters"
294
+ )
295
+
296
+ gr.Markdown("### 💡 Example Questions")
297
+
298
+ # Dynamic examples based on language
299
+ def update_examples(lang):
300
+ lang_code = LANGUAGES.get(lang, "en")
301
+ return gr.update(
302
+ choices=EXAMPLES.get(lang_code, EXAMPLES["en"])
303
+ )
304
+
305
+ examples_dropdown = gr.Dropdown(
306
+ choices=EXAMPLES["en"],
307
+ label="Try these:",
308
+ show_label=False
309
+ )
310
+
311
+ language.change(
312
+ fn=update_examples,
313
+ inputs=[language],
314
+ outputs=[examples_dropdown]
315
+ )
316
+
317
+ # Handle message submission
318
+ def respond(message, chat_history, language, show_debug):
319
+ if not message.strip():
320
+ return "", chat_history
321
+
322
+ # Get bot response
323
+ bot_message = chat_response(message, chat_history, language, show_debug)
324
+
325
+ # Update chat history
326
+ chat_history.append((message, bot_message))
327
+
328
+ return "", chat_history
329
+
330
+ # Handle example selection
331
+ def use_example(example):
332
+ return example
333
+
334
+ msg.submit(respond, [msg, chatbot, language, show_debug], [msg, chatbot])
335
+ submit.click(respond, [msg, chatbot, language, show_debug], [msg, chatbot])
336
+ examples_dropdown.change(use_example, [examples_dropdown], [msg])
337
+
338
+ gr.Markdown(
339
+ """
340
+ ---
341
+ **Note:** This app uses the OpenParlData MCP server to access Swiss parliamentary data.
342
+ Currently returning mock data while the OpenParlData API is in development.
343
+
344
+ Powered by [Mistral AI](https://mistral.ai/) and [Model Context Protocol (MCP)](https://modelcontextprotocol.io/)
345
+ """
346
+ )
347
+
348
+ # Launch the app
349
+ if __name__ == "__main__":
350
+ demo.launch()
mcp/openparldata_mcp.py ADDED
@@ -0,0 +1,603 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ OpenParlData MCP Server
4
+
5
+ This MCP server provides access to Swiss parliamentary data from the OpenParlData API.
6
+ It enables searching and retrieving information about parliamentarians, votes, motions,
7
+ and other parliamentary activities across Swiss federal, cantonal, and municipal levels.
8
+
9
+ Note: This is based on typical parliamentary API endpoints as the actual OpenParlData API
10
+ documentation was not accessible at the time of creation.
11
+ """
12
+
13
+ import os
14
+ import json
15
+ from datetime import datetime
16
+ from typing import Optional, List, Dict, Any
17
+ from enum import Enum
18
+ import httpx
19
+ from mcp.server.fastmcp import FastMCP
20
+ from pydantic import BaseModel, Field, field_validator, ConfigDict
21
+
22
+ # Initialize the MCP server
23
+ mcp = FastMCP("openparldata_mcp")
24
+
25
+ # Constants
26
+ API_BASE_URL = "https://api.openparldata.ch/v1"
27
+ CHARACTER_LIMIT = 25000
28
+ DEFAULT_LIMIT = 20
29
+ MAX_LIMIT = 100
30
+
31
+ # Enums for validation
32
+ class Language(str, Enum):
33
+ DE = "de"
34
+ FR = "fr"
35
+ IT = "it"
36
+ EN = "en"
37
+
38
+ class ParliamentLevel(str, Enum):
39
+ FEDERAL = "federal"
40
+ CANTONAL = "cantonal"
41
+ MUNICIPAL = "municipal"
42
+
43
+ class VoteType(str, Enum):
44
+ FINAL = "final"
45
+ DETAIL = "detail"
46
+ OVERALL = "overall"
47
+
48
+ class ResponseFormat(str, Enum):
49
+ JSON = "json"
50
+ MARKDOWN = "markdown"
51
+
52
+ # Pydantic models for input validation
53
+
54
+ class SearchParliamentariansInput(BaseModel):
55
+ """Input for searching parliamentarians."""
56
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True, extra='forbid')
57
+
58
+ query: Optional[str] = Field(None, description="Search query for name or party", min_length=1, max_length=100)
59
+ canton: Optional[str] = Field(None, description="Canton code (e.g., 'ZH', 'BE', 'GE')", pattern="^[A-Z]{2}$")
60
+ party: Optional[str] = Field(None, description="Party abbreviation (e.g., 'SP', 'SVP', 'FDP')")
61
+ active_only: bool = Field(True, description="Only return active parliamentarians")
62
+ level: Optional[ParliamentLevel] = Field(None, description="Parliament level filter")
63
+ language: Language = Field(Language.EN, description="Response language")
64
+ limit: int = Field(DEFAULT_LIMIT, description="Maximum results to return", ge=1, le=MAX_LIMIT)
65
+ offset: int = Field(0, description="Pagination offset", ge=0)
66
+ response_format: ResponseFormat = Field(ResponseFormat.MARKDOWN, description="Response format")
67
+
68
+ class GetParliamentarianInput(BaseModel):
69
+ """Input for getting a specific parliamentarian's details."""
70
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True, extra='forbid')
71
+
72
+ person_id: str = Field(..., description="Unique ID of the parliamentarian", min_length=1, max_length=50)
73
+ include_votes: bool = Field(False, description="Include recent voting history")
74
+ include_motions: bool = Field(False, description="Include submitted motions")
75
+ language: Language = Field(Language.EN, description="Response language")
76
+ response_format: ResponseFormat = Field(ResponseFormat.MARKDOWN, description="Response format")
77
+
78
+ class SearchVotesInput(BaseModel):
79
+ """Input for searching parliamentary votes."""
80
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True, extra='forbid')
81
+
82
+ query: Optional[str] = Field(None, description="Search query for vote title or description", min_length=1, max_length=200)
83
+ date_from: Optional[str] = Field(None, description="Start date (ISO format: YYYY-MM-DD)", pattern="^\\d{4}-\\d{2}-\\d{2}$")
84
+ date_to: Optional[str] = Field(None, description="End date (ISO format: YYYY-MM-DD)", pattern="^\\d{4}-\\d{2}-\\d{2}$")
85
+ parliament_id: Optional[str] = Field(None, description="Filter by parliament ID")
86
+ vote_type: Optional[VoteType] = Field(None, description="Type of vote")
87
+ level: Optional[ParliamentLevel] = Field(ParliamentLevel.FEDERAL, description="Parliament level")
88
+ language: Language = Field(Language.EN, description="Response language")
89
+ limit: int = Field(DEFAULT_LIMIT, description="Maximum results to return", ge=1, le=MAX_LIMIT)
90
+ offset: int = Field(0, description="Pagination offset", ge=0)
91
+ response_format: ResponseFormat = Field(ResponseFormat.MARKDOWN, description="Response format")
92
+
93
+ class GetVoteDetailsInput(BaseModel):
94
+ """Input for getting detailed vote information."""
95
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True, extra='forbid')
96
+
97
+ vote_id: str = Field(..., description="Unique vote identifier", min_length=1, max_length=50)
98
+ include_individual_votes: bool = Field(False, description="Include how each parliamentarian voted")
99
+ language: Language = Field(Language.EN, description="Response language")
100
+ response_format: ResponseFormat = Field(ResponseFormat.MARKDOWN, description="Response format")
101
+
102
+ class SearchMotionsInput(BaseModel):
103
+ """Input for searching parliamentary motions and proposals."""
104
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True, extra='forbid')
105
+
106
+ query: Optional[str] = Field(None, description="Search query for motion text", min_length=1, max_length=200)
107
+ submitter_id: Optional[str] = Field(None, description="Filter by submitter's ID")
108
+ status: Optional[str] = Field(None, description="Motion status (e.g., 'pending', 'accepted', 'rejected')")
109
+ date_from: Optional[str] = Field(None, description="Start date (ISO format)", pattern="^\\d{4}-\\d{2}-\\d{2}$")
110
+ date_to: Optional[str] = Field(None, description="End date (ISO format)", pattern="^\\d{4}-\\d{2}-\\d{2}$")
111
+ level: Optional[ParliamentLevel] = Field(ParliamentLevel.FEDERAL, description="Parliament level")
112
+ language: Language = Field(Language.EN, description="Response language")
113
+ limit: int = Field(DEFAULT_LIMIT, description="Maximum results", ge=1, le=MAX_LIMIT)
114
+ offset: int = Field(0, description="Pagination offset", ge=0)
115
+ response_format: ResponseFormat = Field(ResponseFormat.MARKDOWN, description="Response format")
116
+
117
+ class SearchDebatesInput(BaseModel):
118
+ """Input for searching parliamentary debates."""
119
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True, extra='forbid')
120
+
121
+ query: Optional[str] = Field(None, description="Search query for debate content", min_length=1, max_length=200)
122
+ date_from: Optional[str] = Field(None, description="Start date (ISO format)", pattern="^\\d{4}-\\d{2}-\\d{2}$")
123
+ date_to: Optional[str] = Field(None, description="End date (ISO format)", pattern="^\\d{4}-\\d{2}-\\d{2}$")
124
+ speaker_id: Optional[str] = Field(None, description="Filter by speaker's ID")
125
+ topic: Optional[str] = Field(None, description="Topic or theme filter")
126
+ parliament_id: Optional[str] = Field(None, description="Parliament identifier")
127
+ level: Optional[ParliamentLevel] = Field(ParliamentLevel.FEDERAL, description="Parliament level")
128
+ language: Language = Field(Language.EN, description="Response language")
129
+ limit: int = Field(DEFAULT_LIMIT, description="Maximum results", ge=1, le=MAX_LIMIT)
130
+ offset: int = Field(0, description="Pagination offset", ge=0)
131
+ response_format: ResponseFormat = Field(ResponseFormat.MARKDOWN, description="Response format")
132
+
133
+ # Helper functions
134
+
135
+ def truncate_response(content: str, limit: int = CHARACTER_LIMIT) -> str:
136
+ """Truncate response if it exceeds character limit."""
137
+ if len(content) <= limit:
138
+ return content
139
+
140
+ return content[:limit] + "\n\n... [Response truncated due to size limit. Use pagination parameters to retrieve more data.]"
141
+
142
+ def format_date(date_str: str) -> str:
143
+ """Format ISO date string to human-readable format."""
144
+ try:
145
+ dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
146
+ return dt.strftime("%B %d, %Y")
147
+ except:
148
+ return date_str
149
+
150
+ def format_parliamentarian_markdown(person: Dict[str, Any]) -> str:
151
+ """Format parliamentarian data as markdown."""
152
+ lines = [
153
+ f"## {person.get('first_name', '')} {person.get('last_name', '')}",
154
+ f"**Party:** {person.get('party', 'N/A')}",
155
+ f"**Canton:** {person.get('canton', 'N/A')}",
156
+ f"**Parliament:** {person.get('parliament_name', 'N/A')}",
157
+ f"**Status:** {'Active' if person.get('active') else 'Inactive'}",
158
+ ]
159
+
160
+ if person.get('email'):
161
+ lines.append(f"**Email:** {person['email']}")
162
+ if person.get('phone'):
163
+ lines.append(f"**Phone:** {person['phone']}")
164
+ if person.get('committee_memberships'):
165
+ lines.append("\n### Committee Memberships")
166
+ for committee in person['committee_memberships']:
167
+ lines.append(f"- {committee}")
168
+
169
+ return "\n".join(lines)
170
+
171
+ def format_vote_markdown(vote: Dict[str, Any]) -> str:
172
+ """Format vote data as markdown."""
173
+ lines = [
174
+ f"## {vote.get('title', 'Vote')}",
175
+ f"**Date:** {format_date(vote.get('date', ''))}",
176
+ f"**Type:** {vote.get('type', 'N/A')}",
177
+ f"**Result:** {vote.get('result', 'N/A')}",
178
+ ]
179
+
180
+ if vote.get('yes_count') is not None:
181
+ lines.extend([
182
+ f"\n### Vote Count",
183
+ f"- **Yes:** {vote['yes_count']}",
184
+ f"- **No:** {vote.get('no_count', 0)}",
185
+ f"- **Abstentions:** {vote.get('abstention_count', 0)}",
186
+ ])
187
+
188
+ if vote.get('description'):
189
+ lines.extend(["\n### Description", vote['description']])
190
+
191
+ return "\n".join(lines)
192
+
193
+ def format_motion_markdown(motion: Dict[str, Any]) -> str:
194
+ """Format motion data as markdown."""
195
+ lines = [
196
+ f"## {motion.get('title', 'Motion')}",
197
+ f"**Submitted:** {format_date(motion.get('submission_date', ''))}",
198
+ f"**Submitter:** {motion.get('submitter_name', 'N/A')}",
199
+ f"**Status:** {motion.get('status', 'N/A')}",
200
+ ]
201
+
202
+ if motion.get('text'):
203
+ lines.extend(["\n### Motion Text", motion['text'][:500] + ("..." if len(motion['text']) > 500 else "")])
204
+
205
+ if motion.get('response'):
206
+ lines.extend(["\n### Government Response", motion['response'][:500] + ("..." if len(motion['response']) > 500 else "")])
207
+
208
+ return "\n".join(lines)
209
+
210
+ # Mock API functions (replace with actual API calls when API is accessible)
211
+
212
+ async def make_api_request(endpoint: str, params: Dict[str, Any]) -> Dict[str, Any]:
213
+ """
214
+ Make an API request to OpenParlData.
215
+ Now using the real OpenParlData API!
216
+ """
217
+ # Clean up params - remove None values
218
+ clean_params = {k: v for k, v in params.items() if v is not None}
219
+
220
+ # Ensure endpoint has trailing slash (API requires it)
221
+ if not endpoint.endswith('/'):
222
+ endpoint = endpoint + '/'
223
+
224
+ try:
225
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
226
+ response = await client.get(f"{API_BASE_URL}{endpoint}", params=clean_params)
227
+ response.raise_for_status()
228
+ return response.json()
229
+ except httpx.HTTPError as e:
230
+ return {
231
+ "status": "error",
232
+ "message": f"API request failed: {str(e)}",
233
+ "endpoint": endpoint,
234
+ "params": clean_params
235
+ }
236
+ except Exception as e:
237
+ return {
238
+ "status": "error",
239
+ "message": f"Unexpected error: {str(e)}",
240
+ "endpoint": endpoint,
241
+ "params": clean_params
242
+ }
243
+
244
+ # Tool implementations
245
+
246
+ @mcp.tool(
247
+ name="openparldata_search_parliamentarians",
248
+ annotations={
249
+ "title": "Search Parliamentarians",
250
+ "readOnlyHint": True,
251
+ "destructiveHint": False,
252
+ "idempotentHint": True,
253
+ "openWorldHint": True
254
+ }
255
+ )
256
+ async def search_parliamentarians(params: SearchParliamentariansInput) -> str:
257
+ """
258
+ Search for parliamentarians across Swiss federal, cantonal, and municipal levels.
259
+
260
+ Returns a list of parliamentarians matching the search criteria, including their
261
+ party affiliation, canton, and current status.
262
+
263
+ Examples:
264
+ - Search for all active federal parliamentarians from Zurich
265
+ - Find all SP party members in cantonal parliaments
266
+ - Search for a specific person by name
267
+ """
268
+ request_params = {
269
+ "search": params.query,
270
+ "active": params.active_only,
271
+ "lang": params.language.value,
272
+ "limit": params.limit,
273
+ "offset": params.offset,
274
+ "sort_by": "-id"
275
+ }
276
+
277
+ # Add canton filter if provided
278
+ if params.canton:
279
+ request_params["body_key"] = params.canton
280
+
281
+ # Add party filter if provided
282
+ if params.party:
283
+ request_params["party"] = params.party
284
+
285
+ # Remove None values
286
+ request_params = {k: v for k, v in request_params.items() if v is not None}
287
+
288
+ try:
289
+ result = await make_api_request("/persons", request_params)
290
+
291
+ if params.response_format == ResponseFormat.JSON:
292
+ return truncate_response(json.dumps(result, indent=2))
293
+
294
+ # Format as markdown
295
+ if result.get("status") == "error":
296
+ return f"# API Error\n\n{result.get('message', 'Unknown error')}\n\n**Endpoint:** {result.get('endpoint', 'N/A')}"
297
+
298
+ # Format actual data from OpenParlData API
299
+ lines = ["# Parliamentarians Search Results\n"]
300
+
301
+ if isinstance(result, dict) and "data" in result:
302
+ items = result["data"]
303
+ meta = result.get("meta", {})
304
+ total = meta.get("total_records", len(items))
305
+
306
+ if not items:
307
+ return "# No Results\n\nNo parliamentarians found matching your criteria."
308
+
309
+ for person in items:
310
+ lines.append(f"## {person.get('firstname', '')} {person.get('lastname', '')}")
311
+ if person.get('party'):
312
+ lines.append(f"**Party:** {person['party']}")
313
+ if person.get('body_key'):
314
+ lines.append(f"**Region:** {person['body_key']}")
315
+ if person.get('active') is not None:
316
+ lines.append(f"**Status:** {'Active' if person['active'] else 'Inactive'}")
317
+ lines.append("\n---\n")
318
+
319
+ lines.append(f"\n**Showing {len(items)} of {total} results**")
320
+ if meta.get("has_more"):
321
+ lines.append(f"\nMore results available. Use offset={params.offset + params.limit}")
322
+
323
+ return truncate_response("\n".join(lines))
324
+
325
+ except Exception as e:
326
+ return f"Error searching parliamentarians: {str(e)}"
327
+
328
+ @mcp.tool(
329
+ name="openparldata_get_parliamentarian",
330
+ annotations={
331
+ "title": "Get Parliamentarian Details",
332
+ "readOnlyHint": True,
333
+ "destructiveHint": False,
334
+ "idempotentHint": True,
335
+ "openWorldHint": True
336
+ }
337
+ )
338
+ async def get_parliamentarian(params: GetParliamentarianInput) -> str:
339
+ """
340
+ Get detailed information about a specific parliamentarian.
341
+
342
+ Returns comprehensive information including biographical data, party membership,
343
+ committee assignments, and optionally their voting history and submitted motions.
344
+ """
345
+ request_params = {
346
+ "include_votes": params.include_votes,
347
+ "include_motions": params.include_motions,
348
+ "lang": params.language.value
349
+ }
350
+
351
+ try:
352
+ result = await make_api_request(f"/parliamentarians/{params.person_id}", request_params)
353
+
354
+ if params.response_format == ResponseFormat.JSON:
355
+ return truncate_response(json.dumps(result, indent=2))
356
+
357
+ if result.get("mock_data"):
358
+ return f"# OpenParlData API Status\n\n{result['message']}\n\n**Person ID:** {params.person_id}"
359
+
360
+ # Format actual data when available
361
+ return truncate_response(format_parliamentarian_markdown(result))
362
+
363
+ except Exception as e:
364
+ return f"Error getting parliamentarian details: {str(e)}"
365
+
366
+ @mcp.tool(
367
+ name="openparldata_search_votes",
368
+ annotations={
369
+ "title": "Search Parliamentary Votes",
370
+ "readOnlyHint": True,
371
+ "destructiveHint": False,
372
+ "idempotentHint": True,
373
+ "openWorldHint": True
374
+ }
375
+ )
376
+ async def search_votes(params: SearchVotesInput) -> str:
377
+ """
378
+ Search for parliamentary votes across different levels of Swiss government.
379
+
380
+ Returns vote records including titles, dates, results, and vote counts.
381
+ Can filter by date range, parliament, and vote type.
382
+ """
383
+ request_params = {
384
+ "search": params.query,
385
+ "date_from": params.date_from,
386
+ "date_to": params.date_to,
387
+ "limit": params.limit,
388
+ "offset": params.offset,
389
+ "sort_by": "-date"
390
+ }
391
+
392
+ request_params = {k: v for k, v in request_params.items() if v is not None}
393
+
394
+ try:
395
+ result = await make_api_request("/votings", request_params)
396
+
397
+ if params.response_format == ResponseFormat.JSON:
398
+ return truncate_response(json.dumps(result, indent=2))
399
+
400
+ if result.get("status") == "error":
401
+ return f"# API Error\n\n{result.get('message', 'Unknown error')}\n\n**Endpoint:** {result.get('endpoint', 'N/A')}"
402
+
403
+ lines = ["# Parliamentary Votes Search Results\n"]
404
+
405
+ if isinstance(result, dict) and "data" in result:
406
+ items = result["data"]
407
+ meta = result.get("meta", {})
408
+ total = meta.get("total_records", len(items))
409
+
410
+ if not items:
411
+ return "# No Results\n\nNo votes found matching your criteria."
412
+
413
+ for voting in items:
414
+ # Handle multilingual title
415
+ title_obj = voting.get('title', {})
416
+ if isinstance(title_obj, dict):
417
+ title = title_obj.get('de') or title_obj.get('fr') or title_obj.get('it') or title_obj.get('en', 'Untitled Vote')
418
+ else:
419
+ title = title_obj or 'Untitled Vote'
420
+
421
+ lines.append(f"## {title}")
422
+ if voting.get('date'):
423
+ lines.append(f"**Date:** {voting['date']}")
424
+ if voting.get('body_key'):
425
+ lines.append(f"**Parliament:** {voting['body_key']}")
426
+ if voting.get('results_yes') is not None:
427
+ lines.append(f"**Yes:** {voting.get('results_yes', 0)} | **No:** {voting.get('results_no', 0)} | **Abstentions:** {voting.get('results_abstention', 0)}")
428
+
429
+ # Show affair title if available
430
+ affair_title_obj = voting.get('affair_title', {})
431
+ if isinstance(affair_title_obj, dict):
432
+ affair_title = affair_title_obj.get('de') or affair_title_obj.get('fr') or affair_title_obj.get('it') or affair_title_obj.get('en')
433
+ if affair_title:
434
+ lines.append(f"*Related to: {affair_title}*")
435
+
436
+ lines.append("\n---\n")
437
+
438
+ lines.append(f"\n**Showing {len(items)} of {total} results**")
439
+ if meta.get("has_more"):
440
+ lines.append(f"\nMore results available.")
441
+
442
+ return truncate_response("\n".join(lines))
443
+
444
+ except Exception as e:
445
+ return f"Error searching votes: {str(e)}"
446
+
447
+ @mcp.tool(
448
+ name="openparldata_get_vote_details",
449
+ annotations={
450
+ "title": "Get Vote Details",
451
+ "readOnlyHint": True,
452
+ "destructiveHint": False,
453
+ "idempotentHint": True,
454
+ "openWorldHint": True
455
+ }
456
+ )
457
+ async def get_vote_details(params: GetVoteDetailsInput) -> str:
458
+ """
459
+ Get detailed information about a specific parliamentary vote.
460
+
461
+ Returns comprehensive vote information including the proposal text,
462
+ voting results, and optionally how each parliamentarian voted.
463
+ """
464
+ request_params = {
465
+ "include_individual": params.include_individual_votes,
466
+ "lang": params.language.value
467
+ }
468
+
469
+ try:
470
+ result = await make_api_request(f"/votes/{params.vote_id}", request_params)
471
+
472
+ if params.response_format == ResponseFormat.JSON:
473
+ return truncate_response(json.dumps(result, indent=2))
474
+
475
+ if result.get("mock_data"):
476
+ return f"# OpenParlData API Status\n\n{result['message']}\n\n**Vote ID:** {params.vote_id}"
477
+
478
+ return truncate_response(format_vote_markdown(result))
479
+
480
+ except Exception as e:
481
+ return f"Error getting vote details: {str(e)}"
482
+
483
+ @mcp.tool(
484
+ name="openparldata_search_motions",
485
+ annotations={
486
+ "title": "Search Parliamentary Motions",
487
+ "readOnlyHint": True,
488
+ "destructiveHint": False,
489
+ "idempotentHint": True,
490
+ "openWorldHint": True
491
+ }
492
+ )
493
+ async def search_motions(params: SearchMotionsInput) -> str:
494
+ """
495
+ Search for parliamentary motions, proposals, and initiatives.
496
+
497
+ Returns motion records including titles, submitters, dates, status,
498
+ and motion text. Can filter by submitter, status, and date range.
499
+ """
500
+ request_params = {
501
+ "q": params.query,
502
+ "submitter_id": params.submitter_id,
503
+ "status": params.status,
504
+ "date_from": params.date_from,
505
+ "date_to": params.date_to,
506
+ "level": params.level.value if params.level else None,
507
+ "lang": params.language.value,
508
+ "limit": params.limit,
509
+ "offset": params.offset
510
+ }
511
+
512
+ request_params = {k: v for k, v in request_params.items() if v is not None}
513
+
514
+ try:
515
+ result = await make_api_request("/motions", request_params)
516
+
517
+ if params.response_format == ResponseFormat.JSON:
518
+ return truncate_response(json.dumps(result, indent=2))
519
+
520
+ if result.get("mock_data"):
521
+ return f"# OpenParlData API Status\n\n{result['message']}\n\n**Endpoint:** {result['endpoint']}"
522
+
523
+ lines = ["# Parliamentary Motions Search Results\n"]
524
+ if result.get("data"):
525
+ for motion in result["data"]:
526
+ lines.append(format_motion_markdown(motion))
527
+ lines.append("\n---\n")
528
+
529
+ return truncate_response("\n".join(lines))
530
+
531
+ except Exception as e:
532
+ return f"Error searching motions: {str(e)}"
533
+
534
+ @mcp.tool(
535
+ name="openparldata_search_debates",
536
+ annotations={
537
+ "title": "Search Parliamentary Debates",
538
+ "readOnlyHint": True,
539
+ "destructiveHint": False,
540
+ "idempotentHint": True,
541
+ "openWorldHint": True
542
+ }
543
+ )
544
+ async def search_debates(params: SearchDebatesInput) -> str:
545
+ """
546
+ Search parliamentary debate transcripts and proceedings.
547
+
548
+ Returns debate records including speakers, topics, dates, and transcript excerpts.
549
+ Can search by content, speaker, date range, and topic.
550
+ """
551
+ request_params = {
552
+ "q": params.query,
553
+ "date_from": params.date_from,
554
+ "date_to": params.date_to,
555
+ "speaker_id": params.speaker_id,
556
+ "topic": params.topic,
557
+ "parliament_id": params.parliament_id,
558
+ "level": params.level.value if params.level else None,
559
+ "lang": params.language.value,
560
+ "limit": params.limit,
561
+ "offset": params.offset
562
+ }
563
+
564
+ request_params = {k: v for k, v in request_params.items() if v is not None}
565
+
566
+ try:
567
+ result = await make_api_request("/debates", request_params)
568
+
569
+ if params.response_format == ResponseFormat.JSON:
570
+ return truncate_response(json.dumps(result, indent=2))
571
+
572
+ if result.get("mock_data"):
573
+ return f"# OpenParlData API Status\n\n{result['message']}\n\n**Endpoint:** {result['endpoint']}"
574
+
575
+ lines = ["# Parliamentary Debates Search Results\n"]
576
+ if result.get("data"):
577
+ for debate in result["data"]:
578
+ lines.extend([
579
+ f"## {debate.get('title', 'Debate')}",
580
+ f"**Date:** {format_date(debate.get('date', ''))}",
581
+ f"**Parliament:** {debate.get('parliament_name', 'N/A')}",
582
+ f"**Topic:** {debate.get('topic', 'N/A')}",
583
+ ])
584
+
585
+ if debate.get('speakers'):
586
+ lines.append("\n### Speakers")
587
+ for speaker in debate['speakers'][:5]:
588
+ lines.append(f"- {speaker}")
589
+
590
+ if debate.get('excerpt'):
591
+ lines.extend(["\n### Excerpt", debate['excerpt'][:500] + "..."])
592
+
593
+ lines.append("\n---\n")
594
+
595
+ return truncate_response("\n".join(lines))
596
+
597
+ except Exception as e:
598
+ return f"Error searching debates: {str(e)}"
599
+
600
+ # Main execution
601
+ if __name__ == "__main__":
602
+ import asyncio
603
+ asyncio.run(mcp.run())
mcp/requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenParlData MCP Server Requirements
2
+ # Python 3.8+ required
3
+
4
+ # Core MCP dependencies
5
+ mcp>=0.1.0
6
+
7
+ # HTTP client for API calls
8
+ httpx>=0.24.0
9
+
10
+ # Data validation
11
+ pydantic>=2.0.0
12
+
13
+ # Optional: For enhanced async support
14
+ anyio>=3.0.0
mcp_integration.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Integration for OpenParlData
3
+ Provides a wrapper for connecting to the OpenParlData MCP server
4
+ and executing tools from the Gradio app.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import json
10
+ import asyncio
11
+ from typing import Optional, Dict, Any, List
12
+ from pathlib import Path
13
+
14
+ # Add mcp directory to path
15
+ mcp_dir = Path(__file__).parent / "mcp"
16
+ sys.path.insert(0, str(mcp_dir))
17
+
18
+ from mcp.client.session import ClientSession
19
+ from mcp.client.stdio import stdio_client, StdioServerParameters
20
+
21
+
22
+ class OpenParlDataClient:
23
+ """Client for interacting with OpenParlData MCP server."""
24
+
25
+ def __init__(self):
26
+ self.session: Optional[ClientSession] = None
27
+ self.available_tools: List[Dict[str, Any]] = []
28
+
29
+ async def connect(self):
30
+ """Connect to the MCP server."""
31
+ # Get the path to the MCP server script
32
+ server_script = Path(__file__).parent / "mcp" / "openparldata_mcp.py"
33
+
34
+ if not server_script.exists():
35
+ raise FileNotFoundError(f"MCP server script not found at {server_script}")
36
+
37
+ # Server parameters for stdio connection
38
+ server_params = StdioServerParameters(
39
+ command=sys.executable, # Python interpreter
40
+ args=[str(server_script)],
41
+ env=None
42
+ )
43
+
44
+ # Create stdio client context
45
+ self.stdio_context = stdio_client(server_params)
46
+ read, write = await self.stdio_context.__aenter__()
47
+
48
+ # Create session
49
+ self.session = ClientSession(read, write)
50
+ await self.session.__aenter__()
51
+
52
+ # Initialize and get available tools
53
+ await self.session.initialize()
54
+
55
+ # List available tools
56
+ tools_result = await self.session.list_tools()
57
+ self.available_tools = [
58
+ {
59
+ "name": tool.name,
60
+ "description": tool.description,
61
+ "input_schema": tool.inputSchema
62
+ }
63
+ for tool in tools_result.tools
64
+ ]
65
+
66
+ return self.available_tools
67
+
68
+ async def disconnect(self):
69
+ """Disconnect from the MCP server."""
70
+ if self.session:
71
+ await self.session.__aexit__(None, None, None)
72
+ if hasattr(self, 'stdio_context'):
73
+ await self.stdio_context.__aexit__(None, None, None)
74
+
75
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str:
76
+ """
77
+ Call an MCP tool with given arguments.
78
+
79
+ Args:
80
+ tool_name: Name of the tool to call
81
+ arguments: Dictionary of arguments for the tool
82
+
83
+ Returns:
84
+ Tool response as string
85
+ """
86
+ if not self.session:
87
+ raise RuntimeError("Not connected to MCP server. Call connect() first.")
88
+
89
+ # Wrap arguments in 'params' key as expected by MCP server
90
+ tool_arguments = {"params": arguments}
91
+
92
+ # Call the tool
93
+ result = await self.session.call_tool(tool_name, arguments=tool_arguments)
94
+
95
+ # Extract text content from result
96
+ if result.content:
97
+ # MCP returns list of content blocks
98
+ text_parts = []
99
+ for content in result.content:
100
+ if hasattr(content, 'text'):
101
+ text_parts.append(content.text)
102
+ elif isinstance(content, dict) and 'text' in content:
103
+ text_parts.append(content['text'])
104
+ return "\n".join(text_parts)
105
+
106
+ return "No response from tool"
107
+
108
+ def get_tool_info(self) -> List[Dict[str, Any]]:
109
+ """Get information about available tools."""
110
+ return self.available_tools
111
+
112
+
113
+ # Convenience functions for common operations
114
+
115
+ async def search_parliamentarians(
116
+ query: Optional[str] = None,
117
+ canton: Optional[str] = None,
118
+ party: Optional[str] = None,
119
+ language: str = "en",
120
+ limit: int = 20,
121
+ show_debug: bool = False
122
+ ) -> tuple[str, Optional[str]]:
123
+ """
124
+ Search for parliamentarians.
125
+
126
+ Returns:
127
+ Tuple of (response_text, debug_info)
128
+ """
129
+ client = OpenParlDataClient()
130
+
131
+ try:
132
+ await client.connect()
133
+
134
+ arguments = {
135
+ "language": language,
136
+ "limit": limit,
137
+ "response_format": "markdown"
138
+ }
139
+
140
+ if query:
141
+ arguments["query"] = query
142
+ if canton:
143
+ arguments["canton"] = canton
144
+ if party:
145
+ arguments["party"] = party
146
+
147
+ debug_info = None
148
+ if show_debug:
149
+ debug_info = f"**Tool:** openparldata_search_parliamentarians\n**Arguments:** ```json\n{json.dumps(arguments, indent=2)}\n```"
150
+
151
+ response = await client.call_tool("openparldata_search_parliamentarians", arguments)
152
+
153
+ return response, debug_info
154
+
155
+ finally:
156
+ await client.disconnect()
157
+
158
+
159
+ async def search_votes(
160
+ query: Optional[str] = None,
161
+ date_from: Optional[str] = None,
162
+ date_to: Optional[str] = None,
163
+ language: str = "en",
164
+ limit: int = 20,
165
+ show_debug: bool = False
166
+ ) -> tuple[str, Optional[str]]:
167
+ """
168
+ Search for parliamentary votes.
169
+
170
+ Returns:
171
+ Tuple of (response_text, debug_info)
172
+ """
173
+ client = OpenParlDataClient()
174
+
175
+ try:
176
+ await client.connect()
177
+
178
+ arguments = {
179
+ "language": language,
180
+ "limit": limit,
181
+ "response_format": "markdown"
182
+ }
183
+
184
+ if query:
185
+ arguments["query"] = query
186
+ if date_from:
187
+ arguments["date_from"] = date_from
188
+ if date_to:
189
+ arguments["date_to"] = date_to
190
+
191
+ debug_info = None
192
+ if show_debug:
193
+ debug_info = f"**Tool:** openparldata_search_votes\n**Arguments:** ```json\n{json.dumps(arguments, indent=2)}\n```"
194
+
195
+ response = await client.call_tool("openparldata_search_votes", arguments)
196
+
197
+ return response, debug_info
198
+
199
+ finally:
200
+ await client.disconnect()
201
+
202
+
203
+ async def search_motions(
204
+ query: Optional[str] = None,
205
+ status: Optional[str] = None,
206
+ language: str = "en",
207
+ limit: int = 20,
208
+ show_debug: bool = False
209
+ ) -> tuple[str, Optional[str]]:
210
+ """
211
+ Search for motions and proposals.
212
+
213
+ Returns:
214
+ Tuple of (response_text, debug_info)
215
+ """
216
+ client = OpenParlDataClient()
217
+
218
+ try:
219
+ await client.connect()
220
+
221
+ arguments = {
222
+ "language": language,
223
+ "limit": limit,
224
+ "response_format": "markdown"
225
+ }
226
+
227
+ if query:
228
+ arguments["query"] = query
229
+ if status:
230
+ arguments["status"] = status
231
+
232
+ debug_info = None
233
+ if show_debug:
234
+ debug_info = f"**Tool:** openparldata_search_motions\n**Arguments:** ```json\n{json.dumps(arguments, indent=2)}\n```"
235
+
236
+ response = await client.call_tool("openparldata_search_motions", arguments)
237
+
238
+ return response, debug_info
239
+
240
+ finally:
241
+ await client.disconnect()
242
+
243
+
244
+ async def execute_mcp_query(
245
+ user_query: str,
246
+ tool_name: str,
247
+ arguments: Dict[str, Any],
248
+ show_debug: bool = False
249
+ ) -> tuple[str, Optional[str]]:
250
+ """
251
+ Execute any MCP tool query.
252
+
253
+ Args:
254
+ user_query: The original user question (for context)
255
+ tool_name: Name of the MCP tool to call
256
+ arguments: Arguments for the tool
257
+ show_debug: Whether to return debug information
258
+
259
+ Returns:
260
+ Tuple of (response_text, debug_info)
261
+ """
262
+ client = OpenParlDataClient()
263
+
264
+ try:
265
+ await client.connect()
266
+
267
+ debug_info = None
268
+ if show_debug:
269
+ debug_info = f"**User Query:** {user_query}\n\n**Tool:** {tool_name}\n**Arguments:** ```json\n{json.dumps(arguments, indent=2)}\n```"
270
+
271
+ response = await client.call_tool(tool_name, arguments)
272
+
273
+ return response, debug_info
274
+
275
+ finally:
276
+ await client.disconnect()
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gradio App Requirements
2
+ # Python 3.11+ recommended
3
+
4
+ # Gradio
5
+ gradio>=5.49.1
6
+
7
+ # Hugging Face
8
+ huggingface-hub>=0.22.0
9
+
10
+ # MCP Support
11
+ mcp>=0.1.0
12
+
13
+ # HTTP Client
14
+ httpx>=0.24.0
15
+
16
+ # Data Validation
17
+ pydantic>=2.0.0
18
+
19
+ # Async Support
20
+ anyio>=3.0.0
21
+
22
+ # Environment Variables
23
+ python-dotenv>=1.0.0